Add session discovery and template support
This commit is contained in:
32
README.md
32
README.md
@@ -34,6 +34,25 @@ Pipeline config lookup for CLI commands:
|
|||||||
- `/usr/local/etc/narratio/pipeline.yml`
|
- `/usr/local/etc/narratio/pipeline.yml`
|
||||||
- `/etc/narratio/pipeline.yml`
|
- `/etc/narratio/pipeline.yml`
|
||||||
|
|
||||||
|
Session config lookup for CLI commands:
|
||||||
|
|
||||||
|
- if `--session <path>` 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 <value>` 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:
|
Optional secrets-from-files config:
|
||||||
|
|
||||||
- `pipeline.secrets.env_dir` may point to a directory of secret files
|
- `pipeline.secrets.env_dir` may point to a directory of secret files
|
||||||
@@ -345,6 +364,7 @@ Starter files:
|
|||||||
|
|
||||||
- `examples/pipeline.minimal.yml`
|
- `examples/pipeline.minimal.yml`
|
||||||
- `examples/session.minimal.yml`
|
- `examples/session.minimal.yml`
|
||||||
|
- `examples/session.template.yml`
|
||||||
- `examples/speakers.yml`
|
- `examples/speakers.yml`
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -363,6 +383,12 @@ go run ./cmd/narratio plan --session examples/session.minimal.yml
|
|||||||
|
|
||||||
Use `--config <path>` to override default pipeline lookup when needed.
|
Use `--config <path>` 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:
|
Run full pipeline:
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
## Operational Note
|
||||||
|
|
||||||
Checksum-based stale detection is not implemented yet.
|
Checksum-based stale detection is not implemented yet.
|
||||||
|
|||||||
@@ -115,6 +115,25 @@ CLI pipeline config path resolution:
|
|||||||
- `/usr/local/etc/narratio/pipeline.yml`
|
- `/usr/local/etc/narratio/pipeline.yml`
|
||||||
- `/etc/narratio/pipeline.yml`
|
- `/etc/narratio/pipeline.yml`
|
||||||
|
|
||||||
|
CLI session config path resolution:
|
||||||
|
|
||||||
|
- when `--session <path>` 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 <value>` 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:
|
Optional pipeline secrets directory:
|
||||||
|
|
||||||
- `pipeline.secrets.env_dir` enables loading environment variables from local files before command execution
|
- `pipeline.secrets.env_dir` enables loading environment variables from local files before command execution
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ Implemented in repository:
|
|||||||
- campaign/session/run local work and spool path helpers
|
- campaign/session/run local work and spool path helpers
|
||||||
- manifest run/path identity fields
|
- manifest run/path identity fields
|
||||||
- examples and tests for the above foundations
|
- 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:
|
- remote storage backend layer:
|
||||||
- object-store abstraction with `List`, `Download`, `Upload`, and `Exists`
|
- object-store abstraction with `List`, `Download`, `Upload`, and `Exists`
|
||||||
- fake storage backend for deterministic, no-network testing
|
- fake storage backend for deterministic, no-network testing
|
||||||
|
|||||||
12
examples/session.template.yml
Normal file
12
examples/session.template.yml
Normal file
@@ -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
|
||||||
@@ -64,12 +64,12 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
|||||||
args []string
|
args []string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "run missing flags", args: []string{"run"}, want: "run: --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: --session is required"},
|
{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: "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 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:"},
|
{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:"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
|
var sessionID string
|
||||||
var force bool
|
var force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
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)")
|
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
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 {
|
if fs.NArg() != 0 {
|
||||||
return fmt.Errorf("plan: unexpected positional arguments")
|
return fmt.Errorf("plan: unexpected positional arguments")
|
||||||
}
|
}
|
||||||
if sessionPath == "" {
|
|
||||||
return fmt.Errorf("plan: --session is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
return fmt.Errorf("plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
|
var sessionID string
|
||||||
var force bool
|
var force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
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")
|
fs.BoolVar(&force, "force", false, "force stage execution")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
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 {
|
if fs.NArg() != 0 {
|
||||||
return fmt.Errorf("resume: unexpected positional arguments")
|
return fmt.Errorf("resume: unexpected positional arguments")
|
||||||
}
|
}
|
||||||
if sessionPath == "" {
|
|
||||||
return fmt.Errorf("resume: --session is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resume: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("resume: %w", err)
|
return fmt.Errorf("resume: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
|
var sessionID string
|
||||||
var force bool
|
var force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
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)")
|
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
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 {
|
if fs.NArg() != 0 {
|
||||||
return fmt.Errorf("run: unexpected positional arguments")
|
return fmt.Errorf("run: unexpected positional arguments")
|
||||||
}
|
}
|
||||||
if sessionPath == "" {
|
|
||||||
return fmt.Errorf("run: --session is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
|
var sessionID string
|
||||||
var force bool
|
var force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
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)")
|
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
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 {
|
if fs.NArg() != 1 {
|
||||||
return fmt.Errorf("run-stage: expected exactly one stage name")
|
return fmt.Errorf("run-stage: expected exactly one stage name")
|
||||||
}
|
}
|
||||||
if sessionPath == "" {
|
|
||||||
return fmt.Errorf("run-stage: --session is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
stageName := fs.Arg(0)
|
stageName := fs.Arg(0)
|
||||||
stages, err := BuildSingleStagePlan(stageName)
|
stages, err := BuildSingleStagePlan(stageName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -41,8 +39,14 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run-stage: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("run-stage: %w", err)
|
return fmt.Errorf("run-stage: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
86
internal/app/session_cli_test.go
Normal file
86
internal/app/session_cli_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
49
internal/app/session_config_path.go
Normal file
49
internal/app/session_config_path.go
Normal file
@@ -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, ", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
68
internal/app/session_config_path_test.go
Normal file
68
internal/app/session_config_path_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ package config
|
|||||||
const (
|
const (
|
||||||
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
|
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
|
||||||
DefaultPipelineConfigPathEtc = "/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
|
// DefaultPipelineConfigSearchPaths defines the default search order for
|
||||||
@@ -16,3 +19,14 @@ var DefaultPipelineConfigSearchPaths = []string{
|
|||||||
DefaultPipelineConfigPathUsrLocal,
|
DefaultPipelineConfigPathUsrLocal,
|
||||||
DefaultPipelineConfigPathEtc,
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"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.
|
// LoadSession loads session configuration from a YAML file with strict field checking.
|
||||||
func LoadSession(path string) (*SessionConfig, error) {
|
func LoadSession(path string) (*SessionConfig, error) {
|
||||||
var cfg SessionConfig
|
return LoadSessionWithOptions(path, SessionLoadOptions{})
|
||||||
if err := decodeStrictYAML("session", path, &cfg); err != nil {
|
}
|
||||||
|
|
||||||
|
// 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)
|
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
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load loads and resolves combined pipeline and session configuration.
|
// Load loads and resolves combined pipeline and session configuration.
|
||||||
func Load(pipelinePath, sessionPath string) (*Config, error) {
|
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)
|
pipelineCfg, err := LoadPipeline(pipelinePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionCfg, err := LoadSession(sessionPath)
|
sessionCfg, err := LoadSessionWithOptions(sessionPath, sessionOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -55,7 +92,11 @@ func decodeStrictYAML(kind, path string, out any) error {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
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)
|
dec.KnownFields(true)
|
||||||
if err := dec.Decode(out); err != nil {
|
if err := dec.Decode(out); err != nil {
|
||||||
return fmt.Errorf("%s file %q: strict decode failed: %w", kind, path, err)
|
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
|
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 {
|
func shortName(path, fallback string) string {
|
||||||
base := filepath.Base(path)
|
base := filepath.Base(path)
|
||||||
if base == "." || base == string(filepath.Separator) {
|
if base == "." || base == string(filepath.Separator) {
|
||||||
|
|||||||
156
internal/config/session_template_test.go
Normal file
156
internal/config/session_template_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user