Skip to main content

plan_issue/
runtime_layout.rs

1//! Canonical runtime layout for plan-issue artifacts.
2//!
3//! Path math defined by `docs/specs/plan-issue-contract-v2.md` "Canonical
4//! Runtime Artifacts (v2)".
5
6use std::error::Error;
7use std::fmt;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12use crate::state;
13
14const RUNTIME_DIR: &str = "out";
15const PLAN_ISSUE_DELIVERY_DIR: &str = "plan-issue-delivery";
16const ISSUE_PREFIX: &str = "issue-";
17const SPRINT_PREFIX: &str = "sprint-";
18const PROMPTS_DIR: &str = "prompts";
19const PLAN_DIR: &str = "plan";
20const SPECS_DIR: &str = "specs";
21const MANIFESTS_DIR: &str = "manifests";
22const WORKTREES_DIR: &str = "worktrees";
23const PLAN_SNAPSHOT_FILE: &str = "plan.snapshot.md";
24const PLAN_BRANCH_REF_FILE: &str = "plan-branch.ref";
25const PROMPT_MANIFEST_FILE: &str = "prompt-manifest.tsv";
26const SPRINT_TASK_SPEC_FILE: &str = "sprint-task-spec.tsv";
27
28/// Errors emitted by canonical runtime-layout helpers.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum RuntimeLayoutError {
31    /// Repo slug is empty or contains a path separator after substitution.
32    InvalidRepoSlug { slug: String },
33    /// Task id is empty or contains a path separator.
34    InvalidTaskId { task_id: String },
35}
36
37impl fmt::Display for RuntimeLayoutError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::InvalidRepoSlug { slug } => {
41                write!(f, "invalid repo slug `{slug}` for runtime layout")
42            }
43            Self::InvalidTaskId { task_id } => {
44                write!(f, "invalid task id `{task_id}` for runtime layout")
45            }
46        }
47    }
48}
49
50impl Error for RuntimeLayoutError {}
51
52/// Resolve `RUNTIME_ROOT="<state-dir>/out/plan-issue-delivery"` using the
53/// plan-issue state-dir resolution chain (CLI override > `PLAN_ISSUE_HOME`
54/// env > XDG default). See [`crate::state::state_dir`] for details.
55pub fn runtime_root() -> PathBuf {
56    state::state_dir()
57        .join(RUNTIME_DIR)
58        .join(PLAN_ISSUE_DELIVERY_DIR)
59}
60
61/// Convert `owner/repo` to `owner__repo`.
62pub fn repo_slug(owner_repo: &str) -> String {
63    owner_repo.trim().replace('/', "__")
64}
65
66/// Create a directory and all parents (idempotent).
67pub fn ensure_dir(path: &Path) -> io::Result<()> {
68    fs::create_dir_all(path)
69}
70
71/// Issue-scoped runtime root.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct IssueRoot {
74    root: PathBuf,
75}
76
77impl IssueRoot {
78    /// Compute `$RUNTIME_ROOT/<repo-slug>/issue-<issue_number>`.
79    pub fn new(repo_slug: &str, issue_number: u64) -> Result<Self, RuntimeLayoutError> {
80        let trimmed = repo_slug.trim();
81        if trimmed.is_empty()
82            || trimmed.contains('/')
83            || trimmed.contains('\\')
84            || trimmed.contains('\0')
85        {
86            return Err(RuntimeLayoutError::InvalidRepoSlug {
87                slug: repo_slug.to_string(),
88            });
89        }
90        let runtime = runtime_root();
91        let root = runtime
92            .join(trimmed)
93            .join(format!("{ISSUE_PREFIX}{issue_number}"));
94        Ok(Self { root })
95    }
96
97    /// `$ISSUE_ROOT`.
98    pub fn root(&self) -> &Path {
99        &self.root
100    }
101
102    /// `$ISSUE_ROOT/plan/plan.snapshot.md`.
103    pub fn plan_snapshot(&self) -> PathBuf {
104        self.root.join(PLAN_DIR).join(PLAN_SNAPSHOT_FILE)
105    }
106
107    /// `$ISSUE_ROOT/plan/plan-branch.ref`.
108    pub fn plan_branch_ref(&self) -> PathBuf {
109        self.root.join(PLAN_DIR).join(PLAN_BRANCH_REF_FILE)
110    }
111
112    /// Plan-scope task-spec TSV at `$ISSUE_ROOT/plan/tasks.tsv`.
113    pub fn plan_task_spec(&self) -> PathBuf {
114        self.root.join(PLAN_DIR).join("tasks.tsv")
115    }
116
117    /// Plan-scope rendered issue body at `$ISSUE_ROOT/plan/issue-body.md`.
118    pub fn plan_issue_body(&self) -> PathBuf {
119        self.root.join(PLAN_DIR).join("issue-body.md")
120    }
121
122    /// `$ISSUE_ROOT/worktrees`.
123    pub fn worktree_root(&self) -> PathBuf {
124        self.root.join(WORKTREES_DIR)
125    }
126
127    /// Canonical assigned-worktree path for one task.
128    ///
129    /// Per `RUNTIME_LAYOUT.md` "Worktree Layout (Assigned Paths)":
130    ///
131    /// - `pr-isolated` → `$WORKTREE_ROOT/pr-isolated/<TASK_ID>`
132    /// - `pr-shared`   → `$WORKTREE_ROOT/pr-shared/<PR_GROUP>`
133    /// - `per-sprint`  → `$WORKTREE_ROOT/per-sprint/sprint-<N>`
134    ///
135    /// Unknown `execution_mode` falls back to the `pr-isolated` shape so
136    /// the dispatch record always names an absolute path under
137    /// `WORKTREE_ROOT`.
138    pub fn assigned_worktree(
139        &self,
140        execution_mode: &str,
141        task_id: &str,
142        pr_group: &str,
143        sprint: i32,
144    ) -> Result<PathBuf, RuntimeLayoutError> {
145        let trim_segment = |seg: &str| -> Result<String, RuntimeLayoutError> {
146            let t = seg.trim();
147            if t.is_empty() || t.contains('/') || t.contains('\\') || t.contains('\0') {
148                return Err(RuntimeLayoutError::InvalidTaskId {
149                    task_id: seg.to_string(),
150                });
151            }
152            Ok(t.to_string())
153        };
154        let root = self.worktree_root();
155        match execution_mode {
156            "pr-shared" => Ok(root.join("pr-shared").join(trim_segment(pr_group)?)),
157            "per-sprint" => Ok(root.join("per-sprint").join(format!("sprint-{sprint}"))),
158            _ => Ok(root.join("pr-isolated").join(trim_segment(task_id)?)),
159        }
160    }
161}
162
163/// Sprint-scoped runtime root.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct SprintRoot {
166    root: PathBuf,
167}
168
169impl SprintRoot {
170    /// Compute `$ISSUE_ROOT/sprint-<n>`.
171    pub fn new(issue: &IssueRoot, sprint: i32) -> Self {
172        let root = issue.root().join(format!("{SPRINT_PREFIX}{sprint}"));
173        Self { root }
174    }
175
176    /// `$SPRINT_ROOT`.
177    pub fn root(&self) -> &Path {
178        &self.root
179    }
180
181    /// `$SPRINT_ROOT/prompts`.
182    pub fn prompts_dir(&self) -> PathBuf {
183        self.root.join(PROMPTS_DIR)
184    }
185
186    /// `$SPRINT_ROOT/manifests`.
187    pub fn manifests_dir(&self) -> PathBuf {
188        self.root.join(MANIFESTS_DIR)
189    }
190
191    /// `$SPRINT_ROOT/specs`.
192    pub fn specs_dir(&self) -> PathBuf {
193        self.root.join(SPECS_DIR)
194    }
195
196    /// `$SPRINT_ROOT/prompts/<TASK_ID>.md`.
197    pub fn task_prompt(&self, task_id: &str) -> Result<PathBuf, RuntimeLayoutError> {
198        let trimmed = task_id.trim();
199        if trimmed.is_empty()
200            || trimmed.contains('/')
201            || trimmed.contains('\\')
202            || trimmed.contains('\0')
203        {
204            return Err(RuntimeLayoutError::InvalidTaskId {
205                task_id: task_id.to_string(),
206            });
207        }
208        Ok(self.prompts_dir().join(format!("{trimmed}.md")))
209    }
210
211    /// `$SPRINT_ROOT/manifests/prompt-manifest.tsv`.
212    pub fn prompt_manifest(&self) -> PathBuf {
213        self.manifests_dir().join(PROMPT_MANIFEST_FILE)
214    }
215
216    /// `$SPRINT_ROOT/specs/sprint-task-spec.tsv`.
217    pub fn task_spec(&self) -> PathBuf {
218        self.specs_dir().join(SPRINT_TASK_SPEC_FILE)
219    }
220
221    /// `$SPRINT_ROOT/manifests/dispatch-<TASK_ID>.json`.
222    pub fn dispatch_record(&self, task_id: &str) -> Result<PathBuf, RuntimeLayoutError> {
223        let trimmed = task_id.trim();
224        if trimmed.is_empty()
225            || trimmed.contains('/')
226            || trimmed.contains('\\')
227            || trimmed.contains('\0')
228        {
229            return Err(RuntimeLayoutError::InvalidTaskId {
230                task_id: task_id.to_string(),
231            });
232        }
233        Ok(self
234            .manifests_dir()
235            .join(format!("dispatch-{trimmed}.json")))
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use nils_test_support::{EnvGuard, GlobalStateLock};
243
244    fn issue_root_for(repo: &str, issue: u64) -> IssueRoot {
245        IssueRoot::new(repo, issue).expect("issue root")
246    }
247
248    /// Reset the global `--state-dir` override and pin the env path to a
249    /// known value. Used by tests that exercise canonical layout math.
250    fn pin_state_dir(lock: &GlobalStateLock, value: &str) -> EnvGuard {
251        crate::state::set_state_dir_override(None);
252        EnvGuard::set(lock, "PLAN_ISSUE_HOME", value)
253    }
254
255    #[test]
256    fn test_runtime_root_uses_state_dir_value() {
257        let lock = GlobalStateLock::new();
258        let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
259
260        let root = runtime_root();
261        assert_eq!(
262            root,
263            PathBuf::from("/tmp/plan-issue-fixture/out/plan-issue-delivery")
264        );
265    }
266
267    #[test]
268    fn test_runtime_root_falls_back_to_xdg_default_when_env_unset() {
269        let lock = GlobalStateLock::new();
270        crate::state::set_state_dir_override(None);
271        let _empty = EnvGuard::remove(&lock, "PLAN_ISSUE_HOME");
272        let _xdg = EnvGuard::set(&lock, "XDG_STATE_HOME", "/tmp/xdg-state");
273
274        let root = runtime_root();
275        assert_eq!(
276            root,
277            PathBuf::from("/tmp/xdg-state/plan-issue/out/plan-issue-delivery")
278        );
279    }
280
281    #[test]
282    fn test_repo_slug_uses_double_underscore() {
283        assert_eq!(
284            repo_slug("graysurf/plan-issue-smoke"),
285            "graysurf__plan-issue-smoke"
286        );
287        assert_eq!(repo_slug("  graysurf/repo  "), "graysurf__repo");
288        assert_eq!(repo_slug("plain-no-slash"), "plain-no-slash");
289    }
290
291    #[test]
292    fn test_issue_root_path_layout() {
293        let lock = GlobalStateLock::new();
294        let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
295
296        let issue = issue_root_for("graysurf__plan-issue-smoke", 17);
297        assert_eq!(
298            issue.root(),
299            Path::new(
300                "/tmp/plan-issue-fixture/out/plan-issue-delivery/graysurf__plan-issue-smoke/issue-17"
301            )
302        );
303        assert_eq!(
304            issue.plan_snapshot(),
305            issue.root().join("plan/plan.snapshot.md")
306        );
307        assert_eq!(
308            issue.plan_branch_ref(),
309            issue.root().join("plan/plan-branch.ref")
310        );
311        assert_eq!(issue.plan_task_spec(), issue.root().join("plan/tasks.tsv"));
312        assert_eq!(
313            issue.plan_issue_body(),
314            issue.root().join("plan/issue-body.md")
315        );
316        assert_eq!(issue.worktree_root(), issue.root().join("worktrees"));
317    }
318
319    #[test]
320    fn test_assigned_worktree_canonical_paths() {
321        let lock = GlobalStateLock::new();
322        let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
323
324        let issue = issue_root_for("graysurf__plan-issue-smoke", 17);
325
326        // pr-isolated: pinned by TASK_ID
327        assert_eq!(
328            issue
329                .assigned_worktree("pr-isolated", "S1T1", "s1-auto-g1", 1)
330                .expect("pr-isolated"),
331            issue.worktree_root().join("pr-isolated").join("S1T1")
332        );
333
334        // pr-shared: pinned by PR_GROUP
335        assert_eq!(
336            issue
337                .assigned_worktree("pr-shared", "S1T1", "s1-auto-g1", 1)
338                .expect("pr-shared"),
339            issue.worktree_root().join("pr-shared").join("s1-auto-g1")
340        );
341
342        // per-sprint: pinned by sprint number
343        assert_eq!(
344            issue
345                .assigned_worktree("per-sprint", "S1T1", "s1", 1)
346                .expect("per-sprint"),
347            issue.worktree_root().join("per-sprint").join("sprint-1")
348        );
349        assert_eq!(
350            issue
351                .assigned_worktree("per-sprint", "S2T1", "s2", 2)
352                .expect("per-sprint sprint-2"),
353            issue.worktree_root().join("per-sprint").join("sprint-2")
354        );
355
356        // Unknown mode falls back to pr-isolated shape.
357        assert_eq!(
358            issue
359                .assigned_worktree("unknown-mode", "S1T1", "s1", 1)
360                .expect("fallback"),
361            issue.worktree_root().join("pr-isolated").join("S1T1")
362        );
363
364        // Empty task id rejected for pr-isolated.
365        assert!(issue.assigned_worktree("pr-isolated", "", "g1", 1).is_err());
366        // Empty pr_group rejected for pr-shared.
367        assert!(issue.assigned_worktree("pr-shared", "S1T1", "", 1).is_err());
368    }
369
370    #[test]
371    fn test_issue_root_rejects_invalid_repo_slug() {
372        let lock = GlobalStateLock::new();
373        let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
374
375        let err = IssueRoot::new("", 1).expect_err("empty slug must reject");
376        assert!(matches!(err, RuntimeLayoutError::InvalidRepoSlug { .. }));
377
378        let err = IssueRoot::new("owner/repo", 1).expect_err("unconverted slash must reject");
379        assert!(matches!(err, RuntimeLayoutError::InvalidRepoSlug { .. }));
380    }
381
382    #[test]
383    fn test_sprint_root_path_layout() {
384        let lock = GlobalStateLock::new();
385        let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
386
387        let issue = issue_root_for("graysurf__plan-issue-smoke", 17);
388        let sprint = SprintRoot::new(&issue, 1);
389        assert_eq!(sprint.root(), issue.root().join("sprint-1"));
390        assert_eq!(sprint.prompts_dir(), sprint.root().join("prompts"));
391        assert_eq!(sprint.manifests_dir(), sprint.root().join("manifests"));
392        assert_eq!(sprint.specs_dir(), sprint.root().join("specs"));
393        assert_eq!(
394            sprint.task_prompt("S1T1").expect("task prompt"),
395            sprint.root().join("prompts/S1T1.md")
396        );
397        assert_eq!(
398            sprint.prompt_manifest(),
399            sprint.root().join("manifests/prompt-manifest.tsv")
400        );
401        assert_eq!(
402            sprint.task_spec(),
403            sprint.root().join("specs/sprint-task-spec.tsv")
404        );
405        assert_eq!(
406            sprint.dispatch_record("S1T1").expect("dispatch record"),
407            sprint.root().join("manifests/dispatch-S1T1.json")
408        );
409    }
410
411    #[test]
412    fn test_sprint_root_rejects_invalid_task_id() {
413        let lock = GlobalStateLock::new();
414        let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
415
416        let issue = issue_root_for("graysurf__plan-issue-smoke", 17);
417        let sprint = SprintRoot::new(&issue, 1);
418
419        let err = sprint.task_prompt("").expect_err("empty id rejected");
420        assert!(matches!(err, RuntimeLayoutError::InvalidTaskId { .. }));
421
422        let err = sprint
423            .dispatch_record("S1/T1")
424            .expect_err("slash in id rejected");
425        assert!(matches!(err, RuntimeLayoutError::InvalidTaskId { .. }));
426    }
427
428    #[test]
429    fn test_ensure_dir_is_idempotent() {
430        let tmp = tempfile::TempDir::new().expect("tempdir");
431        let target = tmp.path().join("a").join("b").join("c");
432
433        ensure_dir(&target).expect("first ensure_dir");
434        ensure_dir(&target).expect("second ensure_dir");
435        assert!(target.is_dir());
436    }
437}