1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4use std::process::Command;
5
6const TOPOLOGY_BRANCH: &str = "cognitive-clusters/v1";
7const CLUSTERS_FILE: &str = ".clusters.json";
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Cluster {
11 pub id: String,
12 pub name: String,
13 pub description: String,
14 pub files: Vec<String>,
15}
16
17impl Cluster {
18 pub fn contains_file(&self, file: &str) -> bool {
19 self.files.iter().any(|f| f == file)
20 }
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ClusterMap {
25 pub version: u8,
26 pub clusters: Vec<Cluster>,
27}
28
29impl ClusterMap {
30 pub fn empty() -> Self {
31 Self {
32 version: 1,
33 clusters: vec![],
34 }
35 }
36
37 pub fn clusters_for_files(&self, files: &[String]) -> Vec<&Cluster> {
38 self.clusters
39 .iter()
40 .filter(|c| files.iter().any(|f| c.contains_file(f)))
41 .collect()
42 }
43}
44
45pub fn branch_exists(repo_path: &Path) -> bool {
46 Command::new("git")
47 .current_dir(repo_path)
48 .args(["rev-parse", "--verify", TOPOLOGY_BRANCH])
49 .output()
50 .map(|o| o.status.success())
51 .unwrap_or(false)
52}
53
54pub fn read_cluster_map(repo_path: &Path) -> Result<Option<ClusterMap>> {
55 if !branch_exists(repo_path) {
56 return Ok(None);
57 }
58
59 let out = Command::new("git")
60 .current_dir(repo_path)
61 .args(["show", &format!("{}:{}", TOPOLOGY_BRANCH, CLUSTERS_FILE)])
62 .output()
63 .context("Failed to run git show for cluster map")?;
64
65 if !out.status.success() {
66 return Ok(None);
67 }
68
69 let map = serde_json::from_slice(&out.stdout).context("Failed to parse cluster map")?;
70 Ok(Some(map))
71}
72
73pub fn write_cluster_map(repo_path: &Path, map: &ClusterMap) -> Result<()> {
74 ensure_topology_branch(repo_path)?;
75
76 let worktree_path = repo_path.join(".git").join("topology-worktree");
77 setup_worktree(repo_path, &worktree_path, TOPOLOGY_BRANCH)?;
78
79 let json = serde_json::to_string_pretty(map).context("Failed to serialize cluster map")?;
80 std::fs::write(worktree_path.join(CLUSTERS_FILE), json)
81 .context("Failed to write cluster map")?;
82
83 commit_worktree(repo_path, &worktree_path, "topology: update cluster map")?;
84 remove_worktree(repo_path, &worktree_path)?;
85
86 Ok(())
87}
88
89fn ensure_topology_branch(repo_path: &Path) -> Result<()> {
90 if branch_exists(repo_path) {
91 return Ok(());
92 }
93
94 let empty_tree = Command::new("git")
95 .current_dir(repo_path)
96 .args(["hash-object", "-t", "tree", "--stdin"])
97 .stdin(std::process::Stdio::null())
98 .output()
99 .context("Failed to create empty tree")?;
100
101 if !empty_tree.status.success() {
102 anyhow::bail!(
103 "Failed to create empty tree: {}",
104 String::from_utf8_lossy(&empty_tree.stderr)
105 );
106 }
107
108 let tree_sha = String::from_utf8_lossy(&empty_tree.stdout)
109 .trim()
110 .to_string();
111
112 let commit = Command::new("git")
113 .current_dir(repo_path)
114 .args([
115 "commit-tree",
116 &tree_sha,
117 "-m",
118 "init: create cognitive-clusters branch",
119 ])
120 .output()
121 .context("Failed to create initial commit")?;
122
123 if !commit.status.success() {
124 anyhow::bail!(
125 "Failed to create initial commit: {}",
126 String::from_utf8_lossy(&commit.stderr)
127 );
128 }
129
130 let commit_sha = String::from_utf8_lossy(&commit.stdout).trim().to_string();
131
132 let out = Command::new("git")
133 .current_dir(repo_path)
134 .args(["branch", TOPOLOGY_BRANCH, &commit_sha])
135 .output()
136 .context("Failed to create cognitive-clusters branch")?;
137
138 if !out.status.success() {
139 anyhow::bail!(
140 "Failed to create branch: {}",
141 String::from_utf8_lossy(&out.stderr)
142 );
143 }
144
145 Ok(())
146}
147
148fn setup_worktree(repo_path: &Path, worktree_path: &Path, branch: &str) -> Result<()> {
149 if worktree_path.exists() {
150 Command::new("git")
151 .current_dir(repo_path)
152 .args([
153 "worktree",
154 "remove",
155 "--force",
156 worktree_path.to_str().unwrap(),
157 ])
158 .output()
159 .ok();
160 std::fs::remove_dir_all(worktree_path).ok();
161 Command::new("git")
162 .current_dir(repo_path)
163 .args(["worktree", "prune"])
164 .output()
165 .ok();
166 }
167
168 let out = Command::new("git")
169 .current_dir(repo_path)
170 .args([
171 "worktree",
172 "add",
173 "--no-checkout",
174 worktree_path.to_str().unwrap(),
175 branch,
176 ])
177 .output()
178 .context("Failed to add topology worktree")?;
179
180 if !out.status.success() {
181 anyhow::bail!(
182 "Failed to set up topology worktree: {}",
183 String::from_utf8_lossy(&out.stderr)
184 );
185 }
186
187 Command::new("git")
188 .current_dir(worktree_path)
189 .args(["checkout", branch, "--", "."])
190 .output()
191 .ok();
192
193 Ok(())
194}
195
196fn commit_worktree(repo_path: &Path, worktree_path: &Path, message: &str) -> Result<()> {
197 Command::new("git")
198 .current_dir(worktree_path)
199 .args(["add", "-A"])
200 .output()
201 .context("Failed to stage topology files")?;
202
203 let status = Command::new("git")
204 .current_dir(worktree_path)
205 .args(["diff", "--cached", "--quiet"])
206 .status()
207 .context("Failed to check worktree status")?;
208
209 if status.success() {
210 return Ok(());
211 }
212
213 let head_sha = Command::new("git")
214 .current_dir(repo_path)
215 .args(["rev-parse", "--short", "HEAD"])
216 .output()
217 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
218 .unwrap_or_else(|_| "unknown".to_string());
219
220 let full_message = format!("{} ({})", message, head_sha);
221
222 let out = Command::new("git")
223 .current_dir(worktree_path)
224 .args(["commit", "-m", &full_message])
225 .output()
226 .context("Failed to commit topology branch")?;
227
228 if !out.status.success() {
229 anyhow::bail!(
230 "Failed to commit topology branch: {}",
231 String::from_utf8_lossy(&out.stderr)
232 );
233 }
234
235 Ok(())
236}
237
238fn remove_worktree(repo_path: &Path, worktree_path: &Path) -> Result<()> {
239 Command::new("git")
240 .current_dir(repo_path)
241 .args([
242 "worktree",
243 "remove",
244 "--force",
245 worktree_path.to_str().unwrap(),
246 ])
247 .output()
248 .context("Failed to remove topology worktree")?;
249
250 Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 fn make_cluster(id: &str, name: &str, files: &[&str]) -> Cluster {
258 Cluster {
259 id: id.to_string(),
260 name: name.to_string(),
261 description: String::new(),
262 files: files.iter().map(|f| f.to_string()).collect(),
263 }
264 }
265
266 #[test]
267 fn cluster_contains_file() {
268 let c = make_cluster("abc", "auth", &["src/auth/mod.rs", "src/auth/jwt.rs"]);
269 assert!(c.contains_file("src/auth/mod.rs"));
270 assert!(!c.contains_file("src/main.rs"));
271 }
272
273 #[test]
274 fn cluster_map_empty() {
275 let map = ClusterMap::empty();
276 assert_eq!(map.version, 1);
277 assert!(map.clusters.is_empty());
278 }
279
280 #[test]
281 fn clusters_for_files_matches() {
282 let map = ClusterMap {
283 version: 1,
284 clusters: vec![
285 make_cluster("a1", "auth", &["src/auth/mod.rs", "src/auth/jwt.rs"]),
286 make_cluster("b2", "db", &["src/db.rs", "src/models.rs"]),
287 ],
288 };
289
290 let files = vec!["src/auth/jwt.rs".to_string()];
291 let matched = map.clusters_for_files(&files);
292 assert_eq!(matched.len(), 1);
293 assert_eq!(matched[0].name, "auth");
294 }
295
296 #[test]
297 fn clusters_for_files_no_match() {
298 let map = ClusterMap {
299 version: 1,
300 clusters: vec![make_cluster("a1", "auth", &["src/auth/mod.rs"])],
301 };
302
303 let files = vec!["src/main.rs".to_string()];
304 let matched = map.clusters_for_files(&files);
305 assert!(matched.is_empty());
306 }
307
308 #[test]
309 fn clusters_for_files_multi_cluster_match() {
310 let map = ClusterMap {
311 version: 1,
312 clusters: vec![
313 make_cluster("a1", "auth", &["src/auth/mod.rs"]),
314 make_cluster("b2", "db", &["src/db.rs"]),
315 ],
316 };
317
318 let files = vec!["src/auth/mod.rs".to_string(), "src/db.rs".to_string()];
319 let matched = map.clusters_for_files(&files);
320 assert_eq!(matched.len(), 2);
321 }
322
323 #[test]
324 fn cluster_map_serialization_roundtrip() {
325 let map = ClusterMap {
326 version: 1,
327 clusters: vec![make_cluster(
328 "abc123",
329 "auth/middleware",
330 &["src/auth/mod.rs"],
331 )],
332 };
333
334 let json = serde_json::to_string(&map).unwrap();
335 let parsed: ClusterMap = serde_json::from_str(&json).unwrap();
336 assert_eq!(parsed.clusters.len(), 1);
337 assert_eq!(parsed.clusters[0].id, "abc123");
338 assert_eq!(parsed.clusters[0].name, "auth/middleware");
339 }
340}