Skip to main content

git_topology/
cluster.rs

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}