agentic_planning/
isolation.rs1use blake3::Hasher;
8use std::fmt;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub struct ProjectId([u8; 32]);
14
15impl ProjectId {
16 pub fn as_bytes(&self) -> &[u8; 32] {
18 &self.0
19 }
20
21 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
33pub 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
50pub fn cache_dir(base: &Path, id: &ProjectId) -> PathBuf {
54 base.join(id.short())
55}
56
57pub 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
98mod 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 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}