namespace ClaudeDo.Data; public static class Paths { /// /// Expands a leading "~" or "%USERPROFILE%" and returns an absolute path. /// Relative paths are resolved against (default: current directory). /// public static string Expand(string path, string? baseDir = null) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Path must not be empty.", nameof(path)); var expanded = System.Environment.ExpandEnvironmentVariables(path); if (expanded.StartsWith("~", StringComparison.Ordinal)) { var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); expanded = home + expanded[1..]; } if (!Path.IsPathRooted(expanded)) expanded = Path.GetFullPath(expanded, baseDir ?? System.Environment.CurrentDirectory); return Path.GetFullPath(expanded); } /// /// Strips trailing directory separators off a path bound for a Windows command line. Windows /// argv rules read \" as an escaped quote, so a quoted token ending in '\' never closes /// ("C:\repo\" parses as C:\repo") and either the path itself or every argument /// after it is corrupted. A bare root ("C:\", "/") is all separator and is returned untouched; /// so is null/blank. /// public static string? TrimTrailingSeparator(string? path) { if (string.IsNullOrWhiteSpace(path)) return path; var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); return trimmed.Length == 0 || trimmed.EndsWith(':') ? path : trimmed; } private const string DirName = ".claudeDo"; private const string LegacyDirName = ".todo-app"; /// /// ~/.claudeDo — parent directory for db, logs, config, sandbox, worktrees. /// Falls back to the legacy ~/.todo-app while that one still exists and the new one /// doesn't, so a failed degrades to "keep using /// the old folder" instead of silently starting over on an empty one. /// public static string AppDataRoot() { var root = Expand("~/" + DirName); if (!Directory.Exists(root)) { var legacy = Expand("~/" + LegacyDirName); if (Directory.Exists(legacy)) return legacy; } return root; } /// /// One-time rename of ~/.todo-app to ~/.claudeDo, including the path literals stored /// inside worker.config.json / ui.config.json. No-op once the new folder exists. /// Called from the Worker at startup before anything opens a file under the root — /// the single mover, so the UI never races it. /// public static void MigrateLegacyAppDataRoot() => MigrateLegacyAppDataRoot(Expand("~")); internal static void MigrateLegacyAppDataRoot(string home) { var root = Path.Combine(home, DirName); var legacy = Path.Combine(home, LegacyDirName); if (Directory.Exists(root) || !Directory.Exists(legacy)) return; // A locked file (e.g. an old worker still holding todo.db) fails the move; AppDataRoot // then keeps returning the legacy path and the next start retries. try { Directory.Move(legacy, root); } catch (IOException) { return; } catch (UnauthorizedAccessException) { return; } foreach (var cfg in Directory.EnumerateFiles(root, "*.config.json")) { try { File.WriteAllText(cfg, File.ReadAllText(cfg).Replace(LegacyDirName, DirName)); } catch (IOException) { /* best effort — the default already points at the new root */ } } } }