ai-dispatch 8.98.0

Multi-AI CLI team orchestrator
// Filesystem paths for aid: ~/.aid/ directory, logs, database.
// Centralizes all path logic so nothing hardcodes paths.

use anyhow::Result;
use std::path::PathBuf;
#[cfg(test)]
use std::cell::RefCell;

use crate::sanitize;

#[cfg(test)]
thread_local! {
    static AID_HOME_OVERRIDE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}

pub fn aid_dir() -> PathBuf {
    #[cfg(test)]
    {
        let maybe = AID_HOME_OVERRIDE.with(|cell| cell.borrow().clone());
        if let Some(p) = maybe {
            return p;
        }
    }
    if let Ok(custom) = std::env::var("AID_HOME") {
        return PathBuf::from(custom);
    }
    dirs_home().join(".aid")
}

pub fn logs_dir() -> PathBuf {
    aid_dir().join("logs")
}

pub fn jobs_dir() -> PathBuf {
    aid_dir().join("jobs")
}

pub fn db_path() -> PathBuf {
    aid_dir().join("aid.db")
}

pub fn config_path() -> PathBuf {
    aid_dir().join("config.toml")
}

pub fn pricing_path() -> PathBuf {
    aid_dir().join("pricing.json")
}

pub fn task_dir(task_id: &str) -> PathBuf {
    aid_dir().join("tasks").join(task_id)
}

pub fn transcript_path(task_id: &str) -> PathBuf {
    task_dir(task_id).join("transcript.md")
}

pub fn log_path(task_id: &str) -> PathBuf {
    // Takes a validated task ID from the input boundary.
    logs_dir().join(format!("{task_id}.jsonl"))
}

pub fn stderr_path(task_id: &str) -> PathBuf {
    // Takes a validated task ID from the input boundary.
    logs_dir().join(format!("{task_id}.stderr"))
}

pub fn job_path(task_id: &str) -> PathBuf {
    // Takes a validated task ID from the input boundary.
    jobs_dir().join(format!("{task_id}.json"))
}

pub fn job_input_path(task_id: &str) -> PathBuf {
    // Takes a validated task ID from the input boundary.
    jobs_dir().join(format!("{task_id}.input"))
}

pub fn steer_signal_path(task_id: &str) -> PathBuf {
    // Takes a validated task ID from the input boundary.
    jobs_dir().join(format!("{task_id}.steer"))
}

/// Returns the workspace directory for a workgroup.
///
/// Defaults to `/tmp/aid-wg-{id}/` in production. Under `#[cfg(test)]`, if
/// `AidHomeGuard::set` has activated an override on the current thread, the
/// workspace is rooted under that override instead — so parallel tests never
/// collide on a shared `/tmp/aid-wg-*` path.
pub fn workspace_dir(workgroup_id: &str) -> Result<PathBuf> {
    sanitize::validate_workgroup_id(workgroup_id)?;
    #[cfg(test)]
    {
        let maybe = AID_HOME_OVERRIDE.with(|cell| cell.borrow().clone());
        if let Some(root) = maybe {
            return Ok(root.join("workgroups").join(workgroup_id));
        }
    }
    Ok(PathBuf::from(format!("/tmp/aid-wg-{workgroup_id}")))
}

pub fn ensure_dirs() -> Result<()> {
    std::fs::create_dir_all(logs_dir())?;
    std::fs::create_dir_all(jobs_dir())?;
    Ok(())
}

fn dirs_home() -> PathBuf {
    std::env::var("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("."))
}

#[cfg(test)]
pub struct AidHomeGuard {
    previous: Option<PathBuf>,
}

#[cfg(test)]
impl AidHomeGuard {
    pub fn set(path: &std::path::Path) -> Self {
        let previous = AID_HOME_OVERRIDE.with(|cell| cell.borrow().clone());
        AID_HOME_OVERRIDE.with(|cell| *cell.borrow_mut() = Some(path.to_path_buf()));
        Self { previous }
    }
}

#[cfg(test)]
impl Drop for AidHomeGuard {
    fn drop(&mut self) {
        AID_HOME_OVERRIDE.with(|cell| *cell.borrow_mut() = self.previous.take());
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn workspace_dir_uses_tmp_without_override() {
        // No AidHomeGuard active: keep the production /tmp/aid-wg-* path.
        let path = workspace_dir("wg-abcd").unwrap();
        assert_eq!(path.to_str().unwrap(), "/tmp/aid-wg-wg-abcd");
    }

    #[test]
    fn workspace_dir_uses_override_in_tests() {
        let temp = tempfile::TempDir::new().unwrap();
        let _guard = AidHomeGuard::set(temp.path());
        let path = workspace_dir("wg-abcd").unwrap();
        assert_eq!(path, temp.path().join("workgroups").join("wg-abcd"));
    }

    #[test]
    fn workspace_dir_rejects_invalid_id() {
        assert!(workspace_dir("wg-../escape").is_err());
        assert!(workspace_dir("not-a-wg").is_err());
    }

    #[test]
    fn paths_are_under_aid_dir() {
        let base = aid_dir();
        assert!(db_path().starts_with(&base));
        assert!(config_path().starts_with(&base));
        assert!(pricing_path().starts_with(&base));
        assert!(jobs_dir().starts_with(&base));
        assert!(logs_dir().starts_with(&base));
        assert!(job_path("t-1234").starts_with(&base));
        assert!(job_input_path("t-1234").starts_with(&base));
        assert!(log_path("t-1234").starts_with(&base));
        assert!(steer_signal_path("t-1234").starts_with(&base));
    }

    #[test]
    fn steer_signal_path_in_jobs() {
        let _guard = AidHomeGuard::set(std::path::Path::new("/tmp/aid-test"));
        let path = steer_signal_path("t-abcd");
        assert!(path.ends_with("jobs/t-abcd.steer"));
    }
}