diff --git a/internal/app/cli.go b/internal/app/cli.go index 8367661..0f6a0fc 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -17,6 +17,7 @@ import ( v2addtarget "github.com/shpoont/dotfiles-manager/internal/v2/addtarget" v2appauthor "github.com/shpoont/dotfiles-manager/internal/v2/appauthor" v2appdiscovery "github.com/shpoont/dotfiles-manager/internal/v2/appdiscovery" + v2catalogdiscovery "github.com/shpoont/dotfiles-manager/internal/v2/catalogdiscovery" v2initcmd "github.com/shpoont/dotfiles-manager/internal/v2/initcmd" v2ledger "github.com/shpoont/dotfiles-manager/internal/v2/ledger" v2lifecycle "github.com/shpoont/dotfiles-manager/internal/v2/lifecycle" @@ -91,6 +92,7 @@ optional.`, rootCmd.AddCommand(newSyncCmd(opts)) rootCmd.AddCommand(newSaveCmd(opts)) rootCmd.AddCommand(newApplyCmd(opts)) + rootCmd.AddCommand(newCatalogCmd(opts)) rootCmd.AddCommand(newRecipeCmd(opts)) rootCmd.AddCommand(newAppCmd(opts)) rootCmd.AddCommand(newDeployCmd(opts)) @@ -618,6 +620,40 @@ func newExplainCmd(opts *rootOptions) *cobra.Command { return cmd } +func newCatalogCmd(opts *rootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "catalog", + Short: "Inspect app/tool support catalogs", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + return fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath()) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newCatalogListCmd(opts)) + return cmd +} + +func newCatalogListCmd(opts *rootOptions) *cobra.Command { + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "list", + Short: "List configured support catalogs", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runCatalogListCommand(cmd, opts, jsonOutput) + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Emit machine-readable JSON output") + return cmd +} + func newRecipeCmd(opts *rootOptions) *cobra.Command { cmd := &cobra.Command{ Use: "recipe", @@ -1229,6 +1265,11 @@ func runAppExplainCommand(cmd *cobra.Command, opts *rootOptions, appOpts v2appdi return err } +func runCatalogListCommand(cmd *cobra.Command, opts *rootOptions, jsonOutput bool) error { + report := v2catalogdiscovery.List() + return emitCatalogDiscoveryReport(cmd.OutOrStdout(), report, jsonOutput) +} + func appDiscoveryRepoRoot(opts *rootOptions, operation string) (string, bool, error) { if opts != nil && strings.TrimSpace(opts.configPath) != "" { if !isExplicitV2Config(opts.configPath) { @@ -1407,6 +1448,19 @@ func emitAppExplainReport(stdout io.Writer, report *v2appdiscovery.ExplainReport return err } +func emitCatalogDiscoveryReport(stdout io.Writer, report *v2catalogdiscovery.Report, jsonOutput bool) error { + if jsonOutput { + payload, err := v2catalogdiscovery.JSON(report) + if err != nil { + return err + } + _, err = fmt.Fprint(stdout, payload) + return err + } + _, err := fmt.Fprintln(stdout, v2catalogdiscovery.Text(report)) + return err +} + func v2initcmdErrorReport(code string, message string) *v2initcmd.Report { report := &v2initcmd.Report{ Schema: v2initcmd.Schema, diff --git a/internal/app/cli_discovery_test.go b/internal/app/cli_discovery_test.go index 177fb24..699ce54 100644 --- a/internal/app/cli_discovery_test.go +++ b/internal/app/cli_discovery_test.go @@ -20,11 +20,13 @@ func TestDiscoveryListBeforeInitIsSupportedAppsAndReadOnly(t *testing.T) { require.Empty(t, stderr) require.Contains(t, stdout, "Supported apps") require.Contains(t, stdout, "git") - require.Contains(t, stdout, "built-in") + require.Contains(t, stdout, "official") require.Contains(t, stdout, "not managed") require.Contains(t, stdout, "dotfiles-manager explain ") - require.Contains(t, stdout, "No live settings were read or changed.") - require.Contains(t, stdout, "No stored settings were changed.") + require.NotContains(t, stdout, "built-in") + require.NotContains(t, stdout, "custom.files") + require.NotContains(t, stdout, "No live settings were read or changed.") + require.NotContains(t, stdout, "No stored settings were changed.") requireNoDiscoveryState(t, projectDir) } @@ -50,10 +52,11 @@ selections: require.Equal(t, "list", payload["command"]) summary := payload["summary"].(map[string]any) require.Equal(t, "ok", summary["status"]) - require.Equal(t, float64(7), summary["apps"]) + require.Equal(t, float64(6), summary["apps"]) require.Equal(t, float64(1), summary["managed"]) git := requireDiscoveryApp(t, payload, "git") + require.Equal(t, "official", git["source"]) require.Equal(t, "managed", git["state"]) require.Equal(t, float64(2), git["selectedSettings"]) require.Equal(t, "recipe://bundled/git", git["recipeRef"]) @@ -61,6 +64,7 @@ selections: zsh := requireDiscoveryApp(t, payload, "zsh") require.Equal(t, "not-managed", zsh["state"]) require.Equal(t, float64(0), zsh["selectedSettings"]) + requireNoDiscoveryApp(t, payload, "custom.files") } func TestListSettingsCompatibilityKeepsPreviousSelectedSettingsOutput(t *testing.T) { @@ -97,21 +101,27 @@ func TestDiscoverySearchMatchesAndNoMatches(t *testing.T) { require.NoError(t, err) require.Empty(t, stderr) require.Contains(t, stdout, `Search results for "git"`) + require.Contains(t, stdout, " APP CATALOG STATE\n git official not managed") require.Contains(t, stdout, "git") + require.Contains(t, stdout, "official") require.Contains(t, stdout, "not managed") require.Contains(t, stdout, "dotfiles-manager explain git") - require.Contains(t, stdout, "No live settings were read or changed.") - require.Contains(t, stdout, "No stored settings were changed.") + require.NotContains(t, stdout, "No live settings were read or changed.") + require.NotContains(t, stdout, "No stored settings were changed.") - stdout, stderr, err = runDiscoveryTextCLI(t, []string{"search", "shell"}) + stdout, stderr, err = runDiscoveryTextCLI(t, []string{"search", "wezterm"}) require.NoError(t, err) require.Empty(t, stderr) - require.Contains(t, stdout, `No supported apps found for "shell".`) - require.Contains(t, stdout, "dotfiles-manager list") - require.Contains(t, stdout, "No live settings were read or changed.") - require.Contains(t, stdout, "No stored settings were changed.") + require.Contains(t, stdout, `No supported apps found for "wezterm".`) + require.Contains(t, stdout, "The current official catalog supports:") + require.Contains(t, stdout, "git, nvim, ssh, starship, tmux, zsh") + require.Contains(t, stdout, "Future versions may refresh official support data or add remote catalogs.") + require.NotContains(t, stdout, "catalog add") + require.NotContains(t, stdout, "local catalog") + require.NotContains(t, stdout, "No live settings were read or changed.") + require.NotContains(t, stdout, "No stored settings were changed.") - payload, _, stderr, err := runDiscoveryJSONCLI(t, []string{"search", "shell", "--json"}) + payload, _, stderr, err := runDiscoveryJSONCLI(t, []string{"search", "wezterm", "--json"}) require.NoError(t, err) require.Empty(t, stderr) require.Equal(t, "dotfiles-manager.v2.apps", payload["schema"]) @@ -153,15 +163,17 @@ func TestTopLevelExplainIsAppOrientedAndUnknownAppIsStable(t *testing.T) { require.Empty(t, stderr) require.Contains(t, stdout, "Git is supported.") require.Contains(t, stdout, "App ID: git") - require.Contains(t, stdout, "Source: built-in support from dotfiles-manager") + require.Contains(t, stdout, "Catalog: official") require.Contains(t, stdout, "State: not managed") require.Contains(t, stdout, "Can manage:") require.Contains(t, stdout, "git:user.email") require.Contains(t, stdout, "Does not manage:") require.Contains(t, stdout, "credential.helper") - require.Contains(t, stdout, "No live values were printed.") - require.Contains(t, stdout, "No live settings were changed.") - require.Contains(t, stdout, "No stored settings were changed.") + require.NotContains(t, stdout, "Source: built-in") + require.NotContains(t, stdout, "Why this source is used:") + require.NotContains(t, stdout, "No live values were printed.") + require.NotContains(t, stdout, "No live settings were changed.") + require.NotContains(t, stdout, "No stored settings were changed.") require.NotContains(t, stdout, "Git recipe") require.NotContains(t, stdout, "recipe explain") @@ -172,15 +184,15 @@ func TestTopLevelExplainIsAppOrientedAndUnknownAppIsStable(t *testing.T) { require.Equal(t, "explain", payload["command"]) app := payload["app"].(map[string]any) require.Equal(t, "git", app["id"]) - require.Equal(t, "built-in", app["source"]) + require.Equal(t, "official", app["source"]) stdout, stderr, err = runDiscoveryTextCLI(t, []string{"explain", "missing"}) require.Error(t, err) require.Empty(t, stderr) require.Contains(t, stdout, "App not supported: missing") require.Contains(t, stdout, "dotfiles-manager search missing") - require.Contains(t, stdout, "No live settings were read or changed.") - require.Contains(t, stdout, "No stored settings were changed.") + require.NotContains(t, stdout, "No live settings were read or changed.") + require.NotContains(t, stdout, "No stored settings were changed.") payload, _, stderr, err = runDiscoveryJSONCLI(t, []string{"explain", "missing", "--json"}) require.Error(t, err) @@ -189,6 +201,19 @@ func TestTopLevelExplainIsAppOrientedAndUnknownAppIsStable(t *testing.T) { require.Equal(t, "explain", payload["command"]) require.Equal(t, "error", payload["summary"].(map[string]any)["status"]) require.Equal(t, "explain.app.notSupported", payload["error"].(map[string]any)["code"]) + + stdout, stderr, err = runDiscoveryTextCLI(t, []string{"explain", "custom.files"}) + require.Error(t, err) + require.Empty(t, stderr) + require.Contains(t, stdout, "App not supported: custom.files") + require.NotContains(t, stdout, "Custom files is supported.") + + stdout, stderr, err = runDiscoveryTextCLI(t, []string{"explain", "custom-files"}) + require.Error(t, err) + require.Empty(t, stderr) + require.Contains(t, stdout, "App not supported: custom-files") + require.NotContains(t, stdout, "Custom files is supported.") + requireNoDiscoveryState(t, projectDir) } @@ -199,11 +224,77 @@ func TestRootHelpSurfacesFlattenedDiscoveryBeforeRecipeNamespace(t *testing.T) { require.Contains(t, stdout, "list") require.Contains(t, stdout, "search") require.Contains(t, stdout, "explain") + require.Contains(t, stdout, "catalog") require.Contains(t, stdout, "recipe") require.Less(t, strings.Index(stdout, "list"), strings.Index(stdout, "recipe")) require.NotContains(t, stdout, "recipe list") } +func TestCatalogListShowsOfficialCatalogState(t *testing.T) { + projectDir := t.TempDir() + setCWD(t, projectDir) + setTempHome(t) + + stdout, stderr, err := runDiscoveryTextCLI(t, []string{"catalog", "list"}) + require.NoError(t, err) + require.Empty(t, stderr) + require.Contains(t, stdout, "Catalogs") + require.Contains(t, stdout, "Catalogs define app/tool support; they do not store your settings.") + require.Contains(t, stdout, "dotfiles-manager/official active for discovery") + require.Contains(t, stdout, "Catalog version: 9f2c7a1") + require.Contains(t, stdout, "Catalog updated: 2026-06-30 18:00 UTC") + require.NotContains(t, stdout, "Source:") + require.NotContains(t, stdout, "Local copy:") + require.NotContains(t, stdout, "Offline use:") + require.NotContains(t, stdout, "Updates:") + require.NotContains(t, stdout, "Removable:") + require.NotContains(t, stdout, "catalog update") + require.NotContains(t, stdout, "catalog add") + requireNoDiscoveryState(t, projectDir) + + payload, _, stderr, err := runDiscoveryJSONCLI(t, []string{"catalog", "list", "--json"}) + require.NoError(t, err) + require.Empty(t, stderr) + require.Equal(t, "dotfiles-manager.v2.catalogs", payload["schema"]) + require.Equal(t, "catalog.list", payload["command"]) + catalogs := payload["catalogs"].([]any) + require.Len(t, catalogs, 1) + official := catalogs[0].(map[string]any) + require.Equal(t, "dotfiles-manager/official", official["id"]) + require.Equal(t, "active for discovery", official["state"]) + require.Equal(t, "9f2c7a1", official["version"]) + require.Equal(t, "2026-06-30 18:00 UTC", official["updated"]) + + stdout, stderr, err = runDiscoveryTextCLI(t, []string{"catalog", "list", "--help"}) + require.NoError(t, err) + require.Empty(t, stderr) + require.NotContains(t, stdout, "--verbose") + + stdout, stderr, err = runDiscoveryTextCLI(t, []string{"catalog", "list", "--verbose"}) + require.Error(t, err) + require.Empty(t, stdout) + require.Empty(t, stderr) + require.Contains(t, err.Error(), "unknown flag: --verbose") +} + +func TestCatalogLifecycleCommandsAreUnsupportedIn228(t *testing.T) { + projectDir := t.TempDir() + setCWD(t, projectDir) + setTempHome(t) + + for _, args := range [][]string{ + {"catalog", "add", "shpoont/custom-recipes"}, + {"catalog", "update"}, + } { + stdout, stderr, err := runDiscoveryTextCLI(t, args) + require.Error(t, err, args) + require.Empty(t, stdout, args) + require.Empty(t, stderr, args) + require.Contains(t, err.Error(), "unknown command", args) + } + requireNoDiscoveryState(t, projectDir) +} + func runDiscoveryTextCLI(t *testing.T, args []string) (string, string, error) { t.Helper() cmd := NewRootCmd() @@ -237,6 +328,15 @@ func requireDiscoveryApp(t *testing.T, payload map[string]any, id string) map[st return nil } +func requireNoDiscoveryApp(t *testing.T, payload map[string]any, id string) { + t.Helper() + apps := payload["apps"].([]any) + for _, raw := range apps { + app := raw.(map[string]any) + require.NotEqual(t, id, app["id"]) + } +} + func requireNoDiscoveryState(t *testing.T, root string) { t.Helper() require.NoFileExists(t, filepath.Join(root, "dotfiles-manager.v2.yaml")) diff --git a/internal/v2/appdiscovery/appdiscovery.go b/internal/v2/appdiscovery/appdiscovery.go index 24209f9..5685c2c 100644 --- a/internal/v2/appdiscovery/appdiscovery.go +++ b/internal/v2/appdiscovery/appdiscovery.go @@ -167,6 +167,10 @@ func Explain(opts Options) (*ExplainReport, error) { appErr := &Error{Code: CodeRepoInvalid, Message: err.Error(), Exit: 2} return failExplainReport(report, appErr), appErr } + if isNormalDiscoveryPseudoApp(query) { + appErr := &Error{Code: CodeAppNotSupported, Message: fmt.Sprintf("app not supported: %s", query), Exit: 2, Details: map[string]any{"app": query}} + return failExplainReport(report, appErr), appErr + } recipeRoot := opts.RepoRoot recipeReport, recipeErr := v2recipe.Explain(v2recipe.ExplainOptions{Target: query, RepoRoot: recipeRoot}) if recipeErr != nil { @@ -225,7 +229,7 @@ func listOrSearch(opts Options, query string) (*Report, error) { appErr := &Error{Code: CodeRepoInvalid, Message: err.Error(), Exit: 2} return failReport(report, appErr), appErr } - for _, target := range v2recipe.ListBundledTargets() { + for _, target := range officialCatalogTargets() { app := appFromBundledTarget(target, counts[target.ID]) if query != "" && !matchesApp(app, query) { continue @@ -245,6 +249,22 @@ func listOrSearch(opts Options, query string) (*Report, error) { return report, nil } +func officialCatalogTargets() []v2recipe.BundledTarget { + var targets []v2recipe.BundledTarget + for _, target := range v2recipe.ListBundledTargets() { + if target.ID == v2recipe.CustomFilesTarget { + continue + } + targets = append(targets, target) + } + return targets +} + +func isNormalDiscoveryPseudoApp(query string) bool { + target, ok := v2recipe.LookupBundledTarget(strings.TrimSpace(query)) + return ok && target.ID == v2recipe.CustomFilesTarget +} + func managedCounts(opts Options) (map[string]int, error) { counts := map[string]int{} if !opts.RepoRootSet || strings.TrimSpace(opts.RepoRoot) == "" { @@ -363,31 +383,26 @@ func ExplainText(report *ExplainReport) string { "Try:", " dotfiles-manager search " + app, " dotfiles-manager list", - "", - "No live settings were read or changed.", - "No stored settings were changed.", }, "\n") } app := report.App lines := []string{app.DisplayName + " is supported.", ""} lines = append(lines, "App ID: "+app.ID) - lines = append(lines, "Source: "+app.SourceDescription) + lines = append(lines, "Catalog: "+app.Source) lines = append(lines, "State: "+strings.ReplaceAll(app.State, "-", " ")) if len(app.Settings) > 0 { lines = append(lines, "", "Can manage:") for _, setting := range app.Settings { - lines = append(lines, fmt.Sprintf(" %-15s %s", setting.Ref, setting.Label)) + lines = append(lines, fmt.Sprintf(" %-15s %s", setting.Ref, normalSettingLabel(app, setting))) } } - if len(app.DoNotManage) > 0 { + doNotManage := normalDoNotManage(app) + if len(doNotManage) > 0 { lines = append(lines, "", "Does not manage:") - for _, item := range app.DoNotManage { + for _, item := range doNotManage { lines = append(lines, " "+item) } } - lines = append(lines, "", "Why this source is used:") - lines = append(lines, " "+sourceReason(app.Source, app.DisplayName)) - lines = append(lines, "", "No live values were printed.", "No live settings were changed.", "No stored settings were changed.") return strings.Join(trimBlank(lines), "\n") } @@ -423,24 +438,9 @@ func ExplainVerboseText(report *ExplainReport) string { } func listText(report *Report) string { - lines := []string{"Supported apps", "", " APP SOURCE STATE"} - for _, app := range report.Apps { - lines = append(lines, fmt.Sprintf(" %-13s %-9s %s", app.ID, app.Source, strings.ReplaceAll(app.State, "-", " "))) - } - managed := managedApps(report.Apps) - if len(managed) > 0 { - lines = append(lines, "", "Managed apps:") - for _, app := range managed { - lines = append(lines, fmt.Sprintf(" %s %d selected setting%s", app.ID, app.SelectedSettings, plural(app.SelectedSettings))) - } - if first := managed[0]; first.ID != "" { - lines = append(lines, "", fmt.Sprintf("Use `dotfiles-manager status %s` to inspect drift for %s.", first.ID, first.DisplayName)) - } - } else { - lines = append(lines, "", "Use `dotfiles-manager explain ` to see what can be managed.") - } - lines = append(lines, "Use `dotfiles-manager list --settings` to list selected managed settings.") - lines = append(lines, "", "No live settings were read or changed.", "No stored settings were changed.") + lines := []string{"Supported apps", ""} + lines = append(lines, formatAppTable(report.Apps)...) + lines = append(lines, "", "Use `dotfiles-manager explain ` to see what can be managed.") return strings.Join(trimBlank(lines), "\n") } @@ -450,20 +450,17 @@ func searchText(report *Report) string { return strings.Join([]string{ fmt.Sprintf("No supported apps found for %q.", query), "", - "Try:", - " dotfiles-manager list", + "The current official catalog supports:", + " " + strings.Join(officialCatalogTargetIDs(), ", "), "", - "No live settings were read or changed.", - "No stored settings were changed.", + "This version searches only the current official catalog.", + "Future versions may refresh official support data or add remote catalogs.", + "This version cannot do that yet.", }, "\n") } - lines := []string{fmt.Sprintf("Search results for %q", query), "", " APP SOURCE STATE"} - for _, app := range report.Apps { - lines = append(lines, fmt.Sprintf(" %-13s %-9s %s", app.ID, app.Source, strings.ReplaceAll(app.State, "-", " "))) - } + lines := []string{fmt.Sprintf("Search results for %q", query), ""} + lines = append(lines, formatAppTable(report.Apps)...) lines = append(lines, "", fmt.Sprintf("Use `dotfiles-manager explain %s` to see what can be managed.", report.Apps[0].ID)) - lines = append(lines, "No live settings were read or changed.") - lines = append(lines, "No stored settings were changed.") return strings.Join(trimBlank(lines), "\n") } @@ -475,6 +472,28 @@ func appsErrorText(report *Report) string { return strings.Join([]string{"Supported apps", "", "Command result:", " " + message, "", "No files changed.", "", "Run with --json for machine-readable details."}, "\n") } +func formatAppTable(apps []App) []string { + appWidth := len("APP") + catalogWidth := len("CATALOG") + for _, app := range apps { + appWidth = maxInt(appWidth, len(app.ID)) + catalogWidth = maxInt(catalogWidth, len(app.Source)) + } + format := fmt.Sprintf(" %%-%ds %%-%ds %%s", appWidth, catalogWidth) + lines := []string{fmt.Sprintf(format, "APP", "CATALOG", "STATE")} + for _, app := range apps { + lines = append(lines, fmt.Sprintf(format, app.ID, app.Source, strings.ReplaceAll(app.State, "-", " "))) + } + return lines +} + +func maxInt(a int, b int) int { + if a > b { + return a + } + return b +} + func baseReport(command string, runID string) *Report { return &Report{ Schema: AppsSchema, @@ -523,17 +542,6 @@ func failExplainReport(report *ExplainReport, err *Error) *ExplainReport { return report } -func managedApps(apps []App) []App { - var out []App - for _, app := range apps { - if app.State == StateManaged { - out = append(out, app) - } - } - sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) - return out -} - func mapExplainError(query string, err error) *Error { if explainErr, ok := err.(*v2recipe.ExplainError); ok { if explainErr.Code == v2recipe.ExplainCodeUnknownTarget { @@ -547,7 +555,7 @@ func mapExplainError(query string, err error) *Error { func appSource(source string) string { switch source { case v2recipe.RecipeSourceBundled: - return "built-in" + return "official" case v2recipe.RecipeSourceLocal: return "local" case "": @@ -560,7 +568,7 @@ func appSource(source string) string { func sourceDescription(source string) string { switch source { case v2recipe.RecipeSourceBundled: - return "built-in support from dotfiles-manager" + return "official catalog" case v2recipe.RecipeSourceLocal: return "local support from this settings folder" default: @@ -568,17 +576,6 @@ func sourceDescription(source string) string { } } -func sourceReason(source string, display string) string { - switch source { - case "built-in": - return fmt.Sprintf("Built-in support is the default for %s and is trusted by the dotfiles-manager release.", display) - case "local": - return fmt.Sprintf("Local support for %s comes from this settings folder and may require review before writes.", display) - default: - return "This source was selected from available support metadata." - } -} - func stateForCount(count int) string { if count > 0 { return StateManaged @@ -619,6 +616,39 @@ func plural(count int) string { return "s" } +func officialCatalogTargetIDs() []string { + targets := officialCatalogTargets() + ids := make([]string, 0, len(targets)) + for _, target := range targets { + ids = append(ids, target.ID) + } + sort.Strings(ids) + return ids +} + +func normalSettingLabel(app ExplainApp, setting ExplainSetting) string { + label := strings.TrimSpace(setting.Label) + if label == "" { + label = displayName("", setting.ID) + } + if app.ID == v2recipe.GitTarget { + lower := strings.ToLower(label[:1]) + label[1:] + return app.DisplayName + " " + lower + } + return label +} + +func normalDoNotManage(app ExplainApp) []string { + if app.ID == v2recipe.GitTarget { + return []string{ + "credential.helper", + "[credential] sections", + "include/includeIf expansion", + } + } + return append([]string(nil), app.DoNotManage...) +} + func trimBlank(lines []string) []string { for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { lines = lines[:len(lines)-1] diff --git a/internal/v2/appdiscovery/appdiscovery_test.go b/internal/v2/appdiscovery/appdiscovery_test.go index df84bd3..23fe65a 100644 --- a/internal/v2/appdiscovery/appdiscovery_test.go +++ b/internal/v2/appdiscovery/appdiscovery_test.go @@ -31,15 +31,16 @@ selections: require.Equal(t, ListCommand, report.Command) require.Equal(t, ListRunID, report.RunID) require.Equal(t, "ok", report.Summary.Status) - require.Equal(t, 7, report.Summary.Apps) + require.Equal(t, 6, report.Summary.Apps) require.Equal(t, 1, report.Summary.Managed) require.Empty(t, report.Diagnostics) require.Nil(t, report.Error) + requireNoApp(t, report, "custom.files") git := requireApp(t, report, "git") require.Equal(t, "Git", git.DisplayName) require.Equal(t, []string{"gitconfig"}, git.Aliases) - require.Equal(t, "built-in", git.Source) + require.Equal(t, "official", git.Source) require.Equal(t, StateManaged, git.State) require.Equal(t, 2, git.SelectedSettings) require.Equal(t, "recipe://bundled/git", git.RecipeRef) @@ -51,10 +52,13 @@ selections: text := Text(report) require.Contains(t, text, "Supported apps") - require.Contains(t, text, "Managed apps:") - require.Contains(t, text, "git 2 selected settings") - require.Contains(t, text, "dotfiles-manager status git") - require.Contains(t, text, "No stored settings were changed.") + require.Contains(t, text, "APP CATALOG STATE") + require.Contains(t, text, "git official managed") + require.Contains(t, text, "Use `dotfiles-manager explain ` to see what can be managed.") + require.NotContains(t, text, "custom.files") + require.NotContains(t, text, "built-in") + require.NotContains(t, text, "No live settings were read or changed.") + require.NotContains(t, text, "No stored settings were changed.") payload, err := JSON(report) require.NoError(t, err) @@ -80,6 +84,10 @@ selections: require.Equal(t, 0, noMatch.Summary.Matches) require.Empty(t, noMatch.Apps) require.Contains(t, Text(noMatch), `No supported apps found for "not-a-supported-app".`) + require.Contains(t, Text(noMatch), "The current official catalog supports:") + require.Contains(t, Text(noMatch), "git, nvim, ssh, starship, tmux, zsh") + require.NotContains(t, Text(noMatch), "catalog add") + require.NotContains(t, Text(noMatch), "local catalog") } func TestSearchValidationAndNilRenderers(t *testing.T) { @@ -125,8 +133,8 @@ selections: require.Equal(t, 1, report.Summary.Managed) require.Equal(t, "git", report.App.ID) require.Equal(t, "Git", report.App.DisplayName) - require.Equal(t, "built-in", report.App.Source) - require.Equal(t, "built-in support from dotfiles-manager", report.App.SourceDescription) + require.Equal(t, "official", report.App.Source) + require.Equal(t, "official catalog", report.App.SourceDescription) require.Equal(t, StateManaged, report.App.State) require.Equal(t, 1, report.App.SelectedSettings) require.NotEmpty(t, report.App.Settings) @@ -135,9 +143,12 @@ selections: text := ExplainText(report) require.Contains(t, text, "Git is supported.") require.Contains(t, text, "State: managed") + require.Contains(t, text, "Catalog: official") require.Contains(t, text, "Can manage:") - require.Contains(t, text, "Why this source is used:") - require.Contains(t, text, "No live values were printed.") + require.NotContains(t, text, "Why this source is used:") + require.NotContains(t, text, "No live values were printed.") + require.NotContains(t, text, "No live settings were changed.") + require.NotContains(t, text, "No stored settings were changed.") verbose := ExplainVerboseText(report) require.Contains(t, verbose, "app: git") @@ -161,6 +172,22 @@ selections: require.Equal(t, CodeAppNotSupported, unknown.Error.Code) require.Contains(t, ExplainText(unknown), "App not supported: missing") require.Contains(t, ExplainVerboseText(unknown), "error[explain.app.notSupported]") + require.NotContains(t, ExplainText(unknown), "No live settings were read or changed.") + require.NotContains(t, ExplainText(unknown), "No stored settings were changed.") + + pseudoApp, err := Explain(Options{Query: "custom.files"}) + require.Error(t, err) + appErr = requireAppError(t, err) + require.Equal(t, CodeAppNotSupported, appErr.Code) + require.Equal(t, "custom.files", appErr.Details["app"]) + require.Equal(t, "error", pseudoApp.Summary.Status) + require.NotContains(t, ExplainText(pseudoApp), "Custom files is supported.") + + _, err = Explain(Options{Query: "custom-files"}) + require.Error(t, err) + appErr = requireAppError(t, err) + require.Equal(t, CodeAppNotSupported, appErr.Code) + require.Equal(t, "custom-files", appErr.Details["app"]) } func TestRepoRootErrorsReturnStableReports(t *testing.T) { @@ -197,7 +224,8 @@ func TestRenderHelpersCoverFallbackSourcesAndErrors(t *testing.T) { }, } require.Contains(t, listText(report), "local.tool") - require.Contains(t, listText(report), "1 selected setting") + require.Contains(t, listText(report), "local") + require.Contains(t, listText(report), "managed") explain := &ExplainReport{ Schema: AppSchema, @@ -215,7 +243,7 @@ func TestRenderHelpersCoverFallbackSourcesAndErrors(t *testing.T) { }, Diagnostics: []Diagnostic{{Severity: "warning", Code: "demo.warning", Message: "review local recipe"}}, } - require.Contains(t, ExplainText(explain), "Local support for Local Tool comes from this settings folder") + require.Contains(t, ExplainText(explain), "Catalog: local") require.Contains(t, ExplainVerboseText(explain), "warning[demo.warning]: review local recipe") unsupported := mapExplainError("tool", errors.New("boom")) @@ -228,12 +256,11 @@ func TestRenderHelpersCoverFallbackSourcesAndErrors(t *testing.T) { require.Equal(t, 7, wrapped.ExitCode()) require.Equal(t, "tool", wrapped.Details["target"]) - require.Equal(t, "built-in", appSource(v2recipe.RecipeSourceBundled)) + require.Equal(t, "official", appSource(v2recipe.RecipeSourceBundled)) require.Equal(t, "local", appSource(v2recipe.RecipeSourceLocal)) require.Equal(t, "unknown", appSource("")) require.Equal(t, "third-party", appSource("third-party")) require.Equal(t, "third.party", sourceDescription("third.party")) - require.Equal(t, "This source was selected from available support metadata.", sourceReason("third-party", "Tool")) require.Equal(t, "Custom Tool", displayName("", "custom_tool")) require.Equal(t, "App", displayName("", "")) require.Equal(t, 1, boolToInt(true)) @@ -300,3 +327,10 @@ func TestMatchesAppUsesAllPublicSearchFields(t *testing.T) { require.False(t, matchesApp(app, "browser")) require.True(t, strings.Contains(Text(&Report{Command: SearchCommand, Query: "browser", Apps: []App{}}), "No supported apps")) } + +func requireNoApp(t *testing.T, report *Report, id string) { + t.Helper() + for _, app := range report.Apps { + require.NotEqual(t, id, app.ID) + } +} diff --git a/internal/v2/catalogdiscovery/catalogdiscovery.go b/internal/v2/catalogdiscovery/catalogdiscovery.go new file mode 100644 index 0000000..bdd3d62 --- /dev/null +++ b/internal/v2/catalogdiscovery/catalogdiscovery.go @@ -0,0 +1,120 @@ +package catalogdiscovery + +import ( + "encoding/json" + "fmt" + "strings" +) + +const ( + Schema = "dotfiles-manager.v2.catalogs" + SchemaVersion = 1 + Command = "catalog.list" + RunID = "catalog-list" + + OfficialCatalogID = "dotfiles-manager/official" + OfficialCatalogVersion = "9f2c7a1" + OfficialCatalogUpdated = "2026-06-30 18:00 UTC" +) + +type Summary struct { + Status string `json:"status"` + Catalogs int `json:"catalogs"` + Failed int `json:"failed"` +} + +type Report struct { + Schema string `json:"schema"` + SchemaVersion int `json:"schemaVersion"` + Command string `json:"command"` + RunID string `json:"runId"` + Summary Summary `json:"summary"` + Catalogs []Catalog `json:"catalogs"` + Diagnostics []Diagnostic `json:"diagnostics"` + Error *ErrorObject `json:"error,omitempty"` +} + +type Catalog struct { + ID string `json:"id"` + State string `json:"state"` + Purpose string `json:"purpose"` + Version string `json:"version"` + Updated string `json:"updated"` +} + +type Diagnostic struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message"` +} + +type ErrorObject struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` +} + +func List() *Report { + return &Report{ + Schema: Schema, + SchemaVersion: SchemaVersion, + Command: Command, + RunID: RunID, + Summary: Summary{Status: "ok", Catalogs: 1}, + Catalogs: []Catalog{ + { + ID: OfficialCatalogID, + State: "active for discovery", + Purpose: "app/tool support", + Version: OfficialCatalogVersion, + Updated: OfficialCatalogUpdated, + }, + }, + Diagnostics: []Diagnostic{}, + } +} + +func JSON(report *Report) (string, error) { + if report == nil { + report = &Report{ + Schema: Schema, + SchemaVersion: SchemaVersion, + Command: Command, + RunID: RunID, + Summary: Summary{Status: "error", Failed: 1}, + Catalogs: []Catalog{}, + Diagnostics: []Diagnostic{}, + } + } + payload, err := json.MarshalIndent(report, "", " ") + if err != nil { + return "", err + } + return string(payload) + "\n", nil +} + +func Text(report *Report) string { + if report == nil || report.Error != nil { + message := "The command could not complete." + if report != nil && report.Error != nil && strings.TrimSpace(report.Error.Message) != "" { + message = report.Error.Message + } + return strings.Join([]string{"Catalogs", "", message}, "\n") + } + lines := []string{ + "Catalogs", + "", + "Catalogs define app/tool support; they do not store your settings.", + "", + } + for _, catalog := range report.Catalogs { + lines = append(lines, fmt.Sprintf(" %s %s", catalog.ID, catalog.State)) + if strings.TrimSpace(catalog.Version) != "" { + lines = append(lines, " Catalog version: "+catalog.Version) + } + if strings.TrimSpace(catalog.Updated) != "" { + lines = append(lines, " Catalog updated: "+catalog.Updated) + } + } + return strings.Join(lines, "\n") +} diff --git a/internal/v2/catalogdiscovery/catalogdiscovery_test.go b/internal/v2/catalogdiscovery/catalogdiscovery_test.go new file mode 100644 index 0000000..7a4fff2 --- /dev/null +++ b/internal/v2/catalogdiscovery/catalogdiscovery_test.go @@ -0,0 +1,59 @@ +package catalogdiscovery + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListOfficialCatalogState(t *testing.T) { + report := List() + + require.Equal(t, Schema, report.Schema) + require.Equal(t, SchemaVersion, report.SchemaVersion) + require.Equal(t, Command, report.Command) + require.Equal(t, RunID, report.RunID) + require.Equal(t, "ok", report.Summary.Status) + require.Equal(t, 1, report.Summary.Catalogs) + require.Empty(t, report.Diagnostics) + require.Len(t, report.Catalogs, 1) + + official := report.Catalogs[0] + require.Equal(t, "dotfiles-manager/official", official.ID) + require.Equal(t, "active for discovery", official.State) + require.Equal(t, "9f2c7a1", official.Version) + require.Equal(t, "2026-06-30 18:00 UTC", official.Updated) + require.Equal(t, "app/tool support", official.Purpose) + + text := Text(report) + require.Contains(t, text, "Catalogs") + require.Contains(t, text, "Catalogs define app/tool support; they do not store your settings.") + require.Contains(t, text, "dotfiles-manager/official active for discovery") + require.Contains(t, text, "Catalog version: 9f2c7a1") + require.Contains(t, text, "Catalog updated: 2026-06-30 18:00 UTC") + require.NotContains(t, text, "Source:") + require.NotContains(t, text, "Local copy:") + require.NotContains(t, text, "Offline use:") + require.NotContains(t, text, "Updates:") + require.NotContains(t, text, "Removable:") + require.NotContains(t, text, "catalog update") + require.NotContains(t, text, "catalog add") + + payload, err := JSON(report) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal([]byte(payload), &decoded)) + require.Equal(t, Schema, decoded["schema"]) + require.Equal(t, Command, decoded["command"]) +} + +func TestNilReportRenderersAreStable(t *testing.T) { + text := Text(nil) + require.Contains(t, text, "Catalogs") + require.Contains(t, text, "The command could not complete.") + + payload, err := JSON(nil) + require.NoError(t, err) + require.Contains(t, payload, `"status": "error"`) +}