1use 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#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum RuntimeLayoutError {
33 InvalidRepoSlug { slug: String },
35 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
54pub fn runtime_root() -> PathBuf {
58 state::state_dir()
59 .join(RUNTIME_DIR)
60 .join(PLAN_ISSUE_DELIVERY_DIR)
61}
62
63pub fn repo_slug(owner_repo: &str) -> String {
65 owner_repo.trim().replace('/', "__")
66}
67
68pub fn ensure_dir(path: &Path) -> io::Result<()> {
70 fs::create_dir_all(path)
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct IssueRoot {
76 root: PathBuf,
77}
78
79impl IssueRoot {
80 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 pub fn root(&self) -> &Path {
101 &self.root
102 }
103
104 pub fn plan_snapshot(&self) -> PathBuf {
106 self.root.join(PLAN_DIR).join(PLAN_SNAPSHOT_FILE)
107 }
108
109 pub fn plan_branch_ref(&self) -> PathBuf {
111 self.root.join(PLAN_DIR).join(PLAN_BRANCH_REF_FILE)
112 }
113
114 pub fn plan_task_spec(&self) -> PathBuf {
116 self.root.join(PLAN_DIR).join("tasks.tsv")
117 }
118
119 pub fn plan_issue_body(&self) -> PathBuf {
121 self.root.join(PLAN_DIR).join("issue-body.md")
122 }
123
124 pub fn worktree_root(&self) -> PathBuf {
126 self.root.join(WORKTREES_DIR)
127 }
128
129 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#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct SprintRoot {
168 root: PathBuf,
169}
170
171impl SprintRoot {
172 pub fn new(issue: &IssueRoot, sprint: i32) -> Self {
174 let root = issue.root().join(format!("{SPRINT_PREFIX}{sprint}"));
175 Self { root }
176 }
177
178 pub fn root(&self) -> &Path {
180 &self.root
181 }
182
183 pub fn prompts_dir(&self) -> PathBuf {
185 self.root.join(PROMPTS_DIR)
186 }
187
188 pub fn manifests_dir(&self) -> PathBuf {
190 self.root.join(MANIFESTS_DIR)
191 }
192
193 pub fn specs_dir(&self) -> PathBuf {
195 self.root.join(SPECS_DIR)
196 }
197
198 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 pub fn prompt_manifest(&self) -> PathBuf {
215 self.manifests_dir().join(PROMPT_MANIFEST_FILE)
216 }
217
218 pub fn task_spec(&self) -> PathBuf {
220 self.specs_dir().join(SPRINT_TASK_SPEC_FILE)
221 }
222
223 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 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 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 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 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 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 assert!(issue.assigned_worktree("pr-isolated", "", "g1", 1).is_err());
368 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}