Skip to content

Commit 50502ad

Browse files
committed
Improve theme validate diagnostics
1 parent a45c5b3 commit 50502ad

9 files changed

Lines changed: 510 additions & 49 deletions

File tree

CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,96 @@ public async Task Validate_ParsesThemeWithoutRegisteringOrApplying()
5454
}
5555
}
5656
}
57+
58+
[Fact]
59+
public async Task Validate_ScansDirectoryAndReportsCounts()
60+
{
61+
using var dir = new TempDirectory();
62+
File.WriteAllText(Path.Combine(dir.Path, "ok.toml"),
63+
"""
64+
name = "ok"
65+
66+
[colors]
67+
literal = "purple"
68+
""");
69+
File.WriteAllText(Path.Combine(dir.Path, "broken.toml"),
70+
"""
71+
name = "broken"
72+
73+
[colors]
74+
literal = "lightyellow3"
75+
""");
76+
77+
var command = new ThemeCommand
78+
{
79+
Action = "validate",
80+
Name = dir.Path,
81+
};
82+
83+
var state = await command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None);
84+
var error = Assert.IsType<ErrorCommandState>(state);
85+
86+
Assert.Contains("1 of 2", error.Exception.Message);
87+
}
88+
89+
[Fact]
90+
public async Task Validate_StrictTreatsWarningsAsErrors()
91+
{
92+
var name = $"strict-{Guid.NewGuid():N}";
93+
var path = Path.Combine(Path.GetTempPath(), name + ".toml");
94+
try
95+
{
96+
File.WriteAllText(path,
97+
$$"""
98+
name = "{{name}}"
99+
100+
[colors]
101+
this_is_not_a_real_slot = "red"
102+
""");
103+
104+
var command = new ThemeCommand
105+
{
106+
Action = "validate",
107+
Name = path,
108+
Strict = true,
109+
};
110+
111+
var state = await command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None);
112+
var error = Assert.IsType<ErrorCommandState>(state);
113+
Assert.Contains("strict mode", error.Exception.Message);
114+
}
115+
finally
116+
{
117+
if (File.Exists(path))
118+
{
119+
File.Delete(path);
120+
}
121+
}
122+
}
123+
124+
private sealed class TempDirectory : IDisposable
125+
{
126+
public TempDirectory()
127+
{
128+
this.Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"cosmos-theme-validate-{Guid.NewGuid():N}");
129+
Directory.CreateDirectory(this.Path);
130+
}
131+
132+
public string Path { get; }
133+
134+
public void Dispose()
135+
{
136+
try
137+
{
138+
if (Directory.Exists(this.Path))
139+
{
140+
Directory.Delete(this.Path, recursive: true);
141+
}
142+
}
143+
catch
144+
{
145+
// Best-effort cleanup.
146+
}
147+
}
148+
}
57149
}

CosmosDBShell.Tests/Shell/ThemeFileTests.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,85 @@ public void Parse_RejectsMultipleColorsInStyleSlot()
116116
Assert.Contains("bold red yellow", ex.Message);
117117
}
118118

