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
317 changes: 306 additions & 11 deletions internal/app/cli.go

Large diffs are not rendered by default.

35 changes: 32 additions & 3 deletions internal/app/cli_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion internal/app/cli_logging_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
103 changes: 103 additions & 0 deletions internal/app/cli_parser_error_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
22 changes: 12 additions & 10 deletions internal/app/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
})
}

Expand All @@ -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"],
})
}

Expand Down
2 changes: 2 additions & 0 deletions internal/app/deploy_payload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
50 changes: 37 additions & 13 deletions internal/app/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"os"
"strings"
"unicode/utf8"

"github.com/pmezard/go-difflib/difflib"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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"
Expand All @@ -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
}
Expand All @@ -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{
Expand All @@ -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
Expand Down Expand Up @@ -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":
Expand Down
Loading