package cli import ( "bytes" "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/distributor/internal/bundle" "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, "html.txt"), "other") assertLocalFile(t, filepath.Join(destinationRoot, "extra.txt"), "unmanaged") catalog := readLocalCatalogState(t, filepath.Join(destinationRoot, storage.StateFileName)) if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "report.md,summary.txt,html.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, "html.txt"), "other") 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) } catalog := readLocalCatalogState(t, filepath.Join(destinationRoot, storage.StateFileName)) if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "html.txt" { t.Fatalf("state outputs = %q, want html.txt", 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{}) writeCatalogDestinationState(t, destinationRoot, manifest, true) 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, "html.txt"), []byte("other"), 0o600); err != nil { t.Fatalf("write other owner 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 } func writeCatalogDestinationState(t *testing.T, root string, manifest bundle.Manifest, includeOtherOwner bool) { t.Helper() createdAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) source := state.CatalogSourceIdentity{ID: manifest.ID, Digest: manifest.Digest, Created: manifest.Created} outputs := []state.CatalogOutputFile{{ Path: "report.md", PipelineID: "reports", DestinationID: "archive", Source: source, Kind: state.OutputKindSource, SHA256: manifest.Files[0].SHA256, Size: manifest.Files[0].Size, CreatedAt: createdAt, UpdatedAt: createdAt, }, { Path: "summary.txt", PipelineID: "reports", DestinationID: "archive", Source: source, Kind: state.OutputKindSource, SHA256: manifest.Files[1].SHA256, Size: manifest.Files[1].Size, CreatedAt: createdAt, UpdatedAt: createdAt, }} if includeOtherOwner { outputs = append(outputs, state.CatalogOutputFile{ Path: "html.txt", PipelineID: "reports", DestinationID: "html", Source: source, Kind: state.OutputKindSource, SHA256: manifest.Files[0].SHA256, Size: manifest.Files[0].Size, CreatedAt: createdAt, UpdatedAt: createdAt, }) } catalog := state.CatalogState{ SchemaVersion: state.CatalogSchemaVersion, DistributorVersion: "test", CreatedAt: createdAt, UpdatedAt: createdAt, State: state.StatePolicy{Mode: state.StateModeCatalog}, Outputs: outputs, } data, err := json.MarshalIndent(catalog, "", " ") if err != nil { t.Fatalf("marshal catalog state: %v", err) } if err := os.WriteFile(filepath.Join(root, storage.StateFileName), append(data, '\n'), 0o600); err != nil { t.Fatalf("write catalog state: %v", err) } } func readLocalCatalogState(t *testing.T, path string) state.CatalogState { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read catalog state: %v", err) } catalog, err := state.ParseCatalog(data) if err != nil { t.Fatalf("parse catalog state: %v", err) } return catalog }