Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/Snipdeck.App/Services/WindowsShellInteractions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,11 @@ public async Task NotifyAsync(string title, string message, string buttonText =
: null;
}

public async Task<ParameterFillResult?> FillParametersAsync(Snip snip)
public async Task<ParameterFillResult?> FillParametersAsync(Snip snip, IReadOnlyList<Parameter> 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(),
Expand Down
2 changes: 1 addition & 1 deletion src/Snipdeck.Core/Abstractions/IShellInteractions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Task NotifyAsync(

Task<CliEditResult?> EditCliAsync(Cli cli);

Task<ParameterFillResult?> FillParametersAsync(Snip snip);
Task<ParameterFillResult?> FillParametersAsync(Snip snip, IReadOnlyList<Parameter> parameters);
}

public sealed record SnipEditResult(Snip Snip);
Expand Down
73 changes: 73 additions & 0 deletions src/Snipdeck.Core/Engine/ParameterResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using Snipdeck.Core.Models;

namespace Snipdeck.Core.Engine
{
/// <summary>
/// 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 <c>{token}</c> 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.
/// </summary>
public static class ParameterResolver
{
public static IReadOnlyList<Parameter> Resolve(
Snip snip,
Cli? cli,
IReadOnlyList<Parameter>? globalParameters = null)
{
ArgumentNullException.ThrowIfNull(snip);

var result = new List<Parameter>();
var seen = new HashSet<string>(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<Parameter>? 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;
}
}
}
7 changes: 7 additions & 0 deletions src/Snipdeck.Core/Models/Cli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,12 @@ public sealed class Cli
public string Name { get; set; } = string.Empty;

public string? IconRef { get; set; }

/// <summary>
/// Parameter definitions shared by every Snip under this CLI. A Snip's
/// <c>{token}</c> resolves to one of these by name when the Snip has no
/// local parameter of that name. See <c>ParameterResolver</c>.
/// </summary>
public List<Parameter> Parameters { get; set; } = [];
}
}
13 changes: 12 additions & 1 deletion src/Snipdeck.Core/Models/SnipStoreDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cli> Clis { get; set; } = [];

public List<Snip> Snips { get; set; } = [];

/// <summary>
/// Parameter definitions available to every Snip across all CLIs. A
/// Snip's <c>{token}</c> resolves to one of these by name when neither
/// the Snip nor its CLI defines a parameter of that name (lowest
/// precedence). See <c>ParameterResolver</c>.
/// </summary>
public List<Parameter> GlobalParameters { get; set; } = [];
}
}
5 changes: 5 additions & 0 deletions src/Snipdeck.Core/Services/JsonSnipStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
3 changes: 3 additions & 0 deletions src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
}
Expand Down
7 changes: 4 additions & 3 deletions src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,18 @@ public sealed partial class ParameterFillViewModel : ObservableObject
private readonly Dictionary<string, string?> _values = new(StringComparer.Ordinal);
private readonly bool _hasParameters;

public ParameterFillViewModel(Snip snip)
public ParameterFillViewModel(Snip snip, IReadOnlyList<Parameter> 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<ParameterInputViewModel>(
snip.Parameters.Select(p => new ParameterInputViewModel(p, OnInputValueChanged)));
parameters.Select(p => new ParameterInputViewModel(p, OnInputValueChanged)));

foreach (var input in Inputs)
{
Expand Down
11 changes: 9 additions & 2 deletions src/Snipdeck.Core/ViewModels/ShellViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using CommunityToolkit.Mvvm.Input;

using Snipdeck.Core.Abstractions;
using Snipdeck.Core.Engine;
using Snipdeck.Core.Models;
using Snipdeck.Core.Services;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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,
};
}

Expand Down
114 changes: 114 additions & 0 deletions tests/Snipdeck.Core.Tests/Engine/ParameterResolverTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public sealed class FakeShellInteractions : IShellInteractions

public Snip? LastFilledSnip { get; private set; }

public IReadOnlyList<Parameter>? LastFilledParameters { get; private set; }

public Task<bool> ConfirmAsync(string title, string message, string confirmButtonText = "Yes", string cancelButtonText = "Cancel")
{
LastConfirmTitle = title;
Expand All @@ -58,9 +60,10 @@ public Task NotifyAsync(string title, string message, string buttonText = "OK")
return Task.FromResult(NextCliEditResult);
}

public Task<ParameterFillResult?> FillParametersAsync(Snip snip)
public Task<ParameterFillResult?> FillParametersAsync(Snip snip, IReadOnlyList<Parameter> parameters)
{
LastFilledSnip = snip;
LastFilledParameters = parameters;
return Task.FromResult(NextParameterFillResult);
}
}
Expand Down
Loading
Loading