diff --git a/internal/application/apply/decisions.go b/internal/application/apply/decisions.go index e633691..f335f9e 100644 --- a/internal/application/apply/decisions.go +++ b/internal/application/apply/decisions.go @@ -45,7 +45,7 @@ func (service *Service) CollectDecisions(ctx context.Context, candidates Candida ordered := reconcile.OrderedDecisionSpecs(specs) decisions := make([]ResolvedDecision, 0, len(ordered)) for _, spec := range ordered { - decision, err := service.collectOne(ctx, spec) + decision, err := service.collectOne(ctx, spec, candidates) if err != nil { return CollectedDecisions{}, err } @@ -56,7 +56,7 @@ func (service *Service) CollectDecisions(ctx context.Context, candidates Candida // collectOne projects one spec into a request, resolves it, and validates // the response before any hook or mutation. -func (service *Service) collectOne(ctx context.Context, spec reconcile.DecisionSpec) (ResolvedDecision, error) { +func (service *Service) collectOne(ctx context.Context, spec reconcile.DecisionSpec, candidates Candidates) (ResolvedDecision, error) { request, err := NewDecisionRequest(DecisionRequestInput{ TargetPath: spec.TargetPath(), Choices: projectChoices(spec.AllChoices()), @@ -64,7 +64,7 @@ func (service *Service) collectOne(ctx context.Context, spec reconcile.DecisionS if err != nil { return ResolvedDecision{}, failure.New(failure.InvalidInput, "apply: project decision request", err) } - response, err := service.resolveRepeatedly(ctx, request) + response, err := service.resolveRepeatedly(ctx, request, service.differenceProvider(candidates)) if err != nil { return ResolvedDecision{}, err } @@ -139,12 +139,12 @@ func projectChoice(choice reconcile.DecisionChoice) DecisionChoice { // resolveRepeatedly asks the resolver until it returns a final choice, // re-requesting when it answers diff so the adapter can show the safe // difference and ask again. -func (service *Service) resolveRepeatedly(ctx context.Context, request DecisionRequest) (DecisionResponse, error) { +func (service *Service) resolveRepeatedly(ctx context.Context, request DecisionRequest, difference DifferenceProvider) (DecisionResponse, error) { for { if err := ctx.Err(); err != nil { return DecisionResponse{}, err } - response, err := service.resolveOnce(ctx, request) + response, err := service.resolveOnce(ctx, request, difference) if err != nil { return DecisionResponse{}, err } @@ -156,11 +156,11 @@ func (service *Service) resolveRepeatedly(ctx context.Context, request DecisionR // resolveOnce asks the resolver once and validates its response against the // allowed choices of the request. -func (service *Service) resolveOnce(ctx context.Context, request DecisionRequest) (DecisionResponse, error) { +func (service *Service) resolveOnce(ctx context.Context, request DecisionRequest, difference DifferenceProvider) (DecisionResponse, error) { if service.resolver == nil { return DecisionResponse{}, failure.New(failure.Operational, "apply: decision resolver is unavailable", nil) } - response, err := service.resolver.Resolve(ctx, request) + response, err := resolveDecision(service.resolver, ctx, request, difference) if err != nil { return DecisionResponse{}, err } @@ -171,3 +171,10 @@ func (service *Service) resolveOnce(ctx context.Context, request DecisionRequest } return DecisionResponse{}, failure.New(failure.InvalidInput, "apply: invalid decision response for "+request.TargetPath(), nil) } + +func resolveDecision(resolver DecisionResolver, ctx context.Context, request DecisionRequest, difference DifferenceProvider) (DecisionResponse, error) { + if differenceResolver, ok := resolver.(DifferenceResolver); ok { + return differenceResolver.ResolveWithDifference(ctx, request, difference) + } + return resolver.Resolve(ctx, request) +} diff --git a/internal/application/apply/decisions_test.go b/internal/application/apply/decisions_test.go index 5da5317..eb648af 100644 --- a/internal/application/apply/decisions_test.go +++ b/internal/application/apply/decisions_test.go @@ -18,6 +18,7 @@ func TestApplyDecisionCollection(t *testing.T) { {"skip is collected", testDecisionSkip}, {"overwrite is collected", testDecisionOverwrite}, {"diff re-requests", testDecisionDiff}, + {"diff provider receives the candidate", testDecisionDifferenceProvider}, {"invalid response", testDecisionInvalid}, {"resolver errors propagate", testDecisionResolverError}, {"collection order", testDecisionOrder}, @@ -29,9 +30,10 @@ func TestApplyDecisionCollection(t *testing.T) { // resolverFake returns queued responses and records the requests. type resolverFake struct { - responses []DecisionResponse - requests []string - err error + responses []DecisionResponse + requests []string + err error + differenceCalls int } func (resolver *resolverFake) Resolve(ctx context.Context, request DecisionRequest) (DecisionResponse, error) { @@ -47,6 +49,14 @@ func (resolver *resolverFake) Resolve(ctx context.Context, request DecisionReque return response, nil } +func (resolver *resolverFake) ResolveWithDifference(ctx context.Context, request DecisionRequest, difference DifferenceProvider) (DecisionResponse, error) { + resolver.differenceCalls++ + if _, ok := difference(ctx, request.TargetPath()); !ok { + return DecisionResponse{}, fmt.Errorf("difference unavailable") + } + return resolver.Resolve(ctx, request) +} + // decisionFixture evaluates one drifting target per name over an empty // state, so every target requires an explicit decision, and returns the // service and the candidates. @@ -123,6 +133,18 @@ func testDecisionDiff(t *testing.T) { } } +func testDecisionDifferenceProvider(t *testing.T) { + resolver := &resolverFake{responses: []DecisionResponse{{Choice: ChoiceSkip}}} + service, candidates := decisionFixture(t, "a.conf") + service.resolver = resolver + if _, err := service.CollectDecisions(context.Background(), candidates); err != nil { + t.Fatalf("collect: %v", err) + } + if resolver.differenceCalls != 1 { + t.Fatalf("difference calls = %d, want one", resolver.differenceCalls) + } +} + func testDecisionInvalid(t *testing.T) { resolver := &resolverFake{responses: []DecisionResponse{{Choice: ""}}} service, candidates := decisionFixture(t, "a.conf") diff --git a/internal/application/apply/difference.go b/internal/application/apply/difference.go new file mode 100644 index 0000000..a3ffe06 --- /dev/null +++ b/internal/application/apply/difference.go @@ -0,0 +1,97 @@ +package apply + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/alyraffauf/cattery/internal/diff" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/filesystem" + "github.com/alyraffauf/cattery/internal/reconcile" +) + +// differenceProvider binds safe diff rendering to the immutable candidates +// collected for one apply. The prompt never re-resolves repository state. +func (service *Service) differenceProvider(candidates Candidates) DifferenceProvider { + byPath := candidatesByPath(candidates) + return func(ctx context.Context, target string) (SafeDifference, bool) { + candidate, ok := byPath[target] + if !ok { + return SafeDifference{}, false + } + return service.safeDifference(ctx, candidates.Home(), candidate) + } +} + +// safeDifference reads and revalidates one ordinary target before building +// the output-safe diff. Secret records remain payload-free by construction. +func (service *Service) safeDifference(ctx context.Context, home string, candidate Candidate) (SafeDifference, bool) { + if err := ctx.Err(); err != nil || candidate.record.Entry != reconcile.PlanEntryFile || candidate.record.Target.Kind() != reconcile.KindFile { + return SafeDifference{}, false + } + destination := filesystem.Destination{Root: home, Relative: candidate.record.TargetPath} + precondition, err := filesystem.Freeze(destination) + if err != nil || precondition.Target().Kind() != filesystem.KindFile { + return SafeDifference{}, false + } + content, err := readFrozenTarget(precondition) + if err != nil { + return SafeDifference{}, false + } + record, err := diff.Build(candidate.record, content) + if err != nil { + return SafeDifference{}, false + } + return safeDifferenceFrom(record), true +} + +func readFrozenTarget(precondition filesystem.Precondition) ([]byte, error) { + destination := precondition.Destination() + path := filepath.Join(destination.Root, filepath.FromSlash(destination.Relative)) + file, err := os.Open(path) + if err != nil { + return nil, failure.New(failure.Operational, "apply: read target "+destination.Relative, err) + } + defer file.Close() + content, err := io.ReadAll(file) + if err != nil { + return nil, failure.New(failure.Operational, "apply: read target "+destination.Relative, err) + } + if err := precondition.Revalidate(); err != nil { + return nil, failure.New(failure.Operational, "apply: target changed "+destination.Relative, err) + } + return content, nil +} + +func safeDifferenceFrom(record diff.SafeRecord) SafeDifference { + lines := strings.TrimSuffix(record.Lines(), "\n") + var renderedLines []string + if lines != "" { + renderedLines = strings.Split(lines, "\n") + } + return SafeDifference{ + Tag: diffTagOf(record.Tag()), + SourceSize: int(record.SourceSize()), + TargetSize: int(record.TargetSize()), + SourceHash: fmt.Sprintf("%x", record.SourceHash()), + TargetHash: fmt.Sprintf("%x", record.TargetHash()), + Lines: renderedLines, + } +} + +func diffTagOf(tag diff.Tag) DiffTag { + switch tag { + case diff.TagText: + return DiffTagText + case diff.TagBinary: + return DiffTagBinary + case diff.TagSecret: + return DiffTagSecret + default: + return DiffTagNone + } +} diff --git a/internal/application/apply/types.go b/internal/application/apply/types.go index 4933829..e300885 100644 --- a/internal/application/apply/types.go +++ b/internal/application/apply/types.go @@ -115,6 +115,16 @@ type DecisionResolver interface { Resolve(context.Context, DecisionRequest) (DecisionResponse, error) } +// DifferenceProvider renders one safe difference for the current apply +// candidate. It returns false when the target cannot produce a difference. +type DifferenceProvider func(context.Context, string) (SafeDifference, bool) + +// DifferenceResolver is the optional prompt seam for resolvers that can show +// a safe difference before asking for the final decision. +type DifferenceResolver interface { + ResolveWithDifference(context.Context, DecisionRequest, DifferenceProvider) (DecisionResponse, error) +} + // RepositoryInput carries the raw repository fields the CLI adapter copies // mechanically: the explicit --repo value and its presence, the raw // CATTERY_REPO value and its presence, and the initial working directory for diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go index 242a5bf..0516885 100644 --- a/internal/cli/prompt.go +++ b/internal/cli/prompt.go @@ -49,6 +49,16 @@ func NewDecisionPrompt(input PromptInput) *DecisionPrompt { // Resolve asks one decision request until a valid final answer arrives. func (p *DecisionPrompt) Resolve(ctx context.Context, request apply.DecisionRequest) (apply.DecisionResponse, error) { + return p.resolve(ctx, request, p.diff) +} + +// ResolveWithDifference resolves one request with the apply candidate's safe +// difference provider. This keeps target reads bound to the frozen evaluation. +func (p *DecisionPrompt) ResolveWithDifference(ctx context.Context, request apply.DecisionRequest, difference apply.DifferenceProvider) (apply.DecisionResponse, error) { + return p.resolve(ctx, request, difference) +} + +func (p *DecisionPrompt) resolve(ctx context.Context, request apply.DecisionRequest, difference apply.DifferenceProvider) (apply.DecisionResponse, error) { if p.isTerminal == nil || !p.isTerminal(0) { return apply.DecisionResponse{}, failure.New(failure.Difference, "cli: decisions require an interactive terminal", nil) } @@ -62,7 +72,7 @@ func (p *DecisionPrompt) Resolve(ctx context.Context, request apply.DecisionRequ if err != nil { return apply.DecisionResponse{}, failure.New(failure.InvalidInput, "cli: EOF before a valid answer", err) } - response, done, err := p.answer(ctx, request, answer) + response, done, err := p.answer(ctx, request, answer, difference) if err != nil { return apply.DecisionResponse{}, err } @@ -100,7 +110,7 @@ func readAnswer(scanner *bufio.Scanner) (string, error) { // answer maps one raw answer to a response, displaying the safe difference // and re-prompting on diff or invalid input. -func (p *DecisionPrompt) answer(ctx context.Context, request apply.DecisionRequest, answer string) (apply.DecisionResponse, bool, error) { +func (p *DecisionPrompt) answer(ctx context.Context, request apply.DecisionRequest, answer string, difference apply.DifferenceProvider) (apply.DecisionResponse, bool, error) { if answer == "" { return apply.DecisionResponse{Choice: request.Choices()[0]}, true, nil } @@ -112,7 +122,7 @@ func (p *DecisionPrompt) answer(ctx context.Context, request apply.DecisionReque if choice != apply.ChoiceDiff { return apply.DecisionResponse{Choice: choice}, true, nil } - if err := p.renderDifference(ctx, request.TargetPath()); err != nil { + if err := p.renderDifference(ctx, request.TargetPath(), difference); err != nil { return apply.DecisionResponse{}, false, err } return apply.DecisionResponse{}, false, nil @@ -122,16 +132,16 @@ func (p *DecisionPrompt) answer(ctx context.Context, request apply.DecisionReque } // renderDifference displays the safe difference of one target on stderr. -func (p *DecisionPrompt) renderDifference(ctx context.Context, target string) error { - if p.diff == nil { +func (p *DecisionPrompt) renderDifference(ctx context.Context, target string, difference apply.DifferenceProvider) error { + if difference == nil { _, err := fmt.Fprintf(p.stderr, "no difference available for %s\n", target) return err } - difference, ok := p.diff(ctx, target) + safeDifference, ok := difference(ctx, target) if !ok { _, err := fmt.Fprintf(p.stderr, "no difference available for %s\n", target) return err } - _, err := fmt.Fprintf(p.stderr, "--- %s ---\n%s", target, strings.Join(difference.LinesCopy(), "\n")) + _, err := fmt.Fprintf(p.stderr, "--- %s ---\n%s", target, strings.Join(safeDifference.LinesCopy(), "\n")) return err }