Forge
csharpdeeb25a2
1#nullable enable
2using System.Diagnostics.CodeAnalysis;
3using CascadeIDE.Contracts;
4
5namespace CascadeIDE.Features.Launch.DataAcquisition;
6
7/// <summary>
8/// <c>.cascade-ide/launch-profiles.toml</c> — каталог launch profiles (ADR 0090), миграция с <c>startup-project.json</c>.
9/// </summary>
10[IoBoundary]
11public static class LaunchProfilesStore
12{
13 public const string FileName = "launch-profiles.toml";
14 public const string DefaultProfileId = "Default";
15 public const string DefaultConfiguration = "Debug";
16
17 public static string GetStorePath(string solutionPath)
18 {
19 var root = BreakpointsFileService.GetWorkspaceRoot(solutionPath);
20 if (string.IsNullOrEmpty(root))
21 return "";
22 return Path.Combine(root, ".cascade-ide", FileName);
23 }
24
25 public static void Delete(string solutionPath)
26 {
27 var p = GetStorePath(solutionPath);
28 if (string.IsNullOrEmpty(p) || !File.Exists(p))
29 return;
30 try { File.Delete(p); }
31 catch { /* ignore */ }
32 }
33
34 /// <summary>Активный стартовый проект: отн. путь к .csproj от корня решения.</summary>
35 public static bool TryGetActiveProjectRelativePath(
36 string solutionPath,
37 [NotNullWhen(true)] out string? projectRelative,
38 [NotNullWhen(false)] out string? error)
39 {
40 projectRelative = null;
41 error = null;
42 if (!TryLoadDocument(solutionPath, out var doc, out var err) || doc is null)
43 {
44 error = UnavailabilityOrError(err);
45 return false;
46 }
47
48 if (string.IsNullOrWhiteSpace(doc.ActiveProfile))
49 {
50 error = "active_profile_missing";
51 return false;
52 }
53
54 var id = doc.ActiveProfile.Trim();
55 if (doc.Profiles is null || !doc.Profiles.TryGetValue(id, out var p) || p is null)
56 {
57 error = "profile_not_found: " + id;
58 return false;
59 }
60
61 if (string.IsNullOrWhiteSpace(p.Project))
62 {
63 error = "profile_target_unresolved: empty project in profile " + id;
64 return false;
65 }
66
67 projectRelative = p.Project!.Trim().Replace('/', Path.DirectorySeparatorChar);
68 return true;
69 }
70
71 /// <summary>Разрешение профиля для запуска: <paramref name="profileName"/> или активный, если <c>null</c>/пусто.</summary>
72 public static bool TryResolveProfileForLaunch(
73 string solutionPath,
74 string? profileName,
75 out LaunchProfileData data,
76 [NotNullWhen(false)] out string? error)
77 {
78 data = default;
79 error = null;
80 if (!TryLoadDocument(solutionPath, out var doc, out var err) || doc is null)
81 {
82 error = UnavailabilityOrError(err);
83 return false;
84 }
85
86 var id = string.IsNullOrWhiteSpace(profileName) ? doc.ActiveProfile : profileName;
87 if (string.IsNullOrWhiteSpace(id))
88 {
89 error = "active_profile_missing";
90 return false;
91 }
92
93 id = id.Trim();
94 if (doc.Profiles is null || !doc.Profiles.TryGetValue(id, out var p) || p is null)
95 {
96 error = "profile_not_found: " + id;
97 return false;
98 }
99
100 if (string.IsNullOrWhiteSpace(p.Project))
101 {
102 error = "profile_target_unresolved: empty project in profile " + id;
103 return false;
104 }
105
106 data = BuildLaunchProfileData(id, p);
107 return true;
108 }
109
110 /// <summary>Установить <c>active_profile</c> в TOML (после проверки существования профиля).</summary>
111 public static bool TrySetActiveProfile(
112 string solutionPath,
113 string profileId,
114 [NotNullWhen(false)] out string? error)
115 {
116 error = null;
117 var id = profileId.Trim();
118 if (string.IsNullOrEmpty(id))
119 {
120 error = "empty_profile_id";
121 return false;
122 }
123
124 if (!TryLoadDocument(solutionPath, out var doc, out var err) || doc is null)
125 {
126 error = UnavailabilityOrError(err);
127 return false;
128 }
129
130 if (doc.Profiles is null || !doc.Profiles.ContainsKey(id))
131 {
132 error = "profile_not_found: " + id;
133 return false;
134 }
135
136 doc.ActiveProfile = id;
137 WriteDocument(solutionPath, doc);
138 return true;
139 }
140
141 /// <summary>Текущий <c>active_profile</c> из TOML (для селектора в UI).</summary>
142 public static bool TryGetActiveProfileName(
143 string solutionPath,
144 [NotNullWhen(true)] out string? name,
145 [NotNullWhen(false)] out string? error)
146 {
147 name = null;
148 error = null;
149 if (!TryLoadDocument(solutionPath, out var doc, out var err) || doc is null)
150 {
151 error = UnavailabilityOrError(err);
152 return false;
153 }
154
155 if (string.IsNullOrWhiteSpace(doc.ActiveProfile))
156 {
157 error = "active_profile_missing";
158 return false;
159 }
160
161 name = doc.ActiveProfile!.Trim();
162 return true;
163 }
164
165 /// <summary>Имена профилей для UI (стабильный порядок).</summary>
166 public static bool TryGetOrderedProfileIds(
167 string solutionPath,
168 [NotNullWhen(true)] out IReadOnlyList<string>? names,
169 [NotNullWhen(false)] out string? error)
170 {
171 names = null;
172 error = null;
173 if (!TryLoadDocument(solutionPath, out var doc, out var err) || doc is null)
174 {
175 error = UnavailabilityOrError(err);
176 return false;
177 }
178
179 if (doc.Profiles is null || doc.Profiles.Count == 0)
180 {
181 error = "no_profiles";
182 return false;
183 }
184
185 names = doc.Profiles.Keys
186 .OrderBy(static s => s, StringComparer.OrdinalIgnoreCase)
187 .ToList();
188 return true;
189 }
190
191 /// <summary>Импорт из <c>Properties/launchSettings.json</c> выбранного проекта (отн. путь к .csproj от корня решения).</summary>
192 public static bool TryImportFromLaunchSettings(
193 string solutionPath,
194 string projectPathRelativeToSolution,
195 out int profilesWritten,
196 [NotNullWhen(false)] out string? error)
197 {
198 profilesWritten = 0;
199 error = null;
200 if (!TryGetLaunchSettingsJsonPath(solutionPath, projectPathRelativeToSolution, out var jsonPath, out var loadError))
201 {
202 error = loadError;
203 return false;
204 }
205
206 if (!File.Exists(jsonPath))
207 {
208 error = "launch_settings_not_found: " + jsonPath;
209 return false;
210 }
211
212 string json;
213 try { json = File.ReadAllText(jsonPath); }
214 catch (Exception ex)
215 {
216 error = "launch_settings_read: " + ex.Message;
217 return false;
218 }
219
220 if (!LaunchSettingsJsonImport.TryReadProjectProfiles(json, projectPathRelativeToSolution, out var list, out var perr))
221 {
222 error = perr;
223 return false;
224 }
225
226 if (!TryLoadDocument(solutionPath, out var doc, out _) || doc is null)
227 doc = new LaunchProfilesTomlModel { Version = 1, ActiveProfile = DefaultProfileId, Profiles = [] };
228 doc.Profiles ??= new Dictionary<string, LaunchProfileModel>(StringComparer.OrdinalIgnoreCase);
229 foreach (var (name, model) in list)
230 {
231 doc.Profiles[name] = model;
232 profilesWritten++;
233 }
234
235 if (string.IsNullOrWhiteSpace(doc.ActiveProfile) || !doc.Profiles.ContainsKey(doc.ActiveProfile!))
236 doc.ActiveProfile = list[0].Name;
237
238 WriteDocument(solutionPath, doc);
239 return true;
240 }
241
242 /// <summary>Корень workspace и путь к <c>Properties/launchSettings.json</c> проекта.</summary>
243 private static bool TryGetLaunchSettingsJsonPath(
244 string solutionPath,
245 string projectPathRelativeToSolution,
246 [NotNullWhen(true)] out string? jsonPath,
247 [NotNullWhen(false)] out string? error)
248 {
249 jsonPath = null;
250 error = null;
251 var solDir = BreakpointsFileService.GetWorkspaceRoot(solutionPath);
252 if (string.IsNullOrEmpty(solDir))
253 {
254 error = "workspace_root_unresolved";
255 return false;
256 }
257
258 var projDir = Path.GetDirectoryName(projectPathRelativeToSolution.Trim().Replace('/', Path.DirectorySeparatorChar));
259 jsonPath = Path.Combine(solDir, projDir ?? string.Empty, "Properties", "launchSettings.json");
260 return true;
261 }
262
263 public static void UpsertActiveProject(string solutionPath, string projectPathRelativeToSolution)
264 {
265 if (!TryLoadDocument(solutionPath, out var doc, out _) || doc is null)
266 doc = new LaunchProfilesTomlModel { Version = 1, ActiveProfile = DefaultProfileId, Profiles = [] };
267
268 doc.Profiles ??= new Dictionary<string, LaunchProfileModel>(StringComparer.OrdinalIgnoreCase);
269 var active = string.IsNullOrWhiteSpace(doc.ActiveProfile) ? DefaultProfileId : doc.ActiveProfile!.Trim();
270 doc.ActiveProfile = active;
271 if (!doc.Profiles.TryGetValue(active, out var p) || p is null)
272 {
273 doc.Profiles[active] = new LaunchProfileModel
274 {
275 Project = null,
276 Configuration = DefaultConfiguration
277 };
278 p = doc.Profiles[active];
279 }
280
281 var rel = projectPathRelativeToSolution.Trim().Replace('/', Path.DirectorySeparatorChar);
282 p!.Project = rel;
283
284 WriteDocument(solutionPath, doc);
285 }
286
287 internal static void WriteDocument(string solutionPath, LaunchProfilesTomlModel doc)
288 {
289 var path = GetStorePath(solutionPath);
290 if (string.IsNullOrEmpty(path))
291 return;
292 var dir = Path.GetDirectoryName(path);
293 if (!string.IsNullOrEmpty(dir))
294 Directory.CreateDirectory(dir);
295 var text = CascadeTomlSerializer.Serialize(doc);
296 File.WriteAllText(path, text);
297 }
298
299 internal static bool TryLoadDocument(
300 string solutionPath,
301 [NotNullWhen(true)] out LaunchProfilesTomlModel? doc,
302 [NotNullWhen(false)] out string? error)
303 {
304 error = null;
305 doc = null;
306 var path = GetStorePath(solutionPath);
307 if (string.IsNullOrEmpty(path))
308 {
309 error = "launch_profiles_unavailable";
310 return false;
311 }
312
313 if (File.Exists(path))
314 {
315 if (TryParseTomlFile(path, out var fromDisk, out var parseError))
316 {
317 doc = fromDisk;
318 return true;
319 }
320
321 error = parseError;
322 return false;
323 }
324
325 if (MigrateFromLegacyJson(solutionPath, out var migrated))
326 {
327 doc = migrated;
328 return true;
329 }
330
331 error = "launch_profiles_not_found";
332 return false;
333 }
334
335 private static string UnavailabilityOrError(string? loadError) =>
336 string.IsNullOrEmpty(loadError) ? "launch_profiles_unavailable" : loadError!;
337
338 private static bool TryParseTomlFile(
339 string filePath,
340 [NotNullWhen(true)] out LaunchProfilesTomlModel? model,
341 [NotNullWhen(false)] out string? error)
342 {
343 model = null;
344 error = null;
345 try
346 {
347 var text = File.ReadAllText(filePath);
348 var m = CascadeTomlSerializer.Deserialize<LaunchProfilesTomlModel>(text);
349 if (m is null)
350 {
351 error = "launch_profiles_parse_failed";
352 return false;
353 }
354
355 if (m.Version < 1)
356 m.Version = 1;
357 model = m;
358 return true;
359 }
360 catch (Exception ex)
361 {
362 error = "launch_profiles_parse_failed: " + ex.Message;
363 return false;
364 }
365 }
366
367 private static LaunchProfileData BuildLaunchProfileData(string id, LaunchProfileModel p)
368 {
369 var rel = p.Project!.Trim().Replace('/', Path.DirectorySeparatorChar);
370 var config = string.IsNullOrWhiteSpace(p.Configuration) ? DefaultConfiguration : p.Configuration!.Trim();
371 var programArgs = p.ProgramArgs is { Count: > 0 } ? p.ProgramArgs : null;
372 var cwdRel = string.IsNullOrWhiteSpace(p.WorkingDirectory) ? null : p.WorkingDirectory!.Trim().Replace('/', Path.DirectorySeparatorChar);
373
374 var env = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
375 if (p.Env is not null)
376 {
377 foreach (var (k, v) in p.Env)
378 {
379 if (string.IsNullOrEmpty(k) || v is null)
380 continue;
381 env[k] = v;
382 }
383 }
384
385 if (!string.IsNullOrWhiteSpace(p.ApplicationUrls) &&
386 !env.ContainsKey("ASPNETCORE_URLS") &&
387 !env.ContainsKey("ASPNETCORE__URLS"))
388 env["ASPNETCORE_URLS"] = p.ApplicationUrls!.Trim();
389
390 var openBrowser = p.LaunchBrowser == true;
391 var launchUrl = string.IsNullOrWhiteSpace(p.LaunchUrl) ? null : p.LaunchUrl.Trim();
392 return new LaunchProfileData(id, rel, config, programArgs, cwdRel, env, openBrowser, launchUrl);
393 }
394
395 private static bool MigrateFromLegacyJson(string solutionPath, [NotNullWhen(true)] out LaunchProfilesTomlModel? doc)
396 {
397 doc = null;
398 if (!StartupProjectStore.TryLoadLegacyJsonOnly(solutionPath, out var rel) || string.IsNullOrEmpty(rel))
399 return false;
400
401 doc = new LaunchProfilesTomlModel
402 {
403 Version = 1,
404 ActiveProfile = DefaultProfileId,
405 Profiles = new Dictionary<string, LaunchProfileModel>(StringComparer.OrdinalIgnoreCase)
406 {
407 [DefaultProfileId] = new LaunchProfileModel
408 {
409 Project = rel,
410 Configuration = DefaultConfiguration
411 }
412 }
413 };
414 WriteDocument(solutionPath, doc);
415 return true;
416 }
417}
418
419/// <summary>Данные одного launch profile для MSBuild + DAP.</summary>
420public readonly record struct LaunchProfileData(
421 string ProfileId,
422 string ProjectRelativeToSolution,
423 string Configuration,
424 IReadOnlyList<string>? ProgramArgs,
425 string? WorkingDirectoryRelative,
426 IReadOnlyDictionary<string, string> Environment,
427 bool OpenLaunchBrowser,
428 // launchUrl / launch_url: full URL or path; merged with first application URL if relative.
429 string? LaunchUrl);
430
431[IoBoundary]
432internal sealed class LaunchProfilesTomlModel
433{
434 public int Version { get; set; } = 1;
435 public string? ActiveProfile { get; set; }
436 public Dictionary<string, LaunchProfileModel>? Profiles { get; set; }
437}
438
439[IoBoundary]
440internal sealed class LaunchProfileModel
441{
442 public string? Project { get; set; }
443 public string? Configuration { get; set; }
444 public List<string>? ProgramArgs { get; set; }
445 public string? WorkingDirectory { get; set; }
446 public string? ApplicationUrls { get; set; }
447 public string? LaunchUrl { get; set; }
448 public Dictionary<string, string>? Env { get; set; }
449 public bool? LaunchBrowser { get; set; }
450}
451
View only · write via MCP/CIDE