From 5a3fd2b8ac06ab0f31cfa33318ab5e0be0181036 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 4 Jun 2026 00:35:19 +0000 Subject: [PATCH] Share command output projections --- docs/internal/app.md | 2 + internal/app/inspect.go | 50 +++---------------- internal/app/inspect_test.go | 44 ++++++++++++++++ internal/app/manifest.go | 34 ++++--------- internal/app/manifest_test.go | 42 ++++++++++++++++ internal/app/output_projection.go | 83 +++++++++++++++++++++++++++++++ internal/app/validate.go | 25 +++------- 7 files changed, 194 insertions(+), 86 deletions(-) create mode 100644 internal/app/manifest_test.go create mode 100644 internal/app/output_projection.go diff --git a/docs/internal/app.md b/docs/internal/app.md index 49d573e..8986e69 100644 --- a/docs/internal/app.md +++ b/docs/internal/app.md @@ -204,6 +204,8 @@ Run helpers are grouped by responsibility: - `run_destination.go`: destination-scoped planning, execution, action recording, and failure bookkeeping. - `run_output.go`: `RunReport`, action/output records, and text/JSON report projection. +- `output_projection.go`: shared bundle and manifest-file result projection for + command JSON output. - `run_summary.go`: summary counters. - `run_failures.go`: destination failure aggregation and partial-result detection. - `run_selection.go`: destination bundle selection, path mapping decisions, and fixed-path warnings. diff --git a/internal/app/inspect.go b/internal/app/inspect.go index 9895c1d..d2e17e1 100644 --- a/internal/app/inspect.go +++ b/internal/app/inspect.go @@ -65,55 +65,19 @@ func writeInspectResult(options InspectOptions, selection sourceSelection) error } type inspectResult struct { - PipelineID string `json:"pipeline_id,omitempty"` - SourceBackend string `json:"source_backend,omitempty"` - BundleCount int `json:"bundle_count"` - Bundles []inspectBundleResult `json:"bundles"` -} - -type inspectBundleResult struct { - Path string `json:"path"` - ID string `json:"id"` - Created string `json:"created"` - Digest string `json:"digest"` - FileCount int `json:"file_count"` - TotalSize int64 `json:"total_size"` - Files []inspectFileResult `json:"files"` -} - -type inspectFileResult struct { - Path string `json:"path"` - SHA256 string `json:"sha256"` - Size int64 `json:"size"` + PipelineID string `json:"pipeline_id,omitempty"` + SourceBackend string `json:"source_backend,omitempty"` + BundleCount int `json:"bundle_count"` + Bundles []bundleDetailResult `json:"bundles"` } func inspectResultFromSelection(selection sourceSelection) inspectResult { - result := inspectResult{ + return inspectResult{ PipelineID: selection.PipelineID, SourceBackend: selection.SourceBackend, BundleCount: len(selection.Bundles), - Bundles: make([]inspectBundleResult, 0, len(selection.Bundles)), + Bundles: bundleDetailsFromBundles(selection.Bundles), } - for _, sourceBundle := range selection.Bundles { - bundleResult := inspectBundleResult{ - Path: storage.DisplayPath(sourceBundle.RootRelativePath), - ID: sourceBundle.Manifest.ID, - Created: sourceBundle.Manifest.Created.Format("2006-01-02T15:04:05Z07:00"), - Digest: sourceBundle.Manifest.Digest, - FileCount: len(sourceBundle.Manifest.Files), - Files: make([]inspectFileResult, 0, len(sourceBundle.Manifest.Files)), - } - for _, file := range sourceBundle.Manifest.Files { - bundleResult.TotalSize += file.Size - bundleResult.Files = append(bundleResult.Files, inspectFileResult{ - Path: file.Path, - SHA256: file.SHA256, - Size: file.Size, - }) - } - result.Bundles = append(result.Bundles, bundleResult) - } - return result } func writeInspection(w io.Writer, selection sourceSelection) error { @@ -134,7 +98,7 @@ func writeInspection(w io.Writer, selection sourceSelection) error { "- path=%s id=%s created=%s digest=%s files=%d\n", storage.DisplayPath(sourceBundle.RootRelativePath), sourceBundle.Manifest.ID, - sourceBundle.Manifest.Created.Format("2006-01-02T15:04:05Z07:00"), + formatManifestCreated(sourceBundle.Manifest.Created), sourceBundle.Manifest.Digest, len(sourceBundle.Manifest.Files), ); err != nil { diff --git a/internal/app/inspect_test.go b/internal/app/inspect_test.go index 3225d08..a9bd5f4 100644 --- a/internal/app/inspect_test.go +++ b/internal/app/inspect_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "gitea.maximumdirect.net/eric/distributor/internal/testutil" ) @@ -87,6 +88,49 @@ func TestInspectConfiguredSourceJSON(t *testing.T) { } } +func TestInspectJSONPreservesCreatedOffsetAndFileMetadata(t *testing.T) { + sourceRoot := t.TempDir() + created := time.Date(2026, 6, 1, 6, 30, 0, 0, time.FixedZone("CDT", -5*60*60)) + testutil.WriteSourceBundle(t, sourceRoot, "daily", testutil.BundleOptions{ + ID: "reports.offset", + Created: created, + Files: []testutil.SourceFile{ + {Path: "report.md", Data: "# Report\n"}, + }, + }) + var stdout bytes.Buffer + + err := Inspect(context.Background(), InspectOptions{ + Path: sourceRoot, + Stdout: &stdout, + OutputFormat: OutputFormatJSON, + }) + + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + result := decodeAppResult(t, stdout.String()) + bundles, ok := result["bundles"].([]any) + if !ok || len(bundles) != 1 { + t.Fatalf("bundles = %#v, want one bundle", result["bundles"]) + } + bundle, ok := bundles[0].(map[string]any) + if !ok { + t.Fatalf("bundle = %#v, want object", bundles[0]) + } + if bundle["created"] != "2026-06-01T06:30:00-05:00" || bundle["file_count"] != float64(1) { + t.Fatalf("bundle = %#v, want offset timestamp and file count", bundle) + } + files, ok := bundle["files"].([]any) + if !ok || len(files) != 1 { + t.Fatalf("files = %#v, want one file", bundle["files"]) + } + file, ok := files[0].(map[string]any) + if !ok || file["path"] != "report.md" || file["sha256"] == "" || file["size"] != float64(9) { + t.Fatalf("file = %#v, want projected file metadata", file) + } +} + func TestInspectConfiguredSourceJSONIncludesSecretConflictWarningWithoutValues(t *testing.T) { name := "DISTRIBUTOR_TEST_INSPECT_SECRET" t.Setenv(name, "process-value") diff --git a/internal/app/manifest.go b/internal/app/manifest.go index d2a3ac5..1f8dd48 100644 --- a/internal/app/manifest.go +++ b/internal/app/manifest.go @@ -92,37 +92,23 @@ func normalizeManifestFiles(files []string) []string { } type manifestCreateResult struct { - ManifestPath string `json:"manifest_path"` - Root string `json:"root"` - ID string `json:"id"` - Created string `json:"created"` - Digest string `json:"digest"` - FileCount int `json:"file_count"` - Files []manifestCreateFileResult `json:"files"` -} - -type manifestCreateFileResult struct { - Path string `json:"path"` - SHA256 string `json:"sha256"` - Size int64 `json:"size"` + ManifestPath string `json:"manifest_path"` + Root string `json:"root"` + ID string `json:"id"` + Created string `json:"created"` + Digest string `json:"digest"` + FileCount int `json:"file_count"` + Files []manifestFileResult `json:"files"` } func manifestCreateResultFromManifest(root string, manifest producerbundle.Manifest) manifestCreateResult { - result := manifestCreateResult{ + return manifestCreateResult{ ManifestPath: filepath.ToSlash(filepath.Join(root, producerbundle.ManifestName)), Root: filepath.ToSlash(root), ID: manifest.ID, - Created: manifest.Created.Format(time.RFC3339), + Created: formatManifestCreated(manifest.Created), Digest: manifest.Digest, FileCount: len(manifest.Files), - Files: make([]manifestCreateFileResult, 0, len(manifest.Files)), + Files: manifestFileResults(manifest.Files), } - for _, file := range manifest.Files { - result.Files = append(result.Files, manifestCreateFileResult{ - Path: file.Path, - SHA256: file.SHA256, - Size: file.Size, - }) - } - return result } diff --git a/internal/app/manifest_test.go b/internal/app/manifest_test.go new file mode 100644 index 0000000..75d5f93 --- /dev/null +++ b/internal/app/manifest_test.go @@ -0,0 +1,42 @@ +package app + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" +) + +func TestManifestCreateJSONPreservesCreatedOffsetAndFileMetadata(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "report.md"), []byte("# Report\n"), 0o600); err != nil { + t.Fatalf("write report: %v", err) + } + var stdout bytes.Buffer + + err := ManifestCreate(context.Background(), ManifestCreateOptions{ + Root: root, + ID: "reports.offset", + Created: "2026-06-01T06:30:00-05:00", + Files: []string{"report.md"}, + Stdout: &stdout, + OutputFormat: OutputFormatJSON, + }) + + if err != nil { + t.Fatalf("ManifestCreate() error = %v", err) + } + result := decodeAppResult(t, stdout.String()) + if result["id"] != "reports.offset" || result["created"] != "2026-06-01T06:30:00-05:00" || result["file_count"] != float64(1) { + t.Fatalf("result = %#v, want manifest metadata", result) + } + files, ok := result["files"].([]any) + if !ok || len(files) != 1 { + t.Fatalf("files = %#v, want one file", result["files"]) + } + file, ok := files[0].(map[string]any) + if !ok || file["path"] != "report.md" || file["sha256"] == "" || file["size"] != float64(9) { + t.Fatalf("file = %#v, want projected file metadata", file) + } +} diff --git a/internal/app/output_projection.go b/internal/app/output_projection.go new file mode 100644 index 0000000..e863d89 --- /dev/null +++ b/internal/app/output_projection.go @@ -0,0 +1,83 @@ +package app + +import ( + "time" + + "gitea.maximumdirect.net/eric/distributor/internal/bundle" + "gitea.maximumdirect.net/eric/distributor/internal/storage" +) + +type bundleSummaryResult struct { + Path string `json:"path"` + ID string `json:"id"` +} + +type bundleDetailResult struct { + Path string `json:"path"` + ID string `json:"id"` + Created string `json:"created"` + Digest string `json:"digest"` + FileCount int `json:"file_count"` + TotalSize int64 `json:"total_size"` + Files []manifestFileResult `json:"files"` +} + +type manifestFileResult struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +func bundleSummaryFromBundle(sourceBundle bundle.Bundle) bundleSummaryResult { + return bundleSummaryResult{ + Path: storage.DisplayPath(sourceBundle.RootRelativePath), + ID: sourceBundle.Manifest.ID, + } +} + +func bundleSummariesFromBundles(sourceBundles []bundle.Bundle) []bundleSummaryResult { + results := make([]bundleSummaryResult, 0, len(sourceBundles)) + for _, sourceBundle := range sourceBundles { + results = append(results, bundleSummaryFromBundle(sourceBundle)) + } + return results +} + +func bundleDetailFromBundle(sourceBundle bundle.Bundle) bundleDetailResult { + result := bundleDetailResult{ + Path: storage.DisplayPath(sourceBundle.RootRelativePath), + ID: sourceBundle.Manifest.ID, + Created: formatManifestCreated(sourceBundle.Manifest.Created), + Digest: sourceBundle.Manifest.Digest, + FileCount: len(sourceBundle.Manifest.Files), + Files: manifestFileResults(sourceBundle.Manifest.Files), + } + for _, file := range sourceBundle.Manifest.Files { + result.TotalSize += file.Size + } + return result +} + +func bundleDetailsFromBundles(sourceBundles []bundle.Bundle) []bundleDetailResult { + results := make([]bundleDetailResult, 0, len(sourceBundles)) + for _, sourceBundle := range sourceBundles { + results = append(results, bundleDetailFromBundle(sourceBundle)) + } + return results +} + +func manifestFileResults(files []bundle.ManifestFile) []manifestFileResult { + results := make([]manifestFileResult, 0, len(files)) + for _, file := range files { + results = append(results, manifestFileResult{ + Path: file.Path, + SHA256: file.SHA256, + Size: file.Size, + }) + } + return results +} + +func formatManifestCreated(created time.Time) string { + return created.Format(time.RFC3339) +} diff --git a/internal/app/validate.go b/internal/app/validate.go index e1027b7..34e8cd3 100644 --- a/internal/app/validate.go +++ b/internal/app/validate.go @@ -6,7 +6,6 @@ import ( "io" "gitea.maximumdirect.net/eric/distributor/internal/config" - "gitea.maximumdirect.net/eric/distributor/internal/storage" ) type ValidateOptions struct { @@ -73,29 +72,17 @@ func writeValidateResult(options ValidateOptions, selection sourceSelection) err } type validateResult struct { - PipelineID string `json:"pipeline_id,omitempty"` - SourceBackend string `json:"source_backend,omitempty"` - BundleCount int `json:"bundle_count"` - Bundles []validateBundleResult `json:"bundles"` -} - -type validateBundleResult struct { - Path string `json:"path"` - ID string `json:"id"` + PipelineID string `json:"pipeline_id,omitempty"` + SourceBackend string `json:"source_backend,omitempty"` + BundleCount int `json:"bundle_count"` + Bundles []bundleSummaryResult `json:"bundles"` } func validateResultFromSelection(selection sourceSelection) validateResult { - result := validateResult{ + return validateResult{ PipelineID: selection.PipelineID, SourceBackend: selection.SourceBackend, BundleCount: len(selection.Bundles), - Bundles: make([]validateBundleResult, 0, len(selection.Bundles)), + Bundles: bundleSummariesFromBundles(selection.Bundles), } - for _, sourceBundle := range selection.Bundles { - result.Bundles = append(result.Bundles, validateBundleResult{ - Path: storage.DisplayPath(sourceBundle.RootRelativePath), - ID: sourceBundle.Manifest.ID, - }) - } - return result }