diff --git a/internal/cli/normalize.go b/internal/cli/normalize.go new file mode 100644 index 0000000..c5ed882 --- /dev/null +++ b/internal/cli/normalize.go @@ -0,0 +1,39 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "gitea.maximumdirect.net/eric/seriatim/internal/config" + "gitea.maximumdirect.net/eric/seriatim/internal/normalize" +) + +func newNormalizeCommand() *cobra.Command { + var opts config.NormalizeOptions + + cmd := &cobra.Command{ + Use: "normalize", + Short: "Normalize a transcript artifact into a standard seriatim output shape", + RunE: func(cmd *cobra.Command, args []string) error { + normalizeOpts := opts + if !cmd.Flags().Changed("output-schema") { + normalizeOpts.OutputSchema = "" + } + + cfg, err := config.NewNormalizeConfig(normalizeOpts) + if err != nil { + return err + } + + return normalize.Run(cmd.Context(), cfg) + }, + } + + flags := cmd.Flags() + flags.StringVar(&opts.InputFile, "input-file", "", "input transcript JSON file") + flags.StringVar(&opts.OutputFile, "output-file", "", "output transcript JSON file") + flags.StringVar(&opts.ReportFile, "report-file", "", "optional report JSON file") + flags.StringVar(&opts.OutputSchema, "output-schema", config.DefaultOutputSchema, "output JSON schema: seriatim-minimal, seriatim-intermediate, or seriatim-full") + flags.StringVar(&opts.OutputModules, "output-modules", config.DefaultOutputModules, "comma-separated output modules") + + return cmd +} diff --git a/internal/cli/normalize_test.go b/internal/cli/normalize_test.go new file mode 100644 index 0000000..65cbb59 --- /dev/null +++ b/internal/cli/normalize_test.go @@ -0,0 +1,104 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestNormalizeCommandIsRecognized(t *testing.T) { + cmd := NewRootCommand() + cmd.SetArgs([]string{"normalize", "--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("normalize command should be recognized: %v", err) + } +} + +func TestNormalizeMissingInputFileFails(t *testing.T) { + dir := t.TempDir() + output := filepath.Join(dir, "normalized.json") + + err := executeNormalize( + "--output-file", output, + ) + if err == nil { + t.Fatal("expected missing input-file error") + } + if !strings.Contains(err.Error(), "--input-file is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNormalizeMissingOutputFileFails(t *testing.T) { + dir := t.TempDir() + input := writeJSONFile(t, dir, "input.json", `{"segments":[]}`) + + err := executeNormalize( + "--input-file", input, + ) + if err == nil { + t.Fatal("expected missing output-file error") + } + if !strings.Contains(err.Error(), "--output-file is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNormalizeInvalidOutputSchemaFails(t *testing.T) { + dir := t.TempDir() + input := writeJSONFile(t, dir, "input.json", `{"segments":[]}`) + output := filepath.Join(dir, "normalized.json") + + err := executeNormalize( + "--input-file", input, + "--output-file", output, + "--output-schema", "compact", + ) + if err == nil { + t.Fatal("expected invalid output schema error") + } + if !strings.Contains(err.Error(), "--output-schema must be one of") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNormalizeInvalidOutputModuleFails(t *testing.T) { + dir := t.TempDir() + input := writeJSONFile(t, dir, "input.json", `{"segments":[]}`) + output := filepath.Join(dir, "normalized.json") + + err := executeNormalize( + "--input-file", input, + "--output-file", output, + "--output-modules", "yaml", + ) + if err == nil { + t.Fatal("expected invalid output module error") + } + if !strings.Contains(err.Error(), "unknown output module") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNormalizeValidFlagsReachNotImplementedBoundary(t *testing.T) { + dir := t.TempDir() + input := writeJSONFile(t, dir, "input.json", `{"segments":[]}`) + output := filepath.Join(dir, "normalized.json") + + err := executeNormalize( + "--input-file", input, + "--output-file", output, + ) + if err == nil { + t.Fatal("expected not implemented error") + } + if !strings.Contains(err.Error(), "not implemented") { + t.Fatalf("unexpected error: %v", err) + } +} + +func executeNormalize(args ...string) error { + cmd := NewRootCommand() + cmd.SetArgs(append([]string{"normalize"}, args...)) + return cmd.Execute() +} diff --git a/internal/cli/root.go b/internal/cli/root.go index c2305cc..9afeaf1 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -17,6 +17,7 @@ func NewRootCommand() *cobra.Command { } cmd.AddCommand(newMergeCommand()) + cmd.AddCommand(newNormalizeCommand()) cmd.AddCommand(newTrimCommand()) return cmd } diff --git a/internal/config/config.go b/internal/config/config.go index b8f47b6..9152917 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,6 +58,15 @@ type TrimOptions struct { AllowEmpty bool } +// NormalizeOptions captures raw CLI option values before validation. +type NormalizeOptions struct { + InputFile string + OutputFile string + ReportFile string + OutputSchema string + OutputModules string +} + // Config is the validated runtime configuration for a merge invocation. type Config struct { InputFiles []string @@ -88,6 +97,15 @@ type TrimConfig struct { AllowEmpty bool } +// NormalizeConfig is the validated runtime configuration for a normalize invocation. +type NormalizeConfig struct { + InputFile string + OutputFile string + ReportFile string + OutputSchema string + OutputModules []string +} + // NewMergeConfig validates raw merge options and returns normalized config. func NewMergeConfig(opts MergeOptions) (Config, error) { cfg := Config{ @@ -247,6 +265,54 @@ func NewTrimConfig(opts TrimOptions) (TrimConfig, error) { }, nil } +// NewNormalizeConfig validates raw normalize options and returns normalized config. +func NewNormalizeConfig(opts NormalizeOptions) (NormalizeConfig, error) { + inputFile := filepath.Clean(strings.TrimSpace(opts.InputFile)) + if strings.TrimSpace(opts.InputFile) == "" { + return NormalizeConfig{}, errors.New("--input-file is required") + } + if err := requireFile(inputFile, "--input-file"); err != nil { + return NormalizeConfig{}, err + } + + outputFile, err := normalizeOutputPath(opts.OutputFile, "--output-file") + if err != nil { + return NormalizeConfig{}, err + } + + reportFile := "" + if strings.TrimSpace(opts.ReportFile) != "" { + reportFile, err = normalizeOutputPath(opts.ReportFile, "--report-file") + if err != nil { + return NormalizeConfig{}, err + } + } + + outputSchema, err := resolveOutputSchema(opts.OutputSchema) + if err != nil { + return NormalizeConfig{}, err + } + + outputModules, err := parseModuleList(opts.OutputModules) + if err != nil { + return NormalizeConfig{}, fmt.Errorf("--output-modules: %w", err) + } + if len(outputModules) == 0 { + return NormalizeConfig{}, errors.New("--output-modules must include at least one module") + } + if err := validateNormalizeOutputModules(outputModules); err != nil { + return NormalizeConfig{}, err + } + + return NormalizeConfig{ + InputFile: inputFile, + OutputFile: outputFile, + ReportFile: reportFile, + OutputSchema: outputSchema, + OutputModules: outputModules, + }, nil +} + func parseModuleList(value string) ([]string, error) { value = strings.TrimSpace(value) if value == "" { @@ -400,3 +466,12 @@ func contains(values []string, target string) bool { } return false } + +func validateNormalizeOutputModules(modules []string) error { + for _, module := range modules { + if module != "json" { + return fmt.Errorf("unknown output module %q", module) + } + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d4570cd..11e4bcd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -711,6 +711,107 @@ func TestNewTrimConfigRejectsInvalidOutputSchemaOverride(t *testing.T) { } } +func TestNewNormalizeConfigRequiresInputFile(t *testing.T) { + dir := t.TempDir() + output := filepath.Join(dir, "normalized.json") + + _, err := NewNormalizeConfig(NormalizeOptions{ + OutputFile: output, + OutputModules: DefaultOutputModules, + }) + if err == nil { + t.Fatal("expected input-file required error") + } + if !strings.Contains(err.Error(), "--input-file is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewNormalizeConfigRequiresOutputFile(t *testing.T) { + dir := t.TempDir() + input := writeTempFile(t, dir, "input.json") + + _, err := NewNormalizeConfig(NormalizeOptions{ + InputFile: input, + OutputModules: DefaultOutputModules, + }) + if err == nil { + t.Fatal("expected output-file required error") + } + if !strings.Contains(err.Error(), "--output-file is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewNormalizeConfigResolvesOutputSchemaDefaultAndEnv(t *testing.T) { + dir := t.TempDir() + input := writeTempFile(t, dir, "input.json") + output := filepath.Join(dir, "normalized.json") + + t.Setenv(OutputSchemaEnv, "") + cfg, err := NewNormalizeConfig(NormalizeOptions{ + InputFile: input, + OutputFile: output, + OutputModules: DefaultOutputModules, + }) + if err != nil { + t.Fatalf("config failed: %v", err) + } + if cfg.OutputSchema != DefaultOutputSchema { + t.Fatalf("output schema = %q, want %q", cfg.OutputSchema, DefaultOutputSchema) + } + + t.Setenv(OutputSchemaEnv, OutputSchemaMinimal) + cfg, err = NewNormalizeConfig(NormalizeOptions{ + InputFile: input, + OutputFile: output, + OutputModules: DefaultOutputModules, + }) + if err != nil { + t.Fatalf("config failed: %v", err) + } + if cfg.OutputSchema != OutputSchemaMinimal { + t.Fatalf("output schema = %q, want %q", cfg.OutputSchema, OutputSchemaMinimal) + } +} + +func TestNewNormalizeConfigRejectsInvalidOutputSchema(t *testing.T) { + dir := t.TempDir() + input := writeTempFile(t, dir, "input.json") + output := filepath.Join(dir, "normalized.json") + + _, err := NewNormalizeConfig(NormalizeOptions{ + InputFile: input, + OutputFile: output, + OutputSchema: "compact", + OutputModules: DefaultOutputModules, + }) + if err == nil { + t.Fatal("expected output schema error") + } + if !strings.Contains(err.Error(), "--output-schema must be one of") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewNormalizeConfigRejectsUnknownOutputModule(t *testing.T) { + dir := t.TempDir() + input := writeTempFile(t, dir, "input.json") + output := filepath.Join(dir, "normalized.json") + + _, err := NewNormalizeConfig(NormalizeOptions{ + InputFile: input, + OutputFile: output, + OutputModules: "json,yaml", + }) + if err == nil { + t.Fatal("expected output module error") + } + if !strings.Contains(err.Error(), "unknown output module") { + t.Fatalf("unexpected error: %v", err) + } +} + func assertPositiveFloatEnvValidation(t *testing.T, envName string) { t.Helper() diff --git a/internal/normalize/normalize.go b/internal/normalize/normalize.go new file mode 100644 index 0000000..d176d7c --- /dev/null +++ b/internal/normalize/normalize.go @@ -0,0 +1,19 @@ +package normalize + +import ( + "context" + "fmt" + + "gitea.maximumdirect.net/eric/seriatim/internal/config" +) + +// Run validates command wiring for normalize and will later execute +// artifact-level normalization. +func Run(ctx context.Context, cfg config.NormalizeConfig) error { + if err := ctx.Err(); err != nil { + return err + } + + // TODO: Implement transcript normalization transformation. + return fmt.Errorf("normalize command is not implemented yet") +}