Skip to main content

agentic_planning/
isolation.rs

1//! Per-project identity isolation.
2//!
3//! Each project gets a deterministic `ProjectId` derived from its canonical
4//! filesystem path. Two folders with the same name but different parent paths
5//! always produce different IDs.
6
7use blake3::Hasher;
8use std::fmt;
9use std::path::{Path, PathBuf};
10
11/// A 32-byte project identity derived from the canonical path via blake3.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub struct ProjectId([u8; 32]);
14
15impl ProjectId {
16    /// Raw bytes.
17    pub fn as_bytes(&self) -> &[u8; 32] {
18        &self.0
19    }
20
21    /// First 16 hex characters — useful for short display.
22    pub fn short(&self) -> String {
23        hex::encode(&self.0[..8])
24    }
25}
26
27impl fmt::Display for ProjectId {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        write!(f, "{}", hex::encode(&self.0))
30    }
31}
32
33/// Derive a deterministic project identity from a filesystem path.
34///
35/// The path is canonicalized first so symlinks resolve to the same identity.
36/// If canonicalization fails (path doesn't exist yet), we fall back to the
37/// absolute path.
38pub fn project_identity(project_path: &Path) -> ProjectId {
39    let canonical = project_path.canonicalize().unwrap_or_else(|_| {
40        std::path::absolute(project_path).unwrap_or(project_path.to_path_buf())
41    });
42
43    let mut hasher = Hasher::new();
44    hasher.update(b"agentic-planning-project-v1:");
45    hasher.update(canonical.to_string_lossy().as_bytes());
46    let hash = hasher.finalize();
47    ProjectId(*hash.as_bytes())
48}
49
50/// Return the cache directory for a given project identity.
51///
52/// Layout: `<base_dir>/<short-id>/`
53pub fn cache_dir(base: &Path, id: &ProjectId) -> PathBuf {
54    base.join(id.short())
55}
56
57/// Resolve the graph file for a project, returning an error if not found.
58/// Never falls back to "latest" or any other project.
59pub fn resolve_graph(base: &Path, id: &ProjectId) -> Result<PathBuf, IsolationError> {
60    let dir = cache_dir(base, id);
61    let graph_file = dir.join("planning.aplan");
62    if graph_file.exists() {
63        Ok(graph_file)
64    } else {
65        Err(IsolationError::GraphNotFound {
66            project_id: *id,
67            expected_path: graph_file,
68        })
69    }
70}
71
72#[derive(Debug)]
73pub enum IsolationError {
74    GraphNotFound {
75        project_id: ProjectId,
76        expected_path: PathBuf,
77    },
78}
79
80impl fmt::Display for IsolationError {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            IsolationError::GraphNotFound {
84                project_id,
85                expected_path,
86            } => write!(
87                f,
88                "graph not found for project {}: {}",
89                project_id.short(),
90                expected_path.display()
91            ),
92        }
93    }
94}
95
96impl std::error::Error for IsolationError {}
97
98// --- hex helper (avoids adding a `hex` crate dependency) ---
99
100mod hex {
101    const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
102
103    pub fn encode(bytes: &[u8]) -> String {
104        let mut s = String::with_capacity(bytes.len() * 2);
105        for &b in bytes {
106            s.push(HEX_CHARS[(b >> 4) as usize] as char);
107            s.push(HEX_CHARS[(b & 0x0f) as usize] as char);
108        }
109        s
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::path::PathBuf;
117
118    #[test]
119    fn same_name_different_path() {
120        let a = project_identity(&PathBuf::from("/home/alice/projects/myapp"));
121        let b = project_identity(&PathBuf::from("/home/bob/projects/myapp"));
122        assert_ne!(a, b, "same folder name under different parents must differ");
123    }
124
125    #[test]
126    fn deterministic() {
127        let path = PathBuf::from("/tmp/test-deterministic-planning");
128        let id1 = project_identity(&path);
129        let id2 = project_identity(&path);
130        assert_eq!(id1, id2);
131    }
132
133    #[test]
134    fn short_is_16_chars() {
135        let id = project_identity(&PathBuf::from("/whatever"));
136        assert_eq!(id.short().len(), 16);
137    }
138
139    #[test]
140    fn display_is_64_chars() {
141        let id = project_identity(&PathBuf::from("/whatever"));
142        assert_eq!(id.to_string().len(), 64);
143    }
144
145    #[test]
146    fn graph_not_found_errors() {
147        let base = PathBuf::from("/tmp/nonexistent-aplan-test");
148        let id = project_identity(&PathBuf::from("/some/project"));
149        assert!(resolve_graph(&base, &id).is_err());
150    }
151
152    #[test]
153    fn stress_multi_project_isolation() {
154        let ids: Vec<ProjectId> = (0..10)
155            .map(|i| project_identity(&PathBuf::from(format!("/workspace/team{}/myapp", i))))
156            .collect();
157
158        // All must be unique
159        for i in 0..ids.len() {
160            for j in (i + 1)..ids.len() {
161                assert_ne!(ids[i], ids[j], "project {} and {} collided", i, j);
162            }
163        }
164    }
165}