using System.Text.Json; namespace Sdt.Config; public static class WorkspaceLoader { public const string FileName = "sdt-workspace.json"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, }; /// /// Walks up from (or CWD) to find sdt-workspace.json. /// Returns null if not found. /// public static (WorkspaceConfig Config, string WorkspaceRoot)? FindAndLoad(string? startDir = null) { var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory()); while (dir is not null) { var candidate = Path.Combine(dir.FullName, FileName); if (File.Exists(candidate)) { try { var json = File.ReadAllText(candidate); var config = JsonSerializer.Deserialize(json, JsonOptions) ?? throw new InvalidOperationException($"{FileName} deserialized to null."); return (config, dir.FullName); } catch (Exception ex) { throw new InvalidOperationException( $"Failed to parse {FileName} at {candidate}: {ex.Message}", ex); } } dir = dir.Parent!; } // No workspace file found; synthesize one by scanning nearby project roots. return TryAutoDiscover(startDir ?? Directory.GetCurrentDirectory()); } /// /// Resolves the absolute project root for a workspace project entry. /// public static string ResolveProjectRoot(string workspaceRoot, WorkspaceProject project) => Path.GetFullPath(Path.IsPathRooted(project.Path) ? project.Path : Path.Combine(workspaceRoot, project.Path)); public static string GetWorkspaceFilePath(string workspaceRoot) => Path.Combine(workspaceRoot, FileName); public static void Save(string workspaceRoot, WorkspaceConfig workspace) { var path = GetWorkspaceFilePath(workspaceRoot); var saveOptions = new JsonSerializerOptions(JsonOptions) { WriteIndented = true }; var json = JsonSerializer.Serialize(workspace, saveOptions); File.WriteAllText(path, json + Environment.NewLine); } private static (WorkspaceConfig Config, string WorkspaceRoot)? TryAutoDiscover(string startDir) { LoadedProjectConfig? loaded; try { loaded = ConfigLoader.FindAndLoad(startDir); } catch { return null; } if (loaded is null) return null; var currentRoot = loaded.ProjectRoot; var parent = Directory.GetParent(currentRoot); var workspaceRoot = parent?.FullName ?? currentRoot; var roots = DiscoverProjectRoots(workspaceRoot, currentRoot); if (roots.Count == 0) return null; var projects = new List(); foreach (var root in roots) { LoadedProjectConfig? cfg; try { cfg = ConfigLoader.FindAndLoad(root); } catch { continue; } var name = cfg?.Config.Name; projects.Add(new WorkspaceProject { Name = string.IsNullOrWhiteSpace(name) ? new DirectoryInfo(root).Name : name!, Description = $"Auto-discovered at {root}", Path = Path.GetRelativePath(workspaceRoot, root), }); } return ( new WorkspaceConfig { Name = "SDT Auto Workspace", Projects = projects }, workspaceRoot); } private static List DiscoverProjectRoots(string workspaceRoot, string currentRoot) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); var excluded = new HashSet(StringComparer.OrdinalIgnoreCase) { "bin", "obj", ".git", ".venv", "node_modules", }; void AddIfProject(string path) { var full = Path.GetFullPath(path); if (File.Exists(Path.Combine(full, "devtool.json"))) set.Add(full); } AddIfProject(currentRoot); AddIfProject(workspaceRoot); try { foreach (var dir in Directory.EnumerateDirectories(workspaceRoot)) { if (excluded.Contains(Path.GetFileName(dir))) continue; AddIfProject(dir); foreach (var sub in Directory.EnumerateDirectories(dir)) { if (excluded.Contains(Path.GetFileName(sub))) continue; AddIfProject(sub); } } } catch { // Ignore inaccessible directories during auto-discovery. } return set.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList(); } }