diff --git a/internal/adapters/seriatim/fake.go b/internal/adapters/seriatim/fake.go index 66487b1..ce666db 100644 --- a/internal/adapters/seriatim/fake.go +++ b/internal/adapters/seriatim/fake.go @@ -68,6 +68,26 @@ func (n *NoopRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma }, nil } +// Render returns the requested output path with placeholder metadata. +func (n *NoopRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) { + if err := ctx.Err(); err != nil { + return RenderResult{}, err + } + if err := materializeRenderPlaceholders(req); err != nil { + return RenderResult{}, err + } + return RenderResult{ + OutputRenderedPath: req.OutputRenderedPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + InvokedBinary: "noop", + Format: req.Format, + Title: req.Title, + Metadata: map[string]any{"placeholder": true}, + }, nil +} + // FakeRunner captures merge requests and returns deterministic responses. type FakeRunner struct { Requests []MergeRequest @@ -79,6 +99,9 @@ type FakeRunner struct { TrimRequests []TrimRequest TrimErr error TrimResult TrimResult + RenderRequests []RenderRequest + RenderErr error + RenderResult RenderResult } // Run records request and returns configured response. @@ -195,6 +218,46 @@ func (f *FakeRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma return res, nil } +// Render records request and returns configured response. +func (f *FakeRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) { + if err := ctx.Err(); err != nil { + return RenderResult{}, err + } + f.RenderRequests = append(f.RenderRequests, req) + if f.RenderErr != nil { + return RenderResult{}, f.RenderErr + } + if err := materializeRenderPlaceholders(req); err != nil { + return RenderResult{}, err + } + res := f.RenderResult + if res.OutputRenderedPath == "" { + res.OutputRenderedPath = req.OutputRenderedPath + } + if res.StdoutLogPath == "" { + res.StdoutLogPath = req.StdoutLogPath + } + if res.StderrLogPath == "" { + res.StderrLogPath = req.StderrLogPath + } + if res.GeneratedConfigPath == "" { + res.GeneratedConfigPath = req.GeneratedConfigPath + } + if res.InvokedBinary == "" { + res.InvokedBinary = "fake" + } + if res.Format == "" { + res.Format = req.Format + } + if res.Title == "" { + res.Title = req.Title + } + if res.Metadata == nil { + res.Metadata = map[string]any{"fake": true} + } + return res, nil +} + func materializePlaceholders(req MergeRequest) error { if req.OutputMergedTranscriptPath != "" { if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil { @@ -301,3 +364,39 @@ func materializeNormalizePlaceholders(req NormalizeRequest) error { } return nil } + +func materializeRenderPlaceholders(req RenderRequest) error { + if req.OutputRenderedPath != "" { + if err := subprocess.WriteFileAtomic(req.OutputRenderedPath, []byte("# Transcript\n\nRendered markdown placeholder.\n"), 0o644); err != nil { + return fmt.Errorf("write rendered transcript %q: %w", req.OutputRenderedPath, err) + } + } + if req.GeneratedConfigPath != "" { + payload := map[string]any{ + "schema": "seriatim.generated.v1", + "placeholder": true, + "command": "render", + "input_path": req.InputTranscriptPath, + "output_path": req.OutputRenderedPath, + "format": req.Format, + "title": req.Title, + "include_timestamps": req.IncludeTimestamps, + "include_segment_ids": req.IncludeSegmentIDs, + "include_metadata": req.IncludeMetadata, + } + if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil { + return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err) + } + } + if req.StdoutLogPath != "" { + if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake render stdout placeholder\n"), 0o644); err != nil { + return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err) + } + } + if req.StderrLogPath != "" { + if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake render stderr placeholder\n"), 0o644); err != nil { + return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err) + } + } + return nil +} diff --git a/internal/adapters/seriatim/fake_test.go b/internal/adapters/seriatim/fake_test.go index c479569..303b91a 100644 --- a/internal/adapters/seriatim/fake_test.go +++ b/internal/adapters/seriatim/fake_test.go @@ -148,3 +148,58 @@ func TestFakeRunnerNormalizeError(t *testing.T) { t.Fatal("expected error, got nil") } } + +func TestFakeRunnerRenderCapturesRequestAndReturnsPath(t *testing.T) { + fake := &FakeRunner{} + dir := t.TempDir() + req := RenderRequest{ + GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.render.yml"), + InputTranscriptPath: filepath.Join(dir, "transcripts", "final.trimmed.json"), + OutputRenderedPath: filepath.Join(dir, "transcripts", "final.trimmed.md"), + Format: "markdown", + Title: "Session render", + IncludeTimestamps: true, + IncludeSegmentIDs: false, + IncludeMetadata: true, + StdoutLogPath: filepath.Join(dir, "logs", "seriatim.render.stdout.log"), + StderrLogPath: filepath.Join(dir, "logs", "seriatim.render.stderr.log"), + } + + res, err := fake.Render(context.Background(), req) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if len(fake.RenderRequests) != 1 || fake.RenderRequests[0].GeneratedConfigPath == "" { + t.Fatalf("render requests = %#v, want captured request", fake.RenderRequests) + } + if res.OutputRenderedPath != req.OutputRenderedPath { + t.Fatalf("rendered path = %q, want %q", res.OutputRenderedPath, req.OutputRenderedPath) + } + if res.Format != req.Format { + t.Fatalf("format = %q, want %q", res.Format, req.Format) + } + if res.Title != req.Title { + t.Fatalf("title = %q, want %q", res.Title, req.Title) + } + + cfgData, err := os.ReadFile(req.GeneratedConfigPath) + if err != nil { + t.Fatalf("read generated config: %v", err) + } + if !strings.Contains(string(cfgData), "command: render") { + t.Fatalf("generated config = %q, want render command marker", string(cfgData)) + } + for _, path := range []string{req.StdoutLogPath, req.StderrLogPath, req.OutputRenderedPath} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected file %q to exist: %v", path, err) + } + } +} + +func TestFakeRunnerRenderError(t *testing.T) { + fake := &FakeRunner{RenderErr: errors.New("boom")} + _, err := fake.Render(context.Background(), RenderRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/adapters/seriatim/runner.go b/internal/adapters/seriatim/runner.go index 1ecf2a7..17d7045 100644 --- a/internal/adapters/seriatim/runner.go +++ b/internal/adapters/seriatim/runner.go @@ -1,4 +1,4 @@ -// Package seriatim declares the adapter contract for transcript merge/normalize/trim execution. +// Package seriatim declares the adapter contract for transcript merge/normalize/trim/render execution. package seriatim import ( @@ -6,11 +6,12 @@ import ( "time" ) -// Runner is the adapter boundary for seriatim merge/normalize/trim invocations. +// Runner is the adapter boundary for seriatim merge/normalize/trim/render invocations. type Runner interface { Run(ctx context.Context, req MergeRequest) (MergeResult, error) Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error) Trim(ctx context.Context, req TrimRequest) (TrimResult, error) + Render(ctx context.Context, req RenderRequest) (RenderResult, error) } // MergeRequest describes a seriatim merge invocation. @@ -90,3 +91,33 @@ type TrimResult struct { KeepSelector string Metadata map[string]any } + +// RenderRequest describes a seriatim render invocation. +type RenderRequest struct { + Binary string + InputTranscriptPath string + OutputRenderedPath string + Format string + Title string + IncludeTimestamps bool + IncludeSegmentIDs bool + IncludeMetadata bool + StdoutLogPath string + StderrLogPath string + GeneratedConfigPath string + Timeout time.Duration +} + +// RenderResult describes a render output. +type RenderResult struct { + OutputRenderedPath string + StdoutLogPath string + StderrLogPath string + GeneratedConfigPath string + ExitCode int + Duration time.Duration + InvokedBinary string + Format string + Title string + Metadata map[string]any +} diff --git a/internal/adapters/seriatim/subprocess.go b/internal/adapters/seriatim/subprocess.go index 66f1443..835e3a7 100644 --- a/internal/adapters/seriatim/subprocess.go +++ b/internal/adapters/seriatim/subprocess.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" ) @@ -384,6 +385,96 @@ func (r *SubprocessRunner) Normalize(ctx context.Context, req NormalizeRequest) }, nil } +// Render executes Seriatim render with deterministic flags and validates non-empty text output. +func (r *SubprocessRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) { + if r == nil { + return RenderResult{}, fmt.Errorf("seriatim subprocess runner is nil") + } + if strings.TrimSpace(req.InputTranscriptPath) == "" { + return RenderResult{}, fmt.Errorf("seriatim render input path is required") + } + if strings.TrimSpace(req.OutputRenderedPath) == "" { + return RenderResult{}, fmt.Errorf("seriatim render output path is required") + } + format := strings.TrimSpace(req.Format) + if format == "" { + format = "markdown" + } + if format != "markdown" { + return RenderResult{}, fmt.Errorf("seriatim render format %q is unsupported", req.Format) + } + + binary := r.binary + if strings.TrimSpace(req.Binary) != "" { + binary = strings.TrimSpace(req.Binary) + } + + timeout := r.timeout + if req.Timeout < 0 { + return RenderResult{}, fmt.Errorf("seriatim render timeout must be >= 0") + } + if req.Timeout > 0 { + timeout = req.Timeout + } + + args := buildRenderArgs(req, format) + if req.GeneratedConfigPath != "" { + if err := writeRenderInvocationConfig(req, args, binary, timeout, format); err != nil { + return RenderResult{}, fmt.Errorf("write seriatim render invocation config %q: %w", req.GeneratedConfigPath, err) + } + } + + runRes, err := subprocess.Run(ctx, subprocess.RunRequest{ + Executable: binary, + Args: args, + Timeout: timeout, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + }) + if err != nil { + return RenderResult{ + OutputRenderedPath: req.OutputRenderedPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: binary, + Format: format, + Title: req.Title, + }, fmt.Errorf("run seriatim render (binary=%q): %w", binary, err) + } + + if err := validateNonEmptyTextFile(req.OutputRenderedPath); err != nil { + return RenderResult{ + OutputRenderedPath: req.OutputRenderedPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: binary, + Format: format, + Title: req.Title, + }, fmt.Errorf("validate seriatim rendered output %q: %w", req.OutputRenderedPath, err) + } + + return RenderResult{ + OutputRenderedPath: req.OutputRenderedPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: binary, + Format: format, + Title: req.Title, + Metadata: map[string]any{ + "adapter": "seriatim_subprocess", + }, + }, nil +} + func (r *SubprocessRunner) buildMergeArgs(req MergeRequest) []string { args := []string{"merge"} @@ -480,6 +571,22 @@ func buildNormalizeArgs(req NormalizeRequest, outputSchema string) []string { return args } +func buildRenderArgs(req RenderRequest, format string) []string { + args := []string{ + "render", + "--input-file", req.InputTranscriptPath, + "--output-file", req.OutputRenderedPath, + "--format", format, + "--include-timestamps", strconv.FormatBool(req.IncludeTimestamps), + "--include-segment-ids", strconv.FormatBool(req.IncludeSegmentIDs), + "--include-metadata", strconv.FormatBool(req.IncludeMetadata), + } + if strings.TrimSpace(req.Title) != "" { + args = append(args, "--title", req.Title) + } + return args +} + func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, timeout time.Duration) error { payload := map[string]any{ "schema": "seriatim.generated.v1", @@ -509,6 +616,24 @@ func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644) } +func writeRenderInvocationConfig(req RenderRequest, args []string, binary string, timeout time.Duration, format string) error { + payload := map[string]any{ + "schema": "seriatim.generated.v1", + "command": "render", + "binary": binary, + "args": args, + "timeout": timeout.String(), + "input_path": req.InputTranscriptPath, + "output_path": req.OutputRenderedPath, + "format": format, + "title": req.Title, + "include_timestamps": req.IncludeTimestamps, + "include_segment_ids": req.IncludeSegmentIDs, + "include_metadata": req.IncludeMetadata, + } + return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644) +} + func validateJSONFile(path string) error { data, err := os.ReadFile(path) if err != nil { @@ -541,3 +666,20 @@ func validateJSONFileWithSegments(path string) error { } return nil } + +func validateNonEmptyTextFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + if len(data) == 0 { + return fmt.Errorf("file is empty") + } + if !utf8.Valid(data) { + return fmt.Errorf("file is not valid utf-8 text") + } + if strings.TrimSpace(string(data)) == "" { + return fmt.Errorf("file has no non-whitespace content") + } + return nil +} diff --git a/internal/adapters/seriatim/subprocess_test.go b/internal/adapters/seriatim/subprocess_test.go index b67a836..d684db7 100644 --- a/internal/adapters/seriatim/subprocess_test.go +++ b/internal/adapters/seriatim/subprocess_test.go @@ -569,6 +569,156 @@ func TestSubprocessRunnerNormalizeInvalidReportJSONFails(t *testing.T) { } } +func TestSubprocessRunnerRenderSuccessInvocationAndProvenance(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "render_success") + recordPath := filepath.Join(t.TempDir(), "record.json") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath) + + wrapper := writeHelperWrapper(t) + runner := mustRunner(t, wrapper, false) + req := renderReqForTest(t) + + res, err := runner.Render(context.Background(), req) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if res.OutputRenderedPath != req.OutputRenderedPath { + t.Fatalf("OutputRenderedPath = %q, want %q", res.OutputRenderedPath, req.OutputRenderedPath) + } + if res.Format != req.Format { + t.Fatalf("Format = %q, want %q", res.Format, req.Format) + } + if res.Title != req.Title { + t.Fatalf("Title = %q, want %q", res.Title, req.Title) + } + if res.InvokedBinary != wrapper { + t.Fatalf("InvokedBinary = %q, want %q", res.InvokedBinary, wrapper) + } + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0", res.ExitCode) + } + if res.Duration <= 0 { + t.Fatalf("Duration = %s, want >0", res.Duration) + } + if res.Metadata == nil || res.Metadata["adapter"] != "seriatim_subprocess" { + t.Fatalf("Metadata = %#v, want adapter marker", res.Metadata) + } + + if _, err := os.Stat(req.OutputRenderedPath); err != nil { + t.Fatalf("rendered output missing: %v", err) + } + if _, err := os.Stat(req.StdoutLogPath); err != nil { + t.Fatalf("stdout log missing: %v", err) + } + if _, err := os.Stat(req.StderrLogPath); err != nil { + t.Fatalf("stderr log missing: %v", err) + } + if _, err := os.Stat(req.GeneratedConfigPath); err != nil { + t.Fatalf("generated config missing: %v", err) + } + + rec := readHelperRecord(t, recordPath) + wantArgs := []string{ + "render", + "--input-file", req.InputTranscriptPath, + "--output-file", req.OutputRenderedPath, + "--format", req.Format, + "--include-timestamps", "true", + "--include-segment-ids", "false", + "--include-metadata", "true", + "--title", req.Title, + } + if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") { + t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs) + } +} + +func TestSubprocessRunnerRenderWithoutTitleOmitsTitleArg(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "render_success") + recordPath := filepath.Join(t.TempDir(), "record.json") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := renderReqForTest(t) + req.Title = "" + if _, err := runner.Render(context.Background(), req); err != nil { + t.Fatalf("Render() error = %v", err) + } + + rec := readHelperRecord(t, recordPath) + for i := 0; i < len(rec.Args); i++ { + if rec.Args[i] == "--title" { + t.Fatalf("args = %#v, did not expect --title", rec.Args) + } + } +} + +func TestSubprocessRunnerRenderSubprocessFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "fail") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := renderReqForTest(t) + _, err := runner.Render(context.Background(), req) + if err == nil { + t.Fatal("Render() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "run seriatim render") { + t.Fatalf("error = %q, want subprocess context", err.Error()) + } +} + +func TestSubprocessRunnerRenderMissingOutputFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "missing_output") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := renderReqForTest(t) + _, err := runner.Render(context.Background(), req) + if err == nil { + t.Fatal("Render() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "validate seriatim rendered output") { + t.Fatalf("error = %q, want output validation context", err.Error()) + } +} + +func TestSubprocessRunnerRenderEmptyOutputFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "render_empty_output") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := renderReqForTest(t) + _, err := runner.Render(context.Background(), req) + if err == nil { + t.Fatal("Render() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "file is empty") { + t.Fatalf("error = %q, want empty-file validation", err.Error()) + } +} + func TestSubprocessRunnerConstructorValidation(t *testing.T) { _, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{}) if err == nil { @@ -702,6 +852,14 @@ func TestSeriatimSubprocessHelper(t *testing.T) { case "normalize_report_missing": writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`) os.Exit(0) + case "render_success": + writeSeriatimHelperFile(outputPath, "# Rendered transcript\n\nHello.\n") + _, _ = os.Stdout.WriteString("seriatim helper render stdout\n") + _, _ = os.Stderr.WriteString("seriatim helper render stderr\n") + os.Exit(0) + case "render_empty_output": + writeSeriatimHelperFile(outputPath, "") + os.Exit(0) default: _, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode)) os.Exit(2) @@ -777,6 +935,25 @@ func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest { return req } +func renderReqForTest(t *testing.T) RenderRequest { + t.Helper() + dir := t.TempDir() + input := filepath.Join(dir, "final.trimmed.json") + writeSeriatimFile(t, input, `{"schema":"seriatim.intermediate.v1","segments":[]}`) + return RenderRequest{ + InputTranscriptPath: input, + OutputRenderedPath: filepath.Join(dir, "final.trimmed.md"), + Format: "markdown", + Title: "Session 42", + IncludeTimestamps: true, + IncludeSegmentIDs: false, + IncludeMetadata: true, + GeneratedConfigPath: filepath.Join(dir, "seriatim.render.generated.yml"), + StdoutLogPath: filepath.Join(dir, "seriatim.render.stdout.log"), + StderrLogPath: filepath.Join(dir, "seriatim.render.stderr.log"), + } +} + func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner { t.Helper() coalesce := 3.0 diff --git a/internal/app/operator_artifact_rendering.go b/internal/app/operator_artifact_rendering.go index 62b7afb..0b479d3 100644 --- a/internal/app/operator_artifact_rendering.go +++ b/internal/app/operator_artifact_rendering.go @@ -32,15 +32,10 @@ func buildHelperArtifactCatalog(cfg *config.Config) (*artifacts.ArtifactCatalog, func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.ArtifactCatalog, locks *effectiveLocks, publishedRemoteState map[string]string) { lockSet := lockSourceSet(locks.All) fmt.Fprintln(out, "Built-in:") - for _, id := range []string{ - artifacts.ArtifactTranscriptBase, - artifacts.ArtifactTranscriptPolished, - artifacts.ArtifactTranscriptFinal, - artifacts.ArtifactTranscriptFinalTrimmed, - artifacts.ArtifactBoundsSession, - } { - writeArtifactLine(out, id, lockSet) + for _, transcript := range artifacts.RuntimeTranscriptArtifacts() { + writeArtifactLine(out, transcript.SourceID, lockSet) } + writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet) fmt.Fprintln(out, "Configured:") for _, entry := range catalog.ListConfigured() { writeArtifactLine(out, entry.SourceID, lockSet) diff --git a/internal/artifactmodel/transcripts.go b/internal/artifactmodel/transcripts.go index c084ce1..06df679 100644 --- a/internal/artifactmodel/transcripts.go +++ b/internal/artifactmodel/transcripts.go @@ -3,24 +3,30 @@ package artifactmodel import "strings" const ( - SourceTranscriptBase = "narratio.transcript.base" - SourceTranscriptPolished = "narratio.transcript.polished" - SourceTranscriptFinal = "narratio.transcript.final" - SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed" + SourceTranscriptBase = "narratio.transcript.base" + SourceTranscriptPolished = "narratio.transcript.polished" + SourceTranscriptFinal = "narratio.transcript.final" + SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed" + SourceTranscriptFinalMarkdown = "narratio.transcript.final_markdown" + SourceTranscriptFinalTrimmedMarkdown = "narratio.transcript.final_trimmed_markdown" ) const ( - TranscriptPathBase = "transcripts/base.json" - TranscriptPathPolished = "transcripts/polished.json" - TranscriptPathFinal = "transcripts/final.json" - TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json" + TranscriptPathBase = "transcripts/base.json" + TranscriptPathPolished = "transcripts/polished.json" + TranscriptPathFinal = "transcripts/final.json" + TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json" + TranscriptPathFinalMarkdown = "transcripts/final.md" + TranscriptPathFinalTrimmedMarkdown = "transcripts/final.trimmed.md" ) const ( - TranscriptOutputKindBase = "transcript_base" - TranscriptOutputKindPolished = "transcript_polished" - TranscriptOutputKindFinal = "transcript_final" - TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed" + TranscriptOutputKindBase = "transcript_base" + TranscriptOutputKindPolished = "transcript_polished" + TranscriptOutputKindFinal = "transcript_final" + TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed" + TranscriptOutputKindFinalMarkdown = "transcript_final_markdown" + TranscriptOutputKindFinalTrimmedMarkdown = "transcript_final_trimmed_markdown" ) // TranscriptArtifactSpec describes one built-in transcript artifact mapping. @@ -56,6 +62,18 @@ var runtimeTranscriptArtifacts = []TranscriptArtifactSpec{ ProducerStage: "trim", OutputKind: TranscriptOutputKindFinalTrimmed, }, + { + SourceID: SourceTranscriptFinalMarkdown, + CanonicalRelPath: TranscriptPathFinalMarkdown, + ProducerStage: "render", + OutputKind: TranscriptOutputKindFinalMarkdown, + }, + { + SourceID: SourceTranscriptFinalTrimmedMarkdown, + CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown, + ProducerStage: "render", + OutputKind: TranscriptOutputKindFinalTrimmedMarkdown, + }, } // RuntimeTranscriptArtifacts returns transcript mappings in pipeline order. diff --git a/internal/artifactmodel/transcripts_test.go b/internal/artifactmodel/transcripts_test.go new file mode 100644 index 0000000..39a7fe4 --- /dev/null +++ b/internal/artifactmodel/transcripts_test.go @@ -0,0 +1,60 @@ +package artifactmodel + +import ( + "reflect" + "testing" +) + +func TestRuntimeTranscriptArtifactsIncludesMarkdownOutputs(t *testing.T) { + want := []TranscriptArtifactSpec{ + { + SourceID: SourceTranscriptBase, + CanonicalRelPath: TranscriptPathBase, + ProducerStage: "merge", + OutputKind: TranscriptOutputKindBase, + }, + { + SourceID: SourceTranscriptPolished, + CanonicalRelPath: TranscriptPathPolished, + ProducerStage: "polish", + OutputKind: TranscriptOutputKindPolished, + }, + { + SourceID: SourceTranscriptFinal, + CanonicalRelPath: TranscriptPathFinal, + ProducerStage: "normalize", + OutputKind: TranscriptOutputKindFinal, + }, + { + SourceID: SourceTranscriptFinalTrimmed, + CanonicalRelPath: TranscriptPathFinalTrimmed, + ProducerStage: "trim", + OutputKind: TranscriptOutputKindFinalTrimmed, + }, + { + SourceID: SourceTranscriptFinalMarkdown, + CanonicalRelPath: TranscriptPathFinalMarkdown, + ProducerStage: "render", + OutputKind: TranscriptOutputKindFinalMarkdown, + }, + { + SourceID: SourceTranscriptFinalTrimmedMarkdown, + CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown, + ProducerStage: "render", + OutputKind: TranscriptOutputKindFinalTrimmedMarkdown, + }, + } + + got := RuntimeTranscriptArtifacts() + if !reflect.DeepEqual(got, want) { + t.Fatalf("RuntimeTranscriptArtifacts() = %#v, want %#v", got, want) + } +} + +func TestLookupRuntimeTranscriptArtifactFindsMarkdownOutputs(t *testing.T) { + for _, source := range []string{SourceTranscriptFinalMarkdown, SourceTranscriptFinalTrimmedMarkdown} { + if _, ok := LookupRuntimeTranscriptArtifact(source); !ok { + t.Fatalf("LookupRuntimeTranscriptArtifact(%q) ok=false, want true", source) + } + } +} diff --git a/internal/artifacts/artifact_resolver.go b/internal/artifacts/artifact_resolver.go index 1e7008b..475677f 100644 --- a/internal/artifacts/artifact_resolver.go +++ b/internal/artifacts/artifact_resolver.go @@ -14,28 +14,34 @@ import ( ) const ( - ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase - ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished - ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal - ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed - ArtifactBoundsSession = "narratio.bounds.session" + ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase + ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished + ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal + ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed + ArtifactTranscriptFinalMarkdown = artifactmodel.SourceTranscriptFinalMarkdown + ArtifactTranscriptFinalTrimmedMarkdown = artifactmodel.SourceTranscriptFinalTrimmedMarkdown + ArtifactBoundsSession = "narratio.bounds.session" ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache" ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache" ) const ( - TranscriptPathBase = artifactmodel.TranscriptPathBase - TranscriptPathPolished = artifactmodel.TranscriptPathPolished - TranscriptPathFinal = artifactmodel.TranscriptPathFinal - TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed + TranscriptPathBase = artifactmodel.TranscriptPathBase + TranscriptPathPolished = artifactmodel.TranscriptPathPolished + TranscriptPathFinal = artifactmodel.TranscriptPathFinal + TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed + TranscriptPathFinalMarkdown = artifactmodel.TranscriptPathFinalMarkdown + TranscriptPathFinalTrimmedMarkdown = artifactmodel.TranscriptPathFinalTrimmedMarkdown ) const ( - TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase - TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished - TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal - TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed + TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase + TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished + TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal + TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed + TranscriptOutputKindFinalMarkdown = artifactmodel.TranscriptOutputKindFinalMarkdown + TranscriptOutputKindFinalTrimmedMarkdown = artifactmodel.TranscriptOutputKindFinalTrimmedMarkdown ) // ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID. @@ -67,7 +73,7 @@ func buildArtifactRegistry() map[string]artifactSpec { CanonicalRelPath: transcript.CanonicalRelPath, ProducerStage: transcript.ProducerStage, OutputKind: transcript.OutputKind, - ContentKind: contentTranscriptJSON, + ContentKind: transcriptContentKind(transcript), } } registry[ArtifactBoundsSession] = artifactSpec{ @@ -80,6 +86,15 @@ func buildArtifactRegistry() map[string]artifactSpec { return registry } +func transcriptContentKind(transcript TranscriptArtifactSpec) artifactContentKind { + switch transcript.SourceID { + case ArtifactTranscriptFinalMarkdown, ArtifactTranscriptFinalTrimmedMarkdown: + return contentText + default: + return contentTranscriptJSON + } +} + // ResolvedSessionArtifact describes one session-level artifact lookup result. type ResolvedSessionArtifact struct { ID string diff --git a/internal/artifacts/artifact_resolver_test.go b/internal/artifacts/artifact_resolver_test.go index 677c99d..8bd18c0 100644 --- a/internal/artifacts/artifact_resolver_test.go +++ b/internal/artifacts/artifact_resolver_test.go @@ -211,6 +211,29 @@ func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) { } } +func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) { + workspace := t.TempDir() + paths := buildSessionPaths(workspace, "campaign", "session") + canonicalPath := filepath.Join(paths.TranscriptsDir, "final.md") + if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(canonicalPath, []byte("# Final transcript\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown) + if err != nil { + t.Fatalf("ResolveSessionArtifact() error = %v", err) + } + if resolved.Path != canonicalPath { + t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath) + } + if resolved.Provenance != "fallback.canonical_path" { + t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance) + } +} + func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") @@ -244,6 +267,26 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) { } } +func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) { + workspace := t.TempDir() + paths := buildSessionPaths(workspace, "campaign", "session") + canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.md") + if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(canonicalPath, []byte{}, 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + _, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmedMarkdown) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "file is empty") { + t.Fatalf("error = %q, want empty file validation", err.Error()) + } +} + func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") diff --git a/internal/artifacts/catalog.go b/internal/artifacts/catalog.go index d3412bc..126bd68 100644 --- a/internal/artifacts/catalog.go +++ b/internal/artifacts/catalog.go @@ -216,11 +216,10 @@ func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error { } func runtimeBuiltInArtifactIDs() []string { - return []string{ - ArtifactTranscriptBase, - ArtifactTranscriptPolished, - ArtifactTranscriptFinal, - ArtifactTranscriptFinalTrimmed, - ArtifactBoundsSession, + ids := make([]string, 0, len(RuntimeTranscriptArtifacts())+1) + for _, transcript := range RuntimeTranscriptArtifacts() { + ids = append(ids, transcript.SourceID) } + ids = append(ids, ArtifactBoundsSession) + return ids } diff --git a/internal/artifacts/catalog_test.go b/internal/artifacts/catalog_test.go index 890d430..2dd9dd2 100644 --- a/internal/artifacts/catalog_test.go +++ b/internal/artifacts/catalog_test.go @@ -23,6 +23,26 @@ func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) { } } +func TestArtifactCatalogRegisterBuiltInsIncludesMarkdownSources(t *testing.T) { + catalog := NewArtifactCatalog() + if err := catalog.RegisterBuiltIns(); err != nil { + t.Fatalf("RegisterBuiltIns() error = %v", err) + } + + for _, sourceID := range []string{ + ArtifactTranscriptFinalMarkdown, + ArtifactTranscriptFinalTrimmedMarkdown, + } { + entry, ok := catalog.Lookup(sourceID) + if !ok { + t.Fatalf("Lookup(%q) ok=false, want true", sourceID) + } + if !entry.Planned { + t.Fatalf("%s planned=false, want true", sourceID) + } + } +} + func TestArtifactCatalogRegisterConfiguredArtifactsDefaultsToEnabled(t *testing.T) { catalog := NewArtifactCatalog() if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{ diff --git a/internal/artifacts/transcripts_test.go b/internal/artifacts/transcripts_test.go index 1bc842f..ef7f7e7 100644 --- a/internal/artifacts/transcripts_test.go +++ b/internal/artifacts/transcripts_test.go @@ -31,6 +31,18 @@ func TestRuntimeTranscriptArtifacts(t *testing.T) { ProducerStage: "trim", OutputKind: TranscriptOutputKindFinalTrimmed, }, + { + SourceID: ArtifactTranscriptFinalMarkdown, + CanonicalRelPath: TranscriptPathFinalMarkdown, + ProducerStage: "render", + OutputKind: TranscriptOutputKindFinalMarkdown, + }, + { + SourceID: ArtifactTranscriptFinalTrimmedMarkdown, + CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown, + ProducerStage: "render", + OutputKind: TranscriptOutputKindFinalTrimmedMarkdown, + }, } got := RuntimeTranscriptArtifacts() @@ -75,6 +87,18 @@ func TestPlannedTranscriptArtifacts(t *testing.T) { ProducerStage: "trim", OutputKind: TranscriptOutputKindFinalTrimmed, }, + { + SourceID: ArtifactTranscriptFinalMarkdown, + CanonicalRelPath: TranscriptPathFinalMarkdown, + ProducerStage: "render", + OutputKind: TranscriptOutputKindFinalMarkdown, + }, + { + SourceID: ArtifactTranscriptFinalTrimmedMarkdown, + CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown, + ProducerStage: "render", + OutputKind: TranscriptOutputKindFinalTrimmedMarkdown, + }, } got := PlannedTranscriptArtifacts() @@ -113,10 +137,14 @@ func TestRuntimeArtifactRegistryUsesTranscriptSpecs(t *testing.T) { if !ok { t.Fatalf("artifactRegistry missing %q", transcript.SourceID) } + wantContentKind := contentTranscriptJSON + if transcript.SourceID == ArtifactTranscriptFinalMarkdown || transcript.SourceID == ArtifactTranscriptFinalTrimmedMarkdown { + wantContentKind = contentText + } if spec.CanonicalRelPath != transcript.CanonicalRelPath || spec.ProducerStage != transcript.ProducerStage || spec.OutputKind != transcript.OutputKind || - spec.ContentKind != contentTranscriptJSON { + spec.ContentKind != wantContentKind { t.Fatalf("artifactRegistry[%q] = %#v, want transcript spec %#v", transcript.SourceID, spec, transcript) } } diff --git a/internal/config/config.go b/internal/config/config.go index 9e92b0d..991bddc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,7 @@ type PipelineConfig struct { Audita AuditaConfig `yaml:"audita"` Normalize *NormalizeConfig `yaml:"normalize"` Trim *TrimConfig `yaml:"trim"` + Render *RenderConfig `yaml:"render"` Scriptorium *ScriptoriumConfig `yaml:"scriptorium"` Notification NotificationConfig `yaml:"notification"` } @@ -209,6 +210,16 @@ type TrimSeriatimConfig struct { Report *bool `yaml:"report"` } +// RenderConfig configures render-stage output formatting behavior. +type RenderConfig struct { + Enabled *bool `yaml:"enabled"` + Format string `yaml:"format"` + Title string `yaml:"title"` + IncludeTimestamps *bool `yaml:"include_timestamps"` + IncludeSegmentIDs bool `yaml:"include_segment_ids"` + IncludeMetadata bool `yaml:"include_metadata"` +} + // ScriptoriumConfig configures Scriptorium-backed artifact generation. type ScriptoriumConfig struct { Binary string `yaml:"binary"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 2d61115..aa1eab5 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -40,6 +40,12 @@ const ( DefaultTrimBoundsTimeout = "10m" DefaultTrimSeriatimReport = false + DefaultRenderEnabled = true + DefaultRenderFormat = "markdown" + DefaultRenderTitle = "" + DefaultRenderTimestamps = true + DefaultRenderSegmentIDs = false + DefaultRenderMetadata = false DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal DefaultNormalizeOutputSchema = "seriatim-intermediate" diff --git a/internal/config/load.go b/internal/config/load.go index 78ffc7b..6c17ef4 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -337,6 +337,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) { } applyNormalizeDefaults(cfg.Normalize) applyTrimDefaults(cfg.Trim) + applyRenderDefaults(&cfg.Render) applyScriptoriumDefaults(cfg.Scriptorium) } @@ -507,6 +508,27 @@ func applyTrimDefaults(cfg *TrimConfig) { } } +func applyRenderDefaults(cfg **RenderConfig) { + if cfg == nil { + return + } + if *cfg == nil { + *cfg = &RenderConfig{} + } + if (*cfg).Enabled == nil { + (*cfg).Enabled = boolPtr(DefaultRenderEnabled) + } + if strings.TrimSpace((*cfg).Format) == "" { + (*cfg).Format = DefaultRenderFormat + } + if strings.TrimSpace((*cfg).Title) == "" { + (*cfg).Title = DefaultRenderTitle + } + if (*cfg).IncludeTimestamps == nil { + (*cfg).IncludeTimestamps = boolPtr(DefaultRenderTimestamps) + } +} + func applyNormalizeDefaults(cfg *NormalizeConfig) { if cfg == nil { return diff --git a/internal/config/render_test.go b/internal/config/render_test.go new file mode 100644 index 0000000..3deca70 --- /dev/null +++ b/internal/config/render_test.go @@ -0,0 +1,128 @@ +package config + +import ( + "strings" + "testing" +) + +func TestRenderLoadAndValidate(t *testing.T) { + tests := []struct { + name string + renderYAML string + wantLoadErr string + wantValidateErr string + assert func(t *testing.T, cfg *Config) + }{ + { + name: "render defaults when omitted", + renderYAML: "", + assert: func(t *testing.T, cfg *Config) { + t.Helper() + if cfg.Pipeline.Render == nil { + t.Fatal("render config should be present via defaults") + } + if cfg.Pipeline.Render.Enabled == nil || !*cfg.Pipeline.Render.Enabled { + t.Fatalf("render.enabled = %#v, want true", cfg.Pipeline.Render.Enabled) + } + if cfg.Pipeline.Render.Format != "markdown" { + t.Fatalf("render.format = %q, want markdown", cfg.Pipeline.Render.Format) + } + if cfg.Pipeline.Render.IncludeTimestamps == nil || !*cfg.Pipeline.Render.IncludeTimestamps { + t.Fatalf("render.include_timestamps = %#v, want true", cfg.Pipeline.Render.IncludeTimestamps) + } + if cfg.Pipeline.Render.IncludeSegmentIDs { + t.Fatalf("render.include_segment_ids = true, want false") + } + if cfg.Pipeline.Render.IncludeMetadata { + t.Fatalf("render.include_metadata = true, want false") + } + }, + }, + { + name: "valid explicit render config", + renderYAML: `render: + enabled: false + format: markdown + title: Session Render + include_timestamps: false + include_segment_ids: true + include_metadata: true +`, + assert: func(t *testing.T, cfg *Config) { + t.Helper() + if cfg.Pipeline.Render == nil { + t.Fatal("render config should be present") + } + if cfg.Pipeline.Render.Enabled == nil || *cfg.Pipeline.Render.Enabled { + t.Fatalf("render.enabled = %#v, want false", cfg.Pipeline.Render.Enabled) + } + if cfg.Pipeline.Render.IncludeTimestamps == nil || *cfg.Pipeline.Render.IncludeTimestamps { + t.Fatalf("render.include_timestamps = %#v, want false", cfg.Pipeline.Render.IncludeTimestamps) + } + if !cfg.Pipeline.Render.IncludeSegmentIDs { + t.Fatalf("render.include_segment_ids = false, want true") + } + if !cfg.Pipeline.Render.IncludeMetadata { + t.Fatalf("render.include_metadata = false, want true") + } + }, + }, + { + name: "invalid render format fails", + renderYAML: `render: + format: html +`, + wantValidateErr: "pipeline.render.format must be markdown", + }, + { + name: "unknown render field fails strict decoding", + renderYAML: `render: + format: markdown + unknown: true +`, + wantLoadErr: "strict decode failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pipelineYAML := testPipelineBaseYAML + if tt.renderYAML != "" { + pipelineYAML += "\n" + tt.renderYAML + } + pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML) + + cfg, err := Load(pipelinePath, sessionPath) + if tt.wantLoadErr != "" { + if err == nil { + t.Fatalf("expected load error containing %q, got nil", tt.wantLoadErr) + } + if !strings.Contains(err.Error(), tt.wantLoadErr) { + t.Fatalf("load error = %q, want to contain %q", err.Error(), tt.wantLoadErr) + } + return + } + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if tt.assert != nil { + tt.assert(t, cfg) + } + + err = Validate(cfg) + if tt.wantValidateErr != "" { + if err == nil { + t.Fatalf("expected validation error containing %q, got nil", tt.wantValidateErr) + } + if !strings.Contains(err.Error(), tt.wantValidateErr) { + t.Fatalf("validation error = %q, want to contain %q", err.Error(), tt.wantValidateErr) + } + return + } + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + }) + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go index 9627dd7..fb3df42 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -88,6 +88,9 @@ func validatePipeline(cfg *PipelineConfig) error { if err := validateTrim(cfg.Trim); err != nil { return err } + if err := validateRender(cfg.Render); err != nil { + return err + } if err := validateScriptorium(cfg.Scriptorium); err != nil { return err } @@ -299,6 +302,26 @@ func validateTrim(cfg *TrimConfig) error { return nil } +func validateRender(cfg *RenderConfig) error { + if cfg == nil { + return nil + } + if cfg.Enabled == nil { + return fmt.Errorf("pipeline.render.enabled must be set (defaults should populate this)") + } + if cfg.IncludeTimestamps == nil { + return fmt.Errorf("pipeline.render.include_timestamps must be set (defaults should populate this)") + } + format := strings.TrimSpace(cfg.Format) + if format != "markdown" { + return fmt.Errorf("pipeline.render.format must be markdown") + } + if cfg.Title != "" && strings.TrimSpace(cfg.Title) == "" { + return fmt.Errorf("pipeline.render.title must be non-empty when provided") + } + return nil +} + func validateWhisperX(cfg WhisperXConfig) error { if strings.TrimSpace(cfg.TranscribeURL) == "" { return fmt.Errorf("pipeline.whisperx.transcribe_url is required")