1use 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#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum RuntimeLayoutError {
31 InvalidRepoSlug { slug: String },
33 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
52pub fn runtime_root() -> PathBuf {
56 state::state_dir()
57 .join(RUNTIME_DIR)
58 .join(PLAN_ISSUE_DELIVERY_DIR)
59}
60
61pub fn repo_slug(owner_repo: &str) -> String {
63 owner_repo.trim().replace('/', "__")
64}
65
66pub fn ensure_dir(path: &Path) -> io::Result<()> {
68 fs::create_dir_all(path)
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct IssueRoot {
74 root: PathBuf,
75}
76
77impl IssueRoot {
78 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 pub fn root(&self) -> &Path {
99 &self.root
100 }
101
102 pub fn plan_snapshot(&self) -> PathBuf {
104 self.root.join(PLAN_DIR).join(PLAN_SNAPSHOT_FILE)
105 }
106
107 pub fn plan_branch_ref(&self) -> PathBuf {
109 self.root.join(PLAN_DIR).join(PLAN_BRANCH_REF_FILE)
110 }
111
112 pub fn plan_task_spec(&self) -> PathBuf {
114 self.root.join(PLAN_DIR).join("tasks.tsv")
115 }
116
117 pub fn plan_issue_body(&self) -> PathBuf {
119 self.root.join(PLAN_DIR).join("issue-body.md")
120 }
121
122 pub fn worktree_root(&self) -> PathBuf {
124 self.root.join(WORKTREES_DIR)
125 }
126
127 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#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct SprintRoot {
166 root: PathBuf,
167}
168
169impl SprintRoot {
170 pub fn new(issue: &IssueRoot, sprint: i32) -> Self {
172 let root = issue.root().join(format!("{SPRINT_PREFIX}{sprint}"));
173 Self { root }
174 }
175
176 pub fn root(&self) -> &Path {
178 &self.root
179 }
180
181 pub fn prompts_dir(&self) -> PathBuf {
183 self.root.join(PROMPTS_DIR)
184 }
185
186 pub fn manifests_dir(&self) -> PathBuf {
188 self.root.join(MANIFESTS_DIR)
189 }
190
191 pub fn specs_dir(&self) -> PathBuf {
193 self.root.join(SPECS_DIR)
194 }
195
196 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 pub fn prompt_manifest(&self) -> PathBuf {
213 self.manifests_dir().join(PROMPT_MANIFEST_FILE)
214 }
215
216 pub fn task_spec(&self) -> PathBuf {
218 self.specs_dir().join(SPRINT_TASK_SPEC_FILE)
219 }
220
221 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 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 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 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 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 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 assert!(issue.assigned_worktree("pr-isolated", "", "g1", 1).is_err());
366 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}