diff --git a/internal/svglide/run.go b/internal/svglide/run.go index 95b24241a..a73e47ec0 100644 --- a/internal/svglide/run.go +++ b/internal/svglide/run.go @@ -109,12 +109,12 @@ func NewRun(cfg NewRunConfig) Run { func DefaultStages() []Stage { return []Stage{ {Name: StageRequest, Status: StatusPending, Inputs: []string{}, Outputs: []string{"request/request.json", "request/source_manifest.json"}, Receipt: "receipts/request.json"}, - {Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json"}, Receipt: "receipts/research.json"}, + {Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json"}, Receipt: "receipts/research.json"}, {Name: StageDesignBrief, Status: StatusPending, Inputs: []string{"request/request.json", "research/research_notes.md"}, Outputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Receipt: "receipts/design_brief.json"}, - {Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"}, + {Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"}, {Name: StageSlideContent, Status: StatusPending, Inputs: []string{"outline/deck.json", "research/research_notes.md"}, Outputs: []string{"content/slide_content.md", "content/slide_content.json"}, Receipt: "receipts/slide_content.json"}, {Name: StageAssets, Status: StatusPending, Inputs: []string{"content/slide_content.json", "brief/visual_system.json"}, Outputs: []string{"assets/assets_plan.json"}, Receipt: "receipts/assets.json"}, - {Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_plan.json"}, Outputs: []string{"slides"}, Receipt: "receipts/svg_author.json"}, - {Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "repair_queue.md", "preview.html"}, Receipt: "receipts/validate_preview_repair.json"}, + {Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_plan.json"}, Outputs: []string{"slides/*.svg"}, Receipt: "receipts/svg_author.json"}, + {Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides/*.svg"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "repair_queue.md", "preview.html"}, Receipt: "receipts/validate_preview_repair.json"}, } } diff --git a/internal/svglide/run_test.go b/internal/svglide/run_test.go index 598874921..d5924652b 100644 --- a/internal/svglide/run_test.go +++ b/internal/svglide/run_test.go @@ -40,6 +40,36 @@ func TestDefaultStagesAreOrdered(t *testing.T) { } } +func TestDefaultStagesRequireGeneratedSlideSVGs(t *testing.T) { + stages := DefaultStages() + svgAuthor := mustStage(t, stages, StageSVGAuthor) + if !reflect.DeepEqual(svgAuthor.Outputs, []string{"slides/*.svg"}) { + t.Fatalf("svg_author Outputs = %v, want slides/*.svg", svgAuthor.Outputs) + } + repair := mustStage(t, stages, StageValidatePreviewRepair) + if !reflect.DeepEqual(repair.Inputs, []string{"slides/*.svg"}) { + t.Fatalf("validate_preview_repair Inputs = %v, want slides/*.svg", repair.Inputs) + } +} + +func TestDefaultStagesResearchInputsMatchPromptContract(t *testing.T) { + stages := DefaultStages() + research := mustStage(t, stages, StageResearch) + want := []string{"request/request.json", "request/source_manifest.json"} + if !reflect.DeepEqual(research.Inputs, want) { + t.Fatalf("research Inputs = %v, want %v", research.Inputs, want) + } +} + +func TestDefaultStagesOutlineInputsMatchPromptContract(t *testing.T) { + stages := DefaultStages() + outline := mustStage(t, stages, StageOutline) + want := []string{"brief/design_brief.json", "brief/visual_system.json"} + if !reflect.DeepEqual(outline.Inputs, want) { + t.Fatalf("outline Inputs = %v, want %v", outline.Inputs, want) + } +} + func TestNewRunDefaultsToCodexRuntime(t *testing.T) { now := time.Date(2026, 7, 2, 15, 4, 5, 0, time.UTC) run := NewRun(NewRunConfig{ @@ -111,3 +141,14 @@ func TestNewRunDefaultsToCodexRuntime(t *testing.T) { t.Fatalf("Policy = %+v, want %+v", run.Policy, wantPolicy) } } + +func mustStage(t *testing.T, stages []Stage, name string) Stage { + t.Helper() + for _, stage := range stages { + if stage.Name == name { + return stage + } + } + t.Fatalf("missing stage %q", name) + return Stage{} +} diff --git a/internal/svglide/status.go b/internal/svglide/status.go new file mode 100644 index 000000000..085ebe974 --- /dev/null +++ b/internal/svglide/status.go @@ -0,0 +1,391 @@ +package svglide + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +type StatusReport struct { + CurrentStage string `json:"current_stage"` + MissingInputs []string `json:"missing_inputs"` + MissingOutputs []string `json:"missing_outputs"` + NextCommand string `json:"next_command"` +} + +type NextTaskReport struct { + Stage string `json:"stage"` + PromptPath string `json:"prompt_path"` + Inputs []string `json:"inputs"` + Outputs []string `json:"outputs"` +} + +func ReadRun(root string) (Run, error) { + safeRoot, err := validate.SafeInputPath(root) + if err != nil { + return Run{}, err + } + return readRunFile(safeRoot) +} + +func InspectStatus(root string) (StatusReport, error) { + safeRoot, run, err := readRun(root) + if err != nil { + return StatusReport{}, err + } + stage, err := currentStage(run) + if err != nil { + return StatusReport{}, err + } + missingInputs, err := missingRunPaths(safeRoot, stage.Inputs) + if err != nil { + return StatusReport{}, err + } + missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs) + if err != nil { + return StatusReport{}, err + } + return StatusReport{ + CurrentStage: stage.Name, + MissingInputs: missingInputs, + MissingOutputs: missingOutputs, + NextCommand: fmt.Sprintf("lark-cli slides +create-svglide --action next --run %s", shellQuote(root)), + }, nil +} + +func NextTask(root string) (NextTaskReport, error) { + safeRoot, run, err := readRun(root) + if err != nil { + return NextTaskReport{}, err + } + stage, err := currentStage(run) + if err != nil { + return NextTaskReport{}, err + } + missingInputs, err := missingRunPaths(safeRoot, stage.Inputs) + if err != nil { + return NextTaskReport{}, err + } + if len(missingInputs) > 0 { + return NextTaskReport{}, fmt.Errorf("current stage %q missing inputs: %s", stage.Name, strings.Join(missingInputs, ", ")) + } + promptPath, err := promptPathForStage(stage.Name) + if err != nil { + return NextTaskReport{}, err + } + inputs, err := validateRunPaths(safeRoot, stage.Inputs) + if err != nil { + return NextTaskReport{}, err + } + outputs, err := validateRunPaths(safeRoot, stage.Outputs) + if err != nil { + return NextTaskReport{}, err + } + return NextTaskReport{ + Stage: stage.Name, + PromptPath: promptPath, + Inputs: inputs, + Outputs: outputs, + }, nil +} + +func readRun(root string) (string, Run, error) { + safeRoot, err := validate.SafeInputPath(root) + if err != nil { + return "", Run{}, err + } + run, err := readRunFile(safeRoot) + if err != nil { + return "", Run{}, err + } + return safeRoot, run, nil +} + +func readRunFile(safeRoot string) (Run, error) { + raw, err := vfs.ReadFile(filepath.Join(safeRoot, "run.json")) + if err != nil { + return Run{}, err + } + var run Run + if err := json.Unmarshal(raw, &run); err != nil { + return Run{}, fmt.Errorf("read run.json: %w", err) + } + return run, nil +} + +func currentStage(run Run) (Stage, error) { + for _, stage := range run.Stages { + if stage.Name == run.CurrentStage { + return stage, nil + } + } + return Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage) +} + +func missingRunPaths(safeRoot string, rels []string) ([]string, error) { + var missing []string + for _, rel := range rels { + if hasGlobMeta(rel) { + exists, err := runGlobExists(safeRoot, rel) + if err != nil { + return nil, err + } + if !exists { + missing = append(missing, rel) + } + continue + } + exists, err := runRegularFileExists(safeRoot, rel) + if err != nil { + return nil, fmt.Errorf("lstat run path %q: %w", rel, err) + } + if !exists { + missing = append(missing, rel) + } + } + return missing, nil +} + +func validateRunPaths(safeRoot string, rels []string) ([]string, error) { + paths := make([]string, 0, len(rels)) + for _, rel := range rels { + if hasGlobMeta(rel) { + if _, _, _, err := validateRunGlobPattern(safeRoot, rel); err != nil { + return nil, err + } + } else { + if _, err := safeRunPath(safeRoot, rel); err != nil { + return nil, err + } + } + paths = append(paths, rel) + } + return paths, nil +} + +func runGlobExists(safeRoot, rel string) (bool, error) { + dirRel, pattern, dirPath, err := validateRunGlobPattern(safeRoot, rel) + if err != nil { + return false, err + } + dirPath, exists, err := runDirectoryExists(safeRoot, dirRel) + if err != nil { + return false, fmt.Errorf("lstat glob directory for %q: %w", rel, err) + } + if !exists { + return false, nil + } + entries, err := vfs.ReadDir(dirPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("read glob directory for %q: %w", rel, err) + } + for _, entry := range entries { + matched, err := filepath.Match(pattern, entry.Name()) + if err != nil { + return false, fmt.Errorf("invalid glob pattern %q: %w", rel, err) + } + if !matched { + continue + } + matchRel := filepath.Join(dirRel, entry.Name()) + exists, err := runRegularFileExists(safeRoot, matchRel) + if err != nil { + return false, fmt.Errorf("lstat glob match %q: %w", matchRel, err) + } + if exists { + return true, nil + } + } + return false, nil +} + +func validateRunGlobPattern(safeRoot, rel string) (string, string, string, error) { + if strings.TrimSpace(rel) == "" { + return "", "", "", fmt.Errorf("run path must not be empty") + } + if isAbsoluteRunPath(rel) { + return "", "", "", fmt.Errorf("run path %q must be relative to run root", rel) + } + cleanRel := filepath.Clean(rel) + dirRel, pattern := filepath.Split(cleanRel) + dirRel = strings.TrimSuffix(dirRel, string(filepath.Separator)) + if pattern == "" { + return "", "", "", fmt.Errorf("glob path %q is missing a file pattern", rel) + } + if _, err := filepath.Match(pattern, ""); err != nil { + return "", "", "", fmt.Errorf("invalid glob pattern %q: %w", rel, err) + } + if dirRel == "" { + dirRel = "." + } + if hasGlobMeta(dirRel) { + return "", "", "", fmt.Errorf("glob path %q is only supported in the file name", rel) + } + dirPath, err := safeRunPath(safeRoot, dirRel) + if err != nil { + return "", "", "", err + } + return dirRel, pattern, dirPath, nil +} + +func runDirectoryExists(safeRoot, rel string) (string, bool, error) { + info, path, exists, err := lstatRunPath(safeRoot, rel) + if err != nil { + return path, false, err + } + if !exists { + return path, false, nil + } + if !info.IsDir() { + return path, false, fmt.Errorf("run path %q is not a directory", rel) + } + return path, true, nil +} + +func runRegularFileExists(safeRoot, rel string) (bool, error) { + info, _, exists, err := lstatRunPath(safeRoot, rel) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + return info.Mode().IsRegular(), nil +} + +func lstatRunPath(safeRoot, rel string) (fs.FileInfo, string, bool, error) { + path, err := safeRunPath(safeRoot, rel) + if err != nil { + return nil, "", false, err + } + cleanRel := filepath.Clean(rel) + if cleanRel == "." { + info, err := vfs.Lstat(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, path, false, nil + } + return nil, path, false, err + } + if info.Mode()&fs.ModeSymlink != 0 { + return nil, path, false, nil + } + return info, path, true, nil + } + parts := strings.Split(cleanRel, string(filepath.Separator)) + cur := safeRoot + var info fs.FileInfo + for i, part := range parts { + if part == "" || part == "." { + continue + } + cur = filepath.Join(cur, part) + info, err = vfs.Lstat(cur) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, path, false, nil + } + return nil, path, false, err + } + if info.Mode()&fs.ModeSymlink != 0 { + return nil, path, false, nil + } + if i < len(parts)-1 && !info.IsDir() { + return nil, path, false, fmt.Errorf("run path component %q is not a directory", filepath.Join(parts[:i+1]...)) + } + } + if info == nil { + return nil, path, false, nil + } + return info, path, true, nil +} + +func hasGlobMeta(path string) bool { + return strings.ContainsAny(path, "*?[") +} + +func safeRunPath(safeRoot, rel string) (string, error) { + if strings.TrimSpace(rel) == "" { + return "", fmt.Errorf("run path must not be empty") + } + if isAbsoluteRunPath(rel) { + return "", fmt.Errorf("run path %q must be relative to run root", rel) + } + cleanRel := filepath.Clean(rel) + path := filepath.Clean(filepath.Join(safeRoot, cleanRel)) + rootRel, err := filepath.Rel(safeRoot, path) + if err != nil { + return "", fmt.Errorf("cannot compare run path %q with run root: %w", rel, err) + } + if rootRel == ".." || strings.HasPrefix(rootRel, ".."+string(filepath.Separator)) || filepath.IsAbs(rootRel) { + return "", fmt.Errorf("run path %q escapes run root", rel) + } + return path, nil +} + +func isAbsoluteRunPath(path string) bool { + path = strings.TrimSpace(path) + if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\`) { + return true + } + if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') { + drive := path[0] + return ('A' <= drive && drive <= 'Z') || ('a' <= drive && drive <= 'z') + } + return false +} + +func shellQuote(value string) string { + if value == "" { + return "''" + } + if isShellBareword(value) { + return value + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func isShellBareword(value string) bool { + for _, r := range value { + if ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') || ('0' <= r && r <= '9') { + continue + } + if strings.ContainsRune("_@%+=:,./-", r) { + continue + } + return false + } + return true +} + +func promptPathForStage(stage string) (string, error) { + switch stage { + case StageRequest: + return "prompts/01_request.task.md", nil + case StageResearch: + return "prompts/02_research.task.md", nil + case StageDesignBrief: + return "prompts/03_design_brief.task.md", nil + case StageOutline: + return "prompts/04_outline.task.md", nil + case StageSlideContent: + return "prompts/05_slide_content.task.md", nil + case StageAssets: + return "prompts/06_assets.task.md", nil + case StageSVGAuthor: + return "prompts/07_svg_author.task.md", nil + case StageValidatePreviewRepair: + return "prompts/08_repair.task.md", nil + default: + return "", fmt.Errorf("stage %q has no prompt mapping", stage) + } +} diff --git a/internal/svglide/status_test.go b/internal/svglide/status_test.go new file mode 100644 index 000000000..e5dd7beb6 --- /dev/null +++ b/internal/svglide/status_test.go @@ -0,0 +1,449 @@ +package svglide + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestStatusReportsMissingOutputs(t *testing.T) { + initStatusTestRun(t) + if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil { + t.Fatal(err) + } + + status, err := InspectStatus("demo") + if err != nil { + t.Fatal(err) + } + + if status.CurrentStage != StageRequest { + t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageRequest) + } + if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") { + t.Fatalf("MissingOutputs = %v, want request/source_manifest.json", status.MissingOutputs) + } + if len(status.MissingInputs) != 0 { + t.Fatalf("MissingInputs = %v, want empty", status.MissingInputs) + } + if status.NextCommand != "lark-cli slides +create-svglide --action next --run demo" { + t.Fatalf("NextCommand = %q, want --action next shortcut with caller root", status.NextCommand) + } +} + +func TestStatusQuotesNextCommandRunPath(t *testing.T) { + tests := []struct { + root string + want string + }{ + { + root: "demo dir", + want: "lark-cli slides +create-svglide --action next --run 'demo dir'", + }, + { + root: "demo' dir", + want: "lark-cli slides +create-svglide --action next --run 'demo'\\'' dir'", + }, + { + root: "demo trail ", + want: "lark-cli slides +create-svglide --action next --run 'demo trail '", + }, + } + for _, tt := range tests { + t.Run(tt.root, func(t *testing.T) { + cwd := initStatusTestRunAt(t, tt.root) + + status, err := InspectStatus(tt.root) + if err != nil { + t.Fatal(err) + } + + if status.NextCommand != tt.want { + t.Fatalf("NextCommand = %q, want %q", status.NextCommand, tt.want) + } + if strings.Contains(status.NextCommand, cwd) { + t.Fatalf("NextCommand = %q, should not contain absolute safe root %q", status.NextCommand, cwd) + } + }) + } +} + +func TestNextReturnsCurrentTaskPrompt(t *testing.T) { + initStatusTestRun(t) + + next, err := NextTask("demo") + if err != nil { + t.Fatal(err) + } + + if next.Stage != StageRequest { + t.Fatalf("Stage = %q, want %q", next.Stage, StageRequest) + } + if next.PromptPath != "prompts/01_request.task.md" { + t.Fatalf("PromptPath = %q, want prompts/01_request.task.md", next.PromptPath) + } + if filepath.IsAbs(next.PromptPath) { + t.Fatalf("PromptPath = %q, want relative path", next.PromptPath) + } + if _, err := os.Stat(filepath.Join("demo", next.PromptPath)); err != nil { + t.Fatalf("missing prompt %s: %v", next.PromptPath, err) + } + if len(next.Inputs) != 0 { + t.Fatalf("Inputs = %v, want empty", next.Inputs) + } + if !slices.Equal(next.Outputs, []string{"request/request.json", "request/source_manifest.json"}) { + t.Fatalf("Outputs = %v, want request outputs", next.Outputs) + } +} + +func TestInspectStatusRejectsUnsafeRunPath(t *testing.T) { + t.Chdir(t.TempDir()) + + if _, err := InspectStatus("../escape"); err == nil { + t.Fatal("expected unsafe run path refusal") + } +} + +func TestReadRunReadsRunJSONAndRejectsAbsoluteRunPath(t *testing.T) { + cwd := initStatusTestRun(t) + + run, err := ReadRun("demo") + if err != nil { + t.Fatal(err) + } + if run.Title != "Demo" || run.CurrentStage != StageRequest { + t.Fatalf("unexpected run: %+v", run) + } + + if _, err := ReadRun(filepath.Join(cwd, "demo")); err == nil { + t.Fatal("expected absolute run path refusal") + } +} + +func TestInspectStatusRejectsEscapingStagePath(t *testing.T) { + initStatusTestRun(t) + run := readStatusTestRunFile(t) + setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"}) + writeStatusTestRunFile(t, run) + + if _, err := InspectStatus("demo"); err == nil { + t.Fatal("expected escaping stage output path refusal") + } +} + +func TestInspectStatusReturnsStatErrorsThatAreNotMissing(t *testing.T) { + initStatusTestRun(t) + if err := os.RemoveAll(filepath.Join("demo", "request")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("demo", "request"), []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := InspectStatus("demo"); err == nil { + t.Fatal("expected stat error when output parent is a file") + } +} + +func TestInspectStatusReportsDirectoryArtifactAsMissing(t *testing.T) { + initStatusTestRun(t) + path := filepath.Join("demo", "request", "source_manifest.json") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatal(err) + } + + status, err := InspectStatus("demo") + if err != nil { + t.Fatal(err) + } + if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") { + t.Fatalf("MissingOutputs = %v, want directory artifact to be missing", status.MissingOutputs) + } +} + +func TestNextTaskRejectsEscapingStagePath(t *testing.T) { + initStatusTestRun(t) + run := readStatusTestRunFile(t) + setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"}) + writeStatusTestRunFile(t, run) + + if _, err := NextTask("demo"); err == nil { + t.Fatal("expected escaping stage output path refusal") + } +} + +func TestNextTaskRejectsMissingCurrentStageInputs(t *testing.T) { + initStatusTestRun(t) + run := readStatusTestRunFile(t) + run.CurrentStage = StageDesignBrief + writeStatusTestRunFile(t, run) + + if _, err := NextTask("demo"); err == nil { + t.Fatal("expected missing current stage inputs to reject next task") + } +} + +func TestNextTaskRejectsResearchMissingSourceManifest(t *testing.T) { + initStatusTestRun(t) + if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil { + t.Fatal(err) + } + run := readStatusTestRunFile(t) + run.CurrentStage = StageResearch + writeStatusTestRunFile(t, run) + + if _, err := NextTask("demo"); err == nil { + t.Fatal("expected missing research source manifest to reject next task") + } +} + +func TestNextTaskRejectsOutlineMissingVisualSystem(t *testing.T) { + initStatusTestRun(t) + if err := os.WriteFile(filepath.Join("demo", "brief", "design_brief.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + run := readStatusTestRunFile(t) + run.CurrentStage = StageOutline + writeStatusTestRunFile(t, run) + + if _, err := NextTask("demo"); err == nil { + t.Fatal("expected missing outline visual system to reject next task") + } +} + +func TestInspectStatusReportsMissingGlobUntilMatched(t *testing.T) { + initStatusTestRun(t) + run := readStatusTestRunFile(t) + run.CurrentStage = StageSVGAuthor + setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"slides/*.svg"}) + writeStatusTestRunFile(t, run) + + status, err := InspectStatus("demo") + if err != nil { + t.Fatal(err) + } + if !slices.Contains(status.MissingOutputs, "slides/*.svg") { + t.Fatalf("MissingOutputs = %v, want slides/*.svg", status.MissingOutputs) + } + + if err := os.WriteFile(filepath.Join("demo", "slides", "01.svg"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + status, err = InspectStatus("demo") + if err != nil { + t.Fatal(err) + } + if slices.Contains(status.MissingOutputs, "slides/*.svg") { + t.Fatalf("MissingOutputs = %v, want glob satisfied by slides/01.svg", status.MissingOutputs) + } +} + +func TestInspectStatusDoesNotSatisfyGlobThroughIntermediateSymlink(t *testing.T) { + cwd := initStatusTestRun(t) + setCurrentStageForStatusTest(t, StageSVGAuthor) + run := readStatusTestRunFile(t) + setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"link/bar/*.svg"}) + writeStatusTestRunFile(t, run) + outside := filepath.Join(filepath.Dir(cwd), "outside") + if err := os.MkdirAll(filepath.Join(outside, "bar"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outside, "bar", "01.svg"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil { + t.Fatal(err) + } + + status, err := InspectStatus("demo") + if err != nil { + t.Fatal(err) + } + if !slices.Contains(status.MissingOutputs, "link/bar/*.svg") { + t.Fatalf("MissingOutputs = %v, want intermediate symlink glob to leave link/bar/*.svg missing", status.MissingOutputs) + } +} + +func TestInspectStatusDoesNotSatisfyArtifactThroughIntermediateSymlink(t *testing.T) { + cwd := initStatusTestRun(t) + run := readStatusTestRunFile(t) + setStatusTestStageOutputs(t, &run, StageRequest, []string{"link/request.json"}) + writeStatusTestRunFile(t, run) + outside := filepath.Join(filepath.Dir(cwd), "outside") + if err := os.MkdirAll(outside, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outside, "request.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil { + t.Fatal(err) + } + + status, err := InspectStatus("demo") + if err != nil { + t.Fatal(err) + } + if !slices.Contains(status.MissingOutputs, "link/request.json") { + t.Fatalf("MissingOutputs = %v, want intermediate symlink artifact to leave link/request.json missing", status.MissingOutputs) + } +} + +func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlinkDirectory(t *testing.T) { + cwd := initStatusTestRun(t) + setCurrentStageForStatusTest(t, StageSVGAuthor) + if err := os.RemoveAll(filepath.Join("demo", "slides")); err != nil { + t.Fatal(err) + } + outsideSlides := filepath.Join(filepath.Dir(cwd), "outside-slides") + if err := os.MkdirAll(outsideSlides, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outsideSlides, "01.svg"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsideSlides, filepath.Join("demo", "slides")); err != nil { + t.Fatal(err) + } + + status, err := InspectStatus("demo") + if err != nil { + t.Fatal(err) + } + if !slices.Contains(status.MissingOutputs, "slides/*.svg") { + t.Fatalf("MissingOutputs = %v, want symlink directory glob to leave slides/*.svg missing", status.MissingOutputs) + } +} + +func TestInspectStatusDoesNotSatisfyGlobWithDirectory(t *testing.T) { + initStatusTestRun(t) + setCurrentStageForStatusTest(t, StageSVGAuthor) + if err := os.Mkdir(filepath.Join("demo", "slides", "01.svg"), 0o755); err != nil { + t.Fatal(err) + } + + status, err := InspectStatus("demo") + if err != nil { + t.Fatal(err) + } + if !slices.Contains(status.MissingOutputs, "slides/*.svg") { + t.Fatalf("MissingOutputs = %v, want directory match to leave slides/*.svg missing", status.MissingOutputs) + } +} + +func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlink(t *testing.T) { + cwd := initStatusTestRun(t) + setCurrentStageForStatusTest(t, StageSVGAuthor) + outside := filepath.Join(filepath.Dir(cwd), "outside.svg") + if err := os.WriteFile(outside, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join("demo", "slides", "01.svg")); err != nil { + t.Fatal(err) + } + + status, err := InspectStatus("demo") + if err != nil { + t.Fatal(err) + } + if !slices.Contains(status.MissingOutputs, "slides/*.svg") { + t.Fatalf("MissingOutputs = %v, want symlink match to leave slides/*.svg missing", status.MissingOutputs) + } +} + +func TestInspectStatusRejectsInvalidGlobPattern(t *testing.T) { + initStatusTestRun(t) + run := readStatusTestRunFile(t) + setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"}) + writeStatusTestRunFile(t, run) + + if _, err := InspectStatus("demo"); err == nil { + t.Fatal("expected invalid glob pattern error") + } +} + +func TestNextTaskRejectsInvalidGlobPattern(t *testing.T) { + initStatusTestRun(t) + run := readStatusTestRunFile(t) + setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"}) + writeStatusTestRunFile(t, run) + + if _, err := NextTask("demo"); err == nil { + t.Fatal("expected invalid glob pattern error") + } +} + +func initStatusTestRun(t *testing.T) string { + return initStatusTestRunAt(t, "demo") +} + +func initStatusTestRunAt(t *testing.T, root string) string { + t.Helper() + cwd := t.TempDir() + t.Chdir(cwd) + if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil { + t.Fatal(err) + } + initRoot := root + if trimmed := strings.TrimSpace(root); trimmed != root { + initRoot = trimmed + } + if err := InitRun(initRoot, InitOptions{Title: "Demo", Input: "source.md"}); err != nil { + t.Fatal(err) + } + if initRoot != root { + if err := os.Rename(initRoot, root); err != nil { + t.Fatal(err) + } + } + return cwd +} + +func readStatusTestRunFile(t *testing.T) Run { + t.Helper() + raw, err := os.ReadFile(filepath.Join("demo", "run.json")) + if err != nil { + t.Fatal(err) + } + var run Run + if err := json.Unmarshal(raw, &run); err != nil { + t.Fatal(err) + } + return run +} + +func writeStatusTestRunFile(t *testing.T, run Run) { + t.Helper() + raw, err := json.MarshalIndent(run, "", " ") + if err != nil { + t.Fatal(err) + } + raw = append(raw, '\n') + if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil { + t.Fatal(err) + } +} + +func setStatusTestStageOutputs(t *testing.T, run *Run, stageName string, outputs []string) { + t.Helper() + for i := range run.Stages { + if run.Stages[i].Name == stageName { + run.Stages[i].Outputs = outputs + return + } + } + t.Fatalf("missing stage %q", stageName) +} + +func setCurrentStageForStatusTest(t *testing.T, stageName string) { + t.Helper() + run := readStatusTestRunFile(t) + run.CurrentStage = stageName + writeStatusTestRunFile(t, run) +}