diff --git a/internal/app/cli.go b/internal/app/cli.go index 1b35d4e..c2b411c 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -7,6 +7,8 @@ import ( "log/slog" "os" "path/filepath" + "regexp" + "strconv" "strings" "github.com/shpoont/dotfiles-manager/internal/config" @@ -23,6 +25,16 @@ type rootOptions struct { var osExit = os.Exit +var ( + executeStdout = io.Writer(os.Stdout) + executeStderr = io.Writer(os.Stderr) +) + +var ( + unknownCommandPattern = regexp.MustCompile(`^unknown command "([^"]+)"`) + unknownShorthandFlagPattern = regexp.MustCompile(`^unknown shorthand flag: '([^']+)' in (.+)$`) +) + func NewRootCmd() *cobra.Command { opts := &rootOptions{} @@ -33,7 +45,7 @@ func NewRootCmd() *cobra.Command { SilenceUsage: true, } rootCmd.Version = currentVersion() - rootCmd.SetVersionTemplate("dotfiles-manager version {{.Version}}\n") + rootCmd.SetVersionTemplate(versionLine() + "\n") rootCmd.Flags().Bool("version", false, "Print version and exit") rootCmd.PersistentFlags().StringVar(&opts.configPath, "config", "", "Path to config file") @@ -179,10 +191,12 @@ type commandOptions struct { func runCommand(cmd *cobra.Command, opts *rootOptions, commandOpts commandOptions) error { pathInput, pathNormalized, pathErr := normalizeScopePath(commandOpts.PathArg) + configPathForErrors := explicitConfigPath(opts.configPath) if pathErr != nil { emitError(cmd.OutOrStdout(), cmd.ErrOrStderr(), commandOpts.JSONOutput, jsonContext{ Command: commandOpts.Name, DryRun: commandOpts.DryRun, + ConfigPath: configPathForErrors, PathInput: pathInput, PathNormalized: pathNormalized, }, pathErr) @@ -194,6 +208,7 @@ func runCommand(cmd *cobra.Command, opts *rootOptions, commandOpts commandOption emitError(cmd.OutOrStdout(), cmd.ErrOrStderr(), commandOpts.JSONOutput, jsonContext{ Command: commandOpts.Name, DryRun: commandOpts.DryRun, + ConfigPath: configPathForErrors, PathInput: pathInput, PathNormalized: pathNormalized, }, err) @@ -204,6 +219,7 @@ func runCommand(cmd *cobra.Command, opts *rootOptions, commandOpts commandOption emitError(cmd.OutOrStdout(), cmd.ErrOrStderr(), commandOpts.JSONOutput, jsonContext{ Command: commandOpts.Name, DryRun: commandOpts.DryRun, + ConfigPath: configPathForErrors, PathInput: pathInput, PathNormalized: pathNormalized, }, err) @@ -215,6 +231,7 @@ func runCommand(cmd *cobra.Command, opts *rootOptions, commandOpts commandOption emitError(cmd.OutOrStdout(), cmd.ErrOrStderr(), commandOpts.JSONOutput, jsonContext{ Command: commandOpts.Name, DryRun: commandOpts.DryRun, + ConfigPath: configPathForErrors, PathInput: pathInput, PathNormalized: pathNormalized, }, err) @@ -225,19 +242,18 @@ func runCommand(cmd *cobra.Command, opts *rootOptions, commandOpts commandOption emitError(cmd.OutOrStdout(), cmd.ErrOrStderr(), commandOpts.JSONOutput, jsonContext{ Command: commandOpts.Name, DryRun: commandOpts.DryRun, + ConfigPath: configPathForErrors, PathInput: pathInput, PathNormalized: pathNormalized, }, err) return err } if commandOpts.IncludePatch && !commandOpts.JSONOutput { - err := dfmerr.New(dfmerr.CodeFlagUnsupported, "Flag not supported for command: --patch", map[string]any{ - "flag": "--patch", - "requires": "--json", - }) + err := patchRequiresJSONError() emitError(cmd.OutOrStdout(), cmd.ErrOrStderr(), commandOpts.JSONOutput, jsonContext{ Command: commandOpts.Name, DryRun: commandOpts.DryRun, + ConfigPath: configPathForErrors, PathInput: pathInput, PathNormalized: pathNormalized, }, err) @@ -250,6 +266,7 @@ func runCommand(cmd *cobra.Command, opts *rootOptions, commandOpts commandOption emitError(cmd.OutOrStdout(), cmd.ErrOrStderr(), commandOpts.JSONOutput, jsonContext{ Command: commandOpts.Name, DryRun: commandOpts.DryRun, + ConfigPath: configPathForErrors, PathInput: pathInput, PathNormalized: pathNormalized, }, err) @@ -261,6 +278,7 @@ func runCommand(cmd *cobra.Command, opts *rootOptions, commandOpts commandOption emitError(cmd.OutOrStdout(), cmd.ErrOrStderr(), commandOpts.JSONOutput, jsonContext{ Command: commandOpts.Name, DryRun: commandOpts.DryRun, + ConfigPath: configPathForErrors, PathInput: pathInput, PathNormalized: pathNormalized, }, err) @@ -275,6 +293,7 @@ func runCommand(cmd *cobra.Command, opts *rootOptions, commandOpts commandOption emitError(cmd.OutOrStdout(), cmd.ErrOrStderr(), commandOpts.JSONOutput, jsonContext{ Command: commandOpts.Name, DryRun: commandOpts.DryRun, + ConfigPath: configPathForErrors, PathInput: pathInput, PathNormalized: pathNormalized, }, err) @@ -295,6 +314,7 @@ func runCommand(cmd *cobra.Command, opts *rootOptions, commandOpts commandOption emitError(cmd.OutOrStdout(), cmd.ErrOrStderr(), commandOpts.JSONOutput, jsonContext{ Command: commandOpts.Name, DryRun: commandOpts.DryRun, + ConfigPath: configPathForErrors, PathInput: pathInput, PathNormalized: pathNormalized, }, err) @@ -308,6 +328,7 @@ func runCommand(cmd *cobra.Command, opts *rootOptions, commandOpts commandOption emitError(cmd.OutOrStdout(), cmd.ErrOrStderr(), commandOpts.JSONOutput, jsonContext{ Command: commandOpts.Name, DryRun: commandOpts.DryRun, + ConfigPath: resolvedConfigPath, PathInput: pathInput, PathNormalized: pathNormalized, }, cfgErr) @@ -345,7 +366,12 @@ func runCommand(cmd *cobra.Command, opts *rootOptions, commandOpts commandOption matchedIndexes = append(matchedIndexes, selection.Index) } - result, err := buildSuccessEnvelope(commandOpts, cfg, absConfigPath, pathInput, pathNormalized, matchedIndexes, selections) + excludedSyncCount := 0 + if len(cfg.Syncs) > len(selections) { + excludedSyncCount = len(cfg.Syncs) - len(selections) + } + + result, err := buildSuccessEnvelope(commandOpts, cfg, absConfigPath, pathInput, pathNormalized, matchedIndexes, selections, excludedSyncCount) if err != nil { logCommandError(commandLogger, err) partialSyncs, partialSummary := extractPartialResult(err) @@ -404,9 +430,8 @@ type jsonContext struct { } func emitError(stdout io.Writer, stderr io.Writer, jsonOutput bool, ctx jsonContext, err error) { - _, _ = fmt.Fprintln(stderr, err.Error()) - if !jsonOutput { + _, _ = fmt.Fprintln(stderr, err.Error()) return } @@ -452,13 +477,29 @@ func emitError(stdout io.Writer, stderr io.Writer, jsonOutput bool, ctx jsonCont _ = emitJSON(stdout, payload) } +func explicitConfigPath(configPath string) any { + trimmed := strings.TrimSpace(configPath) + if trimmed == "" { + return nil + } + return trimmed +} + +func patchRequiresJSONError() error { + return dfmerr.New(dfmerr.CodeFlagUnsupported, "--patch requires --json", map[string]any{ + "flag": "--patch", + "required_flags": []string{"--json"}, + "example": "dotfiles-manager diff --json --patch", + }) +} + func emitJSON(w io.Writer, value any) error { encoder := json.NewEncoder(w) encoder.SetEscapeHTML(false) return encoder.Encode(value) } -func buildSuccessEnvelope(commandOpts commandOptions, cfg *config.Config, configPath string, pathInput any, pathNormalized any, matchedIndexes []int, selections []syncSelection) (map[string]any, error) { +func buildSuccessEnvelope(commandOpts commandOptions, cfg *config.Config, configPath string, pathInput any, pathNormalized any, matchedIndexes []int, selections []syncSelection, excludedSyncCount int) (map[string]any, error) { var ( syncPayloads []any summary map[string]any @@ -491,6 +532,11 @@ func buildSuccessEnvelope(commandOpts commandOptions, cfg *config.Config, config summary = buildSummary(commandOpts.Name, len(syncPayloads)) } + if summary == nil { + summary = map[string]any{} + } + summary["excluded_sync_count"] = excludedSyncCount + return map[string]any{ "schema_version": jsonSchemaVersion, "ok": true, @@ -778,17 +824,20 @@ func buildTextSummaryLine(command string, dryRun bool, summaryValue any) string parts = appendSummaryCount(parts, "incoming-unmanaged", summaryInt(summary, "incoming_unmanaged_count")) parts = appendSummaryCount(parts, "remove-unmanaged", summaryInt(summary, "remove_unmanaged_count")) parts = appendSummaryCount(parts, "remove-missing", summaryInt(summary, "remove_missing_count")) + parts = appendExcludedSyncSummary(parts, summary) return strings.Join(parts, " ") case "deploy": parts := []string{fmt.Sprintf("summary dry-run=%t", dryRun)} parts = appendSummaryCount(parts, "copied", summaryInt(summary, "copy_count")) parts = appendSummaryCount(parts, "remove-unmanaged", summaryInt(summary, "remove_unmanaged_count")) + parts = appendExcludedSyncSummary(parts, summary) return strings.Join(parts, " ") case "import": parts := []string{fmt.Sprintf("summary dry-run=%t", dryRun)} parts = appendSummaryCount(parts, "updated-managed", summaryInt(summary, "update_managed_count")) parts = appendSummaryCount(parts, "added-unmanaged", summaryInt(summary, "add_unmanaged_count")) parts = appendSummaryCount(parts, "removed-missing", summaryInt(summary, "remove_missing_count")) + parts = appendExcludedSyncSummary(parts, summary) return strings.Join(parts, " ") case "diff": parts := []string{"summary"} @@ -801,12 +850,25 @@ func buildTextSummaryLine(command string, dryRun bool, summaryValue any) string parts = appendSummaryCount(parts, "binary", summaryInt(summary, "binary_count")) parts = appendSummaryCount(parts, "type-change", summaryInt(summary, "type_change_count")) parts = appendSummaryCount(parts, "omitted", summaryInt(summary, "omitted_count")) + parts = appendExcludedSyncSummary(parts, summary) return strings.Join(parts, " ") default: - return fmt.Sprintf("summary syncs=%d", summaryInt(summary, "sync_count")) + parts := []string{fmt.Sprintf("summary syncs=%d", summaryInt(summary, "sync_count"))} + parts = appendExcludedSyncSummary(parts, summary) + return strings.Join(parts, " ") } } +func appendExcludedSyncSummary(parts []string, summary map[string]any) []string { + if summary == nil { + return parts + } + if _, exists := summary["excluded_sync_count"]; !exists { + return parts + } + return append(parts, fmt.Sprintf("excluded-syncs=%d", summaryInt(summary, "excluded_sync_count"))) +} + func appendSummaryCount(parts []string, label string, count int) []string { if count <= 0 { return parts @@ -831,12 +893,245 @@ func summaryInt(summary map[string]any, key string) int { } func Execute() int { - if err := NewRootCmd().Execute(); err != nil { + cmd := NewRootCmd() + cmd.SetOut(executeStdout) + cmd.SetErr(executeStderr) + + if err := cmd.Execute(); err != nil { + if parserErr, ok := classifyParserError(err); ok { + parserCtx := parserErrorContextFromArgs(os.Args[1:]) + emitParserError(cmd.OutOrStdout(), cmd.ErrOrStderr(), parserCtx, parserErr) + } return 1 } return 0 } +type parserErrorContext struct { + JSONOutput bool + DryRun bool + Command any + ConfigPath any +} + +func emitParserError(stdout io.Writer, stderr io.Writer, ctx parserErrorContext, parserErr *dfmerr.Error) { + if parserErr == nil { + return + } + + if !ctx.JSONOutput { + _, _ = fmt.Fprintln(stderr, parserErr.Message) + return + } + + payload := map[string]any{ + "schema_version": jsonSchemaVersion, + "ok": false, + "dry_run": ctx.DryRun, + "command": ctx.Command, + "config_path": ctx.ConfigPath, + "path_scope": map[string]any{ + "input": nil, + "normalized": nil, + "matched_sync_indexes": []int{}, + }, + "syncs": []any{}, + "summary": map[string]any{}, + "error": map[string]any{ + "code": parserErr.Code, + "message": parserErr.Message, + }, + } + if len(parserErr.Details) > 0 { + payload["error"].(map[string]any)["details"] = parserErr.Details + } + + _ = emitJSON(stdout, payload) +} + +func classifyParserError(err error) (*dfmerr.Error, bool) { + if err == nil { + return nil, false + } + + if dfmError, ok := dfmerr.As(err); ok { + switch dfmError.Code { + case dfmerr.CodeParserUnknownFlag, dfmerr.CodeParserUnknownCommand, dfmerr.CodeParserArgFailure: + return dfmError, true + default: + return nil, false + } + } + + message := strings.TrimSpace(err.Error()) + if message == "" { + return nil, false + } + + if flag, ok := parserUnknownFlag(message); ok { + details := map[string]any{} + if flag != "" { + details["flag"] = flag + } + if len(details) == 0 { + details = nil + } + return dfmerr.New(dfmerr.CodeParserUnknownFlag, message, details), true + } + + if command, ok := parserUnknownCommand(message); ok { + details := map[string]any{} + if command != "" { + details["command"] = command + } + if len(details) == 0 { + details = nil + } + return dfmerr.New(dfmerr.CodeParserUnknownCommand, message, details), true + } + + if isParserArgFailure(message) { + return dfmerr.New(dfmerr.CodeParserArgFailure, message, nil), true + } + + return nil, false +} + +func parserUnknownFlag(message string) (string, bool) { + const unknownFlagPrefix = "unknown flag: " + if strings.HasPrefix(message, unknownFlagPrefix) { + return strings.TrimSpace(strings.TrimPrefix(message, unknownFlagPrefix)), true + } + + matches := unknownShorthandFlagPattern.FindStringSubmatch(message) + if len(matches) == 3 { + flag := strings.TrimSpace(matches[2]) + if flag == "" { + flag = "-" + matches[1] + } + return flag, true + } + + return "", false +} + +func parserUnknownCommand(message string) (string, bool) { + matches := unknownCommandPattern.FindStringSubmatch(message) + if len(matches) != 2 { + return "", false + } + return matches[1], true +} + +func isParserArgFailure(message string) bool { + if strings.HasPrefix(message, "flag needs an argument: ") { + return true + } + + if strings.HasPrefix(message, "invalid argument ") && strings.Contains(message, " flag") { + return true + } + + if strings.Contains(message, "arg(s)") && (strings.Contains(message, "accepts ") || strings.Contains(message, "requires ")) { + return true + } + + return false +} + +func parserErrorContextFromArgs(args []string) parserErrorContext { + ctx := parserErrorContext{ + JSONOutput: false, + DryRun: false, + Command: nil, + ConfigPath: nil, + } + + waitingFlagValue := "" + for _, arg := range args { + if arg == "--" { + break + } + + if waitingFlagValue != "" { + if waitingFlagValue == "--config" { + ctx.ConfigPath = arg + } + waitingFlagValue = "" + continue + } + + if value, ok := parseLongFlag(arg, "--config"); ok { + if value == nil { + waitingFlagValue = "--config" + } else { + ctx.ConfigPath = *value + } + continue + } + + if value, ok := parseLongFlag(arg, "--json"); ok { + ctx.JSONOutput = parseBoolFlagValue(value, true) + continue + } + + if value, ok := parseLongFlag(arg, "--dry-run"); ok { + ctx.DryRun = parseBoolFlagValue(value, true) + continue + } + + if argRequiresValue(arg) { + waitingFlagValue = arg + continue + } + + if strings.HasPrefix(arg, "-") { + continue + } + + if ctx.Command == nil { + ctx.Command = arg + } + } + + return ctx +} + +func parseLongFlag(arg string, name string) (*string, bool) { + if arg == name { + return nil, true + } + + prefix := name + "=" + if strings.HasPrefix(arg, prefix) { + value := strings.TrimPrefix(arg, prefix) + return &value, true + } + + return nil, false +} + +func parseBoolFlagValue(value *string, defaultValue bool) bool { + if value == nil { + return defaultValue + } + + parsedValue, err := strconv.ParseBool(strings.TrimSpace(*value)) + if err != nil { + return defaultValue + } + return parsedValue +} + +func argRequiresValue(arg string) bool { + switch arg { + case "--config", "--log-file", "--log-level", "--direction", "--context": + return true + default: + return false + } +} + func Main() { osExit(Execute()) } diff --git a/internal/app/cli_additional_test.go b/internal/app/cli_additional_test.go index 41592f3..be5420d 100644 --- a/internal/app/cli_additional_test.go +++ b/internal/app/cli_additional_test.go @@ -54,7 +54,7 @@ func TestInvalidLogLevelWithJSONEnvelope(t *testing.T) { err = cmd.Execute() require.Error(t, err) - require.Contains(t, stderr.String(), "Invalid value for --log-level") + require.Empty(t, stderr.String()) var payload map[string]any require.NoError(t, json.Unmarshal(stdout.Bytes(), &payload)) @@ -63,6 +63,35 @@ func TestInvalidLogLevelWithJSONEnvelope(t *testing.T) { require.Equal(t, "DFM_FLAG_INVALID_VALUE", errorObj["code"]) } +func TestValidationErrorJSONIncludesExplicitConfigPath(t *testing.T) { + tempDir := t.TempDir() + setTempHome(t) + oldWD, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + require.NoError(t, os.Chdir(tempDir)) + + customConfig := filepath.Join(tempDir, "custom.yaml") + + cmd := NewRootCmd() + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"status", "--json", "--dry-run", "--config", customConfig}) + + err = cmd.Execute() + require.Error(t, err) + require.Empty(t, stderr.String()) + + var payload map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &payload)) + require.Equal(t, false, payload["ok"]) + require.Equal(t, customConfig, payload["config_path"]) + errorObj := payload["error"].(map[string]any) + require.Equal(t, "DFM_FLAG_UNSUPPORTED", errorObj["code"]) +} + func TestDeployWithPathScopeValue(t *testing.T) { tempDir := t.TempDir() setTempHome(t) @@ -178,13 +207,13 @@ func TestEmitErrorTextAndJSONBranches(t *testing.T) { emitError(&stdout, &stderr, true, jsonContext{Command: "status"}, errors.New("plain error")) require.Contains(t, stdout.String(), "\"ok\":false") require.Contains(t, stdout.String(), "\"schema_version\":\"4.0\"") - require.Contains(t, stderr.String(), "plain error") + require.Empty(t, stderr.String()) stdout.Reset() stderr.Reset() emitError(&stdout, &stderr, true, jsonContext{Command: "status"}, dfmerr.New(dfmerr.CodeScopeNoMatch, "No sync matched provided path", map[string]any{"path": "~/.config"})) require.Contains(t, stdout.String(), "DFM_SCOPE_NO_MATCH") - require.Contains(t, stderr.String(), "No sync matched provided path") + require.Empty(t, stderr.String()) } func TestEmitErrorJSONIncludesPartialSummary(t *testing.T) { diff --git a/internal/app/cli_logging_test.go b/internal/app/cli_logging_test.go index 85995d4..98522b5 100644 --- a/internal/app/cli_logging_test.go +++ b/internal/app/cli_logging_test.go @@ -99,7 +99,7 @@ func TestStatusJSONErrorLogsIncludeCode(t *testing.T) { err := cmd.Execute() require.Error(t, err) - require.Contains(t, stderr.String(), "Config not found") + require.Empty(t, stderr.String()) logBody := readLogFile(t, logPath) require.Contains(t, logBody, "msg=command.error") diff --git a/internal/app/cli_parser_error_test.go b/internal/app/cli_parser_error_test.go new file mode 100644 index 0000000..71237fa --- /dev/null +++ b/internal/app/cli_parser_error_test.go @@ -0,0 +1,103 @@ +package app + +import ( + "bytes" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func runExecuteWithArgs(t *testing.T, args []string) (int, string, string) { + t.Helper() + + oldArgs := os.Args + oldStdout := executeStdout + oldStderr := executeStderr + defer func() { + os.Args = oldArgs + executeStdout = oldStdout + executeStderr = oldStderr + }() + + var stdout bytes.Buffer + var stderr bytes.Buffer + executeStdout = &stdout + executeStderr = &stderr + os.Args = append([]string{"dotfiles-manager"}, args...) + + exitCode := Execute() + return exitCode, stdout.String(), stderr.String() +} + +func TestExecuteUnknownFlagTextGoesToStderr(t *testing.T) { + exitCode, stdout, stderr := runExecuteWithArgs(t, []string{"status", "--bogus"}) + + require.Equal(t, 1, exitCode) + require.Empty(t, stdout) + require.Contains(t, stderr, "unknown flag: --bogus") +} + +func TestExecuteUnknownFlagWithJSONWritesParserEnvelopeToStdout(t *testing.T) { + exitCode, stdout, stderr := runExecuteWithArgs(t, []string{"status", "--json", "--bogus"}) + + require.Equal(t, 1, exitCode) + require.Empty(t, stderr) + + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + require.Equal(t, false, payload["ok"]) + require.Equal(t, "4.0", payload["schema_version"]) + require.Equal(t, "status", payload["command"]) + + errorObj := payload["error"].(map[string]any) + require.Equal(t, "DFM_PARSER_UNKNOWN_FLAG", errorObj["code"]) + require.Contains(t, errorObj["message"], "unknown flag: --bogus") + + details := errorObj["details"].(map[string]any) + require.Equal(t, "--bogus", details["flag"]) +} + +func TestExecuteUnknownCommandTextGoesToStderr(t *testing.T) { + exitCode, stdout, stderr := runExecuteWithArgs(t, []string{"sttaus"}) + + require.Equal(t, 1, exitCode) + require.Empty(t, stdout) + require.Contains(t, stderr, `unknown command "sttaus"`) +} + +func TestExecuteParserArgFailureTextGoesToStderr(t *testing.T) { + exitCode, stdout, stderr := runExecuteWithArgs(t, []string{"status", "one", "two"}) + + require.Equal(t, 1, exitCode) + require.Empty(t, stdout) + require.Contains(t, stderr, "accepts at most 1 arg(s), received 2") +} + +func TestExecuteParserJSONEnvelopePropagatesConfigFlag(t *testing.T) { + exitCode, stdout, stderr := runExecuteWithArgs(t, []string{"status", "--config", "/tmp/custom.yaml", "--json", "--bogus"}) + + require.Equal(t, 1, exitCode) + require.Empty(t, stderr) + + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + require.Equal(t, "/tmp/custom.yaml", payload["config_path"]) + + errorObj := payload["error"].(map[string]any) + require.Equal(t, "DFM_PARSER_UNKNOWN_FLAG", errorObj["code"]) +} + +func TestExecuteRuntimeValidationJSONUsesStdoutOnly(t *testing.T) { + exitCode, stdout, stderr := runExecuteWithArgs(t, []string{"status", "--json", "--log-level", "verbose"}) + + require.Equal(t, 1, exitCode) + require.Empty(t, stderr) + + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + require.Equal(t, false, payload["ok"]) + errorObj := payload["error"].(map[string]any) + require.Equal(t, "DFM_FLAG_INVALID_VALUE", errorObj["code"]) +} diff --git a/internal/app/deploy.go b/internal/app/deploy.go index c541418..04f215c 100644 --- a/internal/app/deploy.go +++ b/internal/app/deploy.go @@ -352,11 +352,12 @@ func buildDeployOperations(copiedPayload []any, removedPayload []any, state stri continue } operations = append(operations, map[string]any{ - "phase": "copy", - "action": entry["change"], - "state": state, - "path": entry["path"], - "type": entry["type"], + "phase": "copy", + "phase_alias": operationPhaseAlias("copy"), + "action": entry["change"], + "state": state, + "path": entry["path"], + "type": entry["type"], }) } @@ -366,11 +367,12 @@ func buildDeployOperations(copiedPayload []any, removedPayload []any, state stri continue } operations = append(operations, map[string]any{ - "phase": "remove_unmanaged", - "action": "remove", - "state": state, - "path": entry["path"], - "type": entry["type"], + "phase": "remove_unmanaged", + "phase_alias": operationPhaseAlias("remove_unmanaged"), + "action": "remove", + "state": state, + "path": entry["path"], + "type": entry["type"], }) } diff --git a/internal/app/deploy_payload_test.go b/internal/app/deploy_payload_test.go index 20acc99..5b25e75 100644 --- a/internal/app/deploy_payload_test.go +++ b/internal/app/deploy_payload_test.go @@ -44,6 +44,8 @@ func TestDeployDryRunPlansWithoutMutating(t *testing.T) { sync := payload["syncs"].([]any)[0].(map[string]any) require.Equal(t, []string{"lua/init.lua", "lua/new.lua"}, operationPaths(sync, "copy")) require.Equal(t, []string{"lua/old.bak"}, operationPaths(sync, "remove_unmanaged")) + require.Equal(t, "deploy", findOperation(sync, "copy", "lua/init.lua")["phase_alias"]) + require.Equal(t, "remove_unmanaged", findOperation(sync, "remove_unmanaged", "lua/old.bak")["phase_alias"]) summary := payload["summary"].(map[string]any) require.Equal(t, float64(2), summary["copy_count"]) diff --git a/internal/app/diff.go b/internal/app/diff.go index 2fbbaee..8c2fc55 100644 --- a/internal/app/diff.go +++ b/internal/app/diff.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "os" + "strings" "unicode/utf8" "github.com/pmezard/go-difflib/difflib" @@ -120,7 +121,7 @@ func evaluateDiffSync(syncIndex int, syncCfg config.Sync, selection syncSelectio targetCopy := targetEntry if phaseAllowedByDirection("deploy", direction) { - op, opErr := buildDiffOperation("deploy", change, relPath, &sourceCopy, &targetCopy, contextLines, includePatch) + op, opErr := buildDiffOperation("deploy", change, relPath, &sourceCopy, &targetCopy, contextLines, includePatch, omittedEntryCountForPath(relPath, sourceEntries, targetEntries)) if opErr != nil { return nil, diffCounts{}, opErr } @@ -129,7 +130,7 @@ func evaluateDiffSync(syncIndex int, syncCfg config.Sync, selection syncSelectio incrementDiffKindCount(&counts, stringValue(op["diff_kind"])) } if phaseAllowedByDirection("import", direction) { - op, opErr := buildDiffOperation("import", change, relPath, &sourceCopy, &targetCopy, contextLines, includePatch) + op, opErr := buildDiffOperation("import", change, relPath, &sourceCopy, &targetCopy, contextLines, includePatch, omittedEntryCountForPath(relPath, sourceEntries, targetEntries)) if opErr != nil { return nil, diffCounts{}, opErr } @@ -141,7 +142,7 @@ func evaluateDiffSync(syncIndex int, syncCfg config.Sync, selection syncSelectio case hasSource && !hasTarget: sourceCopy := sourceEntry if phaseAllowedByDirection("deploy", direction) { - op, opErr := buildDiffOperation("deploy", "create", relPath, &sourceCopy, nil, contextLines, includePatch) + op, opErr := buildDiffOperation("deploy", "create", relPath, &sourceCopy, nil, contextLines, includePatch, omittedEntryCountForPath(relPath, sourceEntries, targetEntries)) if opErr != nil { return nil, diffCounts{}, opErr } @@ -161,7 +162,7 @@ func evaluateDiffSync(syncIndex int, syncCfg config.Sync, selection syncSelectio return nil, diffCounts{}, matchErr } if matchMissing && phaseAllowedByDirection("remove_missing", direction) { - op, opErr := buildDiffOperation("remove_missing", "remove", relPath, &sourceCopy, nil, contextLines, includePatch) + op, opErr := buildDiffOperation("remove_missing", "remove", relPath, &sourceCopy, nil, contextLines, includePatch, omittedEntryCountForPath(relPath, sourceEntries, targetEntries)) if opErr != nil { return nil, diffCounts{}, opErr } @@ -183,7 +184,7 @@ func evaluateDiffSync(syncIndex int, syncCfg config.Sync, selection syncSelectio return nil, diffCounts{}, incomingErr } if matchIncoming && phaseAllowedByDirection("incoming_unmanaged", direction) { - op, opErr := buildDiffOperation("incoming_unmanaged", "add", relPath, nil, &targetCopy, contextLines, includePatch) + op, opErr := buildDiffOperation("incoming_unmanaged", "add", relPath, nil, &targetCopy, contextLines, includePatch, omittedEntryCountForPath(relPath, sourceEntries, targetEntries)) if opErr != nil { return nil, diffCounts{}, opErr } @@ -201,7 +202,7 @@ func evaluateDiffSync(syncIndex int, syncCfg config.Sync, selection syncSelectio return nil, diffCounts{}, removableErr } if matchRemovable && phaseAllowedByDirection("remove_unmanaged", direction) { - op, opErr := buildDiffOperation("remove_unmanaged", "remove", relPath, nil, &targetCopy, contextLines, includePatch) + op, opErr := buildDiffOperation("remove_unmanaged", "remove", relPath, nil, &targetCopy, contextLines, includePatch, omittedEntryCountForPath(relPath, sourceEntries, targetEntries)) if opErr != nil { return nil, diffCounts{}, opErr } @@ -278,12 +279,13 @@ func incrementDiffKindCount(counts *diffCounts, kind string) { } } -func buildDiffOperation(phase, action, relPath string, sourceEntry, targetEntry *statusEntry, contextLines int, includePatch bool) (map[string]any, error) { +func buildDiffOperation(phase, action, relPath string, sourceEntry, targetEntry *statusEntry, contextLines int, includePatch bool, omittedEntryCount int) (map[string]any, error) { op := map[string]any{ - "phase": phase, - "action": statusActionLabel(action), - "state": "candidate", - "path": relPath, + "phase": phase, + "phase_alias": operationPhaseAlias(phase), + "action": statusActionLabel(action), + "state": "candidate", + "path": relPath, } sourceType := "missing" @@ -307,7 +309,7 @@ func buildDiffOperation(phase, action, relPath string, sourceEntry, targetEntry op["type"] = entryType } - diffMeta, err := buildDiffMetadata(phase, relPath, sourceEntry, targetEntry, contextLines, includePatch) + diffMeta, err := buildDiffMetadata(phase, relPath, sourceEntry, targetEntry, contextLines, includePatch, omittedEntryCount) if err != nil { return nil, err } @@ -318,7 +320,7 @@ func buildDiffOperation(phase, action, relPath string, sourceEntry, targetEntry return op, nil } -func buildDiffMetadata(phase, relPath string, sourceEntry, targetEntry *statusEntry, contextLines int, includePatch bool) (map[string]any, error) { +func buildDiffMetadata(phase, relPath string, sourceEntry, targetEntry *statusEntry, contextLines int, includePatch bool, omittedEntryCount int) (map[string]any, error) { oldEntry, newEntry, oldLabel, newLabel := diffPerspective(phase, relPath, sourceEntry, targetEntry) result := map[string]any{ @@ -329,6 +331,8 @@ func buildDiffMetadata(phase, relPath string, sourceEntry, targetEntry *statusEn if (oldEntry != nil && oldEntry.typeID == "dir") || (newEntry != nil && newEntry.typeID == "dir") { result["diff_kind"] = "omitted" result["reason"] = "directory diff omitted" + result["omitted_entry_count"] = omittedEntryCount + result["inspect_hint"] = "scope diff to this directory path for file-level changes" result["patch_available"] = false result["patch_included"] = false return result, nil @@ -380,6 +384,26 @@ func buildDiffMetadata(phase, relPath string, sourceEntry, targetEntry *statusEn return result, nil } +func omittedEntryCountForPath(relPath string, sourceEntries map[string]statusEntry, targetEntries map[string]statusEntry) int { + if relPath == "" { + return 0 + } + + prefix := relPath + "/" + unique := map[string]struct{}{} + for path := range sourceEntries { + if strings.HasPrefix(path, prefix) { + unique[path] = struct{}{} + } + } + for path := range targetEntries { + if strings.HasPrefix(path, prefix) { + unique[path] = struct{}{} + } + } + return len(unique) +} + func diffPerspective(phase, relPath string, sourceEntry, targetEntry *statusEntry) (oldEntry *statusEntry, newEntry *statusEntry, oldLabel string, newLabel string) { switch phase { case "deploy": diff --git a/internal/app/diff_internal_test.go b/internal/app/diff_internal_test.go index bbd1db0..6229e64 100644 --- a/internal/app/diff_internal_test.go +++ b/internal/app/diff_internal_test.go @@ -13,14 +13,16 @@ import ( func TestBuildDiffMetadataKinds(t *testing.T) { t.Parallel() - metadata, err := buildDiffMetadata("deploy", "lua", &statusEntry{typeID: "dir"}, nil, 3, false) + metadata, err := buildDiffMetadata("deploy", "lua", &statusEntry{typeID: "dir"}, nil, 3, false, 4) require.NoError(t, err) require.Equal(t, "omitted", metadata["diff_kind"]) require.Equal(t, "directory diff omitted", metadata["reason"]) + require.Equal(t, 4, metadata["omitted_entry_count"]) + require.Equal(t, "scope diff to this directory path for file-level changes", metadata["inspect_hint"]) require.Equal(t, false, metadata["patch_available"]) require.Equal(t, false, metadata["patch_included"]) - metadata, err = buildDiffMetadata("deploy", "lua/init.lua", &statusEntry{typeID: "file"}, &statusEntry{typeID: "symlink"}, 3, false) + metadata, err = buildDiffMetadata("deploy", "lua/init.lua", &statusEntry{typeID: "file"}, &statusEntry{typeID: "symlink"}, 3, false, 0) require.NoError(t, err) require.Equal(t, "type_change", metadata["diff_kind"]) require.Equal(t, "type differs", metadata["reason"]) @@ -40,6 +42,7 @@ func TestBuildDiffMetadataKinds(t *testing.T) { &statusEntry{typeID: "file", absPath: targetBinary}, 3, false, + 0, ) require.NoError(t, err) require.Equal(t, "binary", metadata["diff_kind"]) @@ -60,7 +63,7 @@ func TestBuildDiffMetadataPatchModes(t *testing.T) { sourceEntry := &statusEntry{typeID: "file", absPath: sourcePath} targetEntry := &statusEntry{typeID: "file", absPath: targetPath} - metadataNoPatch, err := buildDiffMetadata("deploy", "notes.txt", sourceEntry, targetEntry, 3, false) + metadataNoPatch, err := buildDiffMetadata("deploy", "notes.txt", sourceEntry, targetEntry, 3, false, 0) require.NoError(t, err) require.Equal(t, "unified", metadataNoPatch["diff_kind"]) require.Equal(t, true, metadataNoPatch["patch_available"]) @@ -68,7 +71,7 @@ func TestBuildDiffMetadataPatchModes(t *testing.T) { _, hasPatch := metadataNoPatch["patch"] require.False(t, hasPatch) - metadataWithPatch, err := buildDiffMetadata("deploy", "notes.txt", sourceEntry, targetEntry, 3, true) + metadataWithPatch, err := buildDiffMetadata("deploy", "notes.txt", sourceEntry, targetEntry, 3, true, 0) require.NoError(t, err) require.Equal(t, "unified", metadataWithPatch["diff_kind"]) require.Equal(t, true, metadataWithPatch["patch_available"]) @@ -95,6 +98,7 @@ func TestBuildDiffMetadataOmittedWhenPatchTooLarge(t *testing.T) { &statusEntry{typeID: "file", absPath: targetPath}, 0, true, + 0, ) require.NoError(t, err) require.Equal(t, "omitted", metadata["diff_kind"]) @@ -202,3 +206,23 @@ func TestDiffPerspectivePatchAndCounters(t *testing.T) { require.Equal(t, 1, counts.typeChange) require.Equal(t, 1, counts.omitted) } + +func TestOmittedEntryCountForPathUsesExistingScannedMaps(t *testing.T) { + t.Parallel() + + sourceEntries := map[string]statusEntry{ + "lua": {path: "lua", typeID: "dir"}, + "lua/init.lua": {path: "lua/init.lua", typeID: "file"}, + "lua/plugins": {path: "lua/plugins", typeID: "dir"}, + "lua/plugins/a.lua": {path: "lua/plugins/a.lua", typeID: "file"}, + } + targetEntries := map[string]statusEntry{ + "lua": {path: "lua", typeID: "dir"}, + "lua/plugins": {path: "lua/plugins", typeID: "dir"}, + "lua/plugins/b.lua": {path: "lua/plugins/b.lua", typeID: "file"}, + } + + require.Equal(t, 4, omittedEntryCountForPath("lua", sourceEntries, targetEntries)) + require.Equal(t, 2, omittedEntryCountForPath("lua/plugins", sourceEntries, targetEntries)) + require.Equal(t, 0, omittedEntryCountForPath("missing", sourceEntries, targetEntries)) +} diff --git a/internal/app/diff_payload_test.go b/internal/app/diff_payload_test.go index e661ceb..112f10b 100644 --- a/internal/app/diff_payload_test.go +++ b/internal/app/diff_payload_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/shpoont/dotfiles-manager/internal/dfmerr" "github.com/stretchr/testify/require" ) @@ -35,7 +36,9 @@ func TestDiffTextOutputShowsUnifiedPatchByDefault(t *testing.T) { require.NoError(t, cmd.Execute(), stderr.String()) body := stdout.String() - require.Contains(t, body, "deploy-diff[1] (source -> target)") + require.Contains(t, body, "legend intent: deploy applies source -> target; import applies target -> source") + require.Contains(t, body, "legend patch-orientation: deploy-diff compares target -> source; import-diff compares source -> target") + require.Contains(t, body, "deploy-diff[1] (target -> source)") require.Contains(t, body, "--- target/lua/init.lua") require.Contains(t, body, "+++ source/lua/init.lua") require.Contains(t, body, "@@") @@ -184,6 +187,31 @@ func TestDiffJSONReportsBinaryEntries(t *testing.T) { require.Equal(t, "binary differs", op["reason"]) } +func TestDiffJSONDirectoryOmittedIncludesEntryCountAndHint(t *testing.T) { + projectDir := t.TempDir() + homeDir := setTempHome(t) + setCWD(t, projectDir) + + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "source", "nvim", "lua", "plugins"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(homeDir, ".config", "nvim"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "source", "nvim", "lua", "plugins", "a.lua"), []byte("a\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "source", "nvim", "lua", "plugins", "b.lua"), []byte("b\n"), 0o644)) + + writeConfig(t, projectDir, []byte(`syncs: + - target: .config/nvim + source: source/nvim +`)) + + payload := runJSONCommand(t, []string{"diff", "--json"}) + sync := payload["syncs"].([]any)[0].(map[string]any) + op := findOperation(sync, "deploy", "lua/plugins") + require.NotNil(t, op) + require.Equal(t, "omitted", op["diff_kind"]) + require.Equal(t, "directory diff omitted", op["reason"]) + require.Equal(t, float64(2), op["omitted_entry_count"]) + require.Equal(t, "scope diff to this directory path for file-level changes", op["inspect_hint"]) +} + func TestDiffPatchFlagRequiresJSON(t *testing.T) { projectDir := t.TempDir() setTempHome(t) @@ -213,7 +241,19 @@ func TestDiffPatchFlagRequiresJSON(t *testing.T) { cmd.SetArgs([]string{"diff", "--patch", "~/.config/nvim"}) err = cmd.Execute() require.Error(t, err) - require.Contains(t, stderr.String(), "Flag not supported for command: --patch") + require.Contains(t, stderr.String(), "--patch requires --json") +} + +func TestPatchRequiresJSONErrorProvidesPrescriptiveDetails(t *testing.T) { + err := patchRequiresJSONError() + require.Equal(t, "--patch requires --json", err.Error()) + + dfmError, ok := dfmerr.As(err) + require.True(t, ok) + require.Equal(t, dfmerr.CodeFlagUnsupported, dfmError.Code) + require.Equal(t, "--patch", dfmError.Details["flag"]) + require.Equal(t, []string{"--json"}, dfmError.Details["required_flags"]) + require.Equal(t, "dotfiles-manager diff --json --patch", dfmError.Details["example"]) } func TestDiffFlagValidationErrors(t *testing.T) { diff --git a/internal/app/import.go b/internal/app/import.go index a92757e..86b4570 100644 --- a/internal/app/import.go +++ b/internal/app/import.go @@ -277,11 +277,12 @@ func buildImportOperations(updatedManifestPayload []any, addedUnmanagedPayload [ continue } operations = append(operations, map[string]any{ - "phase": "update_managed", - "action": entry["change"], - "state": state, - "path": entry["path"], - "type": entry["type"], + "phase": "update_managed", + "phase_alias": operationPhaseAlias("update_managed"), + "action": entry["change"], + "state": state, + "path": entry["path"], + "type": entry["type"], }) } @@ -291,11 +292,12 @@ func buildImportOperations(updatedManifestPayload []any, addedUnmanagedPayload [ continue } operations = append(operations, map[string]any{ - "phase": "add_unmanaged", - "action": "add", - "state": state, - "path": entry["path"], - "type": entry["type"], + "phase": "add_unmanaged", + "phase_alias": operationPhaseAlias("add_unmanaged"), + "action": "add", + "state": state, + "path": entry["path"], + "type": entry["type"], }) } @@ -305,11 +307,12 @@ func buildImportOperations(updatedManifestPayload []any, addedUnmanagedPayload [ continue } operations = append(operations, map[string]any{ - "phase": "remove_missing", - "action": "remove", - "state": state, - "path": entry["path"], - "type": entry["type"], + "phase": "remove_missing", + "phase_alias": operationPhaseAlias("remove_missing"), + "action": "remove", + "state": state, + "path": entry["path"], + "type": entry["type"], }) } diff --git a/internal/app/import_payload_test.go b/internal/app/import_payload_test.go index d01a3fc..acc472f 100644 --- a/internal/app/import_payload_test.go +++ b/internal/app/import_payload_test.go @@ -56,6 +56,9 @@ func TestImportDryRunPlansWithoutMutating(t *testing.T) { require.Equal(t, []string{"lua/init.lua"}, operationPaths(sync, "update_managed")) require.Equal(t, []string{"lua/new.lua"}, operationPaths(sync, "add_unmanaged")) require.Equal(t, []string{"lua/missing.lua"}, operationPaths(sync, "remove_missing")) + require.Equal(t, "import", findOperation(sync, "update_managed", "lua/init.lua")["phase_alias"]) + require.Equal(t, "incoming_unmanaged", findOperation(sync, "add_unmanaged", "lua/new.lua")["phase_alias"]) + require.Equal(t, "remove_missing", findOperation(sync, "remove_missing", "lua/missing.lua")["phase_alias"]) summary := payload["summary"].(map[string]any) require.Equal(t, float64(1), summary["update_managed_count"]) diff --git a/internal/app/phase_alias.go b/internal/app/phase_alias.go new file mode 100644 index 0000000..11ff997 --- /dev/null +++ b/internal/app/phase_alias.go @@ -0,0 +1,27 @@ +package app + +func operationPhaseAlias(phase string) string { + switch phase { + case "copy": + return "deploy" + case "update_managed": + return "import" + case "add_unmanaged": + return "incoming_unmanaged" + default: + return phase + } +} + +func phaseHeaderAlias(label string) string { + switch label { + case "copy": + return "deploy" + case "update-managed": + return "import" + case "add-unmanaged": + return "incoming-unmanaged" + default: + return "" + } +} diff --git a/internal/app/phase_alias_test.go b/internal/app/phase_alias_test.go new file mode 100644 index 0000000..53a2fc8 --- /dev/null +++ b/internal/app/phase_alias_test.go @@ -0,0 +1,23 @@ +package app + +import "testing" + +import "github.com/stretchr/testify/require" + +func TestOperationPhaseAliasMappings(t *testing.T) { + t.Parallel() + + require.Equal(t, "deploy", operationPhaseAlias("copy")) + require.Equal(t, "import", operationPhaseAlias("update_managed")) + require.Equal(t, "incoming_unmanaged", operationPhaseAlias("add_unmanaged")) + require.Equal(t, "remove_unmanaged", operationPhaseAlias("remove_unmanaged")) +} + +func TestPhaseHeaderAliasMappings(t *testing.T) { + t.Parallel() + + require.Equal(t, "deploy", phaseHeaderAlias("copy")) + require.Equal(t, "import", phaseHeaderAlias("update-managed")) + require.Equal(t, "incoming-unmanaged", phaseHeaderAlias("add-unmanaged")) + require.Equal(t, "", phaseHeaderAlias("remove-unmanaged")) +} diff --git a/internal/app/regression_ux_validation_test.go b/internal/app/regression_ux_validation_test.go new file mode 100644 index 0000000..1fb9ee0 --- /dev/null +++ b/internal/app/regression_ux_validation_test.go @@ -0,0 +1,111 @@ +package app + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRegressionSelectedDefaults(t *testing.T) { + t.Run("json error policy stdout only", func(t *testing.T) { + projectDir := t.TempDir() + setTempHome(t) + setCWD(t, projectDir) + customConfig := filepath.Join(projectDir, "custom-config.yaml") + + cmd := NewRootCmd() + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"status", "--json", "--dry-run", "--config", customConfig}) + + err := cmd.Execute() + require.Error(t, err) + require.Empty(t, stderr.String()) + + var payload map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &payload)) + require.Equal(t, false, payload["ok"]) + require.Equal(t, customConfig, payload["config_path"]) + require.Equal(t, "DFM_FLAG_UNSUPPORTED", payload["error"].(map[string]any)["code"]) + }) + + t.Run("diff legend remains explicit two-line block", func(t *testing.T) { + diffOutput := buildTextOutput("diff", false, map[string]any{ + "syncs": []any{ + map[string]any{ + "sync": "sync[0] target=~/.config/nvim source=./source/nvim", + "operations": []any{}, + }, + }, + "summary": map[string]any{}, + }) + + intentLine := "legend intent: deploy applies source -> target; import applies target -> source" + orientationLine := "legend patch-orientation: deploy-diff compares target -> source; import-diff compares source -> target" + require.Equal(t, 1, strings.Count(diffOutput, intentLine)) + require.Equal(t, 1, strings.Count(diffOutput, orientationLine)) + }) + + t.Run("scoped summaries expose excluded sync count", func(t *testing.T) { + _, _, scopePath := setupScopedSummaryFixture(t) + + payload := runJSONCommand(t, []string{"status", "--json", scopePath}) + summary := payload["summary"].(map[string]any) + require.Equal(t, float64(1), summary["excluded_sync_count"]) + }) + + t.Run("omitted directory metric uses scanned entry maps", func(t *testing.T) { + sourceEntries := map[string]statusEntry{ + "lua": {path: "lua", typeID: "dir"}, + "lua/init.lua": {path: "lua/init.lua", typeID: "file"}, + } + targetEntries := map[string]statusEntry{ + "lua": {path: "lua", typeID: "dir"}, + "lua/plugins.lua": {path: "lua/plugins.lua", typeID: "file"}, + } + + require.Equal(t, 2, omittedEntryCountForPath("lua", sourceEntries, targetEntries)) + }) + + t.Run("version output is a single enriched provenance line", func(t *testing.T) { + oldVersion := buildVersion + oldCommit := buildCommit + oldDate := buildDate + oldChannel := buildChannel + oldProvenance := buildProvenance + t.Cleanup(func() { + buildVersion = oldVersion + buildCommit = oldCommit + buildDate = oldDate + buildChannel = oldChannel + buildProvenance = oldProvenance + }) + + buildVersion = "2.0.0" + buildCommit = "deadbee" + buildDate = "2026-02-22T12:34:56Z" + buildChannel = "stable" + buildProvenance = "ci" + + cmd := NewRootCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"version"}) + require.NoError(t, cmd.Execute()) + + out := stdout.String() + require.Equal(t, 1, strings.Count(out, "\n")) + require.Contains(t, out, "version=2.0.0") + require.Contains(t, out, "commit=deadbee") + require.Contains(t, out, "date=2026-02-22T12:34:56Z") + require.Contains(t, out, "channel=stable") + require.Contains(t, out, "provenance=ci") + }) +} diff --git a/internal/app/scope_summary_test.go b/internal/app/scope_summary_test.go new file mode 100644 index 0000000..13b5f74 --- /dev/null +++ b/internal/app/scope_summary_test.go @@ -0,0 +1,89 @@ +package app + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestScopedCommandsReportExcludedSyncCountInJSON(t *testing.T) { + projectDir, homeDir, scopePath := setupScopedSummaryFixture(t) + + commands := [][]string{ + {"status", "--json", scopePath}, + {"diff", "--json", scopePath}, + {"deploy", "--json", "--dry-run", scopePath}, + {"import", "--json", "--dry-run", scopePath}, + } + + for _, args := range commands { + cmd := NewRootCmd() + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(args) + + require.NoError(t, cmd.Execute(), stderr.String()) + + var payload map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &payload)) + summary := payload["summary"].(map[string]any) + require.Equal(t, float64(1), summary["sync_count"]) + require.Equal(t, float64(1), summary["excluded_sync_count"]) + } + + unscoped := runJSONCommand(t, []string{"status", "--json"}) + unscopedSummary := unscoped["summary"].(map[string]any) + require.Equal(t, float64(2), unscopedSummary["sync_count"]) + require.Equal(t, float64(0), unscopedSummary["excluded_sync_count"]) + + _ = projectDir + _ = homeDir +} + +func TestScopedTextSummaryReportsExcludedSyncCount(t *testing.T) { + _, _, scopePath := setupScopedSummaryFixture(t) + + cmd := NewRootCmd() + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"status", scopePath}) + + require.NoError(t, cmd.Execute(), stderr.String()) + require.Contains(t, stdout.String(), "excluded-syncs=1") +} + +func setupScopedSummaryFixture(t *testing.T) (projectDir string, homeDir string, scopePath string) { + t.Helper() + + projectDir = t.TempDir() + homeDir = setTempHome(t) + setCWD(t, projectDir) + + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "source", "nvim"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "source", "zsh"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(homeDir, ".config", "nvim"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(homeDir, ".config", "zsh"), 0o755)) + + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "source", "nvim", "init.lua"), []byte("source-nvim\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(homeDir, ".config", "nvim", "init.lua"), []byte("target-nvim\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "source", "zsh", ".zshrc"), []byte("source-zsh\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(homeDir, ".config", "zsh", ".zshrc"), []byte("target-zsh\n"), 0o644)) + + writeConfig(t, projectDir, []byte(`syncs: + - target: .config/nvim + source: source/nvim + - target: .config/zsh + source: source/zsh +`)) + + scopePath = filepath.Join(homeDir, ".config", "nvim") + return projectDir, homeDir, scopePath +} diff --git a/internal/app/status.go b/internal/app/status.go index a386fa4..5d16cee 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -199,6 +199,7 @@ func statusTargetScanPatterns(syncCfg config.Sync) []string { func buildStatusManagedOperation(phase, path, action, sourceType, targetType string) map[string]any { return map[string]any{ "phase": phase, + "phase_alias": operationPhaseAlias(phase), "action": statusActionLabel(action), "state": "candidate", "path": path, @@ -209,11 +210,12 @@ func buildStatusManagedOperation(phase, path, action, sourceType, targetType str func buildStatusTypedOperation(phase, action, path, entryType string) map[string]any { return map[string]any{ - "phase": phase, - "action": statusActionLabel(action), - "state": "candidate", - "path": path, - "type": entryType, + "phase": phase, + "phase_alias": operationPhaseAlias(phase), + "action": statusActionLabel(action), + "state": "candidate", + "path": path, + "type": entryType, } } diff --git a/internal/app/status_payload_test.go b/internal/app/status_payload_test.go index 7ef706c..bd032fb 100644 --- a/internal/app/status_payload_test.go +++ b/internal/app/status_payload_test.go @@ -67,12 +67,15 @@ func TestStatusJSONReportsDriftAndCandidates(t *testing.T) { require.Equal(t, []string{"alpha", "alpha/a.lua", "alpha/z.lua", "lua/init.lua", "lua/only-source.lua"}, operationPaths(sync, "deploy")) require.Equal(t, "can create", findOperation(sync, "deploy", "alpha")["action"]) + require.Equal(t, "deploy", findOperation(sync, "deploy", "alpha")["phase_alias"]) require.Equal(t, "can update", findOperation(sync, "deploy", "lua/init.lua")["action"]) require.Equal(t, []string{"lua/init.lua"}, operationPaths(sync, "import")) require.Equal(t, "can update", findOperation(sync, "import", "lua/init.lua")["action"]) + require.Equal(t, "import", findOperation(sync, "import", "lua/init.lua")["phase_alias"]) require.Equal(t, []string{"lua/new.lua", "lua/old.bak"}, operationPaths(sync, "incoming_unmanaged")) + require.Equal(t, "incoming_unmanaged", findOperation(sync, "incoming_unmanaged", "lua/new.lua")["phase_alias"]) require.Equal(t, []string{"lua/old.bak"}, operationPaths(sync, "remove_unmanaged")) diff --git a/internal/app/text_output.go b/internal/app/text_output.go index 8cda556..f0c8481 100644 --- a/internal/app/text_output.go +++ b/internal/app/text_output.go @@ -14,11 +14,15 @@ func buildTextOutput(command string, dryRun bool, result map[string]any) string lines := make([]string, 0) syncs := syncPayloadMaps(result["syncs"]) + if (command == "deploy" || command == "import") && len(syncs) > 0 { + lines = append(lines, modeBannerLine(dryRun)) + } + if command == "status" && len(syncs) > 0 { lines = append(lines, "reminder: deploy applies source -> target; import applies target -> source") } if command == "diff" && len(syncs) > 0 { - lines = append(lines, "reminder: deploy diff compares target -> source; import diff compares source -> target") + lines = append(lines, diffLegendLines()...) } for idx, sync := range syncs { @@ -38,18 +42,18 @@ func buildTextOutput(command string, dryRun bool, result map[string]any) string lines = appendPhaseBlock(lines, "remove-unmanaged", operationPayloadMapsByPhase(sync, "remove_unmanaged")) lines = appendPhaseBlock(lines, "remove-missing", operationPayloadMapsByPhase(sync, "remove_missing")) case "deploy": - lines = appendPhaseBlock(lines, "copy", operationPayloadMapsByPhase(sync, "copy")) - lines = appendPhaseBlock(lines, "remove-unmanaged", operationPayloadMapsByPhase(sync, "remove_unmanaged")) + lines = appendPhaseBlockWithState(lines, "copy", "", operationPayloadMapsByPhase(sync, "copy"), true) + lines = appendPhaseBlockWithState(lines, "remove-unmanaged", "", operationPayloadMapsByPhase(sync, "remove_unmanaged"), true) case "import": - lines = appendPhaseBlock(lines, "update-managed", operationPayloadMapsByPhase(sync, "update_managed")) - lines = appendPhaseBlock(lines, "add-unmanaged", operationPayloadMapsByPhase(sync, "add_unmanaged")) - lines = appendPhaseBlock(lines, "remove-missing", operationPayloadMapsByPhase(sync, "remove_missing")) + lines = appendPhaseBlockWithState(lines, "update-managed", "", operationPayloadMapsByPhase(sync, "update_managed"), true) + lines = appendPhaseBlockWithState(lines, "add-unmanaged", "", operationPayloadMapsByPhase(sync, "add_unmanaged"), true) + lines = appendPhaseBlockWithState(lines, "remove-missing", "", operationPayloadMapsByPhase(sync, "remove_missing"), true) case "diff": - lines = appendDiffPhaseBlock(lines, "deploy-diff", "(source -> target)", operationPayloadMapsByPhase(sync, "deploy")) - lines = appendDiffPhaseBlock(lines, "import-diff", "(target -> source)", operationPayloadMapsByPhase(sync, "import")) + lines = appendDiffPhaseBlock(lines, "deploy-diff", "(target -> source)", operationPayloadMapsByPhase(sync, "deploy")) + lines = appendDiffPhaseBlock(lines, "import-diff", "(source -> target)", operationPayloadMapsByPhase(sync, "import")) lines = appendDiffPhaseBlock(lines, "incoming-unmanaged", "(target -> source)", operationPayloadMapsByPhase(sync, "incoming_unmanaged")) - lines = appendDiffPhaseBlock(lines, "remove-unmanaged", "(source -> target)", operationPayloadMapsByPhase(sync, "remove_unmanaged")) - lines = appendDiffPhaseBlock(lines, "remove-missing", "(target -> source)", operationPayloadMapsByPhase(sync, "remove_missing")) + lines = appendDiffPhaseBlock(lines, "remove-unmanaged", "(target -> /dev/null)", operationPayloadMapsByPhase(sync, "remove_unmanaged")) + lines = appendDiffPhaseBlock(lines, "remove-missing", "(source -> /dev/null)", operationPayloadMapsByPhase(sync, "remove_missing")) } } @@ -58,6 +62,20 @@ func buildTextOutput(command string, dryRun bool, result map[string]any) string return strings.Join(lines, "\n") } +func diffLegendLines() []string { + return []string{ + "legend intent: deploy applies source -> target; import applies target -> source", + "legend patch-orientation: deploy-diff compares target -> source; import-diff compares source -> target", + } +} + +func modeBannerLine(dryRun bool) string { + if dryRun { + return "MODE: DRY RUN (no writes)" + } + return "MODE: APPLY (writes enabled)" +} + func buildSyncHeader(sync map[string]any) string { label := stringValue(sync["sync"]) if label == "" { @@ -71,15 +89,22 @@ func buildSyncHeader(sync map[string]any) string { } func appendPhaseBlock(lines []string, label string, operations []map[string]any) []string { - return appendPhaseBlockWithContext(lines, label, "", operations) + return appendPhaseBlockWithState(lines, label, "", operations, false) } func appendPhaseBlockWithContext(lines []string, label string, context string, operations []map[string]any) []string { + return appendPhaseBlockWithState(lines, label, context, operations, false) +} + +func appendPhaseBlockWithState(lines []string, label string, context string, operations []map[string]any, includeState bool) []string { if len(operations) == 0 { return lines } header := fmt.Sprintf("%s[%d]", label, len(operations)) + if alias := phaseHeaderAlias(label); alias != "" { + header = fmt.Sprintf("%s [%s]", header, alias) + } if context != "" { header = fmt.Sprintf("%s %s", header, context) } @@ -94,6 +119,11 @@ func appendPhaseBlockWithContext(lines []string, label string, context string, o path = "" } line := fmt.Sprintf(" %-12s %s", action, path) + if includeState { + if marker := stateMarker(op); marker != "" { + line = fmt.Sprintf(" [%s] %-12s %s", marker, action, path) + } + } if details := operationDetails(op); details != "" { line += " (" + details + ")" } @@ -102,6 +132,17 @@ func appendPhaseBlockWithContext(lines []string, label string, context string, o return lines } +func stateMarker(op map[string]any) string { + switch stringValue(op["state"]) { + case "planned": + return "planned" + case "applied": + return "applied" + default: + return "" + } +} + func appendDiffPhaseBlock(lines []string, label string, context string, operations []map[string]any) []string { if len(operations) == 0 { return lines @@ -218,6 +259,16 @@ func operationDetails(op map[string]any) string { func diffNote(op map[string]any) string { reason := stringValue(op["reason"]) kind := stringValue(op["diff_kind"]) + if kind == "omitted" && reason == "directory diff omitted" { + note := reason + if count := summaryInt(op, "omitted_entry_count"); count > 0 { + note = fmt.Sprintf("%s (%d entries)", note, count) + } + if hint := stringValue(op["inspect_hint"]); hint != "" { + note = note + "; " + hint + } + return note + } if reason != "" { return reason } diff --git a/internal/app/text_output_test.go b/internal/app/text_output_test.go index 639ef73..14a66e2 100644 --- a/internal/app/text_output_test.go +++ b/internal/app/text_output_test.go @@ -48,8 +48,8 @@ func TestBuildTextOutputStatusAndDeploy(t *testing.T) { map[string]any{ "sync": "sync[0] target=~/.config/nvim source=./source/nvim", "operations": []any{ - map[string]any{"phase": "copy", "action": "update", "path": "lua/init.lua", "type": "file"}, - map[string]any{"phase": "remove_unmanaged", "action": "remove", "path": "lua/old.bak", "type": "file"}, + map[string]any{"phase": "copy", "action": "update", "state": "planned", "path": "lua/init.lua", "type": "file"}, + map[string]any{"phase": "remove_unmanaged", "action": "remove", "state": "planned", "path": "lua/old.bak", "type": "file"}, }, }, }, @@ -59,9 +59,29 @@ func TestBuildTextOutputStatusAndDeploy(t *testing.T) { }, }) - require.Contains(t, deployOutput, "copy[1]") + require.Contains(t, deployOutput, "MODE: DRY RUN (no writes)") + require.Contains(t, deployOutput, "copy[1] [deploy]") require.Contains(t, deployOutput, "remove-unmanaged[1]") + require.Contains(t, deployOutput, "[planned] update") + require.Contains(t, deployOutput, "[planned] remove") require.Contains(t, deployOutput, "summary dry-run=true copied=1 remove-unmanaged=1") + + importOutput := buildTextOutput("import", false, map[string]any{ + "syncs": []any{ + map[string]any{ + "sync": "sync[0] target=~/.config/nvim source=./source/nvim", + "operations": []any{ + map[string]any{"phase": "update_managed", "action": "update", "state": "applied", "path": "lua/init.lua", "type": "file"}, + }, + }, + }, + "summary": map[string]any{ + "update_managed_count": 1, + }, + }) + require.Contains(t, importOutput, "MODE: APPLY (writes enabled)") + require.Contains(t, importOutput, "update-managed[1] [import]") + require.Contains(t, importOutput, "[applied] update") } func TestBuildTextOutputFallbackAndHelpers(t *testing.T) { @@ -146,10 +166,11 @@ func TestBuildTextOutputDiff(t *testing.T) { }, }) - require.Contains(t, diffOutput, "reminder: deploy diff compares target -> source; import diff compares source -> target") - require.Contains(t, diffOutput, "deploy-diff[1] (source -> target)") + require.Contains(t, diffOutput, "legend intent: deploy applies source -> target; import applies target -> source") + require.Contains(t, diffOutput, "legend patch-orientation: deploy-diff compares target -> source; import-diff compares source -> target") + require.Contains(t, diffOutput, "deploy-diff[1] (target -> source)") require.Contains(t, diffOutput, "--- target/lua/init.lua") - require.Contains(t, diffOutput, "remove-unmanaged[1] (source -> target)") + require.Contains(t, diffOutput, "remove-unmanaged[1] (target -> /dev/null)") require.Contains(t, diffOutput, "note: binary differs") require.Contains(t, diffOutput, "summary deploy-diff=1 remove-unmanaged=1 unified=1 binary=1") } @@ -158,6 +179,12 @@ func TestDiffNoteFallbacks(t *testing.T) { t.Parallel() require.Equal(t, "explicit reason", diffNote(map[string]any{"reason": "explicit reason", "diff_kind": "binary"})) + require.Equal(t, "directory diff omitted (3 entries); scope diff to this directory path for file-level changes", diffNote(map[string]any{ + "reason": "directory diff omitted", + "diff_kind": "omitted", + "omitted_entry_count": 3, + "inspect_hint": "scope diff to this directory path for file-level changes", + })) require.Equal(t, "binary differs", diffNote(map[string]any{"diff_kind": "binary"})) require.Equal(t, "type differs", diffNote(map[string]any{"diff_kind": "type_change"})) require.Equal(t, "patch omitted", diffNote(map[string]any{"diff_kind": "omitted"})) diff --git a/internal/app/version.go b/internal/app/version.go index 5c3a3a9..18fe969 100644 --- a/internal/app/version.go +++ b/internal/app/version.go @@ -5,16 +5,33 @@ import ( "strings" ) -var buildVersion = "dev" +var ( + buildVersion = "dev" + buildCommit = "unknown" + buildDate = "unknown" + buildChannel = "dev" + buildProvenance = "unspecified" +) func currentVersion() string { - version := strings.TrimSpace(buildVersion) - if version == "" { - return "dev" - } - return version + return buildValue(buildVersion, "dev") } func versionLine() string { - return fmt.Sprintf("dotfiles-manager version %s", currentVersion()) + return fmt.Sprintf( + "dotfiles-manager version=%s commit=%s date=%s channel=%s provenance=%s", + currentVersion(), + buildValue(buildCommit, "unknown"), + buildValue(buildDate, "unknown"), + buildValue(buildChannel, "dev"), + buildValue(buildProvenance, "unspecified"), + ) +} + +func buildValue(raw string, fallback string) string { + value := strings.TrimSpace(raw) + if value == "" { + return fallback + } + return value } diff --git a/internal/app/version_test.go b/internal/app/version_test.go index 836a72a..bfd5c91 100644 --- a/internal/app/version_test.go +++ b/internal/app/version_test.go @@ -16,8 +16,22 @@ func TestVersionCommandAndFlagPrintVersionWithoutConfig(t *testing.T) { require.NoError(t, os.Chdir(projectDir)) oldVersion := buildVersion + oldCommit := buildCommit + oldDate := buildDate + oldChannel := buildChannel + oldProvenance := buildProvenance buildVersion = "1.2.3" - t.Cleanup(func() { buildVersion = oldVersion }) + buildCommit = "abc1234" + buildDate = "2026-02-22T10:00:00Z" + buildChannel = "stable" + buildProvenance = "goreleaser" + t.Cleanup(func() { + buildVersion = oldVersion + buildCommit = oldCommit + buildDate = oldDate + buildChannel = oldChannel + buildProvenance = oldProvenance + }) testCases := [][]string{ {"version"}, @@ -33,7 +47,7 @@ func TestVersionCommandAndFlagPrintVersionWithoutConfig(t *testing.T) { cmd.SetArgs(args) require.NoError(t, cmd.Execute()) - require.Equal(t, "dotfiles-manager version 1.2.3\n", stdout.String()) + require.Equal(t, "dotfiles-manager version=1.2.3 commit=abc1234 date=2026-02-22T10:00:00Z channel=stable provenance=goreleaser\n", stdout.String()) require.Empty(t, stderr.String()) } } @@ -46,8 +60,22 @@ func TestVersionCommandFallsBackToDevWhenUnset(t *testing.T) { require.NoError(t, os.Chdir(projectDir)) oldVersion := buildVersion + oldCommit := buildCommit + oldDate := buildDate + oldChannel := buildChannel + oldProvenance := buildProvenance buildVersion = "" - t.Cleanup(func() { buildVersion = oldVersion }) + buildCommit = "" + buildDate = "" + buildChannel = "" + buildProvenance = "" + t.Cleanup(func() { + buildVersion = oldVersion + buildCommit = oldCommit + buildDate = oldDate + buildChannel = oldChannel + buildProvenance = oldProvenance + }) cmd := NewRootCmd() var stdout bytes.Buffer @@ -56,7 +84,7 @@ func TestVersionCommandFallsBackToDevWhenUnset(t *testing.T) { cmd.SetArgs([]string{"version"}) require.NoError(t, cmd.Execute()) - require.Equal(t, "dotfiles-manager version dev\n", stdout.String()) + require.Equal(t, "dotfiles-manager version=dev commit=unknown date=unknown channel=dev provenance=unspecified\n", stdout.String()) } func TestVersionCommandRejectsUnsupportedInputs(t *testing.T) { diff --git a/internal/dfmerr/errors.go b/internal/dfmerr/errors.go index ecb13df..ea6ce0a 100644 --- a/internal/dfmerr/errors.go +++ b/internal/dfmerr/errors.go @@ -19,10 +19,13 @@ const ( CodeConfigPathEscape Code = "DFM_CONFIG_PATH_ESCAPE" CodeConfigPathEnvUndefined Code = "DFM_CONFIG_PATH_ENV_VAR_UNDEFINED" - CodeFlagUnsupported Code = "DFM_FLAG_UNSUPPORTED" - CodeFlagInvalidValue Code = "DFM_FLAG_INVALID_VALUE" - CodeScopeNoMatch Code = "DFM_SCOPE_NO_MATCH" - CodeScopeInvalidPath Code = "DFM_SCOPE_INVALID_PATH" + CodeFlagUnsupported Code = "DFM_FLAG_UNSUPPORTED" + CodeFlagInvalidValue Code = "DFM_FLAG_INVALID_VALUE" + CodeParserUnknownFlag Code = "DFM_PARSER_UNKNOWN_FLAG" + CodeParserUnknownCommand Code = "DFM_PARSER_UNKNOWN_COMMAND" + CodeParserArgFailure Code = "DFM_PARSER_ARG_FAILURE" + CodeScopeNoMatch Code = "DFM_SCOPE_NO_MATCH" + CodeScopeInvalidPath Code = "DFM_SCOPE_INVALID_PATH" CodeIORead Code = "DFM_IO_READ" CodeIOWrite Code = "DFM_IO_WRITE" diff --git a/internal/dfmerr/errors_test.go b/internal/dfmerr/errors_test.go index ff31a39..6896a08 100644 --- a/internal/dfmerr/errors_test.go +++ b/internal/dfmerr/errors_test.go @@ -44,3 +44,9 @@ func TestWithDetailsNonDFMError(t *testing.T) { base := errors.New("plain") require.Equal(t, base, WithDetails(base, map[string]any{"x": 1})) } + +func TestParserErrorCodes(t *testing.T) { + require.Equal(t, Code("DFM_PARSER_UNKNOWN_FLAG"), CodeParserUnknownFlag) + require.Equal(t, Code("DFM_PARSER_UNKNOWN_COMMAND"), CodeParserUnknownCommand) + require.Equal(t, Code("DFM_PARSER_ARG_FAILURE"), CodeParserArgFailure) +} diff --git a/testdata/expected/contract/deploy-dry-run.json b/testdata/expected/contract/deploy-dry-run.json index 163abbf..82e9111 100644 --- a/testdata/expected/contract/deploy-dry-run.json +++ b/testdata/expected/contract/deploy-dry-run.json @@ -14,6 +14,7 @@ "schema_version": "4.0", "summary": { "copy_count": 2, + "excluded_sync_count": 0, "operation_count": 3, "remove_unmanaged_count": 1, "sync_count": 1 @@ -30,6 +31,7 @@ "action": "update", "path": "lua/init.lua", "phase": "copy", + "phase_alias": "deploy", "state": "planned", "type": "file" }, @@ -37,6 +39,7 @@ "action": "create", "path": "lua/only-source.lua", "phase": "copy", + "phase_alias": "deploy", "state": "planned", "type": "file" }, @@ -44,6 +47,7 @@ "action": "remove", "path": "lua/old.bak", "phase": "remove_unmanaged", + "phase_alias": "remove_unmanaged", "state": "planned", "type": "file" } diff --git a/testdata/expected/contract/diff.json b/testdata/expected/contract/diff.json index 9e2a9ec..94fb44f 100644 --- a/testdata/expected/contract/diff.json +++ b/testdata/expected/contract/diff.json @@ -23,6 +23,7 @@ "operations": [ { "phase": "deploy", + "phase_alias": "deploy", "action": "can update", "state": "candidate", "path": "lua/init.lua", @@ -36,6 +37,7 @@ }, { "phase": "import", + "phase_alias": "import", "action": "can update", "state": "candidate", "path": "lua/init.lua", @@ -49,6 +51,7 @@ }, { "phase": "incoming_unmanaged", + "phase_alias": "incoming_unmanaged", "action": "can add", "state": "candidate", "path": "lua/new.lua", @@ -61,6 +64,7 @@ }, { "phase": "incoming_unmanaged", + "phase_alias": "incoming_unmanaged", "action": "can add", "state": "candidate", "path": "lua/old.bak", @@ -73,6 +77,7 @@ }, { "phase": "remove_unmanaged", + "phase_alias": "remove_unmanaged", "action": "can remove", "state": "candidate", "path": "lua/old.bak", @@ -85,6 +90,7 @@ }, { "phase": "deploy", + "phase_alias": "deploy", "action": "can create", "state": "candidate", "path": "lua/only-source.lua", @@ -98,6 +104,7 @@ }, { "phase": "remove_missing", + "phase_alias": "remove_missing", "action": "can remove", "state": "candidate", "path": "lua/only-source.lua", @@ -126,6 +133,7 @@ "summary": { "sync_count": 1, "deploy_count": 2, + "excluded_sync_count": 0, "import_count": 1, "incoming_unmanaged_count": 2, "remove_unmanaged_count": 1, diff --git a/testdata/expected/contract/import-dry-run.json b/testdata/expected/contract/import-dry-run.json index 67a0163..67d9a8c 100644 --- a/testdata/expected/contract/import-dry-run.json +++ b/testdata/expected/contract/import-dry-run.json @@ -14,6 +14,7 @@ "schema_version": "4.0", "summary": { "add_unmanaged_count": 1, + "excluded_sync_count": 0, "operation_count": 3, "remove_missing_count": 1, "sync_count": 1, @@ -32,6 +33,7 @@ "action": "update", "path": "lua/init.lua", "phase": "update_managed", + "phase_alias": "import", "state": "planned", "type": "file" }, @@ -39,6 +41,7 @@ "action": "add", "path": "lua/new.lua", "phase": "add_unmanaged", + "phase_alias": "incoming_unmanaged", "state": "planned", "type": "file" }, @@ -46,6 +49,7 @@ "action": "remove", "path": "lua/missing.lua", "phase": "remove_missing", + "phase_alias": "remove_missing", "state": "planned", "type": "file" } diff --git a/testdata/expected/contract/status.json b/testdata/expected/contract/status.json index 0e41af2..c184c5c 100644 --- a/testdata/expected/contract/status.json +++ b/testdata/expected/contract/status.json @@ -14,6 +14,7 @@ "schema_version": "4.0", "summary": { "deploy_count": 2, + "excluded_sync_count": 0, "import_count": 1, "incoming_unmanaged_count": 2, "operation_count": 7, @@ -36,6 +37,7 @@ "action": "can update", "path": "lua/init.lua", "phase": "deploy", + "phase_alias": "deploy", "source_type": "file", "state": "candidate", "target_type": "file" @@ -44,6 +46,7 @@ "action": "can update", "path": "lua/init.lua", "phase": "import", + "phase_alias": "import", "source_type": "file", "state": "candidate", "target_type": "file" @@ -52,6 +55,7 @@ "action": "can add", "path": "lua/new.lua", "phase": "incoming_unmanaged", + "phase_alias": "incoming_unmanaged", "state": "candidate", "type": "file" }, @@ -59,6 +63,7 @@ "action": "can add", "path": "lua/old.bak", "phase": "incoming_unmanaged", + "phase_alias": "incoming_unmanaged", "state": "candidate", "type": "file" }, @@ -66,6 +71,7 @@ "action": "can remove", "path": "lua/old.bak", "phase": "remove_unmanaged", + "phase_alias": "remove_unmanaged", "state": "candidate", "type": "file" }, @@ -73,6 +79,7 @@ "action": "can create", "path": "lua/only-source.lua", "phase": "deploy", + "phase_alias": "deploy", "source_type": "file", "state": "candidate", "target_type": "missing" @@ -81,6 +88,7 @@ "action": "can remove", "path": "lua/only-source.lua", "phase": "remove_missing", + "phase_alias": "remove_missing", "state": "candidate", "type": "file" }