From 15e7ab8b668a64816be4d805ae1200a7673bffd3 Mon Sep 17 00:00:00 2001 From: "songtianyi.theo" Date: Fri, 3 Jul 2026 01:56:23 +0800 Subject: [PATCH] feat: advance svglide run stages --- internal/svglide/stage.go | 89 +++++++++++++++++++++++++++++++++ internal/svglide/stage_test.go | 91 ++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 internal/svglide/stage.go create mode 100644 internal/svglide/stage_test.go diff --git a/internal/svglide/stage.go b/internal/svglide/stage.go new file mode 100644 index 00000000..bc84d118 --- /dev/null +++ b/internal/svglide/stage.go @@ -0,0 +1,89 @@ +package svglide + +import ( + "fmt" + "path/filepath" + "strings" + "time" +) + +type StageReceipt struct { + Stage string `json:"stage"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + Artifacts []string `json:"artifacts,omitempty"` +} + +func CompleteCurrentStage(root string) (StatusReport, error) { + safeRoot, run, err := readRun(root) + if err != nil { + return StatusReport{}, err + } + index, stage, err := currentStageWithIndex(run) + if err != nil { + return StatusReport{}, err + } + missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs) + if err != nil { + return StatusReport{}, err + } + if len(missingOutputs) > 0 { + return StatusReport{}, fmt.Errorf("current stage %q missing outputs: %s", stage.Name, strings.Join(missingOutputs, ", ")) + } + + if err := writeStageReceipt(safeRoot, StageReceipt{ + Stage: stage.Name, + Status: StatusDone, + Artifacts: stage.Outputs, + }); err != nil { + return StatusReport{}, err + } + + run.Stages[index].Status = StatusDone + if index < len(run.Stages)-1 { + nextStage := &run.Stages[index+1] + run.CurrentStage = nextStage.Name + if nextStage.Status == "" { + nextStage.Status = StatusPending + } + } else { + run.CurrentStage = stage.Name + } + run.UpdatedAt = time.Now().Format(time.RFC3339) + + if err := writeRunFile(safeRoot, run); err != nil { + return StatusReport{}, err + } + return InspectStatus(root) +} + +func currentStageWithIndex(run Run) (int, Stage, error) { + for i, stage := range run.Stages { + if stage.Name == run.CurrentStage { + return i, stage, nil + } + } + return -1, Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage) +} + +func writeRunFile(safeRoot string, run Run) error { + target, err := ensureRunFileTargetForWrite(safeRoot, "run.json") + if err != nil { + return err + } + return writeJSON(target, run) +} + +func writeStageReceipt(safeRoot string, receipt StageReceipt) error { + if strings.TrimSpace(receipt.Stage) == "" { + return fmt.Errorf("stage receipt stage must not be empty") + } + if strings.ContainsAny(receipt.Stage, `/\`) || receipt.Stage == "." || receipt.Stage == ".." { + return fmt.Errorf("stage receipt stage %q must be a file name", receipt.Stage) + } + target, err := ensureRunFileTargetForWrite(safeRoot, filepath.Join("receipts", receipt.Stage+".json")) + if err != nil { + return err + } + return writeJSON(target, receipt) +} diff --git a/internal/svglide/stage_test.go b/internal/svglide/stage_test.go new file mode 100644 index 00000000..5ca2b32a --- /dev/null +++ b/internal/svglide/stage_test.go @@ -0,0 +1,91 @@ +package svglide + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestCompleteCurrentStageAdvancesToNextStage(t *testing.T) { + initStatusTestRun(t) + + status, err := CompleteCurrentStage("demo") + if err != nil { + t.Fatal(err) + } + if status.CurrentStage != StageResearch { + t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageResearch) + } + + run := readStatusTestRunFile(t) + if run.CurrentStage != StageResearch { + t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageResearch) + } + if got := stageStatus(t, run, StageRequest); got != StatusDone { + t.Fatalf("request stage status = %q, want %q", got, StatusDone) + } + if got := stageStatus(t, run, StageResearch); got != StatusPending { + t.Fatalf("research stage status = %q, want %q", got, StatusPending) + } + + raw, err := os.ReadFile(filepath.Join("demo", "receipts", "request.json")) + if err != nil { + t.Fatalf("missing request receipt: %v", err) + } + var receipt StageReceipt + if err := json.Unmarshal(raw, &receipt); err != nil { + t.Fatalf("invalid request receipt: %v", err) + } + if receipt.Stage != StageRequest || receipt.Status != StatusDone { + t.Fatalf("receipt = %+v, want request done", receipt) + } +} + +func TestCompleteCurrentStageRejectsMissingOutput(t *testing.T) { + initStatusTestRun(t) + if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil { + t.Fatal(err) + } + + _, err := CompleteCurrentStage("demo") + if err == nil { + t.Fatal("expected missing output error") + } + + run := readStatusTestRunFile(t) + if run.CurrentStage != StageRequest { + t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest) + } +} + +func TestCompleteCurrentStageDoesNotAdvanceRunWhenReceiptWriteFails(t *testing.T) { + initStatusTestRun(t) + if err := os.Mkdir(filepath.Join("demo", "receipts", "request.json"), 0o755); err != nil { + t.Fatal(err) + } + + _, err := CompleteCurrentStage("demo") + if err == nil { + t.Fatal("expected receipt write error") + } + + run := readStatusTestRunFile(t) + if run.CurrentStage != StageRequest { + t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest) + } + if got := stageStatus(t, run, StageRequest); got == StatusDone { + t.Fatalf("request stage status = %q, want not %q", got, StatusDone) + } +} + +func stageStatus(t *testing.T, run Run, name string) string { + t.Helper() + for _, stage := range run.Stages { + if stage.Name == name { + return stage.Status + } + } + t.Fatalf("missing stage %q", name) + return "" +}