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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions docs/user/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -144,12 +146,17 @@ If `[path]` matches no syncs, command fails.
- `version`/`--version` output one line: `dotfiles-manager version <value>`.
- `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=~/<target> source=./<source>`
- sync headers show configured path text (placeholders stay visible if present in config)
- when `[path]` scopes into a subpath, header appends:
- `scope=<sync-relative-prefix>`
- 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`).
Expand Down
81 changes: 78 additions & 3 deletions internal/app/text_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app

import (
"fmt"
"sort"
"strings"
)

Expand All @@ -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, "")
Expand All @@ -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"))
Expand Down Expand Up @@ -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"])
Expand All @@ -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"])
Expand Down
30 changes: 29 additions & 1 deletion internal/app/text_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:")
}