119+
[Fact]
120+
public void Parse_AggregatesMultipleInvalidValues()
121+
{
122+
var toml = """
123+
name = "many-bad"
124+
125+
[colors]
126+
literal = "lightyellow3"
127+
error = "magneta"
128+
129+
[styles]
130+
unknown_command = "bld"
131+
""";
132+
133+
var ex = Assert.Throws<ThemeLoadException>(() => ThemeFile.Parse(toml, "memory://many.toml", LookupBuiltIn));
134+
Assert.Contains("literal", ex.Message);
135+
Assert.Contains("error", ex.Message);
136+
Assert.Contains("unknown_command", ex.Message);
137+
}
138+
139+
[Fact]
140+
public void Parse_SuggestsClosestColor()
141+
{
142+
var toml = """
143+
name = "typo"
144+
145+
[colors]
146+
literal = "purpel"
147+
""";
148+
149+
var ex = Assert.Throws<ThemeLoadException>(() => ThemeFile.Parse(toml, "memory://typo.toml", LookupBuiltIn));
150+
Assert.Contains("Did you mean 'purple'", ex.Message);
151+
}
152+
153+
[Fact]
154+
public void Parse_SuggestsClosestStyleToken()
155+
{
156+
var toml = """
157+
name = "typo-style"
158+
159+
[styles]
160+
unknown_command = "bld red"
161+
""";
162+
163+
var ex = Assert.Throws<ThemeLoadException>(() => ThemeFile.Parse(toml, "memory://typo-style.toml", LookupBuiltIn));
164+
Assert.Contains("Did you mean 'bold'", ex.Message);
165+
}
166+
167+
[Fact]
168+
public void Parse_WarnsOnSingleEntryBracketCycle()
169+
{
170+
var toml = """
171+
name = "single-cycle"
172+
173+
[colors]
174+
bracket_cycle = ["yellow"]
175+
""";
176+
177+
var result = ThemeFile.Parse(toml, "memory://single-cycle.toml", LookupBuiltIn);
178+
179+
Assert.Contains(result.Warnings, w => w.Contains("only one bracket_cycle color"));
180+
Assert.Equal(new[] { "yellow" }, result.Options.BracketCycle);
181+
}
182+
183+
[Fact]
184+
public void Parse_WarnsOnDuplicateBracketCycle()
185+
{
186+
var toml = """
187+
name = "dup-cycle"
188+
189+
[colors]
190+
bracket_cycle = ["yellow", "yellow", "aqua"]
191+
""";
192+
193+
var result = ThemeFile.Parse(toml, "memory://dup-cycle.toml", LookupBuiltIn);
194+
195+
Assert.Contains(result.Warnings, w => w.Contains("duplicate bracket_cycle"));
196+
}
197+
119198
[Fact]
120199
public void Parse_RejectsUnknownExtends()
121200
{

CosmosDBShell.Tests/Shell/ThemeProfileTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,13 @@ public void ThemeLocalizationKeys_AreDefined()
184184
Assert.False(string.IsNullOrWhiteSpace(MessageService.GetString("command-theme-validated")));
185185
Assert.False(string.IsNullOrWhiteSpace(MessageService.GetString("command-theme-validate-missing-path")));
186186
Assert.False(string.IsNullOrWhiteSpace(MessageService.GetString("theme-file-error-invalid-style")));
187+
Assert.False(string.IsNullOrWhiteSpace(MessageService.GetString("theme-file-error-invalid-color-suggested")));
188+
Assert.False(string.IsNullOrWhiteSpace(MessageService.GetString("theme-file-error-invalid-style-suggested")));
189+
Assert.False(string.IsNullOrWhiteSpace(MessageService.GetString("theme-file-warning-bracket-cycle-single")));
190+
Assert.False(string.IsNullOrWhiteSpace(MessageService.GetString("theme-file-warning-bracket-cycle-duplicates")));
191+
Assert.False(string.IsNullOrWhiteSpace(MessageService.GetString("command-theme-validate-summary")));
192+
Assert.False(string.IsNullOrWhiteSpace(MessageService.GetString("command-theme-validate-no-files")));
193+
Assert.False(string.IsNullOrWhiteSpace(MessageService.GetString("command-theme-validate-strict-failed")));
187194
}
188195

189196
/// <summary>

CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ internal class ThemeCommand : CosmosCommand
3838
[CosmosOption("force", "f")]
3939
public bool Force { get; init; }
4040

41+
[CosmosOption("strict")]
42+
public bool Strict { get; init; }
43+
4144
public override Task<CommandState> ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token)
4245
{
4346
var action = (this.Action ?? "current").Trim().ToLowerInvariant();
@@ -314,13 +317,25 @@ private CommandState RunValidate(CommandState commandState)
314317
var requested = string.IsNullOrWhiteSpace(this.Name) ? this.Path : this.Name;
315318
if (string.IsNullOrWhiteSpace(requested))
316319
{
317-
var message = MessageService.GetString("command-theme-validate-missing-path");
318-
AnsiConsole.MarkupLine(message);
319-
return new ErrorCommandState(new CommandException("theme", message));
320+
return this.RunValidateDirectory(commandState, ThemeFile.DefaultUserThemesDirectory());
321+
}
322+
323+
if (Directory.Exists(requested))
324+
{
325+
return this.RunValidateDirectory(commandState, requested);
320326
}
321327

322328
var path = ResolveThemePath(requested);
329+
if (Directory.Exists(path))
330+
{
331+
return this.RunValidateDirectory(commandState, path);
332+
}
333+
334+
return this.RunValidateFile(commandState, path);
335+
}
323336

