diff --git a/CHANGELOG.md b/CHANGELOG.md index cdbc3b7..61f65d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 structured parameters, and defaults to a safe dry-run — `--write` backs the store up first, mints fresh identifiers, creates CLIs on demand and skips duplicates (`--allow-duplicates` to override). `--store`, `--cli` and `--into` - control the target. See [`tools/Snipdeck.Importer/README.md`](tools/Snipdeck.Importer/README.md). + control the target. It also de-duplicates parameters used by two or more snips in + a CLI, promoting them to CLI-scoped shared parameters (Choice params match on their + option set regardless of name/order, most common name wins; Text params match by + name, most common default wins) — `--no-share-parameters` disables it. See + [`tools/Snipdeck.Importer/README.md`](tools/Snipdeck.Importer/README.md). - **Move a snip to a different CLI.** The snip editor now has a **CLI** selector, so a snip can be re-homed to another CLI (useful after a bulk import lands snips in a fallback CLI). diff --git a/tools/Snipdeck.Importer.Tests/ParameterSharerTests.cs b/tools/Snipdeck.Importer.Tests/ParameterSharerTests.cs new file mode 100644 index 0000000..9de14c6 --- /dev/null +++ b/tools/Snipdeck.Importer.Tests/ParameterSharerTests.cs @@ -0,0 +1,233 @@ +using Snipdeck.Core.Models; +using Snipdeck.Importer.Merge; + +namespace Snipdeck.Importer.Tests +{ + public class ParameterSharerTests + { + private static Snip TextSnip(string template, params (string name, string? def)[] ps) + { + return new Snip + { + Title = template, + CommandTemplate = template, + Parameters = [.. ps.Select(p => new Parameter { Name = p.name, Type = ParameterType.Text, Default = p.def })], + }; + } + + private static Parameter Choice(string name, string? def, params string[] options) + { + return new Parameter { Name = name, Type = ParameterType.Choice, Options = [.. options], Default = def }; + } + + private static void AnalyzeAndApply(Cli cli, params Snip[] snips) + { + var plan = ParameterSharer.Analyze(cli.Parameters, snips); + ParameterSharer.Apply(cli, plan); + } + + [Fact] + public void A_text_parameter_used_by_two_snips_is_promoted_and_stripped_from_the_snips() + { + var cli = new Cli { Name = "mpt-app" }; + var a = TextSnip("mpt-app x {id}", ("id", "1005")); + var b = TextSnip("mpt-app y {id}", ("id", "1005")); + + AnalyzeAndApply(cli, a, b); + + var shared = Assert.Single(cli.Parameters); + Assert.Equal("id", shared.Name); + Assert.Equal(ParameterType.Text, shared.Type); + Assert.Equal("1005", shared.Default); + Assert.Empty(a.Parameters); + Assert.Empty(b.Parameters); + // Templates are unchanged for text (the token already matches the shared name). + Assert.Equal("mpt-app x {id}", a.CommandTemplate); + } + + [Fact] + public void A_single_use_parameter_is_left_local() + { + var cli = new Cli { Name = "mpt-app" }; + var a = TextSnip("mpt-app x {only}", ("only", "v")); + + AnalyzeAndApply(cli, a); + + Assert.Empty(cli.Parameters); + Assert.Single(a.Parameters); + } + + [Fact] + public void Text_promotion_uses_the_most_common_default_even_when_empty() + { + var cli = new Cli { Name = "mpt-app" }; + // Two snips with empty (null) default, one with "X" -> empty wins. + var a = TextSnip("a {agreement}", ("agreement", null)); + var b = TextSnip("b {agreement}", ("agreement", null)); + var c = TextSnip("c {agreement}", ("agreement", "X")); + + AnalyzeAndApply(cli, a, b, c); + + var shared = Assert.Single(cli.Parameters); + Assert.Equal("agreement", shared.Name); + Assert.Null(shared.Default); + } + + [Fact] + public void Choice_parameters_match_by_option_set_regardless_of_order() + { + var cli = new Cli { Name = "mpt-app" }; + var a = new Snip { Title = "a", CommandTemplate = "a {authId}", Parameters = [Choice("authId", "x", "x", "y", "z")] }; + var b = new Snip { Title = "b", CommandTemplate = "b {authId}", Parameters = [Choice("authId", "z", "z", "y", "x")] }; + + AnalyzeAndApply(cli, a, b); + + var shared = Assert.Single(cli.Parameters); + Assert.Equal("authId", shared.Name); + Assert.Equal(ParameterType.Choice, shared.Type); + Assert.Equal(["x", "y", "z"], shared.Options.OrderBy(o => o, StringComparer.Ordinal).ToList()); + Assert.Empty(a.Parameters); + Assert.Empty(b.Parameters); + } + + [Fact] + public void Choice_promotion_picks_the_most_common_name_and_rewrites_other_template_tokens() + { + var cli = new Cli { Name = "mpt-app" }; + // Same option set, two snips name it "authId", one names it "auth" -> "authId" wins. + var a = new Snip { Title = "a", CommandTemplate = "a {authId}", Parameters = [Choice("authId", "p", "p", "q")] }; + var b = new Snip { Title = "b", CommandTemplate = "b {authId}", Parameters = [Choice("authId", "p", "p", "q")] }; + var c = new Snip { Title = "c", CommandTemplate = "c {auth}", Parameters = [Choice("auth", "q", "q", "p")] }; + + AnalyzeAndApply(cli, a, b, c); + + var shared = Assert.Single(cli.Parameters); + Assert.Equal("authId", shared.Name); + // The odd-named snip's template token was rewritten to the winning name. + Assert.Equal("c {authId}", c.CommandTemplate); + Assert.Empty(c.Parameters); + } + + [Fact] + public void Existing_compatible_shared_choice_is_reused_not_duplicated() + { + // Existing shared default ("x") matches the imported group's default, with the same + // option set in a different order — so it's reused, not duplicated. + var cli = new Cli { Name = "mpt-app", Parameters = [Choice("authId", "x", "x", "y")] }; + var a = new Snip { Title = "a", CommandTemplate = "a {authId}", Parameters = [Choice("authId", "x", "y", "x")] }; + var b = new Snip { Title = "b", CommandTemplate = "b {authId}", Parameters = [Choice("authId", "x", "x", "y")] }; + + AnalyzeAndApply(cli, a, b); + + // No new shared param added; the locals are stripped to inherit the existing one. + Assert.Single(cli.Parameters); + Assert.Empty(a.Parameters); + Assert.Empty(b.Parameters); + } + + [Fact] + public void An_existing_same_option_choice_under_a_different_name_is_not_duplicated() + { + // CLI already shares a [x,y] choice named "authId"; imported snips use "auth" (same set). + var cli = new Cli { Name = "mpt-app", Parameters = [Choice("authId", "x", "x", "y")] }; + var a = new Snip { Title = "a", CommandTemplate = "a {auth}", Parameters = [Choice("auth", "x", "x", "y")] }; + var b = new Snip { Title = "b", CommandTemplate = "b {auth}", Parameters = [Choice("auth", "x", "x", "y")] }; + + AnalyzeAndApply(cli, a, b); + + // No second [x,y] choice is added to the CLI; imported snips keep their local "auth". + Assert.Single(cli.Parameters); + Assert.Equal("authId", cli.Parameters[0].Name); + Assert.Equal("auth", Assert.Single(a.Parameters).Name); + Assert.Equal("auth", Assert.Single(b.Parameters).Name); + } + + [Fact] + public void Existing_text_param_with_a_different_default_is_not_reused() + { + // CLI already shares {env} defaulting to "prod"; imported snips default it to "dev". + // They must keep their own local {env} rather than silently inheriting "prod". + var cli = new Cli { Name = "deploy", Parameters = [new Parameter { Name = "env", Type = ParameterType.Text, Default = "prod" }] }; + var a = TextSnip("deploy a {env}", ("env", "dev")); + var b = TextSnip("deploy b {env}", ("env", "dev")); + + AnalyzeAndApply(cli, a, b); + + // The CLI's shared param is untouched, and the imported snips keep their "dev" default. + Assert.Equal("prod", Assert.Single(cli.Parameters).Default); + Assert.Equal("dev", Assert.Single(a.Parameters).Default); + Assert.Equal("dev", Assert.Single(b.Parameters).Default); + } + + [Fact] + public void Existing_incompatible_name_leaves_parameters_local() + { + // CLI already has a Text "authId"; imported choices named "authId" must not be merged into it. + var cli = new Cli { Name = "mpt-app", Parameters = [new Parameter { Name = "authId", Type = ParameterType.Text }] }; + var a = new Snip { Title = "a", CommandTemplate = "a {authId}", Parameters = [Choice("authId", "x", "x", "y")] }; + var b = new Snip { Title = "b", CommandTemplate = "b {authId}", Parameters = [Choice("authId", "x", "x", "y")] }; + + AnalyzeAndApply(cli, a, b); + + Assert.Single(cli.Parameters); // unchanged + Assert.Single(a.Parameters); // left local + Assert.Single(b.Parameters); + } + + [Fact] + public void Two_distinct_same_option_choices_in_one_snip_are_not_collapsed() + { + // {source} and {dest} happen to share an option set but are different arguments. + var cli = new Cli { Name = "cp" }; + var a = new Snip { Title = "a", CommandTemplate = "cp {source} {dest}", Parameters = [Choice("source", "x", "x", "y"), Choice("dest", "x", "x", "y")] }; + var b = new Snip { Title = "b", CommandTemplate = "cp {source} {dest}", Parameters = [Choice("source", "x", "x", "y"), Choice("dest", "x", "x", "y")] }; + + AnalyzeAndApply(cli, a, b); + + // The two tokens must remain distinct, not merged into "{source} {source}". + Assert.Equal("cp {source} {dest}", a.CommandTemplate); + Assert.Equal("cp {source} {dest}", b.CommandTemplate); + // "source" is promoted; "dest" stays local on each snip. + Assert.Single(cli.Parameters); + Assert.Equal("source", cli.Parameters[0].Name); + Assert.Equal("dest", Assert.Single(a.Parameters).Name); + Assert.Equal("dest", Assert.Single(b.Parameters).Name); + } + + [Fact] + public void A_choice_and_a_text_param_with_the_same_name_do_not_both_promote() + { + var cli = new Cli { Name = "tool" }; + // Choice "fmt" used by 2 snips; Text "fmt" used by 2 other snips. + var c1 = new Snip { Title = "c1", CommandTemplate = "c1 {fmt}", Parameters = [Choice("fmt", "json", "json", "yaml")] }; + var c2 = new Snip { Title = "c2", CommandTemplate = "c2 {fmt}", Parameters = [Choice("fmt", "json", "json", "yaml")] }; + var t1 = TextSnip("t1 {fmt}", ("fmt", "raw")); + var t2 = TextSnip("t2 {fmt}", ("fmt", "raw")); + + AnalyzeAndApply(cli, c1, c2, t1, t2); + + // Exactly one CLI param named "fmt" (the Choice, promoted first); no duplicate name. + var shared = Assert.Single(cli.Parameters); + Assert.Equal("fmt", shared.Name); + Assert.Equal(ParameterType.Choice, shared.Type); + // The text snips keep their local "fmt" rather than clashing with the choice. + Assert.Single(t1.Parameters); + Assert.Single(t2.Parameters); + } + + [Fact] + public void Analyze_does_not_mutate_until_apply() + { + var cli = new Cli { Name = "mpt-app" }; + var a = TextSnip("a {id}", ("id", "1")); + var b = TextSnip("b {id}", ("id", "1")); + + var plan = ParameterSharer.Analyze(cli.Parameters, [a, b]); + + // Pure analysis: nothing changed yet. + Assert.Empty(cli.Parameters); + Assert.Single(a.Parameters); + Assert.False(plan.IsEmpty); + } + } +} diff --git a/tools/Snipdeck.Importer.Tests/StoreMergerTests.cs b/tools/Snipdeck.Importer.Tests/StoreMergerTests.cs index 1317e48..3baaf12 100644 --- a/tools/Snipdeck.Importer.Tests/StoreMergerTests.cs +++ b/tools/Snipdeck.Importer.Tests/StoreMergerTests.cs @@ -185,6 +185,239 @@ public void Into_is_used_only_for_unconfident_suggestions() Assert.Equal("fallback", plan.Items[1].TargetCliName); } + [Fact] + public void ShareParameters_promotes_duplicated_params_to_the_created_cli_and_strips_the_snips() + { + var doc = new SnipStoreDocument(); + var a = new Snip + { + Title = "A", + CommandTemplate = "mpt-app a {authId}", + Parameters = [new Parameter { Name = "authId", Type = ParameterType.Choice, Options = ["x", "y"], Default = "x" }], + }; + var b = new Snip + { + Title = "B", + CommandTemplate = "mpt-app b {authId}", + Parameters = [new Parameter { Name = "authId", Type = ParameterType.Choice, Options = ["y", "x"], Default = "y" }], + }; + var options = _defaults with { ShareParameters = true }; + + var plan = StoreMerger.Plan( + doc, + [new SnippetCandidate("mpt-app", true, a), new SnippetCandidate("mpt-app", true, b)], + options); + + Assert.Equal(1, plan.SharedParameterCount); + + StoreMerger.Apply(doc, plan); + + var cli = Assert.Single(doc.Clis); + var shared = Assert.Single(cli.Parameters); + Assert.Equal("authId", shared.Name); + // Both snips now inherit the shared parameter instead of carrying their own. + Assert.All(doc.Snips, s => Assert.Empty(s.Parameters)); + } + + [Fact] + public void Two_imported_snips_that_unify_to_the_same_template_are_deduped() + { + // {auth} and {authId} (same options, same title) become identical after choice-name + // unification; the second must be skipped rather than imported as a duplicate. + var doc = new SnipStoreDocument(); + static Snip Named(string token) + { + return new Snip + { + Title = "Run", + CommandTemplate = $"x run {{{token}}}", + Parameters = [new Parameter { Name = token, Type = ParameterType.Choice, Options = ["a", "b"], Default = "a" }], + }; + } + // Two name "authId" so it wins; one "auth" that unifies to {authId} -> duplicate of them. + var c1 = new SnippetCandidate("x", true, Named("authId")); + var c2 = new SnippetCandidate("x", true, Named("auth")); + var options = _defaults with { ShareParameters = true }; + + var plan = StoreMerger.Plan(doc, [c1, c2], options); + StoreMerger.Apply(doc, plan); + + // Only one "Run" snip lands (they were the same command once unified). + Assert.Single(doc.Snips); + Assert.Equal("x run {authId}", doc.Snips[0].CommandTemplate); + } + + [Fact] + public void Swapped_same_option_choices_are_not_treated_as_duplicates() + { + // Two distinct choices that happen to share an option set must keep positional identity: + // "cp {source} {dest}" and "cp {dest} {source}" are different commands, not duplicates. + var doc = new SnipStoreDocument(); + static Snip Swapped(string a, string b) + { + return new Snip + { + Title = "Copy", + CommandTemplate = $"cp {{{a}}} {{{b}}}", + Parameters = + [ + new Parameter { Name = a, Type = ParameterType.Choice, Options = ["x", "y"], Default = "x" }, + new Parameter { Name = b, Type = ParameterType.Choice, Options = ["x", "y"], Default = "x" }, + ], + }; + } + var options = _defaults with { ShareParameters = true }; + + var plan = StoreMerger.Plan( + doc, + [new SnippetCandidate("cp", true, Swapped("source", "dest")), new SnippetCandidate("cp", true, Swapped("dest", "source"))], + options); + + // Both are imported — neither is wrongly collapsed into the other. + Assert.Equal(2, plan.ImportCount); + } + + [Fact] + public void A_skipped_duplicate_does_not_rename_or_drop_a_genuinely_importable_snip() + { + // Existing store snip uses {authId} (with its choice param). + var cli = new Cli { Name = "x" }; + var doc = new SnipStoreDocument + { + Clis = { cli }, + Snips = + { + new Snip + { + CliId = cli.Id, + Title = "Existing", + CommandTemplate = "x run {authId}", + Parameters = [new Parameter { Name = "authId", Type = ParameterType.Choice, Options = ["a", "b"], Default = "a" }], + }, + }, + }; + + // A genuinely-new snip uses {auth}; plus a re-import of the existing {authId} snip (a dup). + var real = new SnippetCandidate("x", true, new Snip + { + Title = "Real", + CommandTemplate = "x do {auth}", + Parameters = [new Parameter { Name = "auth", Type = ParameterType.Choice, Options = ["a", "b"], Default = "a" }], + }); + var dup = new SnippetCandidate("x", true, new Snip + { + Title = "Existing", + CommandTemplate = "x run {authId}", + Parameters = [new Parameter { Name = "authId", Type = ParameterType.Choice, Options = ["a", "b"], Default = "a" }], + }); + var options = _defaults with { ShareParameters = true }; + + var plan = StoreMerger.Plan(doc, [real, dup], options); + StoreMerger.Apply(doc, plan); + + // The re-imported existing snip is skipped; "Real" is imported and NOT renamed to {authId} + // (the skipped duplicate must not drive a rename), and stays local (no sharing group of 2). + Assert.True(plan.Items.Single(i => ReferenceEquals(i.Candidate, dup)).IsDuplicateSkip); + Assert.Equal("x do {auth}", real.Snip.CommandTemplate); + Assert.Equal("auth", Assert.Single(real.Snip.Parameters).Name); + Assert.Empty(cli.Parameters); + } + + [Fact] + public void A_duplicate_only_import_does_not_mutate_the_existing_clis_shared_parameters() + { + var cli = new Cli { Name = "x" }; + var doc = new SnipStoreDocument + { + Clis = { cli }, + Snips = + { + new Snip { CliId = cli.Id, Title = "A", CommandTemplate = "x a {p}" }, + new Snip { CliId = cli.Id, Title = "B", CommandTemplate = "x b {p}" }, + }, + }; + + // Re-import the same two snips (exact duplicates), each carrying a {p} parameter. + static SnippetCandidate Dup(string title, string template) + { + return new SnippetCandidate("x", true, new Snip + { + Title = title, + CommandTemplate = template, + Parameters = [new Parameter { Name = "p", Type = ParameterType.Text, Default = "v" }], + }); + } + var options = _defaults with { ShareParameters = true }; + + var plan = StoreMerger.Plan(doc, [Dup("A", "x a {p}"), Dup("B", "x b {p}")], options); + + Assert.Equal(0, plan.ImportCount); + Assert.Equal(0, plan.SharedParameterCount); + + StoreMerger.Apply(doc, plan); + + // Nothing imported, and the existing CLI gained no shared parameters. + Assert.Equal(2, doc.Snips.Count); + Assert.Empty(cli.Parameters); + } + + [Fact] + public void Promotion_does_not_rebind_a_pre_existing_snip_in_the_target_cli() + { + // The CLI already has a snip that uses a bare {env} token (no local/CLI/global definition). + var cli = new Cli { Name = "deploy" }; + var doc = new SnipStoreDocument + { + Clis = { cli }, + Snips = { new Snip { CliId = cli.Id, Title = "Existing", CommandTemplate = "deploy --env {env}" } }, + }; + + // Importing two snips that would otherwise promote a shared {env} must NOT add it, + // because that would start binding the pre-existing "Existing" snip's {env}. + static SnippetCandidate Env(string title) + { + return new SnippetCandidate("deploy", true, new Snip + { + Title = title, + CommandTemplate = $"deploy {title} {{env}}", + Parameters = [new Parameter { Name = "env", Type = ParameterType.Text, Default = "dev" }], + }); + } + var options = _defaults with { ShareParameters = true }; + + var plan = StoreMerger.Plan(doc, [Env("a"), Env("b")], options); + StoreMerger.Apply(doc, plan); + + // No shared {env} added to the CLI; imported snips keep it local. + Assert.Empty(cli.Parameters); + Assert.Equal(3, doc.Snips.Count); + Assert.All(doc.Snips.Where(s => s.Title is "a" or "b"), s => Assert.Single(s.Parameters)); + } + + [Fact] + public void Sharing_is_off_by_default_so_params_stay_local() + { + var doc = new SnipStoreDocument(); + var a = TextParamSnip("A", "mpt-app a {id}"); + var b = TextParamSnip("B", "mpt-app b {id}"); + + var plan = StoreMerger.Plan(doc, [a, b], _defaults); + StoreMerger.Apply(doc, plan); + + Assert.Empty(Assert.Single(doc.Clis).Parameters); + Assert.All(doc.Snips, s => Assert.Single(s.Parameters)); + } + + private static SnippetCandidate TextParamSnip(string title, string template) + { + return new SnippetCandidate("mpt-app", true, new Snip + { + Title = title, + CommandTemplate = template, + Parameters = [new Parameter { Name = "id", Type = ParameterType.Text, Default = "1" }], + }); + } + [Fact] public void Unconfident_with_no_into_falls_back_to_a_generic_bucket() { diff --git a/tools/Snipdeck.Importer/Commands/ImportCommandSettings.cs b/tools/Snipdeck.Importer/Commands/ImportCommandSettings.cs index 6281cc6..72080e6 100644 --- a/tools/Snipdeck.Importer/Commands/ImportCommandSettings.cs +++ b/tools/Snipdeck.Importer/Commands/ImportCommandSettings.cs @@ -33,5 +33,9 @@ public class ImportCommandSettings : CommandSettings [CommandOption("--allow-duplicates")] [Description("Import snips even if one with the same title and command already exists.")] public bool AllowDuplicates { get; init; } + + [CommandOption("--no-share-parameters")] + [Description("Keep every parameter on its snip instead of promoting duplicates to CLI-shared parameters.")] + public bool NoShareParameters { get; init; } } } diff --git a/tools/Snipdeck.Importer/Commands/SnipCommandImportCommand.cs b/tools/Snipdeck.Importer/Commands/SnipCommandImportCommand.cs index e44dbea..7485892 100644 --- a/tools/Snipdeck.Importer/Commands/SnipCommandImportCommand.cs +++ b/tools/Snipdeck.Importer/Commands/SnipCommandImportCommand.cs @@ -38,7 +38,11 @@ protected override async Task ExecuteAsync( var store = new JsonSnipStore(targets.StorePath); var document = await store.LoadAsync(cancellationToken).ConfigureAwait(false); - var options = new MergeOptions(settings.Cli, settings.Into, settings.AllowDuplicates); + var options = new MergeOptions( + settings.Cli, + settings.Into, + settings.AllowDuplicates, + ShareParameters: !settings.NoShareParameters); var plan = StoreMerger.Plan(document, candidates, options); DryRunRenderer.Render(source.DisplayName, targets.StorePath, plan, settings.Write); diff --git a/tools/Snipdeck.Importer/Merge/ParameterSharer.cs b/tools/Snipdeck.Importer/Merge/ParameterSharer.cs new file mode 100644 index 0000000..85d4299 --- /dev/null +++ b/tools/Snipdeck.Importer/Merge/ParameterSharer.cs @@ -0,0 +1,358 @@ +using Snipdeck.Core.Models; + +namespace Snipdeck.Importer.Merge +{ + /// + /// De-duplicates the parameters of the snips imported into a single CLI and promotes the + /// shared ones up to CLI-scoped parameters (), so the resolver + /// inherits them by token name. Only parameters used by two or more snips are promoted; + /// single-use parameters stay local. + /// + /// Matching rules: + /// + /// Choice parameters match when their option sets are equal (order-independent); + /// the name is not part of the match, and the most common name wins. Snips that used a different + /// name have their template token rewritten to the winning name. + /// Text parameters match by name; the most common default value wins (including an + /// empty/absent default). + /// + /// Ties are broken by first appearance. Analysis is pure; performs the mutation. + /// + /// + public static class ParameterSharer + { + private const char _separator = '\u0001'; + + /// + /// Unifies choice-parameter names across the given snips: for each option set used by two + /// or more snips, the most common name wins and each snip's template token and local + /// parameter name are rewritten to it. This is normalisation only — it does not promote or + /// strip anything — so that duplicate detection and later promotion agree on token names. + /// Mutates the snips. Run it before de-duplication. + /// + public static void NormalizeChoiceNames(IReadOnlyList snips) + { + ArgumentNullException.ThrowIfNull(snips); + + var occurrences = new List(); + var order = 0; + foreach (var snip in snips) + { + foreach (var parameter in snip.Parameters) + { + occurrences.Add(new Occurrence(snip, parameter, order++)); + } + } + + foreach (var group in occurrences + .Where(o => o.Parameter.Type == ParameterType.Choice) + .GroupBy(o => OptionSetKey(o.Parameter.Options))) + { + var members = group.ToList(); + if (DistinctSnipCount(members) < 2) + { + continue; + } + + var winningName = MostCommonName(members); + + // At most one occurrence per snip, preferring one already named the winner; never + // rename onto a token the snip already uses (would merge two distinct arguments). + foreach (var bySnip in members.GroupBy(m => m.Snip)) + { + var target = bySnip + .OrderByDescending(m => string.Equals(m.Parameter.Name, winningName, StringComparison.Ordinal)) + .ThenBy(m => m.Order) + .First(); + + if (string.Equals(target.Parameter.Name, winningName, StringComparison.Ordinal) + || TemplateContainsToken(target.Snip.CommandTemplate, winningName)) + { + continue; + } + + target.Snip.CommandTemplate = target.Snip.CommandTemplate.Replace( + "{" + target.Parameter.Name + "}", "{" + winningName + "}", StringComparison.Ordinal); + target.Parameter.Name = winningName; + } + } + } + + public static CliSharePlan Analyze( + IReadOnlyList existingCliParameters, + IReadOnlyList snips, + IReadOnlySet? protectedNames = null) + { + ArgumentNullException.ThrowIfNull(existingCliParameters); + ArgumentNullException.ThrowIfNull(snips); + + // Names a new CLI-scoped parameter must not take, because a pre-existing snip in the CLI + // would newly bind to (or have an inherited global overridden by) it. + var offLimits = protectedNames ?? new HashSet(StringComparer.Ordinal); + + // Record every (snip, parameter) occurrence with a stable order for deterministic ties. + var occurrences = new List(); + var order = 0; + foreach (var snip in snips) + { + foreach (var parameter in snip.Parameters) + { + occurrences.Add(new Occurrence(snip, parameter, order++)); + } + } + + var existingByName = new Dictionary(StringComparer.Ordinal); + foreach (var p in existingCliParameters) + { + _ = existingByName.TryAdd(p.Name, p); + } + + var sharedToAdd = new List(); + // Names already promoted this run, so a second group (e.g. a Text param sharing a + // name with a promoted Choice) can't add a duplicate CLI-scoped name. + var claimed = new HashSet(StringComparer.Ordinal); + // snip -> (local names to remove, token renames) + var edits = new Dictionary Remove, Dictionary Renames)>(); + + void RecordEdit(Snip snip, string removeName, string? renameTo) + { + if (!edits.TryGetValue(snip, out var edit)) + { + edit = ([], new Dictionary(StringComparer.Ordinal)); + edits[snip] = edit; + } + + edit.Remove.Add(removeName); + if (renameTo is not null && !string.Equals(removeName, renameTo, StringComparison.Ordinal)) + { + edit.Renames[removeName] = renameTo; + } + } + + // --- Choice parameters: grouped by option-set (order-independent) --- + foreach (var group in occurrences + .Where(o => o.Parameter.Type == ParameterType.Choice) + .GroupBy(o => OptionSetKey(o.Parameter.Options))) + { + var members = group.ToList(); + if (DistinctSnipCount(members) < 2) + { + continue; + } + + var winningName = MostCommonName(members); + if (claimed.Contains(winningName)) + { + // Name already promoted by an earlier group — can't share it twice; leave local. + continue; + } + + var options = MostCommonOptions(members); + var chosenDefault = MostCommonDefault(members); + + // Choice matching is name-independent: if the CLI already shares a choice with the + // same option set (under ANY name), this parameter already exists there — don't add a + // duplicate. Reuse it only when fully compatible (same name AND default); otherwise, + // or if the winning name is already taken by another param, leave the imported snips' + // parameters local rather than duplicating or silently changing them. + var existingEquivalent = existingCliParameters.FirstOrDefault(p => + p.Type == ParameterType.Choice && SameOptionSet(p.Options, options)); + + if (existingEquivalent is not null || existingByName.ContainsKey(winningName)) + { + var compatible = existingEquivalent is not null + && string.Equals(existingEquivalent.Name, winningName, StringComparison.Ordinal) + && string.Equals(existingEquivalent.Default, chosenDefault, StringComparison.Ordinal); + if (!compatible) + { + continue; + } + } + else if (offLimits.Contains(winningName)) + { + // Promoting this name would rebind a pre-existing snip in the CLI; leave local. + continue; + } + else + { + sharedToAdd.Add(new Parameter + { + Name = winningName, + Type = ParameterType.Choice, + Options = [.. options], + Default = chosenDefault, + }); + } + + _ = claimed.Add(winningName); + + // Merge at most one parameter per snip — two distinct same-option choices in one + // snip (e.g. {source} and {dest}) are different arguments and must not collapse + // into a single token. Prefer the occurrence already named the winning name; skip + // a rename that would collide with another token already in the snip's template. + foreach (var bySnip in members.GroupBy(m => m.Snip)) + { + var target = bySnip + .OrderByDescending(m => string.Equals(m.Parameter.Name, winningName, StringComparison.Ordinal)) + .ThenBy(m => m.Order) + .First(); + + if (!string.Equals(target.Parameter.Name, winningName, StringComparison.Ordinal) + && TemplateContainsToken(target.Snip.CommandTemplate, winningName)) + { + continue; + } + + RecordEdit(target.Snip, target.Parameter.Name, winningName); + } + } + + // --- Text parameters: grouped by name --- + foreach (var group in occurrences + .Where(o => o.Parameter.Type == ParameterType.Text) + .GroupBy(o => o.Parameter.Name, StringComparer.Ordinal)) + { + var members = group.ToList(); + if (DistinctSnipCount(members) < 2) + { + continue; + } + + var name = group.Key; + if (claimed.Contains(name)) + { + // Name already promoted (e.g. as a Choice) — don't add a clashing CLI param. + continue; + } + + var winningDefault = MostCommonDefault(members); + + if (existingByName.TryGetValue(name, out var existing)) + { + // Reuse only when the existing shared default matches; otherwise leave these + // local so imported snips keep their own default rather than silently adopting it. + if (existing.Type != ParameterType.Text + || !string.Equals(existing.Default, winningDefault, StringComparison.Ordinal)) + { + continue; + } + } + else if (offLimits.Contains(name)) + { + // Promoting this name would rebind a pre-existing snip in the CLI; leave local. + continue; + } + else + { + sharedToAdd.Add(new Parameter + { + Name = name, + Type = ParameterType.Text, + Default = winningDefault, + }); + } + + _ = claimed.Add(name); + + foreach (var member in members) + { + RecordEdit(member.Snip, name, renameTo: null); + } + } + + var snipEdits = edits + .Select(kvp => new SnipParameterEdit(kvp.Key, kvp.Value.Remove, kvp.Value.Renames)) + .ToList(); + return new CliSharePlan(sharedToAdd, snipEdits); + } + + public static void Apply(Cli cli, CliSharePlan plan) + { + ArgumentNullException.ThrowIfNull(cli); + ArgumentNullException.ThrowIfNull(plan); + + cli.Parameters.AddRange(plan.SharedToAdd); + + foreach (var edit in plan.SnipEdits) + { + foreach (var (oldName, newName) in edit.TokenRenames) + { + edit.Snip.CommandTemplate = edit.Snip.CommandTemplate.Replace( + "{" + oldName + "}", "{" + newName + "}", StringComparison.Ordinal); + } + + var remove = new HashSet(edit.RemoveLocalNames, StringComparer.Ordinal); + _ = edit.Snip.Parameters.RemoveAll(p => remove.Contains(p.Name)); + } + } + + private static int DistinctSnipCount(IEnumerable members) + { + return members.Select(m => m.Snip).Distinct().Count(); + } + + private static string OptionSetKey(IEnumerable options) + { + return string.Join(_separator, options.Distinct(StringComparer.Ordinal).OrderBy(o => o, StringComparer.Ordinal)); + } + + private static bool SameOptionSet(IEnumerable a, IEnumerable b) + { + return new HashSet(a, StringComparer.Ordinal).SetEquals(b); + } + + private static string MostCommonName(IEnumerable members) + { + return members + .GroupBy(m => m.Parameter.Name, StringComparer.Ordinal) + .Select(g => (g.Key, Count: g.Count(), First: g.Min(m => m.Order))) + .OrderByDescending(g => g.Count) + .ThenBy(g => g.First) + .First().Key; + } + + private static string? MostCommonDefault(IEnumerable members) + { + return members + .GroupBy(m => m.Parameter.Default) + .Select(g => (g.Key, Count: g.Count(), First: g.Min(m => m.Order))) + .OrderByDescending(g => g.Count) + .ThenBy(g => g.First) + .First().Key; + } + + /// The most common option ordering among a choice group; ties broken by first appearance. + private static List MostCommonOptions(IEnumerable members) + { + return members + .GroupBy(m => string.Join(_separator, m.Parameter.Options)) + .Select(g => + { + var picked = g.OrderBy(m => m.Order).First(); + return (Count: g.Count(), picked.Order, picked.Parameter.Options); + }) + .OrderByDescending(g => g.Count) + .ThenBy(g => g.Order) + .First().Options; + } + + private static bool TemplateContainsToken(string template, string name) + { + return template.Contains("{" + name + "}", StringComparison.Ordinal); + } + + private readonly record struct Occurrence(Snip Snip, Parameter Parameter, int Order); + } + + /// Parameters to add to a CLI plus the per-snip edits that promotion requires. + public sealed record CliSharePlan(IReadOnlyList SharedToAdd, IReadOnlyList SnipEdits) + { + public bool IsEmpty => SharedToAdd.Count == 0 && SnipEdits.Count == 0; + } + + /// The local parameters to drop from a snip and the token renames its template needs. + public sealed record SnipParameterEdit( + Snip Snip, + IReadOnlyList RemoveLocalNames, + IReadOnlyDictionary TokenRenames); +} diff --git a/tools/Snipdeck.Importer/Merge/StoreMerger.cs b/tools/Snipdeck.Importer/Merge/StoreMerger.cs index 9aed541..3885587 100644 --- a/tools/Snipdeck.Importer/Merge/StoreMerger.cs +++ b/tools/Snipdeck.Importer/Merge/StoreMerger.cs @@ -1,3 +1,4 @@ +using Snipdeck.Core.Engine; using Snipdeck.Core.Models; using Snipdeck.Importer.Sources; @@ -24,6 +25,8 @@ public static MergePlan Plan( // De-duplication is scoped to the CLI a snip lands in: CLI is Snipdeck's organising // axis, so the same (Title, CommandTemplate) under two different CLIs is legitimate. + // The key is the literal template (positional, exact) so dedup never drops a snip that + // merely differs in a choice token's name or position. var cliNameById = target.Clis.ToDictionary(c => c.Id, c => c.Name); var existing = new HashSet<(string, string, string)>(); foreach (var snip in target.Snips) @@ -39,35 +42,129 @@ public static MergePlan Plan( target.Clis.Select(c => c.Name), StringComparer.OrdinalIgnoreCase); + // Resolve each candidate's target CLI up front. + var resolved = new List<(SnippetCandidate Candidate, string CliName)>(candidates.Count); + foreach (var candidate in candidates) + { + resolved.Add((candidate, ResolveCliName(candidate, options))); + } + + // Phase 1 — duplicate detection on the literal (as-imported) template. Exact duplicates + // (including re-imports of existing snips) are excluded here, so they can't skew the + // choice-name unification below. var items = new List(candidates.Count); - var clisToCreate = new List(); - var seenNewClis = new HashSet(StringComparer.OrdinalIgnoreCase); var plannedKeys = new HashSet<(string, string, string)>(); - - foreach (var candidate in candidates) + foreach (var (candidate, cliName) in resolved) { - var cliName = ResolveCliName(candidate, options); var key = DedupeKey(cliName, candidate.Snip.Title, candidate.Snip.CommandTemplate); - var isDuplicate = !options.AllowDuplicates && (existing.Contains(key) || plannedKeys.Contains(key)); - items.Add(new MergePlanItem(candidate, cliName, isDuplicate)); + if (!isDuplicate) + { + _ = plannedKeys.Add(key); + } + } - if (isDuplicate) + if (options.ShareParameters) + { + // Phase 2 — unify choice names across the imported snips of each CLI (mutates them). + foreach (var group in items + .Where(i => !i.IsDuplicateSkip) + .GroupBy(i => i.TargetCliName, StringComparer.OrdinalIgnoreCase)) { - continue; + ParameterSharer.NormalizeChoiceNames([.. group.Select(i => i.Candidate.Snip)]); + } + + // Phase 3 — unification can make two imported snips (or an imported and an existing + // one) identical; re-check duplicates on the normalised templates. This is positional + // (literal), so two distinct same-option choices in one command stay distinct. + if (!options.AllowDuplicates) + { + items = RededupeOnNormalisedTemplates(items, existing); + } + } + + // Phase 4 — CLIs to create and shared-parameter promotion, both over the FINAL imported set. + var clisToCreate = new List(); + var seenNewClis = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in items) + { + if (!item.IsDuplicateSkip + && !existingCliNames.Contains(item.TargetCliName) + && seenNewClis.Add(item.TargetCliName)) + { + clisToCreate.Add(item.TargetCliName); + } + } + + var sharePlans = options.ShareParameters + ? BuildSharePlans(target, items) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + return new MergePlan(items, clisToCreate, sharePlans); + } + + private static Dictionary BuildSharePlans( + SnipStoreDocument target, + IReadOnlyList items) + { + var existingClisByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cli in target.Clis) + { + _ = existingClisByName.TryAdd(cli.Name, cli); + } + + var plans = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var group in items + .Where(i => !i.IsDuplicateSkip) + .GroupBy(i => i.TargetCliName, StringComparer.OrdinalIgnoreCase)) + { + var existingCli = existingClisByName.GetValueOrDefault(group.Key); + var existingParameters = existingCli?.Parameters ?? (IReadOnlyList)[]; + var snips = group.Select(i => i.Candidate.Snip).ToList(); + + // Names that pre-existing snips in this CLI resolve from outside their own locals + // (a bare token, or an inherited global). Promoting a new CLI parameter with such a + // name would silently rebind those unrelated snips, so it is left off-limits. + var protectedNames = ProtectedNames(target, existingCli); + + var plan = ParameterSharer.Analyze(existingParameters, snips, protectedNames); + if (!plan.IsEmpty) + { + plans[group.Key] = plan; } + } + + return plans; + } - _ = plannedKeys.Add(key); + private static HashSet ProtectedNames(SnipStoreDocument target, Cli? cli) + { + var names = new HashSet(StringComparer.Ordinal); + if (cli is null) + { + return names; + } - if (!existingCliNames.Contains(cliName) && seenNewClis.Add(cliName)) + foreach (var snip in target.Snips) + { + if (snip.IsTrash || snip.CliId != cli.Id) + { + continue; + } + + var localNames = snip.Parameters.Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + foreach (var token in SubstitutionEngine.ExtractTokens(snip.CommandTemplate)) { - clisToCreate.Add(cliName); + if (!localNames.Contains(token)) + { + _ = names.Add(token); + } } } - return new MergePlan(items, clisToCreate); + return names; } public static void Apply(SnipStoreDocument target, MergePlan plan) @@ -102,6 +199,46 @@ public static void Apply(SnipStoreDocument target, MergePlan plan) snip.CliId = cli.Id; target.Snips.Add(snip); } + + // Promote duplicated parameters to CLI-shared parameters (after snips are attached, + // so the shared definitions and any template-token rewrites land on the real objects). + foreach (var (cliName, sharePlan) in plan.SharePlansByCli) + { + if (clisByName.TryGetValue(cliName, out var cli)) + { + ParameterSharer.Apply(cli, sharePlan); + } + } + } + + private static List RededupeOnNormalisedTemplates( + List items, + HashSet<(string, string, string)> existingKeys) + { + var seen = new HashSet<(string, string, string)>(existingKeys); + var result = new List(items.Count); + foreach (var item in items) + { + if (item.IsDuplicateSkip) + { + result.Add(item); + continue; + } + + // The template was mutated in place by NormalizeChoiceNames. + var key = DedupeKey(item.TargetCliName, item.Candidate.Snip.Title, item.Candidate.Snip.CommandTemplate); + if (seen.Contains(key)) + { + result.Add(item with { IsDuplicateSkip = true }); + } + else + { + _ = seen.Add(key); + result.Add(item); + } + } + + return result; } private static (string, string, string) DedupeKey(string cliName, string title, string commandTemplate) @@ -130,16 +267,24 @@ private static string ResolveCliName(SnippetCandidate candidate, MergeOptions op } /// Knobs that shape a merge, mapped from the CLI options. - public sealed record MergeOptions(string? ForceCli, string? Into, bool AllowDuplicates); + public sealed record MergeOptions(string? ForceCli, string? Into, bool AllowDuplicates, bool ShareParameters = false); /// One candidate's planned outcome. public sealed record MergePlanItem(SnippetCandidate Candidate, string TargetCliName, bool IsDuplicateSkip); - /// The full plan: per-candidate decisions and the CLIs that would be created. - public sealed record MergePlan(IReadOnlyList Items, IReadOnlyList ClisToCreate) + /// + /// The full plan: per-candidate decisions, the CLIs that would be created, and the + /// parameter-sharing plan per target CLI (keyed by CLI name; empty when sharing is off). + /// + public sealed record MergePlan( + IReadOnlyList Items, + IReadOnlyList ClisToCreate, + IReadOnlyDictionary SharePlansByCli) { public int ImportCount => Items.Count(i => !i.IsDuplicateSkip); public int SkipCount => Items.Count(i => i.IsDuplicateSkip); + + public int SharedParameterCount => SharePlansByCli.Values.Sum(p => p.SharedToAdd.Count); } } diff --git a/tools/Snipdeck.Importer/Output/DryRunRenderer.cs b/tools/Snipdeck.Importer/Output/DryRunRenderer.cs index b3c365b..6ed77f3 100644 --- a/tools/Snipdeck.Importer/Output/DryRunRenderer.cs +++ b/tools/Snipdeck.Importer/Output/DryRunRenderer.cs @@ -60,6 +60,27 @@ public static void Render(string sourceName, string storePath, MergePlan plan, b AnsiConsole.WriteLine(); AnsiConsole.MarkupLineInterpolated( $"CLIs to create: {plan.ClisToCreate.Count} Snips to import: {plan.ImportCount} Skipped (duplicates): {plan.SkipCount}"); + + RenderSharedParameters(plan); + } + + private static void RenderSharedParameters(MergePlan plan) + { + if (plan.SharePlansByCli.Count == 0) + { + return; + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLineInterpolated( + $"Shared parameters (scoped to their CLI): {plan.SharedParameterCount}"); + foreach (var (cliName, share) in plan.SharePlansByCli + .Where(kvp => kvp.Value.SharedToAdd.Count > 0) + .OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)) + { + var names = string.Join(", ", share.SharedToAdd.Select(p => p.Name)); + AnsiConsole.MarkupLineInterpolated($" {cliName}: {names}"); + } } private static string Escape(string value) diff --git a/tools/Snipdeck.Importer/README.md b/tools/Snipdeck.Importer/README.md index c21ff1d..d30dda8 100644 --- a/tools/Snipdeck.Importer/README.md +++ b/tools/Snipdeck.Importer/README.md @@ -36,6 +36,7 @@ is JSON — point the importer straight at it. | `--cli ` | — | Force every imported snip into this CLI, overriding auto-detection. | | `--into ` | — | Fallback CLI for snips whose CLI could not be confidently auto-detected. | | `--allow-duplicates` | off | Import snips even if one with the same title and command already exists. | +| `--no-share-parameters` | off | Keep every parameter on its snip instead of promoting duplicates to CLI-shared parameters. | ### Dry-run first @@ -64,6 +65,13 @@ snipdeck-importer snipcommand snipcommand.db --write parameter list (Choice with options, or Text), with a sensible default carried across. Variable names containing spaces or punctuation are slugified into legal token names. +- **Shares duplicated parameters.** When a parameter recurs across two or more snips in the + same CLI, it is promoted to a **CLI-scoped shared parameter** and removed from the + individual snips (which then inherit it by token name). Choice parameters match on their + option *set* (order-independent) — names need not match, the most common name wins, and + snips that used a different name have their template token rewritten to it. Text parameters + match by name, and the most common default value wins (including an empty default). + Single-use parameters stay on their snip. Pass `--no-share-parameters` to disable. - **Carries metadata.** Title, description, tags and favourite flag come across; usage counts and last-used timestamps are preserved when present. Trashed entries are skipped.