diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a1f27..10f04fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- **Shared parameter definitions — resolution groundwork.** A Snip's + `{token}` now resolves its parameter definition by name with precedence: + the Snip's own parameter overrides a CLI-scoped one, which overrides a + global one. A Snip can omit a parameter to inherit the shared definition + (e.g. an `env` Choice defined once on a CLI). Store schema is now v2 + (additive: `Cli.Parameters` + `SnipStoreDocument.GlobalParameters`); an + older build refuses a v2 store rather than dropping shared definitions. + The UI to manage shared parameters lands next; existing snips are + unaffected. - **JSON stores moved to System.Text.Json source generation.** `JsonSnipStore` and `JsonSettingsStore` now serialise via a generated `JsonSerializerContext` instead of the reflection-based serializer, removing the IL2026 trim warnings. diff --git a/src/Snipdeck.App/Services/WindowsShellInteractions.cs b/src/Snipdeck.App/Services/WindowsShellInteractions.cs index f9c7ece..4f9c8aa 100644 --- a/src/Snipdeck.App/Services/WindowsShellInteractions.cs +++ b/src/Snipdeck.App/Services/WindowsShellInteractions.cs @@ -88,10 +88,11 @@ public async Task NotifyAsync(string title, string message, string buttonText = : null; } - public async Task FillParametersAsync(Snip snip) + public async Task FillParametersAsync(Snip snip, IReadOnlyList parameters) { ArgumentNullException.ThrowIfNull(snip); - var fill = new ParameterFillViewModel(snip); + ArgumentNullException.ThrowIfNull(parameters); + var fill = new ParameterFillViewModel(snip, parameters); var dialog = new ParameterFillDialog(fill) { XamlRoot = GetXamlRoot(), diff --git a/src/Snipdeck.Core/Abstractions/IShellInteractions.cs b/src/Snipdeck.Core/Abstractions/IShellInteractions.cs index 1087d3a..fa9319b 100644 --- a/src/Snipdeck.Core/Abstractions/IShellInteractions.cs +++ b/src/Snipdeck.Core/Abstractions/IShellInteractions.cs @@ -24,7 +24,7 @@ Task NotifyAsync( Task EditCliAsync(Cli cli); - Task FillParametersAsync(Snip snip); + Task FillParametersAsync(Snip snip, IReadOnlyList parameters); } public sealed record SnipEditResult(Snip Snip); diff --git a/src/Snipdeck.Core/Engine/ParameterResolver.cs b/src/Snipdeck.Core/Engine/ParameterResolver.cs new file mode 100644 index 0000000..00291a7 --- /dev/null +++ b/src/Snipdeck.Core/Engine/ParameterResolver.cs @@ -0,0 +1,73 @@ +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Engine +{ + /// + /// Computes the effective parameter set to present when filling a Snip, + /// resolving each by name with precedence: the Snip's own (local) parameters + /// override CLI-scoped definitions, which override global definitions. + /// + /// The result is additive to the pre-shared-parameters behaviour: it always + /// includes the Snip's local parameters, then adds a shared definition for + /// any {token} in the template that the Snip doesn't define locally. + /// Tokens defined in no scope are left out (the engine copies them verbatim), + /// preserving the "bare token copies as-is" behaviour. + /// + public static class ParameterResolver + { + public static IReadOnlyList Resolve( + Snip snip, + Cli? cli, + IReadOnlyList? globalParameters = null) + { + ArgumentNullException.ThrowIfNull(snip); + + var result = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + // 1. Local parameters — the override layer; presented as-is. + foreach (var local in snip.Parameters) + { + if (seen.Add(local.Name)) + { + result.Add(local); + } + } + + // 2. Tokens used by the template but not defined locally — inherit + // from the CLI scope first, then global. First match wins. + foreach (var token in SubstitutionEngine.ExtractTokens(snip.CommandTemplate)) + { + if (seen.Contains(token)) + { + continue; + } + + var inherited = FindByName(cli?.Parameters, token) ?? FindByName(globalParameters, token); + if (inherited is not null) + { + _ = seen.Add(token); + result.Add(inherited); + } + } + + return result; + } + + private static Parameter? FindByName(IReadOnlyList? parameters, string name) + { + if (parameters is null) + { + return null; + } + foreach (var parameter in parameters) + { + if (string.Equals(parameter.Name, name, StringComparison.Ordinal)) + { + return parameter; + } + } + return null; + } + } +} diff --git a/src/Snipdeck.Core/Models/Cli.cs b/src/Snipdeck.Core/Models/Cli.cs index e34307c..213cbf2 100644 --- a/src/Snipdeck.Core/Models/Cli.cs +++ b/src/Snipdeck.Core/Models/Cli.cs @@ -7,5 +7,12 @@ public sealed class Cli public string Name { get; set; } = string.Empty; public string? IconRef { get; set; } + + /// + /// Parameter definitions shared by every Snip under this CLI. A Snip's + /// {token} resolves to one of these by name when the Snip has no + /// local parameter of that name. See ParameterResolver. + /// + public List Parameters { get; set; } = []; } } diff --git a/src/Snipdeck.Core/Models/SnipStoreDocument.cs b/src/Snipdeck.Core/Models/SnipStoreDocument.cs index 6631c8f..835a1ac 100644 --- a/src/Snipdeck.Core/Models/SnipStoreDocument.cs +++ b/src/Snipdeck.Core/Models/SnipStoreDocument.cs @@ -2,12 +2,23 @@ namespace Snipdeck.Core.Models { public sealed class SnipStoreDocument { - public const int CurrentSchemaVersion = 1; + // v2 adds shared parameter definitions (Cli.Parameters + GlobalParameters). + // Additive and forward-incompatible: a v1-only build refuses a v2 store + // rather than silently dropping shared parameters. + public const int CurrentSchemaVersion = 2; public int SchemaVersion { get; set; } = CurrentSchemaVersion; public List Clis { get; set; } = []; public List Snips { get; set; } = []; + + /// + /// Parameter definitions available to every Snip across all CLIs. A + /// Snip's {token} resolves to one of these by name when neither + /// the Snip nor its CLI defines a parameter of that name (lowest + /// precedence). See ParameterResolver. + /// + public List GlobalParameters { get; set; } = []; } } diff --git a/src/Snipdeck.Core/Services/JsonSnipStore.cs b/src/Snipdeck.Core/Services/JsonSnipStore.cs index dcb4527..7865597 100644 --- a/src/Snipdeck.Core/Services/JsonSnipStore.cs +++ b/src/Snipdeck.Core/Services/JsonSnipStore.cs @@ -54,6 +54,11 @@ public async Task SaveAsync(SnipStoreDocument document, CancellationToken cancel { ArgumentNullException.ThrowIfNull(document); + // Stamp the current schema version on every write so a store touched + // by this build is marked v2 — an older (v1-only) build then refuses + // it rather than silently dropping the shared-parameter collections. + document.SchemaVersion = SnipStoreDocument.CurrentSchemaVersion; + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { diff --git a/src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs b/src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs index a273755..46ecd4b 100644 --- a/src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs @@ -34,6 +34,9 @@ public Cli BuildUpdatedCli() Id = Cli.Id, Name = Name.Trim(), IconRef = Cli.IconRef, + // Carry shared parameter definitions through the rebuild so a + // rename/icon edit doesn't wipe CLI-scoped parameters. + Parameters = Cli.Parameters, }; } } diff --git a/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs b/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs index c522a8d..b0bc9de 100644 --- a/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs @@ -12,17 +12,18 @@ public sealed partial class ParameterFillViewModel : ObservableObject private readonly Dictionary _values = new(StringComparer.Ordinal); private readonly bool _hasParameters; - public ParameterFillViewModel(Snip snip) + public ParameterFillViewModel(Snip snip, IReadOnlyList parameters) { ArgumentNullException.ThrowIfNull(snip); + ArgumentNullException.ThrowIfNull(parameters); Snip = snip; // Captured before building Inputs: a parameter with a default fires its // change callback (→ UpdateResolution) during construction, before the // Inputs collection is assigned, so UpdateResolution can't read Inputs. - _hasParameters = snip.Parameters.Count > 0; + _hasParameters = parameters.Count > 0; Inputs = new ObservableCollection( - snip.Parameters.Select(p => new ParameterInputViewModel(p, OnInputValueChanged))); + parameters.Select(p => new ParameterInputViewModel(p, OnInputValueChanged))); foreach (var input in Inputs) { diff --git a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs index 0e986b6..bd8668a 100644 --- a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs @@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input; using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Engine; using Snipdeck.Core.Models; using Snipdeck.Core.Services; @@ -98,19 +99,23 @@ private async Task CopySnipAsync(SnipCardViewModel? cardVm) return; } var snip = cardVm.Snip; + // Effective parameters: local overrides plus any shared (CLI/global) + // definitions the template's tokens inherit by name. + var cli = _document.Clis.FirstOrDefault(c => c.Id == snip.CliId); + var parameters = ParameterResolver.Resolve(snip, cli, _document.GlobalParameters); string commandToCopy; // Skip the flyout only when there's nothing to show: no parameters to // fill and no description to read. A described-but-parameterless snip // still opens the flyout so its (rendered) description is visible // before the copy. - if (snip.Parameters.Count == 0 && string.IsNullOrWhiteSpace(snip.Description)) + if (parameters.Count == 0 && string.IsNullOrWhiteSpace(snip.Description)) { commandToCopy = snip.CommandTemplate; } else { - var result = await _interactions.FillParametersAsync(snip).ConfigureAwait(true); + var result = await _interactions.FillParametersAsync(snip, parameters).ConfigureAwait(true); if (result is null) { return; @@ -248,6 +253,7 @@ private async Task NewCliAsync() Id = saved.Id, Name = saved.Name, IconRef = await _iconStorage.SaveIconAsync(saved.Id, bytes).ConfigureAwait(true), + Parameters = saved.Parameters, }; } @@ -292,6 +298,7 @@ private async Task EditCurrentCliAsync() Id = updated.Id, Name = updated.Name, IconRef = await _iconStorage.SaveIconAsync(updated.Id, bytes).ConfigureAwait(true), + Parameters = updated.Parameters, }; } diff --git a/tests/Snipdeck.Core.Tests/Engine/ParameterResolverTests.cs b/tests/Snipdeck.Core.Tests/Engine/ParameterResolverTests.cs new file mode 100644 index 0000000..467e4e1 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Engine/ParameterResolverTests.cs @@ -0,0 +1,114 @@ +using Snipdeck.Core.Engine; +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Tests.Engine +{ + public class ParameterResolverTests + { + private static Parameter P(string name, ParameterType type = ParameterType.Text, string? def = null) => + new() { Name = name, Type = type, Default = def }; + + [Fact] + public void Local_parameters_are_returned_as_is() + { + var snip = new Snip { CommandTemplate = "x {a}", Parameters = [P("a")] }; + + var result = ParameterResolver.Resolve(snip, cli: null, globalParameters: null); + + var p = Assert.Single(result); + Assert.Equal("a", p.Name); + } + + [Fact] + public void Template_token_inherits_from_the_cli_scope_when_not_defined_locally() + { + var snip = new Snip { CommandTemplate = "deploy {env}" }; + var cli = new Cli { Parameters = [P("env", ParameterType.Choice, "dev")] }; + + var result = ParameterResolver.Resolve(snip, cli, globalParameters: null); + + var p = Assert.Single(result); + Assert.Equal("env", p.Name); + Assert.Equal(ParameterType.Choice, p.Type); + Assert.Equal("dev", p.Default); + } + + [Fact] + public void Local_definition_overrides_a_cli_definition_of_the_same_name() + { + var snip = new Snip + { + CommandTemplate = "deploy {env}", + Parameters = [P("env", ParameterType.Text, "local-default")], + }; + var cli = new Cli { Parameters = [P("env", ParameterType.Choice, "dev")] }; + + var result = ParameterResolver.Resolve(snip, cli, globalParameters: null); + + var p = Assert.Single(result); + Assert.Equal(ParameterType.Text, p.Type); + Assert.Equal("local-default", p.Default); + } + + [Fact] + public void Cli_definition_takes_precedence_over_global() + { + var snip = new Snip { CommandTemplate = "x {region}" }; + var cli = new Cli { Parameters = [P("region", def: "cli")] }; + var global = new[] { P("region", def: "global") }; + + var result = ParameterResolver.Resolve(snip, cli, global); + + Assert.Equal("cli", Assert.Single(result).Default); + } + + [Fact] + public void Global_is_used_when_neither_local_nor_cli_define_the_token() + { + var snip = new Snip { CommandTemplate = "x {region}" }; + var global = new[] { P("region", def: "global") }; + + var result = ParameterResolver.Resolve(snip, cli: new Cli(), globalParameters: global); + + Assert.Equal("global", Assert.Single(result).Default); + } + + [Fact] + public void Tokens_defined_in_no_scope_are_not_returned() + { + // Preserves the "bare token copies verbatim" behaviour — no input is + // surfaced for an undefined token. + var snip = new Snip { CommandTemplate = "git commit -m {message}" }; + + var result = ParameterResolver.Resolve(snip, cli: new Cli(), globalParameters: []); + + Assert.Empty(result); + } + + [Fact] + public void Local_params_not_in_the_template_are_still_included() + { + var snip = new Snip { CommandTemplate = "static command", Parameters = [P("unused")] }; + + var result = ParameterResolver.Resolve(snip, cli: null, globalParameters: null); + + Assert.Equal("unused", Assert.Single(result).Name); + } + + [Fact] + public void Local_first_then_inherited_in_template_order() + { + var snip = new Snip + { + CommandTemplate = "run {a} {b} {c}", + Parameters = [P("a")], // local + }; + var cli = new Cli { Parameters = [P("b")] }; + var global = new[] { P("c") }; + + var result = ParameterResolver.Resolve(snip, cli, global); + + Assert.Equal(["a", "b", "c"], result.Select(p => p.Name)); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs b/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs index 46bbba9..70a626a 100644 --- a/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs +++ b/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs @@ -32,6 +32,8 @@ public sealed class FakeShellInteractions : IShellInteractions public Snip? LastFilledSnip { get; private set; } + public IReadOnlyList? LastFilledParameters { get; private set; } + public Task ConfirmAsync(string title, string message, string confirmButtonText = "Yes", string cancelButtonText = "Cancel") { LastConfirmTitle = title; @@ -58,9 +60,10 @@ public Task NotifyAsync(string title, string message, string buttonText = "OK") return Task.FromResult(NextCliEditResult); } - public Task FillParametersAsync(Snip snip) + public Task FillParametersAsync(Snip snip, IReadOnlyList parameters) { LastFilledSnip = snip; + LastFilledParameters = parameters; return Task.FromResult(NextParameterFillResult); } } diff --git a/tests/Snipdeck.Core.Tests/ViewModels/CliEditorViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/CliEditorViewModelTests.cs new file mode 100644 index 0000000..b20448f --- /dev/null +++ b/tests/Snipdeck.Core.Tests/ViewModels/CliEditorViewModelTests.cs @@ -0,0 +1,40 @@ +using Snipdeck.Core.Models; +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.Core.Tests.ViewModels +{ + public class CliEditorViewModelTests + { + [Fact] + public void BuildUpdatedCli_applies_the_edited_name_and_keeps_id_and_icon() + { + var cli = new Cli { Name = "pl-app", IconRef = "icons/pl.png" }; + var vm = new CliEditorViewModel(cli) { Name = " pl " }; + + var updated = vm.BuildUpdatedCli(); + + Assert.Equal(cli.Id, updated.Id); + Assert.Equal("pl", updated.Name); + Assert.Equal("icons/pl.png", updated.IconRef); + } + + [Fact] + public void BuildUpdatedCli_preserves_shared_parameters() + { + // A rename/icon edit must not drop CLI-scoped parameter definitions. + var cli = new Cli + { + Name = "pl-app", + Parameters = [new Parameter { Name = "env", Type = ParameterType.Choice, Options = ["dev", "prod"] }], + }; + var vm = new CliEditorViewModel(cli) { Name = "renamed" }; + + var updated = vm.BuildUpdatedCli(); + + var p = Assert.Single(updated.Parameters); + Assert.Equal("env", p.Name); + Assert.Equal(ParameterType.Choice, p.Type); + Assert.Equal(["dev", "prod"], p.Options); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs index 9601d32..d716e25 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs @@ -10,7 +10,7 @@ public void Snip_with_no_parameters_is_immediately_copy_enabled_with_template_as { var snip = new Snip { CommandTemplate = "echo hi" }; - var vm = new ParameterFillViewModel(snip); + var vm = new ParameterFillViewModel(snip, snip.Parameters); Assert.True(vm.IsCopyEnabled); Assert.Equal("echo hi", vm.Preview); @@ -26,7 +26,7 @@ public void Parameterless_snip_with_token_like_template_text_stays_copy_enabled( // than being gated off as "unresolved". var snip = new Snip { CommandTemplate = "git commit -m {message}" }; - var vm = new ParameterFillViewModel(snip); + var vm = new ParameterFillViewModel(snip, snip.Parameters); Assert.Empty(vm.Inputs); Assert.True(vm.IsCopyEnabled); @@ -42,7 +42,7 @@ public void Defaults_pre_fill_inputs_and_drive_copy_enabled_state() Parameters = [new Parameter { Name = "name", Default = "world" }], }; - var vm = new ParameterFillViewModel(snip); + var vm = new ParameterFillViewModel(snip, snip.Parameters); Assert.True(vm.IsCopyEnabled); Assert.Equal("echo world", vm.Preview); @@ -57,7 +57,7 @@ public void Missing_value_keeps_copy_disabled_and_leaves_token_in_preview() Parameters = [new Parameter { Name = "name" }], }; - var vm = new ParameterFillViewModel(snip); + var vm = new ParameterFillViewModel(snip, snip.Parameters); Assert.False(vm.IsCopyEnabled); Assert.Equal("echo {name}", vm.Preview); @@ -72,7 +72,7 @@ public void Editing_an_input_refreshes_the_preview_live() Parameters = [new Parameter { Name = "env", Default = "dev" }], }; - var vm = new ParameterFillViewModel(snip); + var vm = new ParameterFillViewModel(snip, snip.Parameters); Assert.Equal("deploy dev", vm.Preview); vm.Inputs[0].Value = "prod"; @@ -94,7 +94,7 @@ public void Multiple_inputs_resolve_independently() ], }; - var vm = new ParameterFillViewModel(snip); + var vm = new ParameterFillViewModel(snip, snip.Parameters); Assert.Equal("git tag -a v1.0.0 -m \"Release\"", vm.Preview); Assert.True(vm.IsCopyEnabled);