package cli import ( "bytes" "context" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/distributor/internal/state" "gitea.maximumdirect.net/eric/distributor/internal/storage" "gitea.maximumdirect.net/eric/distributor/internal/testutil" ) func TestExecutePruneRejectsInvalidFlags(t *testing.T) { tests := []struct { name string args []string wantStderr string }{ { name: "missing config", args: []string{"prune", "--pipeline", "reports", "--destination", "archive", "--dry-run"}, wantStderr: "requires --config", }, { name: "missing pipeline", args: []string{"prune", "--config", "config.yml", "--destination", "archive", "--dry-run"}, wantStderr: "requires --pipeline", }, { name: "missing destination", args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--dry-run"}, wantStderr: "requires --destination", }, { name: "missing mode", args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive"}, wantStderr: "requires exactly one of --dry-run or --apply", }, { name: "conflicting modes", args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--dry-run", "--apply"}, wantStderr: "requires exactly one of --dry-run or --apply", }, { name: "invalid format", args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--dry-run", "--format", "xml"}, wantStderr: "format must be text or json", }, { name: "positional", args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--dry-run", "extra"}, wantStderr: "does not accept positional arguments", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var stdout, stderr bytes.Buffer code := Execute(context.Background(), tt.args, &stdout, &stderr) if code != exitUsage { t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitUsage, stderr.String()) } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } if !strings.Contains(stderr.String(), tt.wantStderr) { t.Fatalf("stderr = %q, want substring %q", stderr.String(), tt.wantStderr) } }) } } func TestExecutePruneDryRunReportsWithoutWriting(t *testing.T) { destinationRoot, configPath := writePruneLocalFixture(t) var stdout, stderr bytes.Buffer code := Execute(context.Background(), []string{ "prune", "--config", configPath, "--pipeline", "reports", "--destination", "archive", "--dry-run", }, &stdout, &stderr) if code != exitOK { t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String()) } if output := stdout.String(); !strings.Contains(output, "status=would_change") || !strings.Contains(output, "planned=2") || !strings.Contains(output, "deleted=0") { t.Fatalf("stdout = %q, want dry-run prune summary", output) } assertLocalFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n") assertLocalFile(t, filepath.Join(destinationRoot, "summary.txt"), "Summary\n") assertLocalFile(t, filepath.Join(destinationRoot, "extra.txt"), "unmanaged") destinationState := testutil.ReadDestinationState(t, filepath.Join(destinationRoot, storage.StateFileName)) if got := strings.Join(state.ManagedOutputPaths(destinationState), ","); got != "report.md,summary.txt" { t.Fatalf("state outputs = %q, want original outputs", got) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestExecutePruneJSONReport(t *testing.T) { _, configPath := writePruneLocalFixture(t) var stdout, stderr bytes.Buffer code := Execute(context.Background(), []string{ "prune", "--config", configPath, "--pipeline", "reports", "--destination", "archive", "--dry-run", "--format", "json", }, &stdout, &stderr) if code != exitOK { t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String()) } envelope := decodeEnvelope(t, &stdout) if envelope["command"] != "prune" || envelope["ok"] != true { t.Fatalf("envelope = %#v, want prune ok", envelope) } result := envelopeResult(t, envelope) if result["would_change"] != true || result["state_changed"] != false || result["dry_run"] != true { t.Fatalf("result = %#v, want dry-run pending change", result) } planned, ok := result["planned_outputs"].([]any) if !ok || len(planned) != 2 { t.Fatalf("planned outputs = %#v, want two", result["planned_outputs"]) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestExecutePruneApplyDeletesManagedOutputs(t *testing.T) { destinationRoot, configPath := writePruneLocalFixture(t) var stdout, stderr bytes.Buffer code := Execute(context.Background(), []string{ "prune", "--config", configPath, "--pipeline", "reports", "--destination", "archive", "--apply", }, &stdout, &stderr) if code != exitOK { t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String()) } if output := stdout.String(); !strings.Contains(output, "status=changed") || !strings.Contains(output, "deleted=2") { t.Fatalf("stdout = %q, want applied prune summary", output) } if _, err := os.Stat(filepath.Join(destinationRoot, "report.md")); !os.IsNotExist(err) { t.Fatalf("report.md stat error = %v, want not exist", err) } if _, err := os.Stat(filepath.Join(destinationRoot, "summary.txt")); !os.IsNotExist(err) { t.Fatalf("summary.txt stat error = %v, want not exist", err) } assertLocalFile(t, filepath.Join(destinationRoot, "extra.txt"), "unmanaged") if _, err := os.Stat(filepath.Join(destinationRoot, storage.StateFileName)); err != nil { t.Fatalf("state file stat error = %v", err) } destinationState := testutil.ReadDestinationState(t, filepath.Join(destinationRoot, storage.StateFileName)) if got := state.ManagedOutputPaths(destinationState); len(got) != 0 { t.Fatalf("state outputs = %#v, want none", got) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func writePruneLocalFixture(t *testing.T) (string, string) { t.Helper() sourceRoot := t.TempDir() destinationRoot := t.TempDir() manifest := testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{}) testutil.WriteDestinationState(t, destinationRoot, "", manifest, testutil.DestinationStateOptions{}) for _, file := range testutil.DefaultSourceFiles() { path := filepath.Join(destinationRoot, filepath.FromSlash(file.Path)) if err := os.WriteFile(path, []byte(file.Data), 0o600); err != nil { t.Fatalf("write destination output: %v", err) } } if err := os.WriteFile(filepath.Join(destinationRoot, "extra.txt"), []byte("unmanaged"), 0o600); err != nil { t.Fatalf("write unmanaged output: %v", err) } configPath := filepath.Join(t.TempDir(), "config.yml") config := ` pipelines: - id: reports source: backend: local path: ` + sourceRoot + ` destinations: - id: archive backend: local path: ` + destinationRoot + ` retention: prune: enabled: true older_than: 1h ` if err := os.WriteFile(configPath, []byte(strings.TrimSpace(config)+"\n"), 0o600); err != nil { t.Fatalf("write prune config: %v", err) } return destinationRoot, configPath }