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