use std::error::Error;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::state;
const RUNTIME_DIR: &str = "out";
const PLAN_ISSUE_DELIVERY_DIR: &str = "plan-issue-delivery";
const ISSUE_PREFIX: &str = "issue-";
const SPRINT_PREFIX: &str = "sprint-";
const PROMPTS_DIR: &str = "prompts";
const PLAN_DIR: &str = "plan";
const SPECS_DIR: &str = "specs";
const MANIFESTS_DIR: &str = "manifests";
const WORKTREES_DIR: &str = "worktrees";
const PLAN_SNAPSHOT_FILE: &str = "plan.snapshot.md";
const PLAN_BRANCH_REF_FILE: &str = "plan-branch.ref";
const PROMPT_MANIFEST_FILE: &str = "prompt-manifest.tsv";
const SPRINT_TASK_SPEC_FILE: &str = "sprint-task-spec.tsv";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuntimeLayoutError {
InvalidRepoSlug { slug: String },
InvalidTaskId { task_id: String },
}
impl fmt::Display for RuntimeLayoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidRepoSlug { slug } => {
write!(f, "invalid repo slug `{slug}` for runtime layout")
}
Self::InvalidTaskId { task_id } => {
write!(f, "invalid task id `{task_id}` for runtime layout")
}
}
}
}
impl Error for RuntimeLayoutError {}
pub fn runtime_root() -> PathBuf {
state::state_dir()
.join(RUNTIME_DIR)
.join(PLAN_ISSUE_DELIVERY_DIR)
}
pub fn repo_slug(owner_repo: &str) -> String {
owner_repo.trim().replace('/', "__")
}
pub fn ensure_dir(path: &Path) -> io::Result<()> {
fs::create_dir_all(path)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueRoot {
root: PathBuf,
}
impl IssueRoot {
pub fn new(repo_slug: &str, issue_number: u64) -> Result<Self, RuntimeLayoutError> {
let trimmed = repo_slug.trim();
if trimmed.is_empty()
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.contains('\0')
{
return Err(RuntimeLayoutError::InvalidRepoSlug {
slug: repo_slug.to_string(),
});
}
let runtime = runtime_root();
let root = runtime
.join(trimmed)
.join(format!("{ISSUE_PREFIX}{issue_number}"));
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn plan_snapshot(&self) -> PathBuf {
self.root.join(PLAN_DIR).join(PLAN_SNAPSHOT_FILE)
}
pub fn plan_branch_ref(&self) -> PathBuf {
self.root.join(PLAN_DIR).join(PLAN_BRANCH_REF_FILE)
}
pub fn plan_task_spec(&self) -> PathBuf {
self.root.join(PLAN_DIR).join("tasks.tsv")
}
pub fn plan_issue_body(&self) -> PathBuf {
self.root.join(PLAN_DIR).join("issue-body.md")
}
pub fn worktree_root(&self) -> PathBuf {
self.root.join(WORKTREES_DIR)
}
pub fn assigned_worktree(
&self,
execution_mode: &str,
task_id: &str,
pr_group: &str,
sprint: i32,
) -> Result<PathBuf, RuntimeLayoutError> {
let trim_segment = |seg: &str| -> Result<String, RuntimeLayoutError> {
let t = seg.trim();
if t.is_empty() || t.contains('/') || t.contains('\\') || t.contains('\0') {
return Err(RuntimeLayoutError::InvalidTaskId {
task_id: seg.to_string(),
});
}
Ok(t.to_string())
};
let root = self.worktree_root();
match execution_mode {
"pr-shared" => Ok(root.join("pr-shared").join(trim_segment(pr_group)?)),
"per-sprint" => Ok(root.join("per-sprint").join(format!("sprint-{sprint}"))),
_ => Ok(root.join("pr-isolated").join(trim_segment(task_id)?)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SprintRoot {
root: PathBuf,
}
impl SprintRoot {
pub fn new(issue: &IssueRoot, sprint: i32) -> Self {
let root = issue.root().join(format!("{SPRINT_PREFIX}{sprint}"));
Self { root }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn prompts_dir(&self) -> PathBuf {
self.root.join(PROMPTS_DIR)
}
pub fn manifests_dir(&self) -> PathBuf {
self.root.join(MANIFESTS_DIR)
}
pub fn specs_dir(&self) -> PathBuf {
self.root.join(SPECS_DIR)
}
pub fn task_prompt(&self, task_id: &str) -> Result<PathBuf, RuntimeLayoutError> {
let trimmed = task_id.trim();
if trimmed.is_empty()
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.contains('\0')
{
return Err(RuntimeLayoutError::InvalidTaskId {
task_id: task_id.to_string(),
});
}
Ok(self.prompts_dir().join(format!("{trimmed}.md")))
}
pub fn prompt_manifest(&self) -> PathBuf {
self.manifests_dir().join(PROMPT_MANIFEST_FILE)
}
pub fn task_spec(&self) -> PathBuf {
self.specs_dir().join(SPRINT_TASK_SPEC_FILE)
}
pub fn dispatch_record(&self, task_id: &str) -> Result<PathBuf, RuntimeLayoutError> {
let trimmed = task_id.trim();
if trimmed.is_empty()
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.contains('\0')
{
return Err(RuntimeLayoutError::InvalidTaskId {
task_id: task_id.to_string(),
});
}
Ok(self
.manifests_dir()
.join(format!("dispatch-{trimmed}.json")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use nils_test_support::{EnvGuard, GlobalStateLock};
fn issue_root_for(repo: &str, issue: u64) -> IssueRoot {
IssueRoot::new(repo, issue).expect("issue root")
}
fn pin_state_dir(lock: &GlobalStateLock, value: &str) -> EnvGuard {
crate::state::set_state_dir_override(None);
EnvGuard::set(lock, "PLAN_ISSUE_HOME", value)
}
#[test]
fn test_runtime_root_uses_state_dir_value() {
let lock = GlobalStateLock::new();
let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
let root = runtime_root();
assert_eq!(
root,
PathBuf::from("/tmp/plan-issue-fixture/out/plan-issue-delivery")
);
}
#[test]
fn test_runtime_root_falls_back_to_xdg_default_when_env_unset() {
let lock = GlobalStateLock::new();
crate::state::set_state_dir_override(None);
let _empty = EnvGuard::remove(&lock, "PLAN_ISSUE_HOME");
let _xdg = EnvGuard::set(&lock, "XDG_STATE_HOME", "/tmp/xdg-state");
let root = runtime_root();
assert_eq!(
root,
PathBuf::from("/tmp/xdg-state/plan-issue/out/plan-issue-delivery")
);
}
#[test]
fn test_repo_slug_uses_double_underscore() {
assert_eq!(
repo_slug("graysurf/plan-issue-smoke"),
"graysurf__plan-issue-smoke"
);
assert_eq!(repo_slug(" graysurf/repo "), "graysurf__repo");
assert_eq!(repo_slug("plain-no-slash"), "plain-no-slash");
}
#[test]
fn test_issue_root_path_layout() {
let lock = GlobalStateLock::new();
let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
let issue = issue_root_for("graysurf__plan-issue-smoke", 17);
assert_eq!(
issue.root(),
Path::new(
"/tmp/plan-issue-fixture/out/plan-issue-delivery/graysurf__plan-issue-smoke/issue-17"
)
);
assert_eq!(
issue.plan_snapshot(),
issue.root().join("plan/plan.snapshot.md")
);
assert_eq!(
issue.plan_branch_ref(),
issue.root().join("plan/plan-branch.ref")
);
assert_eq!(issue.plan_task_spec(), issue.root().join("plan/tasks.tsv"));
assert_eq!(
issue.plan_issue_body(),
issue.root().join("plan/issue-body.md")
);
assert_eq!(issue.worktree_root(), issue.root().join("worktrees"));
}
#[test]
fn test_assigned_worktree_canonical_paths() {
let lock = GlobalStateLock::new();
let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
let issue = issue_root_for("graysurf__plan-issue-smoke", 17);
assert_eq!(
issue
.assigned_worktree("pr-isolated", "S1T1", "s1-auto-g1", 1)
.expect("pr-isolated"),
issue.worktree_root().join("pr-isolated").join("S1T1")
);
assert_eq!(
issue
.assigned_worktree("pr-shared", "S1T1", "s1-auto-g1", 1)
.expect("pr-shared"),
issue.worktree_root().join("pr-shared").join("s1-auto-g1")
);
assert_eq!(
issue
.assigned_worktree("per-sprint", "S1T1", "s1", 1)
.expect("per-sprint"),
issue.worktree_root().join("per-sprint").join("sprint-1")
);
assert_eq!(
issue
.assigned_worktree("per-sprint", "S2T1", "s2", 2)
.expect("per-sprint sprint-2"),
issue.worktree_root().join("per-sprint").join("sprint-2")
);
assert_eq!(
issue
.assigned_worktree("unknown-mode", "S1T1", "s1", 1)
.expect("fallback"),
issue.worktree_root().join("pr-isolated").join("S1T1")
);
assert!(issue.assigned_worktree("pr-isolated", "", "g1", 1).is_err());
assert!(issue.assigned_worktree("pr-shared", "S1T1", "", 1).is_err());
}
#[test]
fn test_issue_root_rejects_invalid_repo_slug() {
let lock = GlobalStateLock::new();
let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
let err = IssueRoot::new("", 1).expect_err("empty slug must reject");
assert!(matches!(err, RuntimeLayoutError::InvalidRepoSlug { .. }));
let err = IssueRoot::new("owner/repo", 1).expect_err("unconverted slash must reject");
assert!(matches!(err, RuntimeLayoutError::InvalidRepoSlug { .. }));
}
#[test]
fn test_sprint_root_path_layout() {
let lock = GlobalStateLock::new();
let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
let issue = issue_root_for("graysurf__plan-issue-smoke", 17);
let sprint = SprintRoot::new(&issue, 1);
assert_eq!(sprint.root(), issue.root().join("sprint-1"));
assert_eq!(sprint.prompts_dir(), sprint.root().join("prompts"));
assert_eq!(sprint.manifests_dir(), sprint.root().join("manifests"));
assert_eq!(sprint.specs_dir(), sprint.root().join("specs"));
assert_eq!(
sprint.task_prompt("S1T1").expect("task prompt"),
sprint.root().join("prompts/S1T1.md")
);
assert_eq!(
sprint.prompt_manifest(),
sprint.root().join("manifests/prompt-manifest.tsv")
);
assert_eq!(
sprint.task_spec(),
sprint.root().join("specs/sprint-task-spec.tsv")
);
assert_eq!(
sprint.dispatch_record("S1T1").expect("dispatch record"),
sprint.root().join("manifests/dispatch-S1T1.json")
);
}
#[test]
fn test_sprint_root_rejects_invalid_task_id() {
let lock = GlobalStateLock::new();
let _guard = pin_state_dir(&lock, "/tmp/plan-issue-fixture");
let issue = issue_root_for("graysurf__plan-issue-smoke", 17);
let sprint = SprintRoot::new(&issue, 1);
let err = sprint.task_prompt("").expect_err("empty id rejected");
assert!(matches!(err, RuntimeLayoutError::InvalidTaskId { .. }));
let err = sprint
.dispatch_record("S1/T1")
.expect_err("slash in id rejected");
assert!(matches!(err, RuntimeLayoutError::InvalidTaskId { .. }));
}
#[test]
fn test_ensure_dir_is_idempotent() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let target = tmp.path().join("a").join("b").join("c");
ensure_dir(&target).expect("first ensure_dir");
ensure_dir(&target).expect("second ensure_dir");
assert!(target.is_dir());
}
}