diff --git a/docs/user/commands.md b/docs/user/commands.md index 0be3a85..5ba6169 100644 --- a/docs/user/commands.md +++ b/docs/user/commands.md @@ -69,11 +69,13 @@ Candidate-set scanning is opt-in: Example text output shape: ```text +reminder: deploy applies source -> target; import applies target -> source sync[0] target=~/.config/nvim source=./source/nvim -deploy[2] +deploy[2] (source -> target) can create lua/init.lua (file->missing) -import[1] - can update lua/plugins.lua (file->file) +import[1] (target -> source) + can update lua/init.lua (file->file) +hint: same path in deploy/import: lua/init.lua remove-missing[1] can remove lua/legacy.lua (file) summary deploy=2 import=1 remove-missing=1 @@ -144,12 +146,17 @@ If `[path]` matches no syncs, command fails. - `version`/`--version` output one line: `dotfiles-manager version `. - `version`/`--version` exit `0` and do not require config. - text mode prints per-sync sections with exact file operations. +- status text includes one concise direction reminder line once per run. - every sync header uses: - `sync[idx] target=~/ source=./` - sync headers show configured path text (placeholders stay visible if present in config) - when `[path]` scopes into a subpath, header appends: - `scope=` - text mode only prints non-empty phase blocks. +- status phase headers include direction: + - `deploy[n] (source -> target)` + - `import[n] (target -> source)` +- status prints `hint: same path in deploy/import: ...` when a path appears in both direction blocks. - text summary line only includes non-zero categories. - status actions are potential, human-readable phrases (`can create`, `can update`, `can replace type`, `can add`, `can remove`). - deploy/import actions remain actual execution verbs (`create`, `update`, `replace_type`, `add`, `remove`). diff --git a/internal/app/text_output.go b/internal/app/text_output.go index 9b3fedd..a156f7a 100644 --- a/internal/app/text_output.go +++ b/internal/app/text_output.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "sort" "strings" ) @@ -13,6 +14,10 @@ func buildTextOutput(command string, dryRun bool, result map[string]any) string lines := make([]string, 0) syncs := syncPayloadMaps(result["syncs"]) + if command == "status" && len(syncs) > 0 { + lines = append(lines, "reminder: deploy applies source -> target; import applies target -> source") + } + for idx, sync := range syncs { if idx > 0 { lines = append(lines, "") @@ -21,8 +26,11 @@ func buildTextOutput(command string, dryRun bool, result map[string]any) string switch command { case "status": - lines = appendPhaseBlock(lines, "deploy", operationPayloadMapsByPhase(sync, "deploy")) - lines = appendPhaseBlock(lines, "import", operationPayloadMapsByPhase(sync, "import")) + deployOps := operationPayloadMapsByPhase(sync, "deploy") + importOps := operationPayloadMapsByPhase(sync, "import") + lines = appendPhaseBlockWithContext(lines, "deploy", "(source -> target)", deployOps) + lines = appendPhaseBlockWithContext(lines, "import", "(target -> source)", importOps) + lines = appendStatusDirectionHint(lines, deployOps, importOps) lines = appendPhaseBlock(lines, "incoming-unmanaged", operationPayloadMapsByPhase(sync, "incoming_unmanaged")) lines = appendPhaseBlock(lines, "remove-unmanaged", operationPayloadMapsByPhase(sync, "remove_unmanaged")) lines = appendPhaseBlock(lines, "remove-missing", operationPayloadMapsByPhase(sync, "remove_missing")) @@ -54,11 +62,19 @@ func buildSyncHeader(sync map[string]any) string { } func appendPhaseBlock(lines []string, label string, operations []map[string]any) []string { + return appendPhaseBlockWithContext(lines, label, "", operations) +} + +func appendPhaseBlockWithContext(lines []string, label string, context string, operations []map[string]any) []string { if len(operations) == 0 { return lines } - lines = append(lines, fmt.Sprintf("%s[%d]", label, len(operations))) + header := fmt.Sprintf("%s[%d]", label, len(operations)) + if context != "" { + header = fmt.Sprintf("%s %s", header, context) + } + lines = append(lines, header) for _, op := range operations { action := stringValue(op["action"]) path := stringValue(op["path"]) @@ -77,6 +93,65 @@ func appendPhaseBlock(lines []string, label string, operations []map[string]any) return lines } +func appendStatusDirectionHint(lines []string, deployOps []map[string]any, importOps []map[string]any) []string { + overlapPaths := overlappingOperationPaths(deployOps, importOps) + if len(overlapPaths) == 0 { + return lines + } + + const maxHintPaths = 3 + preview := overlapPaths + extra := 0 + if len(preview) > maxHintPaths { + extra = len(preview) - maxHintPaths + preview = preview[:maxHintPaths] + } + + hint := fmt.Sprintf("hint: same path in deploy/import: %s", strings.Join(preview, ", ")) + if extra > 0 { + hint = fmt.Sprintf("%s (+%d more)", hint, extra) + } + return append(lines, hint) +} + +func overlappingOperationPaths(leftOps []map[string]any, rightOps []map[string]any) []string { + if len(leftOps) == 0 || len(rightOps) == 0 { + return nil + } + + leftPaths := make(map[string]struct{}, len(leftOps)) + for _, op := range leftOps { + path := stringValue(op["path"]) + if path != "" { + leftPaths[path] = struct{}{} + } + } + if len(leftPaths) == 0 { + return nil + } + + overlapSet := make(map[string]struct{}) + for _, op := range rightOps { + path := stringValue(op["path"]) + if path == "" { + continue + } + if _, ok := leftPaths[path]; ok { + overlapSet[path] = struct{}{} + } + } + if len(overlapSet) == 0 { + return nil + } + + overlapPaths := make([]string, 0, len(overlapSet)) + for path := range overlapSet { + overlapPaths = append(overlapPaths, path) + } + sort.Strings(overlapPaths) + return overlapPaths +} + func operationDetails(op map[string]any) string { sourceType := stringValue(op["source_type"]) targetType := stringValue(op["target_type"]) diff --git a/internal/app/text_output_test.go b/internal/app/text_output_test.go index fe41540..c5b5cf6 100644 --- a/internal/app/text_output_test.go +++ b/internal/app/text_output_test.go @@ -33,7 +33,10 @@ func TestBuildTextOutputStatusAndDeploy(t *testing.T) { }) require.Contains(t, statusOutput, "sync[0] target=~/.config/nvim source=./source/nvim scope=lua") - require.Contains(t, statusOutput, "deploy[1]") + require.Contains(t, statusOutput, "reminder: deploy applies source -> target; import applies target -> source") + require.Contains(t, statusOutput, "deploy[1] (source -> target)") + require.Contains(t, statusOutput, "import[1] (target -> source)") + require.Contains(t, statusOutput, "hint: same path in deploy/import: lua/init.lua") require.Contains(t, statusOutput, "can create lua/init.lua (file->missing)") require.Contains(t, statusOutput, "incoming-unmanaged[1]") require.Contains(t, statusOutput, "summary deploy=1 import=1 incoming-unmanaged=1") @@ -82,3 +85,28 @@ func TestBuildTextOutputFallbackAndHelpers(t *testing.T) { require.Equal(t, "~/.config/nvim", display.Target) require.Equal(t, "./source/nvim", display.Source) } + +func TestBuildTextOutputStatusHintAppearsOnlyForOverlaps(t *testing.T) { + t.Parallel() + + noOverlapOutput := buildTextOutput("status", false, map[string]any{ + "syncs": []any{ + map[string]any{ + "sync": "sync[0] target=~/.config/nvim source=./source/nvim", + "operations": []any{ + map[string]any{"phase": "deploy", "action": "can update", "path": "lua/init.lua", "source_type": "file", "target_type": "file"}, + map[string]any{"phase": "import", "action": "can update", "path": "lua/plugins.lua", "source_type": "file", "target_type": "file"}, + }, + }, + }, + "summary": map[string]any{ + "deploy_count": 1, + "import_count": 1, + "operation_count": 2, + }, + }) + + require.Contains(t, noOverlapOutput, "deploy[1] (source -> target)") + require.Contains(t, noOverlapOutput, "import[1] (target -> source)") + require.NotContains(t, noOverlapOutput, "hint: same path in deploy/import:") +}