diff --git a/internal/cli/trim.go b/internal/cli/trim.go index 037d68e..d35f308 100644 --- a/internal/cli/trim.go +++ b/internal/cli/trim.go @@ -1,41 +1,12 @@ package cli import ( - "encoding/json" - "fmt" - "os" - "sort" - "github.com/spf13/cobra" "gitea.maximumdirect.net/eric/seriatim/internal/config" - "gitea.maximumdirect.net/eric/seriatim/internal/report" - triminternal "gitea.maximumdirect.net/eric/seriatim/internal/trim" + "gitea.maximumdirect.net/eric/seriatim/internal/trim" ) -type trimAuditReport struct { - Operation string `json:"operation"` - InputFile string `json:"input_file"` - OutputFile string `json:"output_file"` - InputSchema string `json:"input_schema"` - OutputSchema string `json:"output_schema"` - Mode string `json:"mode"` - Selector string `json:"selector"` - SelectedIDs []int `json:"selected_ids"` - AllowEmpty bool `json:"allow_empty"` - InputSegmentCount int `json:"input_segment_count"` - RetainedSegmentCount int `json:"retained_segment_count"` - RemovedSegmentCount int `json:"removed_segment_count"` - RemovedInputIDs []int `json:"removed_input_ids"` - OldToNewIDMapping []trimIDMapping `json:"old_to_new_id_mapping"` - OverlapGroupsRecomputed bool `json:"overlap_groups_recomputed"` -} - -type trimIDMapping struct { - OldID int `json:"old_id"` - NewID int `json:"new_id"` -} - func newTrimCommand() *cobra.Command { var opts config.TrimOptions @@ -53,99 +24,7 @@ func newTrimCommand() *cobra.Command { return err } - selector, err := triminternal.ParseSelector(cfg.Selector) - if err != nil { - return fmt.Errorf("invalid selector %q: %w", cfg.Selector, err) - } - - data, err := os.ReadFile(cfg.InputFile) - if err != nil { - return fmt.Errorf("read --input-file %q: %w", cfg.InputFile, err) - } - - artifact, err := triminternal.ParseArtifactJSON(data) - if err != nil { - return fmt.Errorf("--input-file %q: %w", cfg.InputFile, err) - } - inputSegmentCount := artifact.SegmentCount() - inputSchema := artifact.Schema - - mode := triminternal.ModeKeep - if cfg.Mode == "remove" { - mode = triminternal.ModeRemove - } - - trimmed, err := triminternal.ApplyArtifact(artifact, triminternal.Options{ - Mode: mode, - Selector: selector, - AllowEmpty: cfg.AllowEmpty, - }) - if err != nil { - return err - } - - outputSchema := artifact.Schema - if cfg.OutputSchema != "" { - outputSchema = cfg.OutputSchema - } - - outputArtifact, err := triminternal.ConvertArtifact(trimmed.Artifact, outputSchema) - if err != nil { - return err - } - - if err := triminternal.ValidateArtifact(outputArtifact); err != nil { - return fmt.Errorf("validate trimmed output: %w", err) - } - - if err := writeOutputJSON(cfg.OutputFile, outputArtifact.Value()); err != nil { - return err - } - - if cfg.ReportFile != "" { - audit := trimAuditReport{ - Operation: "trim", - InputFile: cfg.InputFile, - OutputFile: cfg.OutputFile, - InputSchema: inputSchema, - OutputSchema: outputArtifact.Schema, - Mode: cfg.Mode, - Selector: cfg.Selector, - SelectedIDs: selector.IDs(), - AllowEmpty: cfg.AllowEmpty, - InputSegmentCount: inputSegmentCount, - RetainedSegmentCount: len(trimmed.OldToNewID), - RemovedSegmentCount: len(trimmed.RemovedIDs), - RemovedInputIDs: append([]int(nil), trimmed.RemovedIDs...), - OldToNewIDMapping: orderedIDMapping(trimmed.OldToNewID), - OverlapGroupsRecomputed: trimmed.OverlapGroupsRecomputed, - } - auditJSON, err := json.Marshal(audit) - if err != nil { - return fmt.Errorf("marshal trim audit report: %w", err) - } - - rpt := report.Report{ - Metadata: report.Metadata{ - Application: outputArtifact.Application(), - Version: outputArtifact.Version(), - InputReader: "trim-artifact", - InputFiles: []string{cfg.InputFile}, - OutputModules: []string{"json"}, - }, - Events: []report.Event{ - report.Info("trim", "trim", fmt.Sprintf("trimmed %d input segment(s) into %d output segment(s) with mode=%s", inputSegmentCount, outputArtifact.SegmentCount(), cfg.Mode)), - report.Info("trim", "trim-audit", string(auditJSON)), - report.Info("trim", "validate-output", fmt.Sprintf("validated %d output segment(s)", outputArtifact.SegmentCount())), - report.Info("output", "json", "wrote transcript JSON"), - }, - } - if err := report.WriteJSON(cfg.ReportFile, rpt); err != nil { - return err - } - } - - return nil + return trim.Run(cmd.Context(), cfg) }, } @@ -160,32 +39,3 @@ func newTrimCommand() *cobra.Command { return cmd } - -func writeOutputJSON(path string, value any) error { - file, err := os.Create(path) - if err != nil { - return err - } - defer file.Close() - - enc := json.NewEncoder(file) - enc.SetIndent("", " ") - return enc.Encode(value) -} - -func orderedIDMapping(mapping map[int]int) []trimIDMapping { - keys := make([]int, 0, len(mapping)) - for oldID := range mapping { - keys = append(keys, oldID) - } - sort.Ints(keys) - - pairs := make([]trimIDMapping, 0, len(keys)) - for _, oldID := range keys { - pairs = append(pairs, trimIDMapping{ - OldID: oldID, - NewID: mapping[oldID], - }) - } - return pairs -} diff --git a/internal/cli/trim_test.go b/internal/cli/trim_test.go index 196c5ea..f1664db 100644 --- a/internal/cli/trim_test.go +++ b/internal/cli/trim_test.go @@ -12,6 +12,29 @@ import ( "gitea.maximumdirect.net/eric/seriatim/schema" ) +type trimAuditReport struct { + Operation string `json:"operation"` + InputFile string `json:"input_file"` + OutputFile string `json:"output_file"` + InputSchema string `json:"input_schema"` + OutputSchema string `json:"output_schema"` + Mode string `json:"mode"` + Selector string `json:"selector"` + SelectedIDs []int `json:"selected_ids"` + AllowEmpty bool `json:"allow_empty"` + InputSegmentCount int `json:"input_segment_count"` + RetainedSegmentCount int `json:"retained_segment_count"` + RemovedSegmentCount int `json:"removed_segment_count"` + RemovedInputIDs []int `json:"removed_input_ids"` + OldToNewIDMapping []trimIDMapping `json:"old_to_new_id_mapping"` + OverlapGroupsRecomputed bool `json:"overlap_groups_recomputed"` +} + +type trimIDMapping struct { + OldID int `json:"old_id"` + NewID int `json:"new_id"` +} + func TestTrimKeepModeEndToEnd(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") diff --git a/internal/trim/run.go b/internal/trim/run.go new file mode 100644 index 0000000..aca64c3 --- /dev/null +++ b/internal/trim/run.go @@ -0,0 +1,167 @@ +package trim + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + + "gitea.maximumdirect.net/eric/seriatim/internal/config" + "gitea.maximumdirect.net/eric/seriatim/internal/report" +) + +type auditReport struct { + Operation string `json:"operation"` + InputFile string `json:"input_file"` + OutputFile string `json:"output_file"` + InputSchema string `json:"input_schema"` + OutputSchema string `json:"output_schema"` + Mode string `json:"mode"` + Selector string `json:"selector"` + SelectedIDs []int `json:"selected_ids"` + AllowEmpty bool `json:"allow_empty"` + InputSegmentCount int `json:"input_segment_count"` + RetainedSegmentCount int `json:"retained_segment_count"` + RemovedSegmentCount int `json:"removed_segment_count"` + RemovedInputIDs []int `json:"removed_input_ids"` + OldToNewIDMapping []idMapping `json:"old_to_new_id_mapping"` + OverlapGroupsRecomputed bool `json:"overlap_groups_recomputed"` +} + +type idMapping struct { + OldID int `json:"old_id"` + NewID int `json:"new_id"` +} + +// Run executes artifact-level trim orchestration. +func Run(ctx context.Context, cfg config.TrimConfig) error { + if err := ctx.Err(); err != nil { + return err + } + + selector, err := ParseSelector(cfg.Selector) + if err != nil { + return fmt.Errorf("invalid selector %q: %w", cfg.Selector, err) + } + + data, err := os.ReadFile(cfg.InputFile) + if err != nil { + return fmt.Errorf("read --input-file %q: %w", cfg.InputFile, err) + } + + artifact, err := ParseArtifactJSON(data) + if err != nil { + return fmt.Errorf("--input-file %q: %w", cfg.InputFile, err) + } + inputSegmentCount := artifact.SegmentCount() + inputSchema := artifact.Schema + + mode := ModeKeep + if cfg.Mode == "remove" { + mode = ModeRemove + } + + trimmed, err := ApplyArtifact(artifact, Options{ + Mode: mode, + Selector: selector, + AllowEmpty: cfg.AllowEmpty, + }) + if err != nil { + return err + } + + outputSchema := artifact.Schema + if cfg.OutputSchema != "" { + outputSchema = cfg.OutputSchema + } + + outputArtifact, err := ConvertArtifact(trimmed.Artifact, outputSchema) + if err != nil { + return err + } + + if err := ValidateArtifact(outputArtifact); err != nil { + return fmt.Errorf("validate trimmed output: %w", err) + } + + if err := writeOutputJSON(cfg.OutputFile, outputArtifact.Value()); err != nil { + return err + } + + if cfg.ReportFile == "" { + return nil + } + + audit := auditReport{ + Operation: "trim", + InputFile: cfg.InputFile, + OutputFile: cfg.OutputFile, + InputSchema: inputSchema, + OutputSchema: outputArtifact.Schema, + Mode: cfg.Mode, + Selector: cfg.Selector, + SelectedIDs: selector.IDs(), + AllowEmpty: cfg.AllowEmpty, + InputSegmentCount: inputSegmentCount, + RetainedSegmentCount: len(trimmed.OldToNewID), + RemovedSegmentCount: len(trimmed.RemovedIDs), + RemovedInputIDs: append([]int(nil), trimmed.RemovedIDs...), + OldToNewIDMapping: orderedIDMapping(trimmed.OldToNewID), + OverlapGroupsRecomputed: trimmed.OverlapGroupsRecomputed, + } + auditJSON, err := json.Marshal(audit) + if err != nil { + return fmt.Errorf("marshal trim audit report: %w", err) + } + + rpt := report.Report{ + Metadata: report.Metadata{ + Application: outputArtifact.Application(), + Version: outputArtifact.Version(), + InputReader: "trim-artifact", + InputFiles: []string{cfg.InputFile}, + OutputModules: []string{"json"}, + }, + Events: []report.Event{ + report.Info("trim", "trim", fmt.Sprintf("trimmed %d input segment(s) into %d output segment(s) with mode=%s", inputSegmentCount, outputArtifact.SegmentCount(), cfg.Mode)), + report.Info("trim", "trim-audit", string(auditJSON)), + report.Info("trim", "validate-output", fmt.Sprintf("validated %d output segment(s)", outputArtifact.SegmentCount())), + report.Info("output", "json", "wrote transcript JSON"), + }, + } + if err := report.WriteJSON(cfg.ReportFile, rpt); err != nil { + return err + } + + return nil +} + +func writeOutputJSON(path string, value any) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + enc := json.NewEncoder(file) + enc.SetIndent("", " ") + return enc.Encode(value) +} + +func orderedIDMapping(mapping map[int]int) []idMapping { + keys := make([]int, 0, len(mapping)) + for oldID := range mapping { + keys = append(keys, oldID) + } + sort.Ints(keys) + + pairs := make([]idMapping, 0, len(keys)) + for _, oldID := range keys { + pairs = append(pairs, idMapping{ + OldID: oldID, + NewID: mapping[oldID], + }) + } + return pairs +} diff --git a/internal/trim/run_test.go b/internal/trim/run_test.go new file mode 100644 index 0000000..4c53be2 --- /dev/null +++ b/internal/trim/run_test.go @@ -0,0 +1,28 @@ +package trim + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "gitea.maximumdirect.net/eric/seriatim/internal/config" +) + +func TestRunReturnsContextErrorBeforeWork(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := Run(ctx, config.TrimConfig{ + InputFile: filepath.Join(dir, "input.json"), + OutputFile: filepath.Join(dir, "output.json"), + Mode: "keep", + Selector: "1", + OutputSchema: "", + AllowEmpty: false, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } +}