diff --git a/go.mod b/go.mod index f503d672..33c502b6 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/errors v0.9.1 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/samber/lo v1.51.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.10 @@ -89,7 +90,6 @@ require ( github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/onsi/gomega v1.27.6 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect diff --git a/internal/sync/prompt/diff.go b/internal/sync/prompt/diff.go new file mode 100644 index 00000000..2edfc67d --- /dev/null +++ b/internal/sync/prompt/diff.go @@ -0,0 +1,422 @@ +package prompt + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "slices" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/pmezard/go-difflib/difflib" + "golang.org/x/term" +) + +type variationFieldDiff struct { + Before json.RawMessage `json:"before"` + After json.RawMessage `json:"after"` +} + +type variationDiffFields map[string]variationFieldDiff + +// renderVariationDiff formats structured field changes as terminal or Markdown +// unified diffs, choosing side-by-side output when the terminal is wide enough. +func renderVariationDiff( + fields variationDiffFields, + outputKind string, + width int, + presentation variationDiffPresentation, +) (string, error) { + fields, err := collapseWholeVariationDiff(fields) + if err != nil { + return "", err + } + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) + } + slices.Sort(keys) + + var rendered strings.Builder + for _, key := range keys { + diff := fields[key] + if presentation.reverse { + diff.Before, diff.After = diff.After, diff.Before + } + before, err := formatDiffValue(diff.Before, presentation.beforeLabel, "") + if err != nil { + return "", err + } + after, err := formatDiffValue(diff.After, presentation.afterLabel, presentation.missingAfter) + if err != nil { + return "", err + } + change := "changed" + if len(diff.Before) == 0 { + change = "added" + } + if len(diff.After) == 0 { + change = "removed" + } + + diffLines, err := unifiedDiffLines( + before, + after, + presentation.beforeLabel, + presentation.afterLabel, + ) + if err != nil { + return "", err + } + if outputKind == "markdown" { + _, _ = fmt.Fprintf(&rendered, "\n#### %s (%s)\n\n", key, change) + _, _ = fmt.Fprintf( + &rendered, + "```diff\n%s\n```\n", + strings.Join(diffLines, "\n"), + ) + continue + } + _, _ = fmt.Fprintf(&rendered, "\n %s (%s)\n", key, change) + if width >= 100 { + rendered.WriteString(renderSideBySideUnifiedDiff(diffLines, width)) + } else { + rendered.WriteString(renderUnifiedDiff(diffLines, width > 0)) + } + } + return rendered.String(), nil +} + +// collapseWholeVariationDiff combines field-level all-add or all-remove +// changes into one resource-level diff without rewrapping an existing resource. +func collapseWholeVariationDiff( + fields map[string]variationFieldDiff, +) (map[string]variationFieldDiff, error) { + if len(fields) == 0 { + return fields, nil + } + if _, alreadyWholeVariation := fields["variation"]; alreadyWholeVariation && len(fields) == 1 { + return fields, nil + } + + allAdded := true + allRemoved := true + for _, diff := range fields { + allAdded = allAdded && len(diff.Before) == 0 + allRemoved = allRemoved && len(diff.After) == 0 + } + if !allAdded && !allRemoved { + return fields, nil + } + + values := make(map[string]json.RawMessage, len(fields)) + for key, diff := range fields { + if allAdded { + values[key] = diff.After + } else { + values[key] = diff.Before + } + } + value, err := json.Marshal(values) + if err != nil { + return nil, fmt.Errorf("combine variation diff: %w", err) + } + + combined := variationFieldDiff{} + if allAdded { + combined.After = value + } else { + combined.Before = value + } + return map[string]variationFieldDiff{"variation": combined}, nil +} + +// unifiedDiffLines delegates line-level comparison to go-difflib while keeping +// labels and context consistent across output modes. +func unifiedDiffLines( + before string, + after string, + beforeLabel string, + afterLabel string, +) ([]string, error) { + diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: diffInputLines(before), + B: diffInputLines(after), + FromFile: beforeLabel, + ToFile: afterLabel, + Context: 3, + }) + if err != nil { + return nil, fmt.Errorf("build variation diff: %w", err) + } + return strings.Split(strings.TrimSuffix(diff, "\n"), "\n"), nil +} + +// diffInputLines gives every logical line the terminator expected by difflib. +func diffInputLines(value string) []string { + lines := strings.Split(value, "\n") + for index := range lines { + lines[index] += "\n" + } + return lines +} + +// renderUnifiedDiff renders a conventional single-column diff and highlights +// paired replacements more precisely than independent added/removed lines. +func renderUnifiedDiff(lines []string, color bool) string { + var rendered strings.Builder + for index := 0; index < len(lines); { + change := scanDiffChange(lines, index) + if len(change.removed) != 0 && len(change.added) != 0 { + styledRemoved := append([]string(nil), change.removed...) + styledAdded := append([]string(nil), change.added...) + pairs := min(len(change.removed), len(change.added)) + for pair := 0; pair < pairs; pair++ { + if color { + styledRemoved[pair], styledAdded[pair] = renderChangedLinePair( + change.removed[pair], + change.added[pair], + ) + } + } + for index := pairs; index < len(styledRemoved); index++ { + styledRemoved[index] = styleDiffLine(styledRemoved[index], color) + } + for index := pairs; index < len(styledAdded); index++ { + styledAdded[index] = styleDiffLine(styledAdded[index], color) + } + for _, line := range append(styledRemoved, styledAdded...) { + _, _ = fmt.Fprintf(&rendered, " %s\n", line) + } + index = change.next + continue + } + _, _ = fmt.Fprintf( + &rendered, + " %s\n", + styleDiffLine(lines[index], color), + ) + index++ + } + return rendered.String() +} + +// renderSideBySideUnifiedDiff aligns removed and added lines into equal-width +// columns while retaining unified-diff headers and hunks. +func renderSideBySideUnifiedDiff(lines []string, width int) string { + if len(lines) < 2 { + return renderUnifiedDiff(lines, true) + } + + const ( + indentWidth = 4 + columnGap = 2 + ) + columnWidth := (width - indentWidth - columnGap) / 2 + cellStyle := lipgloss.NewStyle().Width(columnWidth) + + var rendered strings.Builder + writeRow := func(before, after string) { + before = ansi.Wordwrap(before, columnWidth, ",:") + after = ansi.Wordwrap(after, columnWidth, ",:") + row := lipgloss.JoinHorizontal( + lipgloss.Top, + cellStyle.Render(before), + strings.Repeat(" ", columnGap), + cellStyle.Render(after), + ) + _, _ = fmt.Fprintf( + &rendered, + "%s\n", + indentBlock(row, indentWidth), + ) + } + + writeRow(styleDiffLine(lines[0], true), styleDiffLine(lines[1], true)) + for index := 2; index < len(lines); { + if strings.HasPrefix(lines[index], "@@") { + _, _ = fmt.Fprintf( + &rendered, + " %s\n", + styleDiffLine(lines[index], true), + ) + index++ + continue + } + + change := scanDiffChange(lines, index) + if len(change.removed) != 0 || len(change.added) != 0 { + for pair := 0; pair < max(len(change.removed), len(change.added)); pair++ { + var beforeLine, afterLine string + switch { + case pair < len(change.removed) && pair < len(change.added): + beforeLine, afterLine = renderChangedLinePair( + change.removed[pair], + change.added[pair], + ) + case pair < len(change.removed): + beforeLine = styleDiffLine(change.removed[pair], true) + default: + afterLine = styleDiffLine(change.added[pair], true) + } + writeRow(beforeLine, afterLine) + } + index = change.next + continue + } + + context := styleDiffLine(lines[index], true) + writeRow(context, context) + index++ + } + return rendered.String() +} + +type diffChange struct { + removed []string + added []string + next int +} + +// scanDiffChange groups adjacent removed and added lines into one replacement block. +func scanDiffChange(lines []string, start int) diffChange { + removedEnd := start + for removedEnd < len(lines) && isRemovedDiffLine(lines[removedEnd]) { + removedEnd++ + } + + addedEnd := removedEnd + for addedEnd < len(lines) && isAddedDiffLine(lines[addedEnd]) { + addedEnd++ + } + + return diffChange{ + removed: lines[start:removedEnd], + added: lines[removedEnd:addedEnd], + next: addedEnd, + } +} + +// indentBlock applies the same left margin to every rendered line. +func indentBlock(value string, spaces int) string { + prefix := strings.Repeat(" ", spaces) + return prefix + strings.ReplaceAll(value, "\n", "\n"+prefix) +} + +// isRemovedDiffLine distinguishes content removals from the --- file header. +func isRemovedDiffLine(line string) bool { + return strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") +} + +// isAddedDiffLine distinguishes content additions from the +++ file header. +func isAddedDiffLine(line string) bool { + return strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") +} + +// styleDiffLine applies semantic colors to headers, hunks, context, and changes. +func styleDiffLine(line string, color bool) string { + if !color { + return line + } + switch { + case strings.HasPrefix(line, "---"), isRemovedDiffLine(line): + return lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(line) + case strings.HasPrefix(line, "+++"), isAddedDiffLine(line): + return lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(line) + case strings.HasPrefix(line, "@@"): + return lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Render(line) + default: + return lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(line) + } +} + +// renderChangedLinePair highlights only the changed span within paired lines. +func renderChangedLinePair(before, after string) (string, string) { + if !isRemovedDiffLine(before) || !isAddedDiffLine(after) { + return styleDiffLine(before, true), styleDiffLine(after, true) + } + + prefix, removed, added, suffix := changedParts(before[1:], after[1:]) + removedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + addedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + removedHighlight := removedStyle. + Background(lipgloss.Color("52")). + Bold(true) + addedHighlight := addedStyle. + Background(lipgloss.Color("22")). + Bold(true) + + return removedStyle.Render("-"+prefix) + + removedHighlight.Render(removed) + + removedStyle.Render(suffix), + addedStyle.Render("+"+prefix) + + addedHighlight.Render(added) + + addedStyle.Render(suffix) +} + +// changedParts separates two lines into their shared prefix, changed middle, +// and shared suffix using runes rather than bytes. +func changedParts(before, after string) ( + prefix string, + removed string, + added string, + suffix string, +) { + beforeRunes := []rune(before) + afterRunes := []rune(after) + prefixLength := 0 + for prefixLength < min(len(beforeRunes), len(afterRunes)) && + beforeRunes[prefixLength] == afterRunes[prefixLength] { + prefixLength++ + } + + suffixLength := 0 + for suffixLength < len(beforeRunes)-prefixLength && + suffixLength < len(afterRunes)-prefixLength && + beforeRunes[len(beforeRunes)-1-suffixLength] == + afterRunes[len(afterRunes)-1-suffixLength] { + suffixLength++ + } + + beforeChangeEnd := len(beforeRunes) - suffixLength + afterChangeEnd := len(afterRunes) - suffixLength + return string(beforeRunes[:prefixLength]), + string(beforeRunes[prefixLength:beforeChangeEnd]), + string(afterRunes[prefixLength:afterChangeEnd]), + string(beforeRunes[beforeChangeEnd:]) +} + +// formatDiffValue pretty-prints JSON and substitutes readable absence markers +// for missing local or LaunchDarkly resources. +func formatDiffValue(value json.RawMessage, label string, missingValue string) (string, error) { + if len(value) == 0 || bytes.Equal(value, []byte("null")) { + if missingValue != "" { + return missingValue, nil + } + if strings.HasPrefix(label, "LaunchDarkly") { + return "(does not exist in LaunchDarkly)", nil + } + return "(does not exist locally)", nil + } + var formatted bytes.Buffer + if err := json.Indent(&formatted, value, "", " "); err != nil { + return "", fmt.Errorf("format variation diff: %w", err) + } + return formatted.String(), nil +} + +// terminalWidth returns zero for redirected output or unavailable terminal metadata. +func terminalWidth(out io.Writer) int { + file, ok := out.(*os.File) + if !ok || !term.IsTerminal(int(file.Fd())) { + return 0 + } + width, _, err := term.GetSize(int(file.Fd())) + if err != nil { + return 0 + } + return width +} diff --git a/internal/sync/prompt/execute.go b/internal/sync/prompt/execute.go new file mode 100644 index 00000000..ea147fde --- /dev/null +++ b/internal/sync/prompt/execute.go @@ -0,0 +1,253 @@ +package prompt + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +type currentResourceState struct { + localFingerprint string + serverFingerprint string + serverMode syncdomain.VariationMode +} + +// executePlan applies each independently executable resource and advances the +// manifest only for resources that succeed. +func executePlan( + repositoryRoot string, + localStore synclocal.Store, + client syncapi.Client, + manifest syncmanifest.Manifest, + plan Plan, +) ([]ResourceOutcome, syncmanifest.Manifest, error) { + // Keep the reviewed baseline immutable while successful resources advance + // the result manifest independently. + manifest.Resources = append([]syncmanifest.Resource(nil), manifest.Resources...) + + outcomes := make([]ResourceOutcome, 0, len(plan.Resources)) + var failures []error + + for _, resource := range plan.Resources { + outcome := ResourceOutcome{ID: resource.ID, Action: resource.Action, Status: OutcomeSucceeded} + + switch resource.Action { + case ActionInSync: + // The manifest already represents this state. + case ActionUpdateManifest: + manifest.SetFingerprint(resource.ID, resource.LocalFingerprint) + case ActionRemoveManifest: + manifest.Remove(resource.ID) + case ActionCreateServer, ActionUpdateServer, ActionArchiveServer, ActionUpdateLocal, ActionDeleteLocal: + if err := applyResourceChange(repositoryRoot, localStore, client, resource); err != nil { + outcome.Status, outcome.Error = OutcomeFailed, err.Error() + failures = append(failures, fmt.Errorf("%s/%s: %w", resource.ID.ProjectKey, resource.ID.LookupKey, err)) + break + } + recordSuccessfulChange(&manifest, resource) + default: + outcome.Status, outcome.Error = OutcomeSkipped, "resource is not executable" + } + + outcomes = append(outcomes, outcome) + } + + return outcomes, manifest, errors.Join(failures...) +} + +// applyResourceChange verifies the reviewed state and applies one local or +// server mutation. +func applyResourceChange(repositoryRoot string, localStore synclocal.Store, client syncapi.Client, resource PlannedResource) error { + if err := verifyResourceUnchanged(repositoryRoot, client, resource); err != nil { + return err + } + if changesServer(resource.Action) { + return applyServerChange(client, resource) + } + if err := applyLocalChange(localStore, resource); err != nil { + return err + } + return verifyLocalResult(repositoryRoot, resource) +} + +// recordSuccessfulChange updates the manifest to the state selected by the +// completed action. +func recordSuccessfulChange(manifest *syncmanifest.Manifest, resource PlannedResource) { + switch { + case resource.Action == ActionArchiveServer || resource.Action == ActionDeleteLocal: + manifest.Remove(resource.ID) + case changesServer(resource.Action): + manifest.SetFingerprint(resource.ID, resource.LocalFingerprint) + default: + manifest.SetFingerprint(resource.ID, resource.ServerFingerprint) + } +} + +// verifyResourceUnchanged prevents a reviewed action from using stale local or +// server state. +func verifyResourceUnchanged(repositoryRoot string, client syncapi.Client, reviewed PlannedResource) error { + current, err := readCurrentResourceState(repositoryRoot, client, reviewed.ID) + if err != nil { + return err + } + if current.localFingerprint != reviewed.LocalFingerprint || + current.serverFingerprint != reviewed.ServerFingerprint || + current.serverMode != reviewed.ServerMode { + return fmt.Errorf("resource changed after review; run sync again") + } + return nil +} + +// applyServerChange performs one variation mutation through the existing +// public config APIs. +func applyServerChange(client syncapi.Client, resource PlannedResource) error { + configKey, variationKey, err := splitVariationLookupKey(resource.ID.LookupKey) + if err != nil { + return err + } + + var mutationErr error + switch resource.Action { + case ActionCreateServer: + mutationErr = client.CreateVariation(resource.ID.ProjectKey, configKey, *resource.Local) + case ActionUpdateServer: + mutationErr = client.UpdateVariation(resource.ID.ProjectKey, configKey, *resource.Local) + case ActionArchiveServer: + mutationErr = client.ArchiveVariation(resource.ID.ProjectKey, configKey, variationKey) + } + if mutationErr == nil { + return nil + } + if !syncapi.MutationMayHaveSucceeded(mutationErr) { + return mutationErr + } + + // A network error can hide a successful write, so re-read only when the + // mutation result is uncertain. + state, readErr := client.ReadVariation(resource.ID.ProjectKey, configKey, variationKey) + if readErr != nil { + return errors.Join(mutationErr, fmt.Errorf("verify server variation: %w", readErr)) + } + + actualFingerprint := "" + if state.Exists { + actualFingerprint, readErr = syncdomain.FingerprintVariation(resource.ID.ProjectKey, resource.ID.LookupKey, state.Variation) + if readErr != nil { + return errors.Join(mutationErr, readErr) + } + } + expectedFingerprint := resource.LocalFingerprint + if resource.Action == ActionArchiveServer { + expectedFingerprint = "" + } + + switch actualFingerprint { + case expectedFingerprint: + return nil + case resource.ServerFingerprint: + return mutationErr + default: + return fmt.Errorf("server variation changed concurrently after an uncertain write: %w", mutationErr) + } +} + +// verifyLocalResult confirms that a local file mutation produced the selected +// server state. +func verifyLocalResult(repositoryRoot string, resource PlannedResource) error { + actualFingerprint, err := readLocalFingerprint(repositoryRoot, resource.ID) + if err != nil { + return err + } + + expectedFingerprint := resource.ServerFingerprint + if resource.Action == ActionDeleteLocal { + expectedFingerprint = "" + } + if actualFingerprint != expectedFingerprint { + return fmt.Errorf("local variation did not match the expected state after sync") + } + return nil +} + +// readCurrentResourceState reads the local and server fingerprints used for +// optimistic concurrency checks. +func readCurrentResourceState(repositoryRoot string, client syncapi.Client, id ResourceID) (currentResourceState, error) { + localFingerprint, err := readLocalFingerprint(repositoryRoot, id) + if err != nil { + return currentResourceState{}, err + } + + serverResource, err := readServerResource(client, id) + if err != nil { + return currentResourceState{}, err + } + serverFingerprint := "" + if serverResource.Variation != nil { + serverFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, *serverResource.Variation) + } + return currentResourceState{ + localFingerprint: localFingerprint, serverFingerprint: serverFingerprint, serverMode: serverResource.ConfigMode, + }, err +} + +// readLocalFingerprint returns the current fingerprint for one local resource, +// or an empty fingerprint when the resource does not exist. +func readLocalFingerprint(repositoryRoot string, id ResourceID) (string, error) { + localResources, err := synclocal.CompileWorkspace(repositoryRoot) + if err != nil { + return "", err + } + + for _, resource := range localResources { + if resource.Kind != id.Kind || resource.ProjectKey != id.ProjectKey || resource.LookupKey != id.LookupKey { + continue + } + var variation syncdomain.Variation + if err := json.Unmarshal(resource.Payload, &variation); err != nil { + return "", err + } + return syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, variation) + } + return "", nil +} + +// readServerResource reads one supported resource from LaunchDarkly. +func readServerResource(client syncapi.Client, id ResourceID) (ServerResource, error) { + if id.Kind != syncdomain.KindVariation { + return ServerResource{}, fmt.Errorf("unsupported sync resource kind %q", id.Kind) + } + configKey, variationKey, err := splitVariationLookupKey(id.LookupKey) + if err != nil { + return ServerResource{}, err + } + state, err := client.ReadVariation(id.ProjectKey, configKey, variationKey) + if err != nil { + return ServerResource{}, err + } + + resource := ServerResource{ConfigMode: state.ConfigMode} + if state.Exists { + resource.Variation = &state.Variation + } + return resource, nil +} + +// splitVariationLookupKey separates a config key from its variation key. +func splitVariationLookupKey(lookupKey string) (string, string, error) { + configKey, variationKey, ok := strings.Cut(lookupKey, "/") + if !ok || configKey == "" || variationKey == "" || strings.Contains(variationKey, "/") { + return "", "", fmt.Errorf("invalid variation lookup key %q", lookupKey) + } + return configKey, variationKey, nil +} + +// changesServer reports whether an action mutates LaunchDarkly. +func changesServer(action Action) bool { + return action == ActionCreateServer || action == ActionUpdateServer || action == ActionArchiveServer +} diff --git a/internal/sync/prompt/execute_test.go b/internal/sync/prompt/execute_test.go new file mode 100644 index 00000000..f910412e --- /dev/null +++ b/internal/sync/prompt/execute_test.go @@ -0,0 +1,78 @@ +package prompt + +import ( + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +func TestExecutePlanDoesNotMutateReviewedManifest(t *testing.T) { + id := ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/variation"} + reviewedManifest := syncmanifest.Manifest{ + FormatVersion: syncmanifest.FormatVersion, + Resources: []syncmanifest.Resource{{ + ResourceKind: id.Kind, + ProjectKey: id.ProjectKey, + LookupKey: id.LookupKey, + Fingerprint: "reviewed", + }}, + } + plan := Plan{Resources: []PlannedResource{{ + ID: id, + Action: ActionUpdateManifest, + LocalFingerprint: "updated", + }}} + + _, updatedManifest, err := executePlan("", synclocal.Store{}, syncapi.Client{}, reviewedManifest, plan) + + require.NoError(t, err) + assert.Equal(t, "reviewed", reviewedManifest.Resources[0].Fingerprint) + assert.Equal(t, "updated", updatedManifest.Resources[0].Fingerprint) +} + +func TestApplyServerChangeDoesNotRereadAfterDefinitiveAPIError(t *testing.T) { + transport := &definitiveMutationAPI{} + variation := testVariation("local") + resource := PlannedResource{ + ID: testResourceID(), + Action: ActionUpdateServer, + Local: &variation, + } + + err := applyServerChange(syncapi.NewClient(transport, "token", "https://example.com"), resource) + + require.ErrorContains(t, err, `"statusCode":400`) + assert.Zero(t, transport.reads) +} + +type definitiveMutationAPI struct { + reads int +} + +func (api *definitiveMutationAPI) MakeRequest( + _ string, + method string, + _ string, + _ string, + _ url.Values, + _ []byte, + _ bool, +) ([]byte, error) { + if method == "GET" { + api.reads++ + return []byte(`{"key":"support","mode":"agent","variations":[]}`), nil + } + return nil, errors.New(`{"code":"invalid_request","statusCode":400}`) +} + +func (*definitiveMutationAPI) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} diff --git a/internal/sync/prompt/local_changes.go b/internal/sync/prompt/local_changes.go new file mode 100644 index 00000000..677c29d5 --- /dev/null +++ b/internal/sync/prompt/local_changes.go @@ -0,0 +1,43 @@ +package prompt + +import ( + "fmt" + + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" +) + +// applyLocalChange writes the server state represented by one reviewed action. +func applyLocalChange(store synclocal.Store, resource PlannedResource) error { + configKey, variationKey, err := splitVariationLookupKey(resource.ID.LookupKey) + if err != nil { + return err + } + + switch resource.Action { + case ActionUpdateLocal: + if resource.Local == nil { + _, err := store.Add([]synclocal.VariationFile{{ + ProjectKey: resource.ID.ProjectKey, + ConfigKey: configKey, + Upsert: true, + Variation: *resource.Server, + }}) + return err + } + _, err := store.ReplaceVariations([]synclocal.VariationReplacement{{ + ProjectKey: resource.ID.ProjectKey, + ConfigKey: configKey, + Variation: *resource.Server, + }}) + return err + case ActionDeleteLocal: + _, err := store.DeleteVariations([]synclocal.VariationDeletion{{ + ProjectKey: resource.ID.ProjectKey, + ConfigKey: configKey, + VariationKey: variationKey, + }}) + return err + default: + return fmt.Errorf("action %q does not change a local file", resource.Action) + } +} diff --git a/internal/sync/prompt/output.go b/internal/sync/prompt/output.go new file mode 100644 index 00000000..85d4988c --- /dev/null +++ b/internal/sync/prompt/output.go @@ -0,0 +1,224 @@ +package prompt + +import ( + "encoding/json" + "fmt" + "io" + + syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" +) + +// OutcomeStatus describes whether a reviewed resource action completed. +type OutcomeStatus string + +const ( + OutcomeSucceeded OutcomeStatus = "succeeded" + OutcomeFailed OutcomeStatus = "failed" + OutcomeSkipped OutcomeStatus = "skipped" +) + +// ResourceOutcome records the result of executing one planned resource action. +type ResourceOutcome struct { + ID ResourceID `json:"-"` + Action Action `json:"action"` + Status OutcomeStatus `json:"status"` + Error string `json:"error,omitempty"` +} + +type planResourceOutput struct { + ResourceKind string `json:"resourceKind"` + ProjectKey string `json:"projectKey"` + LookupKey string `json:"lookupKey"` + Action Action `json:"action"` + Error string `json:"error,omitempty"` + Diff variationDiffFields `json:"diff,omitempty"` +} + +type outcomeOutput struct { + ResourceKind string `json:"resourceKind"` + ProjectKey string `json:"projectKey"` + LookupKey string `json:"lookupKey"` + Action Action `json:"action"` + Status OutcomeStatus `json:"status"` + Error string `json:"error,omitempty"` +} + +// writePlanOutput renders the local synchronization plan. +func writePlanOutput(out io.Writer, outputKind string, plan Plan) error { + if outputKind == "" { + outputKind = "plaintext" + } + if outputKind == "json" { + resources := make([]planResourceOutput, 0, len(plan.Resources)) + for _, resource := range plan.Resources { + resources = append(resources, planResourceOutput{ + ResourceKind: string(resource.ID.Kind), + ProjectKey: resource.ID.ProjectKey, + LookupKey: resource.ID.LookupKey, + Action: resource.Action, + Error: resource.Error, + Diff: resource.Diff, + }) + } + return writeJSON(out, map[string]any{"resources": resources}) + } + if outputKind != "plaintext" && outputKind != "markdown" { + return fmt.Errorf("unsupported output kind %q", outputKind) + } + return writePlanReview(out, outputKind, plan, terminalWidth(out)) +} + +// writeOutcomeOutput renders execution results, including partial failures. +func writeOutcomeOutput(out io.Writer, outputKind string, outcomes []ResourceOutcome) error { + if outputKind == "" { + outputKind = "plaintext" + } + if outputKind == "json" { + resources := make([]outcomeOutput, 0, len(outcomes)) + for _, outcome := range outcomes { + resources = append(resources, outcomeOutput{ + ResourceKind: string(outcome.ID.Kind), + ProjectKey: outcome.ID.ProjectKey, + LookupKey: outcome.ID.LookupKey, + Action: outcome.Action, + Status: outcome.Status, + Error: outcome.Error, + }) + } + return writeJSON(out, map[string]any{"resources": resources}) + } + if outputKind != "plaintext" && outputKind != "markdown" { + return fmt.Errorf("unsupported output kind %q", outputKind) + } + + console := syncconsole.New(out) + if outputKind == "markdown" { + _ = console.Line("## Sync results") + } else { + _ = console.Line("Sync results:") + } + for _, outcome := range outcomes { + _ = console.Printf( + "- %s/%s action=%s status=%s\n", + outcome.ID.ProjectKey, + outcome.ID.LookupKey, + outcome.Action, + outcome.Status, + ) + if outcome.Error != "" { + _ = console.Printf(" Error: %s\n", outcome.Error) + } + } + return nil +} + +// writePlanReview renders the human review view, including action descriptions, +// validation failures, and any variation diff. +func writePlanReview(out io.Writer, outputKind string, plan Plan, width int) error { + console := syncconsole.New(out) + if len(plan.Resources) == 0 { + _ = console.Line("No prompt variations are tracked.") + return nil + } + + currentProject := "" + for _, resource := range plan.Resources { + if resource.ID.ProjectKey != currentProject { + if currentProject != "" { + _ = console.Line("") + } + currentProject = resource.ID.ProjectKey + if outputKind == "markdown" { + _ = console.Printf("## Project `%s`\n", currentProject) + } else { + _ = console.Printf("Project: %s\n", currentProject) + } + } + + if outputKind == "markdown" { + _ = console.Printf( + "\n### `%s`\n\nAction: **%s**\n", + resource.ID.LookupKey, + actionDescription(resource.Action), + ) + } else { + _ = console.Printf( + "\n%s\n Action: %s\n", + resource.ID.LookupKey, + actionDescription(resource.Action), + ) + } + if resource.Error != "" { + _ = console.Printf(" Error: %s\n", resource.Error) + } + if len(resource.Diff) != 0 { + rendered, err := renderVariationDiff(resource.Diff, outputKind, width, diffPresentation(resource.Action)) + if err != nil { + return err + } + _ = console.Write(rendered) + } + } + return nil +} + +// actionDescription translates internal reconciliation actions into user-facing language. +func actionDescription(action Action) string { + switch action { + case ActionInSync: + return "No change (in sync)" + case ActionCreateServer: + return "Create the variation in LaunchDarkly" + case ActionUpdateServer: + return "Update LaunchDarkly from the local file" + case ActionArchiveServer: + return "Archive the variation in LaunchDarkly" + case ActionUpdateLocal: + return "Update the local file from LaunchDarkly" + case ActionDeleteLocal: + return "Delete the local file" + case ActionUpdateManifest: + return "Record the matching state in the manifest" + case ActionRemoveManifest: + return "Remove the deleted resource from the manifest" + case ActionConflict: + return "Resolve the conflict before syncing" + case ActionError: + return "Fix the resource before syncing" + default: + return string(action) + } +} + +type variationDiffPresentation struct { + beforeLabel string + afterLabel string + missingAfter string + reverse bool +} + +// diffPresentation chooses labels, direction, and absence text for an action. +func diffPresentation(action Action) variationDiffPresentation { + switch action { + case ActionUpdateLocal, ActionDeleteLocal: + return variationDiffPresentation{beforeLabel: "Local file now", afterLabel: "Local file after sync", reverse: true} + case ActionCreateServer, ActionUpdateServer: + return variationDiffPresentation{beforeLabel: "LaunchDarkly now", afterLabel: "LaunchDarkly after sync"} + case ActionArchiveServer: + return variationDiffPresentation{ + beforeLabel: "LaunchDarkly now", afterLabel: "LaunchDarkly after sync", missingAfter: "(archived in LaunchDarkly)", + } + default: + return variationDiffPresentation{beforeLabel: "LaunchDarkly now", afterLabel: "Local file now"} + } +} + +// writeJSON emits indented, newline-terminated JSON for machine-readable output. +func writeJSON(out io.Writer, value any) error { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + return fmt.Errorf("write sync output: %w", err) + } + return nil +} diff --git a/internal/sync/prompt/output_test.go b/internal/sync/prompt/output_test.go new file mode 100644 index 00000000..3c38cc5c --- /dev/null +++ b/internal/sync/prompt/output_test.go @@ -0,0 +1,56 @@ +package prompt + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestWritePlanAndOutcomeJSON(t *testing.T) { + id := testResourceID() + plan := Plan{Resources: []PlannedResource{{ID: id, Action: ActionUpdateServer}}} + + var output bytes.Buffer + require.NoError(t, writePlanOutput(&output, "json", plan)) + assert.JSONEq(t, `{"resources":[{"resourceKind":"variation","projectKey":"production","lookupKey":"support/default","action":"update_server"}]}`, output.String()) + + output.Reset() + require.NoError(t, writeOutcomeOutput(&output, "json", []ResourceOutcome{{ + ID: id, Action: ActionUpdateServer, Status: OutcomeSucceeded, + }})) + assert.JSONEq(t, `{"resources":[{"resourceKind":"variation","projectKey":"production","lookupKey":"support/default","action":"update_server","status":"succeeded"}]}`, output.String()) +} + +func TestWritePlanDescribesMissingLaunchDarklyVariation(t *testing.T) { + variation := syncdomain.Variation{Mode: syncdomain.VariationModeCompletion, Key: "default", Name: "Default"} + plan := Plan{Resources: []PlannedResource{{ + ID: testResourceID(), + Action: ActionCreateServer, + Diff: variationDiff(nil, &variation), + }}} + + var output bytes.Buffer + require.NoError(t, writePlanOutput(&output, "plaintext", plan)) + + assert.Contains(t, output.String(), "(does not exist in LaunchDarkly)") + assert.NotContains(t, output.String(), "-null") +} + +func TestWritePlanDescribesArchivedLaunchDarklyVariation(t *testing.T) { + variation := syncdomain.Variation{Mode: syncdomain.VariationModeCompletion, Key: "default", Name: "Default"} + plan := Plan{Resources: []PlannedResource{{ + ID: testResourceID(), + Action: ActionArchiveServer, + Diff: variationDiff(&variation, nil), + }}} + + var output bytes.Buffer + require.NoError(t, writePlanOutput(&output, "plaintext", plan)) + + assert.Contains(t, output.String(), "Action: Archive the variation in LaunchDarkly") + assert.Contains(t, output.String(), "(archived in LaunchDarkly)") +} diff --git a/internal/sync/prompt/plan.go b/internal/sync/prompt/plan.go new file mode 100644 index 00000000..907e2627 --- /dev/null +++ b/internal/sync/prompt/plan.go @@ -0,0 +1,263 @@ +package prompt + +import ( + "encoding/json" + "fmt" + "slices" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +// Action describes the one change needed to reconcile a resource. +type Action string + +const ( + ActionInSync Action = "in_sync" + ActionCreateServer Action = "create_server" + ActionUpdateServer Action = "update_server" + ActionArchiveServer Action = "archive_server" + ActionUpdateLocal Action = "update_local" + ActionDeleteLocal Action = "delete_local" + ActionUpdateManifest Action = "update_manifest" + ActionRemoveManifest Action = "remove_manifest" + ActionConflict Action = "conflict" + ActionError Action = "error" +) + +// ResourceID is the shared identity of a synchronized resource. +type ResourceID = syncdomain.ResourceID + +// ServerResource contains a variation and the mode owned by its parent config. +// The variation APIs cannot change that mode. +type ServerResource struct { + Variation *syncdomain.Variation + ConfigMode syncdomain.VariationMode +} + +// PlannedResource contains the compared local/server state and the action +// selected for one resource. +type PlannedResource struct { + ID ResourceID + Action Action + Upsert bool + BaselineFingerprint string + LocalFingerprint string + ServerFingerprint string + ServerMode syncdomain.VariationMode + Local *syncdomain.Variation + Server *syncdomain.Variation + Diff variationDiffFields + Error string +} + +// Plan contains sync decisions in deterministic resource order. +type Plan struct { + Resources []PlannedResource +} + +// BuildPlan compares the committed baseline with current local and server +// state. Server must contain an entry, with a nil Variation for absence, for +// every candidate resource. +func BuildPlan(baseline syncmanifest.Manifest, local []syncdomain.SyncedResource, server map[ResourceID]ServerResource) Plan { + localByID := make(map[ResourceID]syncdomain.SyncedResource, len(local)) + resourceIDs := make(map[ResourceID]struct{}, len(local)+len(baseline.Resources)) + + for _, resource := range local { + id := ResourceID{Kind: resource.Kind, ProjectKey: resource.ProjectKey, LookupKey: resource.LookupKey} + localByID[id] = resource + resourceIDs[id] = struct{}{} + } + + baselineByID := make(map[ResourceID]string, len(baseline.Resources)) + for _, resource := range baseline.Resources { + id := resource.ID() + baselineByID[id] = resource.Fingerprint + resourceIDs[id] = struct{}{} + } + + orderedIDs := make([]ResourceID, 0, len(resourceIDs)) + for id := range resourceIDs { + orderedIDs = append(orderedIDs, id) + } + slices.SortFunc(orderedIDs, syncdomain.CompareResourceIDs) + + plan := Plan{Resources: make([]PlannedResource, 0, len(orderedIDs))} + for _, id := range orderedIDs { + localResource, localExists := localByID[id] + baselineFingerprint, tracked := baselineByID[id] + plan.Resources = append(plan.Resources, buildPlannedResource( + id, localResource, localExists, server[id], baselineFingerprint, tracked, + )) + } + + return plan +} + +// buildPlannedResource validates one local/server pair before choosing its action. +func buildPlannedResource( + id ResourceID, + localResource syncdomain.SyncedResource, + localExists bool, + serverResource ServerResource, + baselineFingerprint string, + tracked bool, +) PlannedResource { + resource := PlannedResource{ + ID: id, BaselineFingerprint: baselineFingerprint, Server: serverResource.Variation, ServerMode: serverResource.ConfigMode, + } + + if localExists { + resource.Upsert = localResource.Upsert + var variation syncdomain.Variation + if err := json.Unmarshal(localResource.Payload, &variation); err != nil { + resource.Action, resource.Error = ActionError, fmt.Sprintf("decode local variation: %s", err) + return resource + } + if variation.Mode != serverResource.ConfigMode { + resource.Action = ActionError + resource.Error = fmt.Sprintf("local mode %q does not match config mode %q", variation.Mode, serverResource.ConfigMode) + return resource + } + resource.Local = &variation + } + + var err error + if resource.Local != nil { + resource.LocalFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, *resource.Local) + if err != nil { + resource.Action, resource.Error = ActionError, err.Error() + return resource + } + } + if resource.Server != nil { + resource.ServerFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, *resource.Server) + if err != nil { + resource.Action, resource.Error = ActionError, fmt.Sprintf("invalid server variation: %s", err) + return resource + } + } + + resource.Action = chooseAction(tracked, resource) + if resource.Action == ActionError { + resource.Error = "variation does not exist in LaunchDarkly; set upsert: true to create it" + } + resource.Diff = variationDiff(resource.Server, resource.Local) + return resource +} + +// RequiresConfirmation reports whether the plan changes local or server +// resources. Manifest-only bookkeeping is safe to perform without prompting. +func (plan Plan) RequiresConfirmation() bool { + for _, resource := range plan.Resources { + switch resource.Action { + case ActionCreateServer, ActionUpdateServer, ActionArchiveServer, ActionUpdateLocal, ActionDeleteLocal: + return true + } + } + return false +} + +// BlockingError returns a readable error for conflicts or invalid resources. +func (plan Plan) BlockingError() error { + for _, resource := range plan.Resources { + switch resource.Action { + case ActionConflict: + return fmt.Errorf("cannot sync conflicted resource %s/%s", resource.ID.ProjectKey, resource.ID.LookupKey) + case ActionError: + return fmt.Errorf("cannot sync %s/%s: %s", resource.ID.ProjectKey, resource.ID.LookupKey, resource.Error) + } + } + return nil +} + +// HasChanges reports whether synchronization has work to perform. +func (plan Plan) HasChanges() bool { + for _, resource := range plan.Resources { + if resource.Action != ActionInSync { + return true + } + } + return false +} + +// chooseAction compares local and server fingerprints with the manifest +// baseline to determine which side changed. +func chooseAction(tracked bool, resource PlannedResource) Action { + localExists := resource.Local != nil + serverExists := resource.Server != nil + + // Without a baseline there is no direction to infer. Adopt identical state, + // honor explicit local upsert, and require a choice for divergent content. + if !tracked { + switch { + case localExists && serverExists && resource.LocalFingerprint == resource.ServerFingerprint: + return ActionUpdateManifest + case localExists && serverExists: + return ActionConflict + case localExists && resource.Upsert: + return ActionCreateServer + case localExists: + return ActionError + default: + return ActionInSync + } + } + + localUnchanged := resource.LocalFingerprint == resource.BaselineFingerprint + serverUnchanged := resource.ServerFingerprint == resource.BaselineFingerprint + switch { + // Neither side moved from the common ancestor. + case localUnchanged && serverUnchanged: + return ActionInSync + // Both sides independently reached the same state, including deletion. + case resource.LocalFingerprint == resource.ServerFingerprint: + if !localExists && !serverExists { + return ActionRemoveManifest + } + return ActionUpdateManifest + // Only local moved, so local is authoritative for this run. + case !localUnchanged && serverUnchanged: + if !localExists { + return ActionArchiveServer + } + if serverExists { + return ActionUpdateServer + } + return ActionConflict + // Only LaunchDarkly moved, so pull or mirror its deletion locally. + case localUnchanged && !serverUnchanged: + if !serverExists { + return ActionDeleteLocal + } + if localExists { + return ActionUpdateLocal + } + return ActionConflict + // Both sides moved to different states. + default: + return ActionConflict + } +} + +// variationDiff builds the structured diff rendered during plan review. +func variationDiff(before, after *syncdomain.Variation) variationDiffFields { + if before == nil && after == nil { + return nil + } + beforeJSON, _ := json.Marshal(before) + afterJSON, _ := json.Marshal(after) + if before == nil { + beforeJSON = nil + } + if after == nil { + afterJSON = nil + } + if string(beforeJSON) == string(afterJSON) { + return nil + } + + return variationDiffFields{ + "variation": {Before: beforeJSON, After: afterJSON}, + } +} diff --git a/internal/sync/prompt/plan_test.go b/internal/sync/prompt/plan_test.go new file mode 100644 index 00000000..0b899b30 --- /dev/null +++ b/internal/sync/prompt/plan_test.go @@ -0,0 +1,126 @@ +package prompt + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +func TestBuildPlanThreeWayMatrix(t *testing.T) { + baseline := testVariation("baseline") + localChange := testVariation("local") + serverChange := testVariation("server") + + tests := []struct { + name string + local *syncdomain.Variation + server *syncdomain.Variation + expected Action + }{ + {"unchanged", &baseline, &baseline, ActionInSync}, + {"local edit", &localChange, &baseline, ActionUpdateServer}, + {"server edit", &baseline, &serverChange, ActionUpdateLocal}, + {"same edit", &localChange, &localChange, ActionUpdateManifest}, + {"divergent edits", &localChange, &serverChange, ActionConflict}, + {"local deletion", nil, &baseline, ActionArchiveServer}, + {"server deletion", &baseline, nil, ActionDeleteLocal}, + {"both deleted", nil, nil, ActionRemoveManifest}, + {"local edit after server deletion", &localChange, nil, ActionConflict}, + {"server edit after local deletion", nil, &serverChange, ActionConflict}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + id := testResourceID() + baselineFingerprint, err := syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, baseline) + require.NoError(t, err) + manifest := syncmanifest.Manifest{ + FormatVersion: syncmanifest.FormatVersion, + Resources: []syncmanifest.Resource{{ + ResourceKind: id.Kind, + ProjectKey: id.ProjectKey, + LookupKey: id.LookupKey, + Fingerprint: baselineFingerprint, + }}, + } + + plan := BuildPlan(manifest, localResources(test.local, false), map[ResourceID]ServerResource{ + id: {Variation: test.server, ConfigMode: syncdomain.VariationModeAgent}, + }) + require.Len(t, plan.Resources, 1) + require.Equal(t, test.expected, plan.Resources[0].Action) + }) + } +} + +func TestBuildPlanFirstSync(t *testing.T) { + local := testVariation("local") + different := testVariation("server") + id := testResourceID() + + tests := []struct { + name string + local *syncdomain.Variation + server *syncdomain.Variation + upsert bool + expected Action + }{ + {"equal resources are adopted", &local, &local, false, ActionUpdateManifest}, + {"different resources conflict", &local, &different, true, ActionConflict}, + {"upsert creates missing server resource", &local, nil, true, ActionCreateServer}, + {"missing server without upsert is an error", &local, nil, false, ActionError}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan := BuildPlan(syncmanifest.New(), localResources(test.local, test.upsert), map[ResourceID]ServerResource{ + id: {Variation: test.server, ConfigMode: syncdomain.VariationModeAgent}, + }) + require.Equal(t, test.expected, plan.Resources[0].Action) + }) + } +} + +func TestBuildPlanRejectsParentConfigModeMismatch(t *testing.T) { + local := testVariation("local") + id := testResourceID() + + plan := BuildPlan(syncmanifest.New(), localResources(&local, true), map[ResourceID]ServerResource{ + id: {ConfigMode: syncdomain.VariationModeCompletion}, + }) + + require.Equal(t, ActionError, plan.Resources[0].Action) + require.Contains(t, plan.Resources[0].Error, "does not match config mode") +} + +func testResourceID() ResourceID { + return ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "production", LookupKey: "support/default"} +} + +func testVariation(name string) syncdomain.Variation { + return syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, + Key: "default", + Name: name, + Instructions: "Help", + } +} + +func localResources(variation *syncdomain.Variation, upsert bool) []syncdomain.SyncedResource { + if variation == nil { + return nil + } + payload, _ := json.Marshal(variation) + id := testResourceID() + return []syncdomain.SyncedResource{{ + Kind: id.Kind, + ProjectKey: id.ProjectKey, + LookupKey: id.LookupKey, + Payload: payload, + Upsert: upsert, + }} +}