diff --git a/internal/cli/run.go b/internal/cli/run.go index a5cc104..6b27e86 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -9,8 +9,9 @@ import ( "time" "gitea.maximumdirect.net/eric/audita/internal/core/config" - coreio "gitea.maximumdirect.net/eric/audita/internal/core/io" + "gitea.maximumdirect.net/eric/audita/internal/core/io" "gitea.maximumdirect.net/eric/audita/internal/core/reporting" + "gitea.maximumdirect.net/eric/audita/internal/core/schema" ) type processInvocation struct { @@ -22,25 +23,61 @@ type processInvocation struct { } var processRunner = func(inv processInvocation, stdout io.Writer) error { - transcriptBytes, err := coreio.ReadRequiredFile(inv.TranscriptPath, "transcript") + transcriptBytes, err := io.ReadRequiredFile(inv.TranscriptPath, "transcript") if err != nil { return err } - if err := coreio.ValidateWellFormedJSON(inv.TranscriptPath, transcriptBytes); err != nil { - return err - } - if _, err := coreio.ReadRequiredFile(inv.GlossaryPath, "glossary"); err != nil { + + glossaryBytes, err := io.ReadRequiredFile(inv.GlossaryPath, "glossary") + if err != nil { return err } + // Parse and validate transcript using typed schema + transcript, err := schema.ParseSourceTranscriptJSON(transcriptBytes) + if err != nil { + return fmt.Errorf("invalid transcript: %w", err) + } + + // Parse and validate glossary using typed schema + glossary, err := schema.ParseGlossaryYAML(glossaryBytes) + if err != nil { + return fmt.Errorf("invalid glossary: %w", err) + } + + // Convert to canonical transcript format for output + outputTranscript := &schema.Transcript{ + Segments: make([]schema.Segment, len(transcript.Segments)), + } + for i, s := range transcript.Segments { + id := i + 1 + if s.ID != nil { + id = *s.ID + } + outputTranscript.Segments[i] = schema.Segment{ + ID: id, + Speaker: s.Speaker, + Start: s.Start, + End: s.End, + Text: s.Text, + Categories: s.Categories, + } + } + + // Serialize output transcript to JSON + outputBytes, err := schema.TranscriptToJSON(outputTranscript) + if err != nil { + return fmt.Errorf("failed to serialize transcript: %w", err) + } + if strings.TrimSpace(inv.OutputPath) != "" { - if err := coreio.WriteFile(inv.OutputPath, transcriptBytes); err != nil { + if err := io.WriteFile(inv.OutputPath, outputBytes); err != nil { return err } return nil } - if _, err := stdout.Write(transcriptBytes); err != nil { + if _, err := stdout.Write(outputBytes); err != nil { return fmt.Errorf("failed to write transcript to stdout: %w", err) } return nil diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index cc67465..f521fb6 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -341,49 +341,71 @@ func TestRunProcessReportJSONBestEffortOnFailure(t *testing.T) { } } -func TestRunProcessCLIOverridesEnvironment(t *testing.T) { +func TestRunProcessInvalidTranscriptSchema(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - t.Setenv("AUDITA_MODEL", "env-model") - t.Setenv("AUDITA_VALIDATION_LLM_CONCURRENCY", "2") - - transcriptPath := fixturePath("tiny_transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - - var captured processInvocation - originalRunner := processRunner - processRunner = func(inv processInvocation, output io.Writer) error { - _ = output - captured = inv - return nil + exitCode := Run([]string{"process", fixturePath("transcript_empty_speaker.json"), "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) + if exitCode == 0 { + t.Fatalf("expected nonzero exit code for invalid transcript schema") } - t.Cleanup(func() { - processRunner = originalRunner - }) + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout, got %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "invalid transcript:") { + t.Fatalf("expected invalid transcript error, got %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "speaker") { + t.Fatalf("expected speaker validation error, got %q", stderr.String()) + } +} - exitCode := Run([]string{ - "process", - transcriptPath, - "--glossary", - glossaryPath, - "--model", - "cli-model", - "--validation-llm-concurrency", - "5", - }, &stdout, &stderr) +func TestRunProcessValidBareArrayTranscript(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := Run([]string{"process", "../core/schema/testdata/transcript_bare_array.json", "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d", exitCode) + t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } - if captured.Config.PrimaryLLM.Model != "cli-model" { - t.Fatalf("expected CLI model override, got %q", captured.Config.PrimaryLLM.Model) + if stderr.Len() != 0 { + t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } - if captured.Config.ValidationLLM.Concurrency == nil || *captured.Config.ValidationLLM.Concurrency != 5 { - t.Fatalf("expected CLI validation concurrency override, got %#v", captured.Config.ValidationLLM.Concurrency) + + // Should produce valid JSON output + if !json.Valid(stdout.Bytes()) { + t.Fatalf("expected valid JSON output, got %q", stdout.String()) } - if captured.GlossaryPath != glossaryPath { - t.Fatalf("unexpected glossary path: %q", captured.GlossaryPath) + + // Parse the output to verify it's a proper transcript + var output any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("failed to unmarshal output JSON: %v", err) + } + + // Should be an array at the top level (canonical object form would be {"segments": [...]}) + outputArray, ok := output.([]any) + if !ok { + t.Fatalf("expected output to be JSON array, got %T", output) + } + if len(outputArray) != 2 { + t.Fatalf("expected 2 segments in output, got %d", len(outputArray)) + } +} + +func TestRunProcessInvalidGlossarySchema(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"process", fixturePath("tiny_transcript.json"), "--glossary", "../core/schema/testdata/glossary_missing_fields.yaml"}, &stdout, &stderr) + if exitCode == 0 { + t.Fatalf("expected nonzero exit code for invalid glossary schema") + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout, got %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "invalid glossary:") { + t.Fatalf("expected invalid glossary error, got %q", stderr.String()) } } @@ -438,5 +460,82 @@ func readProcessReport(t *testing.T, path string) minimalProcessReport { if err := json.Unmarshal(raw, &report); err != nil { t.Fatalf("failed to unmarshal process report JSON: %v (raw: %q)", err, string(raw)) } - return report +func TestRunProcessStdoutBehaviorWithoutOutput(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"process", "../core/schema/testdata/transcript_bare_array.json", "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("expected empty stderr on success, got %q", stderr.String()) + } + + // Should have transcript JSON in stdout + if stdout.Len() == 0 { + t.Fatalf("expected transcript JSON in stdout, got empty stdout") + } + if !json.Valid(stdout.Bytes()) { + t.Fatalf("expected valid JSON in stdout, got %q", stdout.String()) + } +} + +func TestRunProcessEmptyStdoutWithOutput(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + transcriptPath := "../core/schema/testdata/transcript_bare_array.json" + glossaryPath := fixturePath("tiny_glossary.yaml") + outputPath := filepath.Join(t.TempDir(), "corrected.json") + + exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath}, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("expected empty stderr on success, got %q", stderr.String()) + } + + // Verify output file was created and contains valid JSON + outputBytes := readFile(t, outputPath) + if !json.Valid(outputBytes) { + t.Fatalf("expected valid JSON in output file, got %q", string(outputBytes)) + } +} + +func TestRunProcessReportJSONOnParseFailure(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + reportPath := filepath.Join(t.TempDir(), "report.json") + exitCode := Run([]string{ + "process", + fixturePath("transcript_empty_speaker.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--report-json", + reportPath, + }, &stdout, &stderr) + if exitCode == 0 { + t.Fatalf("expected nonzero exit code for parse failure") + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) + } + + // Report should still be written on parse failure + report := readProcessReport(t, reportPath) + if report.Status != "failed" { + t.Fatalf("expected failed report status, got %q", report.Status) + } + if report.ErrorMessage == "" { + t.Fatalf("expected error message in failed report") + } + if !strings.Contains(report.ErrorMessage, "invalid transcript:") { + t.Fatalf("expected invalid transcript error in report, got %q", report.ErrorMessage) + } }