diff --git a/README.md b/README.md index 9f89f1c..5ad9979 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,25 @@ Pipeline config lookup for CLI commands: - `/usr/local/etc/narratio/pipeline.yml` - `/etc/narratio/pipeline.yml` +Session config lookup for CLI commands: + +- if `--session ` is provided, Narratio uses that path +- if `--session` is omitted, Narratio searches in this order: + - `./session.yml` + - `/usr/local/etc/narratio/session.yml` + - `/etc/narratio/session.yml` + +Session template support: + +- Narratio renders `session.yml` templates before strict YAML decode. +- `--session-id ` provides the `session_id` template variable. +- Supported placeholder forms: + - `{{session_id}}` + - `{{ session_id }}` +- unresolved template placeholders fail with a clear error. +- strict YAML validation still runs after rendering. +- concrete `session.yml` files without templates remain fully supported. + Optional secrets-from-files config: - `pipeline.secrets.env_dir` may point to a directory of secret files @@ -345,6 +364,7 @@ Starter files: - `examples/pipeline.minimal.yml` - `examples/session.minimal.yml` +- `examples/session.template.yml` - `examples/speakers.yml` ## Commands @@ -363,6 +383,12 @@ go run ./cmd/narratio plan --session examples/session.minimal.yml Use `--config ` to override default pipeline lookup when needed. +Run with a discoverable session template: + +```bash +go run ./cmd/narratio run --session-id 2026-04-04 +``` + Run full pipeline: ```bash @@ -375,6 +401,12 @@ Run analyze only: go run ./cmd/narratio run-stage --config examples/pipeline.minimal.yml --session examples/session.minimal.yml analyze ``` +Resume with a template session ID: + +```bash +go run ./cmd/narratio resume --config examples/pipeline.minimal.yml --session examples/session.template.yml --session-id 2026-04-04 +``` + ## Operational Note Checksum-based stale detection is not implemented yet. diff --git a/architecture.md b/architecture.md index 35d6319..31aa6ec 100644 --- a/architecture.md +++ b/architecture.md @@ -115,6 +115,25 @@ CLI pipeline config path resolution: - `/usr/local/etc/narratio/pipeline.yml` - `/etc/narratio/pipeline.yml` +CLI session config path resolution: + +- when `--session ` is provided, that path is used +- when `--session` is omitted, Narratio searches defaults in order: + - `./session.yml` + - `/usr/local/etc/narratio/session.yml` + - `/etc/narratio/session.yml` + +Session template rendering: + +- session templates are rendered before strict YAML decode +- `--session-id ` provides the `session_id` template variable +- supported placeholders: + - `{{session_id}}` + - `{{ session_id }}` +- unresolved placeholders fail clearly +- strict `KnownFields(true)` YAML validation still applies after rendering +- if rendered `session.session_id` conflicts with `--session-id`, load fails clearly + Optional pipeline secrets directory: - `pipeline.secrets.env_dir` enables loading environment variables from local files before command execution diff --git a/docs/roadmap/narratio-s3-archive-implementation-plan.md b/docs/roadmap/narratio-s3-archive-implementation-plan.md index 922ebae..1092a3a 100644 --- a/docs/roadmap/narratio-s3-archive-implementation-plan.md +++ b/docs/roadmap/narratio-s3-archive-implementation-plan.md @@ -95,6 +95,11 @@ Implemented in repository: - campaign/session/run local work and spool path helpers - manifest run/path identity fields - examples and tests for the above foundations +- session template operator UX: + - default session config discovery (`./session.yml`, `/usr/local/etc/narratio/session.yml`, `/etc/narratio/session.yml`) + - `--session-id` template injection for `session_id` + - session template rendering before strict YAML decode + - unresolved template placeholders and `session_id` mismatches fail clearly - remote storage backend layer: - object-store abstraction with `List`, `Download`, `Upload`, and `Exists` - fake storage backend for deterministic, no-network testing diff --git a/examples/session.template.yml b/examples/session.template.yml new file mode 100644 index 0000000..b2e5ac0 --- /dev/null +++ b/examples/session.template.yml @@ -0,0 +1,12 @@ +session_id: "{{ session_id }}" +campaign: sample-campaign +date: "" +title: "" +inputs: + audio_dir: ./audio + # Optional S3 input alternative. Do not configure with audio_dir/audio_files. + # audio_s3: + # prefix: "audio/{{ session_id }}/" + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index f034221..ee63021 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -64,12 +64,12 @@ func TestExecuteMissingRequiredFlags(t *testing.T) { args []string want string }{ - {name: "run missing flags", args: []string{"run"}, want: "run: --session is required"}, - {name: "plan missing flags", args: []string{"plan"}, want: "plan: --session is required"}, + {name: "run missing flags", args: []string{"run"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"}, + {name: "plan missing flags", args: []string{"plan"}, want: "plan: no pipeline config path provided and no default pipeline config found; searched:"}, {name: "status missing flags", args: []string{"status"}, want: "status: --manifest is required"}, - {name: "resume missing flags", args: []string{"resume"}, want: "resume: --session is required"}, + {name: "resume missing flags", args: []string{"resume"}, want: "resume: no pipeline config path provided and no default pipeline config found; searched:"}, {name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected exactly one stage name"}, - {name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: --session is required"}, + {name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: no pipeline config path provided and no default pipeline config found; searched:"}, {name: "run missing config uses defaults", args: []string{"run", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"}, } diff --git a/internal/app/plan.go b/internal/app/plan.go index 1d7e572..23f936d 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -21,9 +21,11 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var sessionPath string + var sessionID string var force bool fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&sessionPath, "session", "", "path to session.yml") + fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") if err := fs.Parse(args); err != nil { @@ -32,16 +34,18 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { if fs.NArg() != 0 { return fmt.Errorf("plan: unexpected positional arguments") } - if sessionPath == "" { - return fmt.Errorf("plan: --session is required") - } - resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath) if err != nil { return fmt.Errorf("plan: %w", err) } + resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) + if err != nil { + return fmt.Errorf("plan: %w", err) + } - cfg, err := config.Load(resolvedPipelinePath, sessionPath) + cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ + SessionID: sessionID, + }) if err != nil { return fmt.Errorf("plan: %w", err) } diff --git a/internal/app/resume.go b/internal/app/resume.go index 9a22019..c0629c5 100644 --- a/internal/app/resume.go +++ b/internal/app/resume.go @@ -17,9 +17,11 @@ func Resume(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var sessionPath string + var sessionID string var force bool fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&sessionPath, "session", "", "path to session.yml") + fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.BoolVar(&force, "force", false, "force stage execution") if err := fs.Parse(args); err != nil { @@ -28,16 +30,18 @@ func Resume(ctx context.Context, args []string, out io.Writer) error { if fs.NArg() != 0 { return fmt.Errorf("resume: unexpected positional arguments") } - if sessionPath == "" { - return fmt.Errorf("resume: --session is required") - } - resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath) if err != nil { return fmt.Errorf("resume: %w", err) } + resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) + if err != nil { + return fmt.Errorf("resume: %w", err) + } - cfg, err := config.Load(resolvedPipelinePath, sessionPath) + cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ + SessionID: sessionID, + }) if err != nil { return fmt.Errorf("resume: %w", err) } diff --git a/internal/app/run.go b/internal/app/run.go index f020cac..b9fbb45 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -16,9 +16,11 @@ func Run(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var sessionPath string + var sessionID string var force bool fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&sessionPath, "session", "", "path to session.yml") + fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") if err := fs.Parse(args); err != nil { @@ -27,16 +29,18 @@ func Run(ctx context.Context, args []string, out io.Writer) error { if fs.NArg() != 0 { return fmt.Errorf("run: unexpected positional arguments") } - if sessionPath == "" { - return fmt.Errorf("run: --session is required") - } - resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath) if err != nil { return fmt.Errorf("run: %w", err) } + resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) + if err != nil { + return fmt.Errorf("run: %w", err) + } - cfg, err := config.Load(resolvedPipelinePath, sessionPath) + cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ + SessionID: sessionID, + }) if err != nil { return fmt.Errorf("run: %w", err) } diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go index cc1b081..fd457d3 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -16,9 +16,11 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var sessionPath string + var sessionID string var force bool fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&sessionPath, "session", "", "path to session.yml") + fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") if err := fs.Parse(args); err != nil { @@ -27,10 +29,6 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { if fs.NArg() != 1 { return fmt.Errorf("run-stage: expected exactly one stage name") } - if sessionPath == "" { - return fmt.Errorf("run-stage: --session is required") - } - stageName := fs.Arg(0) stages, err := BuildSingleStagePlan(stageName) if err != nil { @@ -41,8 +39,14 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { if err != nil { return fmt.Errorf("run-stage: %w", err) } + resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) + if err != nil { + return fmt.Errorf("run-stage: %w", err) + } - cfg, err := config.Load(resolvedPipelinePath, sessionPath) + cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ + SessionID: sessionID, + }) if err != nil { return fmt.Errorf("run-stage: %w", err) } diff --git a/internal/app/session_cli_test.go b/internal/app/session_cli_test.go new file mode 100644 index 0000000..09f5f6d --- /dev/null +++ b/internal/app/session_cli_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPlanUsesDiscoveredSessionTemplateWithSessionID(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + + sessionTemplate := `session_id: "{{ session_id }}" +campaign: sample-campaign +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil { + t.Fatalf("write session template: %v", err) + } + + cwd := filepath.Dir(sessionPath) + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd(): %v", err) + } + if err := os.Chdir(cwd); err != nil { + t.Fatalf("Chdir(%q): %v", cwd, err) + } + t.Cleanup(func() { _ = os.Chdir(originalWD) }) + + var out bytes.Buffer + if err := Plan(context.Background(), []string{"--config", pipelinePath, "--session-id", "2026-04-04"}, &out); err != nil { + t.Fatalf("Plan() error = %v", err) + } + if !strings.Contains(out.String(), "narratio plan: workdir prepared") { + t.Fatalf("output = %q, want plan output", out.String()) + } +} + +func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + + var out bytes.Buffer + err := Plan(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--session-id", "2026-04-04"}, &out) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "session_id mismatch") { + t.Fatalf("error = %q, want mismatch context", err.Error()) + } +} + +func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + + var out bytes.Buffer + err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--session-id", "2026-05-03", "prepare"}, &out) + if err != nil { + t.Fatalf("RunStage() error = %v", err) + } + if !strings.Contains(out.String(), "stage=prepare") { + t.Fatalf("output = %q, want stage output", out.String()) + } +} + +func TestResolveSessionConfigPathErrorIncludesSearchedPaths(t *testing.T) { + _, err := resolveSessionConfigPathWithCandidates("", []string{"./session.yml", "/usr/local/etc/narratio/session.yml", "/etc/narratio/session.yml"}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "searched") { + t.Fatalf("error = %q, want searched paths", err.Error()) + } + if !strings.Contains(err.Error(), "pass --session") { + t.Fatalf("error = %q, want explicit-session guidance", err.Error()) + } +} diff --git a/internal/app/session_config_path.go b/internal/app/session_config_path.go new file mode 100644 index 0000000..5305b20 --- /dev/null +++ b/internal/app/session_config_path.go @@ -0,0 +1,49 @@ +package app + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +func resolveSessionConfigPath(flagValue string) (string, error) { + return resolveSessionConfigPathWithCandidates(flagValue, config.DefaultSessionConfigSearchPaths) +} + +func resolveSessionConfigPathWithCandidates(flagValue string, candidates []string) (string, error) { + if explicit := strings.TrimSpace(flagValue); explicit != "" { + return explicit, nil + } + + ordered := make([]string, 0, len(candidates)) + for _, raw := range candidates { + path := strings.TrimSpace(raw) + if path == "" { + continue + } + ordered = append(ordered, path) + info, err := os.Stat(path) + if err == nil { + if info.IsDir() { + continue + } + return filepath.Clean(path), nil + } + if errors.Is(err, os.ErrNotExist) { + continue + } + return "", fmt.Errorf("check default session config %q: %w", path, err) + } + + if len(ordered) == 0 { + return "", fmt.Errorf("no session config path provided and no default locations configured") + } + return "", fmt.Errorf( + "no session config path provided and no default session config found; searched: %s; pass --session to use an explicit path", + strings.Join(ordered, ", "), + ) +} diff --git a/internal/app/session_config_path_test.go b/internal/app/session_config_path_test.go new file mode 100644 index 0000000..5755f5d --- /dev/null +++ b/internal/app/session_config_path_test.go @@ -0,0 +1,68 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveSessionConfigPathWithCandidatesExplicitWins(t *testing.T) { + got, err := resolveSessionConfigPathWithCandidates(" ./custom/session.yml ", []string{"./session.yml", "/a", "/b"}) + if err != nil { + t.Fatalf("resolveSessionConfigPathWithCandidates() error = %v", err) + } + if got != "./custom/session.yml" { + t.Fatalf("resolved path = %q, want explicit path", got) + } +} + +func TestResolveSessionConfigPathWithCandidatesUsesFirstExisting(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "first.yml") + second := filepath.Join(dir, "second.yml") + if err := os.WriteFile(second, []byte("session_id: 2026-05-03\n"), 0o644); err != nil { + t.Fatalf("write second default: %v", err) + } + + got, err := resolveSessionConfigPathWithCandidates("", []string{first, second}) + if err != nil { + t.Fatalf("resolveSessionConfigPathWithCandidates() error = %v", err) + } + if got != filepath.Clean(second) { + t.Fatalf("resolved path = %q, want %q", got, filepath.Clean(second)) + } +} + +func TestResolveSessionConfigPathWithCandidatesPrecedence(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "first.yml") + second := filepath.Join(dir, "second.yml") + if err := os.WriteFile(first, []byte("session_id: 2026-05-03\n"), 0o644); err != nil { + t.Fatalf("write first default: %v", err) + } + if err := os.WriteFile(second, []byte("session_id: 2026-05-03\n"), 0o644); err != nil { + t.Fatalf("write second default: %v", err) + } + + got, err := resolveSessionConfigPathWithCandidates("", []string{first, second}) + if err != nil { + t.Fatalf("resolveSessionConfigPathWithCandidates() error = %v", err) + } + if got != filepath.Clean(first) { + t.Fatalf("resolved path = %q, want first candidate %q", got, filepath.Clean(first)) + } +} + +func TestResolveSessionConfigPathWithCandidatesMissing(t *testing.T) { + _, err := resolveSessionConfigPathWithCandidates("", []string{"/does/not/exist/one.yml", "/does/not/exist/two.yml"}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "no default session config found") { + t.Fatalf("error = %q, want missing-defaults context", err.Error()) + } + if !strings.Contains(err.Error(), "pass --session") { + t.Fatalf("error = %q, want explicit-path guidance", err.Error()) + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 765c438..7d8d759 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -5,6 +5,9 @@ package config const ( DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml" DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml" + DefaultSessionConfigPathLocal = "./session.yml" + DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml" + DefaultSessionConfigPathEtc = "/etc/narratio/session.yml" ) // DefaultPipelineConfigSearchPaths defines the default search order for @@ -16,3 +19,14 @@ var DefaultPipelineConfigSearchPaths = []string{ DefaultPipelineConfigPathUsrLocal, DefaultPipelineConfigPathEtc, } + +// DefaultSessionConfigSearchPaths defines the default search order for +// session.yml when callers do not provide an explicit path. +// +// Keep this in a variable so future defaults can be extended without changing +// call sites. +var DefaultSessionConfigSearchPaths = []string{ + DefaultSessionConfigPathLocal, + DefaultSessionConfigPathUsrLocal, + DefaultSessionConfigPathEtc, +} diff --git a/internal/config/load.go b/internal/config/load.go index 5d5d951..0c36e7d 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -5,6 +5,8 @@ import ( "io" "os" "path/filepath" + "regexp" + "strings" "gopkg.in/yaml.v3" ) @@ -21,21 +23,56 @@ func LoadPipeline(path string) (*PipelineConfig, error) { // LoadSession loads session configuration from a YAML file with strict field checking. func LoadSession(path string) (*SessionConfig, error) { - var cfg SessionConfig - if err := decodeStrictYAML("session", path, &cfg); err != nil { + return LoadSessionWithOptions(path, SessionLoadOptions{}) +} + +// SessionLoadOptions configures session template rendering behavior. +type SessionLoadOptions struct { + SessionID string +} + +// LoadSessionWithOptions loads session configuration from a YAML file with +// strict field checking after template rendering. +func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfig, error) { + sessionBytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("load session config: session file %q: open: %w", path, err) + } + + rendered, err := renderSessionTemplate(string(sessionBytes), opts) + if err != nil { return nil, fmt.Errorf("load session config: %w", err) } + + var cfg SessionConfig + if err := decodeStrictYAMLFromReader("session", path, strings.NewReader(rendered), &cfg); err != nil { + return nil, fmt.Errorf("load session config: %w", err) + } + if strings.TrimSpace(opts.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != strings.TrimSpace(opts.SessionID) { + return nil, fmt.Errorf( + "load session config: session file %q: session_id mismatch: --session-id %q does not match rendered session_id %q", + path, + strings.TrimSpace(opts.SessionID), + strings.TrimSpace(cfg.SessionID), + ) + } return &cfg, nil } // Load loads and resolves combined pipeline and session configuration. func Load(pipelinePath, sessionPath string) (*Config, error) { + return LoadWithSessionOptions(pipelinePath, sessionPath, SessionLoadOptions{}) +} + +// LoadWithSessionOptions loads and resolves combined pipeline and session +// configuration with session template options. +func LoadWithSessionOptions(pipelinePath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) { pipelineCfg, err := LoadPipeline(pipelinePath) if err != nil { return nil, err } - sessionCfg, err := LoadSession(sessionPath) + sessionCfg, err := LoadSessionWithOptions(sessionPath, sessionOpts) if err != nil { return nil, err } @@ -55,7 +92,11 @@ func decodeStrictYAML(kind, path string, out any) error { } defer f.Close() - dec := yaml.NewDecoder(f) + return decodeStrictYAMLFromReader(kind, path, f, out) +} + +func decodeStrictYAMLFromReader(kind, path string, r io.Reader, out any) error { + dec := yaml.NewDecoder(r) dec.KnownFields(true) if err := dec.Decode(out); err != nil { return fmt.Errorf("%s file %q: strict decode failed: %w", kind, path, err) @@ -69,6 +110,36 @@ func decodeStrictYAML(kind, path string, out any) error { return nil } +var sessionTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`) + +func renderSessionTemplate(content string, opts SessionLoadOptions) (string, error) { + sessionID := strings.TrimSpace(opts.SessionID) + rendered := content + if sessionID != "" { + rendered = strings.ReplaceAll(rendered, "{{session_id}}", sessionID) + rendered = strings.ReplaceAll(rendered, "{{ session_id }}", sessionID) + } + + unresolved := sessionTemplatePattern.FindAllStringSubmatch(rendered, -1) + if len(unresolved) > 0 { + vars := make([]string, 0, len(unresolved)) + for _, m := range unresolved { + if len(m) > 1 { + vars = append(vars, m[1]) + } + } + if len(vars) > 0 { + return "", fmt.Errorf( + "session file template rendering failed: unresolved template variable(s): %s; pass --session-id when using {{ session_id }}", + strings.Join(vars, ", "), + ) + } + return "", fmt.Errorf("session file template rendering failed: unresolved template placeholders remain") + } + + return rendered, nil +} + func shortName(path, fallback string) string { base := filepath.Base(path) if base == "." || base == string(filepath.Separator) { diff --git a/internal/config/session_template_test.go b/internal/config/session_template_test.go new file mode 100644 index 0000000..e8a8638 --- /dev/null +++ b/internal/config/session_template_test.go @@ -0,0 +1,156 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadSessionWithOptionsRendersCompactPlaceholder(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.yml") + sessionYAML := `session_id: "{{session_id}}" +campaign: sample-campaign +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { + t.Fatalf("write session.yml: %v", err) + } + + cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"}) + if err != nil { + t.Fatalf("LoadSessionWithOptions() error = %v", err) + } + if cfg.SessionID != "2026-04-04" { + t.Fatalf("SessionID = %q, want 2026-04-04", cfg.SessionID) + } +} + +func TestLoadSessionWithOptionsRendersSpacedPlaceholder(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.yml") + sessionYAML := `session_id: "{{ session_id }}" +campaign: sample-campaign +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { + t.Fatalf("write session.yml: %v", err) + } + + cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"}) + if err != nil { + t.Fatalf("LoadSessionWithOptions() error = %v", err) + } + if cfg.SessionID != "2026-04-04" { + t.Fatalf("SessionID = %q, want 2026-04-04", cfg.SessionID) + } +} + +func TestLoadSessionWithOptionsUnresolvedPlaceholderFails(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.yml") + sessionYAML := `session_id: "{{ session_id }}" +campaign: sample-campaign +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { + t.Fatalf("write session.yml: %v", err) + } + + _, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "unresolved template variable") { + t.Fatalf("error = %q, want unresolved-variable context", err.Error()) + } + if !strings.Contains(err.Error(), "session_id") { + t.Fatalf("error = %q, want session_id variable", err.Error()) + } +} + +func TestLoadSessionWithOptionsMismatchFails(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.yml") + sessionYAML := `session_id: 2026-05-03 +campaign: sample-campaign +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { + t.Fatalf("write session.yml: %v", err) + } + + _, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "session_id mismatch") { + t.Fatalf("error = %q, want mismatch context", err.Error()) + } +} + +func TestLoadSessionWithOptionsUnknownFieldStillRejectedAfterRendering(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.yml") + sessionYAML := `session_id: "{{ session_id }}" +campaign: sample-campaign +unknown_field: true +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { + t.Fatalf("write session.yml: %v", err) + } + + _, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "strict decode failed") { + t.Fatalf("error = %q, want strict-decode context", err.Error()) + } +} + +func TestLoadSessionWithOptionsConcreteSessionStillLoads(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.yml") + sessionYAML := `session_id: 2026-05-03 +campaign: sample-campaign +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { + t.Fatalf("write session.yml: %v", err) + } + + cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{}) + if err != nil { + t.Fatalf("LoadSessionWithOptions() error = %v", err) + } + if cfg.SessionID != "2026-05-03" { + t.Fatalf("SessionID = %q, want 2026-05-03", cfg.SessionID) + } +}