Skip to main content

plan_issue/
runtime_layout.rs

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