337+
private CommandState RunValidateFile(CommandState commandState, string path)
338+
{
324339
try
325340
{
326341
var result = ThemeRegistry.Instance.ValidateFile(path);
@@ -335,6 +350,18 @@ private CommandState RunValidate(CommandState commandState)
335350
AnsiConsole.MarkupLine(Theme.FormatWarning(warning));
336351
}
337352

353+
if (this.Strict && result.Warnings.Count > 0)
354+
{
355+
var strictMessage = MessageService.GetArgsString(
356+
"command-theme-validate-strict-failed",
357+
"name",
358+
result.Name,
359+
"count",
360+
result.Warnings.Count);
361+
AnsiConsole.MarkupLine(Theme.FormatError(strictMessage));
362+
return new ErrorCommandState(new CommandException("theme", strictMessage));
363+
}
364+
338365
commandState.IsPrinted = true;
339366
commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new
340367
{
@@ -359,6 +386,99 @@ private CommandState RunValidate(CommandState commandState)
359386
}
360387
}
361388

389+
private CommandState RunValidateDirectory(CommandState commandState, string directory)
390+
{
391+
var files = ThemeFile.EnumerateThemeFiles(directory);
392+
if (files.Length == 0)
393+
{
394+
var emptyMessage = MessageService.GetArgsString("command-theme-validate-no-files", "directory", directory);
395+
AnsiConsole.MarkupLine(Theme.FormatMuted(emptyMessage));
396+
commandState.IsPrinted = true;
397+
commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new
398+
{
399+
directory,
400+
files = Array.Empty<object>(),
401+
valid = 0,
402+
invalid = 0,
403+
}));
404+
return commandState;
405+
}
406+
407+
var fileResults = new List<Dictionary<string, object?>>();
408+
var validCount = 0;
409+
var invalidCount = 0;
410+
411+
foreach (var file in files)
412+
{
413+
var entry = new Dictionary<string, object?>
414+
{
415+
["path"] = file,
416+
};
417+
418+
try
419+
{
420+
var result = ThemeRegistry.Instance.ValidateFile(file);
421+
var failedStrict = this.Strict && result.Warnings.Count > 0;
422+
423+
entry["name"] = result.Name;
424+
entry["valid"] = !failedStrict;
425+
entry["warnings"] = result.Warnings;
426+
427+
if (failedStrict)
428+
{
429+
invalidCount++;
430+
AnsiConsole.MarkupLine($" {Theme.FormatError("\u2717")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}");
431+
}
432+
else
433+
{
434+
validCount++;
435+
AnsiConsole.MarkupLine($" {Theme.FormatHelpAccent("\u2713")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}");
436+
}
437+
438+
foreach (var warning in result.Warnings)
439+
{
440+
AnsiConsole.MarkupLine(" " + Theme.FormatWarning(warning));
441+
}
442+
}
443+
catch (Exception ex) when (ex is ThemeLoadException || ex is FileNotFoundException || ex is DirectoryNotFoundException)
444+
{
445+
invalidCount++;
446+
entry["valid"] = false;
447+
entry["error"] = ex.Message;
448+
AnsiConsole.MarkupLine($" {Theme.FormatError("\u2717")} {Markup.Escape(System.IO.Path.GetFileName(file))}");
449+
AnsiConsole.MarkupLine(" " + Theme.FormatError(ex.Message));
450+
}
451+
452+
fileResults.Add(entry);
453+
}
454+
455+
var summary = MessageService.GetArgsString(
456+
"command-theme-validate-summary",
457+
"valid",
458+
validCount,
459+
"total",
460+
files.Length,
461+
"directory",
462+
directory);
463+
AnsiConsole.MarkupLine(summary);
464+
465+
commandState.IsPrinted = true;
466+
commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new
467+
{
468+
directory,
469+
files = fileResults,
470+
valid = validCount,
471+
invalid = invalidCount,
472+
}));
473+
474+
if (invalidCount > 0)
475+
{
476+
return new ErrorCommandState(new CommandException("theme", summary));
477+
}
478+
479+
return commandState;
480+
}
481+
362482
private CommandState RunReload(CommandState commandState)
363483
{
364484
var registry = ThemeRegistry.Instance;

0 commit comments

Comments
 (0)