diff --git a/go.mod b/go.mod index d4ba092..4058cfd 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module gitea.maximumdirect.net/eric/narratio go 1.25.0 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 0e9417b..3a7d289 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -2,18 +2,22 @@ package app import ( "bytes" + "os" + "path/filepath" "strings" "testing" ) func TestExecuteValidCommands(t *testing.T) { + pipelinePath, sessionPath := writeValidConfigFiles(t) + cases := []struct { name string args []string wantOut string }{ - {name: "run", args: []string{"run"}, wantOut: "narratio run: not yet implemented"}, - {name: "plan", args: []string{"plan"}, wantOut: "narratio plan: not yet implemented"}, + {name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: configuration loaded and valid"}, + {name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio plan: configuration loaded and valid"}, {name: "status", args: []string{"status"}, wantOut: "narratio status: not yet implemented"}, {name: "resume", args: []string{"resume"}, wantOut: "narratio resume: not yet implemented"}, {name: "run-stage", args: []string{"run-stage", "polish"}, wantOut: "narratio run-stage: not yet implemented"}, @@ -38,6 +42,35 @@ func TestExecuteValidCommands(t *testing.T) { } } +func TestExecuteMissingConfigFlags(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {name: "run missing flags", args: []string{"run"}, want: "run: --config and --session are required"}, + {name: "plan missing flags", args: []string{"plan"}, want: "plan: --config and --session are required"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Execute(tc.args, &stdout, &stderr) + if code == 0 { + t.Fatalf("exit code = 0, want non-zero") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), tc.want) { + t.Fatalf("stderr = %q, want to contain %q", stderr.String(), tc.want) + } + }) + } +} + func TestExecuteInvalidCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -73,3 +106,46 @@ func TestExecuteMissingCommand(t *testing.T) { t.Fatalf("stderr = %q, want usage message", stderr.String()) } } + +func writeValidConfigFiles(t *testing.T) (string, string) { + t.Helper() + + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + sessionPath := filepath.Join(dir, "session.yml") + + pipelineYAML := `workspace: + root: /tmp/narratio +storage: + backend: s3 +whisperx: + timeout: 15m +seriatim: + timeout: 30s +audita: + timeout: 1h +analyzer: + timeout: 20m + artifacts: + output_dir: artifacts +notification: + timeout: 10s +` + + sessionYAML := `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + + if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { + t.Fatalf("write pipeline config: %v", err) + } + if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { + t.Fatalf("write session config: %v", err) + } + + return pipelinePath, sessionPath +} diff --git a/internal/app/plan.go b/internal/app/plan.go index 37909fe..2e21439 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -2,10 +2,41 @@ package app import ( "context" + "flag" + "fmt" "io" + + "gitea.maximumdirect.net/eric/narratio/internal/config" ) -// Plan is a placeholder for future stage planning behavior. -func Plan(_ context.Context, _ []string, out io.Writer) error { - return placeholder(out, "plan") +// Plan validates configuration inputs and reports readiness for future planning. +func Plan(_ context.Context, args []string, out io.Writer) error { + fs := flag.NewFlagSet("plan", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var pipelinePath string + var sessionPath string + fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml") + fs.StringVar(&sessionPath, "session", "", "path to session.yml") + + if err := fs.Parse(args); err != nil { + return fmt.Errorf("plan: invalid flags: %w", err) + } + if fs.NArg() != 0 { + return fmt.Errorf("plan: unexpected positional arguments") + } + if pipelinePath == "" || sessionPath == "" { + return fmt.Errorf("plan: --config and --session are required") + } + + cfg, err := config.Load(pipelinePath, sessionPath) + if err != nil { + return fmt.Errorf("plan: %w", err) + } + if err := config.Validate(cfg); err != nil { + return fmt.Errorf("plan: %w", err) + } + + _, err = fmt.Fprintln(out, "narratio plan: configuration loaded and valid") + return err } diff --git a/internal/app/run.go b/internal/app/run.go index 99175a5..c2d0535 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -2,10 +2,41 @@ package app import ( "context" + "flag" + "fmt" "io" + + "gitea.maximumdirect.net/eric/narratio/internal/config" ) -// Run is a placeholder for the future end-to-end pipeline execution command. -func Run(_ context.Context, _ []string, out io.Writer) error { - return placeholder(out, "run") +// Run validates configuration inputs and reports readiness for future execution. +func Run(_ context.Context, args []string, out io.Writer) error { + fs := flag.NewFlagSet("run", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var pipelinePath string + var sessionPath string + fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml") + fs.StringVar(&sessionPath, "session", "", "path to session.yml") + + if err := fs.Parse(args); err != nil { + return fmt.Errorf("run: invalid flags: %w", err) + } + if fs.NArg() != 0 { + return fmt.Errorf("run: unexpected positional arguments") + } + if pipelinePath == "" || sessionPath == "" { + return fmt.Errorf("run: --config and --session are required") + } + + cfg, err := config.Load(pipelinePath, sessionPath) + if err != nil { + return fmt.Errorf("run: %w", err) + } + if err := config.Validate(cfg); err != nil { + return fmt.Errorf("run: %w", err) + } + + _, err = fmt.Fprintln(out, "narratio run: configuration loaded and valid") + return err } diff --git a/internal/config/config.go b/internal/config/config.go index 5a356c5..f6cca38 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,15 +1,91 @@ package config -// Config contains loaded pipeline and session configuration. +// Config is the resolved combined configuration from pipeline.yml and session.yml. type Config struct { - Pipeline *Pipeline - Session *Session + Pipeline *PipelineConfig + Session *SessionConfig + PipelinePath string + SessionPath string } -// Pipeline represents durable pipeline-level settings. -type Pipeline struct{} - -// Session represents per-session inputs and metadata. -type Session struct { - SessionID string +// PipelineConfig contains durable pipeline-level settings. +type PipelineConfig struct { + Workspace WorkspaceConfig `yaml:"workspace"` + Storage StorageConfig `yaml:"storage"` + WhisperX WhisperXConfig `yaml:"whisperx"` + Seriatim SeriatimConfig `yaml:"seriatim"` + Audita AuditaConfig `yaml:"audita"` + Analyzer AnalyzerConfig `yaml:"analyzer"` + Notification NotificationConfig `yaml:"notification"` +} + +// SessionConfig contains per-session inputs and metadata. +type SessionConfig struct { + SessionID string `yaml:"session_id"` + Campaign string `yaml:"campaign"` + Date string `yaml:"date"` + Title string `yaml:"title"` + Inputs SessionInputsConfig `yaml:"inputs"` +} + +// WorkspaceConfig configures local workspace behavior. +type WorkspaceConfig struct { + Root string `yaml:"root"` +} + +// StorageConfig configures storage backends and related parameters. +type StorageConfig struct { + Backend string `yaml:"backend"` + Bucket string `yaml:"bucket"` + Prefix string `yaml:"prefix"` +} + +// WhisperXConfig configures WhisperX adapter settings. +type WhisperXConfig struct { + BaseURL string `yaml:"base_url"` + Timeout string `yaml:"timeout"` + Concurrency int `yaml:"concurrency"` +} + +// SeriatimConfig configures seriatim adapter settings. +type SeriatimConfig struct { + BinaryPath string `yaml:"binary_path"` + Timeout string `yaml:"timeout"` + Args []string `yaml:"args"` +} + +// AuditaConfig configures audita adapter settings. +type AuditaConfig struct { + BinaryPath string `yaml:"binary_path"` + Timeout string `yaml:"timeout"` + Args []string `yaml:"args"` +} + +// AnalyzerConfig configures analyzer adapter settings. +type AnalyzerConfig struct { + BinaryPath string `yaml:"binary_path"` + Timeout string `yaml:"timeout"` + Artifacts ArtifactSettings `yaml:"artifacts"` +} + +// NotificationConfig configures notification backend settings. +type NotificationConfig struct { + Backend string `yaml:"backend"` + Recipient string `yaml:"recipient"` + Timeout string `yaml:"timeout"` +} + +// ArtifactSettings configures generated artifact selection and paths. +type ArtifactSettings struct { + OutputDir string `yaml:"output_dir"` + Types []string `yaml:"types"` +} + +// SessionInputsConfig contains per-session input references. +type SessionInputsConfig struct { + AudioDir string `yaml:"audio_dir"` + AudioFiles []string `yaml:"audio_files"` + SpeakersFile string `yaml:"speakers_file"` + AutocorrectFile string `yaml:"autocorrect_file"` + GlossaryFile string `yaml:"glossary_file"` } diff --git a/internal/config/load.go b/internal/config/load.go index 0e64d21..cfdcbdd 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -1,8 +1,68 @@ package config -import "fmt" +import ( + "fmt" + "io" + "os" -// Load is a placeholder for strict pipeline/session config decoding. -func Load(_ string, _ string) (*Config, error) { - return nil, fmt.Errorf("config load: not yet implemented") + "gopkg.in/yaml.v3" +) + +// LoadPipeline loads pipeline configuration from a YAML file with strict field checking. +func LoadPipeline(path string) (*PipelineConfig, error) { + var cfg PipelineConfig + if err := decodeStrictYAML(path, &cfg); err != nil { + return nil, fmt.Errorf("load pipeline config: %w", err) + } + return &cfg, nil +} + +// LoadSession loads session configuration from a YAML file with strict field checking. +func LoadSession(path string) (*SessionConfig, error) { + var cfg SessionConfig + if err := decodeStrictYAML(path, &cfg); err != nil { + return nil, fmt.Errorf("load session config: %w", err) + } + return &cfg, nil +} + +// Load loads and resolves combined pipeline and session configuration. +func Load(pipelinePath, sessionPath string) (*Config, error) { + pipelineCfg, err := LoadPipeline(pipelinePath) + if err != nil { + return nil, err + } + + sessionCfg, err := LoadSession(sessionPath) + if err != nil { + return nil, err + } + + return &Config{ + Pipeline: pipelineCfg, + Session: sessionCfg, + PipelinePath: pipelinePath, + SessionPath: sessionPath, + }, nil +} + +func decodeStrictYAML(path string, out any) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open %q: %w", path, err) + } + defer f.Close() + + dec := yaml.NewDecoder(f) + dec.KnownFields(true) + if err := dec.Decode(out); err != nil { + return fmt.Errorf("decode %q: %w", path, err) + } + + var extra any + if err := dec.Decode(&extra); err != nil && err != io.EOF { + return fmt.Errorf("decode trailing content in %q: %w", path, err) + } + + return nil } diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go new file mode 100644 index 0000000..626515e --- /dev/null +++ b/internal/config/load_validate_test.go @@ -0,0 +1,182 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadAndValidate(t *testing.T) { + tests := []struct { + name string + pipelineYAML string + sessionYAML string + wantLoadErr string + wantValidate string + }{ + { + name: "valid minimal config", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + timeout: 10m +seriatim: + timeout: 30s +audita: + timeout: 1h +analyzer: + timeout: 20m +notification: + timeout: 15s +`, + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`, + }, + { + name: "unknown pipeline field fails", + pipelineYAML: `workspace: + root: /tmp/narratio +bogus: true +`, + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`, + wantLoadErr: "field bogus not found", + }, + { + name: "unknown session field fails", + pipelineYAML: `workspace: + root: /tmp/narratio +`, + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +unknown_field: true +`, + wantLoadErr: "field unknown_field not found", + }, + { + name: "missing required field fails", + pipelineYAML: `workspace: + root: /tmp/narratio +`, + sessionYAML: `session_id: "" +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`, + wantValidate: "session.session_id is required", + }, + { + name: "invalid timeout fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + timeout: definitely-not-a-duration +`, + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`, + wantValidate: "pipeline.whisperx.timeout must be a valid duration", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pipelinePath, sessionPath := writeConfigFiles(t, tt.pipelineYAML, tt.sessionYAML) + + 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 cfg.PipelinePath != pipelinePath { + t.Fatalf("PipelinePath = %q, want %q", cfg.PipelinePath, pipelinePath) + } + if cfg.SessionPath != sessionPath { + t.Fatalf("SessionPath = %q, want %q", cfg.SessionPath, sessionPath) + } + + err = Validate(cfg) + if tt.wantValidate != "" { + if err == nil { + t.Fatalf("expected validation error containing %q, got nil", tt.wantValidate) + } + if !strings.Contains(err.Error(), tt.wantValidate) { + t.Fatalf("validation error = %q, want to contain %q", err.Error(), tt.wantValidate) + } + return + } + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + }) + } +} + +func TestValidateMissingAudioSource(t *testing.T) { + cfg := &Config{ + Pipeline: &PipelineConfig{Workspace: WorkspaceConfig{Root: "/tmp/narratio"}}, + Session: &SessionConfig{ + SessionID: "2026-05-03", + Inputs: SessionInputsConfig{ + SpeakersFile: "speakers.yml", + AutocorrectFile: "autocorrect.yml", + GlossaryFile: "glossary.yml", + }, + }, + } + + err := Validate(cfg) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "audio_dir or at least one audio_files") { + t.Fatalf("error = %q, want audio source guidance", err.Error()) + } +} + +func writeConfigFiles(t *testing.T, pipelineYAML, sessionYAML string) (string, string) { + t.Helper() + + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + sessionPath := filepath.Join(dir, "session.yml") + + if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { + t.Fatalf("write pipeline.yml: %v", err) + } + if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { + t.Fatalf("write session.yml: %v", err) + } + + return pipelinePath, sessionPath +} diff --git a/internal/config/validate.go b/internal/config/validate.go index 9bbb567..965c7d6 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -1,8 +1,90 @@ package config -import "fmt" +import ( + "fmt" + "strings" + "time" +) -// Validate is a placeholder for configuration validation logic. -func Validate(_ *Config) error { - return fmt.Errorf("config validation: not yet implemented") +// Validate checks resolved configuration for required fields and parseable durations. +func Validate(cfg *Config) error { + if cfg == nil { + return fmt.Errorf("config is nil") + } + if cfg.Pipeline == nil { + return fmt.Errorf("pipeline config is required") + } + if cfg.Session == nil { + return fmt.Errorf("session config is required") + } + + if err := validatePipeline(cfg.Pipeline); err != nil { + return err + } + if err := validateSession(cfg.Session); err != nil { + return err + } + + return nil +} + +func validatePipeline(cfg *PipelineConfig) error { + if strings.TrimSpace(cfg.Workspace.Root) == "" { + return fmt.Errorf("pipeline.workspace.root is required") + } + + if err := validateDuration("pipeline.whisperx.timeout", cfg.WhisperX.Timeout); err != nil { + return err + } + if err := validateDuration("pipeline.seriatim.timeout", cfg.Seriatim.Timeout); err != nil { + return err + } + if err := validateDuration("pipeline.audita.timeout", cfg.Audita.Timeout); err != nil { + return err + } + if err := validateDuration("pipeline.analyzer.timeout", cfg.Analyzer.Timeout); err != nil { + return err + } + if err := validateDuration("pipeline.notification.timeout", cfg.Notification.Timeout); err != nil { + return err + } + + return nil +} + +func validateSession(cfg *SessionConfig) error { + if strings.TrimSpace(cfg.SessionID) == "" { + return fmt.Errorf("session.session_id is required") + } + + if strings.TrimSpace(cfg.Inputs.SpeakersFile) == "" { + return fmt.Errorf("session.inputs.speakers_file is required") + } + if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" { + return fmt.Errorf("session.inputs.autocorrect_file is required") + } + if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { + return fmt.Errorf("session.inputs.glossary_file is required") + } + + hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != "" + hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0 + if !hasAudioDir && !hasAudioFiles { + return fmt.Errorf("session.inputs requires audio_dir or at least one audio_files entry") + } + + return nil +} + +func validateDuration(fieldName, value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + + if _, err := time.ParseDuration(trimmed); err != nil { + return fmt.Errorf("%s must be a valid duration: %w", fieldName, err) + } + + return nil }