Skip to main content

aivcs_core/multi_repo/
model.rs

1//! Multi-repo model: repo identity, dependency graph, and constraints.
2//!
3//! EPIC9: Cross-repo dependency-aware execution and change graph.
4
5use serde::{Deserialize, Serialize};
6
7/// Identifies a repository (e.g. org/name or URL).
8#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
9pub struct RepoId {
10    /// Canonical name, e.g. "stevedores-org/aivcs".
11    pub name: String,
12}
13
14impl RepoId {
15    pub fn new(name: impl Into<String>) -> Self {
16        Self { name: name.into() }
17    }
18}
19
20/// Directed dependency: `dependent` depends on `dependency` (dependent is built after dependency).
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct RepoDependency {
23    pub dependent: RepoId,
24    pub dependency: RepoId,
25}
26
27/// Cross-repo change graph: repos and their dependencies.
28/// Supports topological order and cycle detection for dependency-aware execution.
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct CrossRepoGraph {
31    /// All repo nodes.
32    pub repos: Vec<RepoId>,
33    /// Directed edges: from dependency -> dependent (dependent runs after dependency).
34    pub dependencies: Vec<RepoDependency>,
35}
36
37impl CrossRepoGraph {
38    pub fn new(repos: Vec<RepoId>, dependencies: Vec<RepoDependency>) -> Self {
39        Self {
40            repos,
41            dependencies,
42        }
43    }
44
45    /// Returns repos in dependency order: dependencies first, dependents last.
46    /// Fails with an error if the graph contains a cycle.
47    pub fn execution_order(&self) -> Result<Vec<RepoId>, String> {
48        use std::collections::{HashMap, HashSet, VecDeque};
49
50        let names: HashSet<&str> = self.repos.iter().map(|r| r.name.as_str()).collect();
51        let mut in_degree: HashMap<&str, u32> = names.iter().map(|&n| (n, 0)).collect();
52        let mut out_edges: HashMap<&str, Vec<&str>> =
53            names.iter().map(|&n| (n, Vec::new())).collect();
54
55        for d in &self.dependencies {
56            if !names.contains(d.dependency.name.as_str())
57                || !names.contains(d.dependent.name.as_str())
58            {
59                continue;
60            }
61            out_edges
62                .get_mut(d.dependency.name.as_str())
63                .unwrap()
64                .push(d.dependent.name.as_str());
65            *in_degree.get_mut(d.dependent.name.as_str()).unwrap() += 1;
66        }
67
68        let mut queue: VecDeque<&str> = in_degree
69            .iter()
70            .filter(|(_, &d)| d == 0)
71            .map(|(&n, _)| n)
72            .collect();
73        let mut order = Vec::with_capacity(self.repos.len());
74
75        while let Some(n) = queue.pop_front() {
76            order.push(RepoId::new(n));
77            for &m in out_edges.get(n).unwrap_or(&vec![]) {
78                let deg = in_degree.get_mut(m).unwrap();
79                *deg -= 1;
80                if *deg == 0 {
81                    queue.push_back(m);
82                }
83            }
84        }
85
86        if order.len() != self.repos.len() {
87            return Err("cycle detected in repo dependencies".to_string());
88        }
89        Ok(order)
90    }
91
92    /// Returns true if the graph has a cycle.
93    pub fn has_cycle(&self) -> bool {
94        self.execution_order().is_err()
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_repo_id_equality() {
104        let a = RepoId::new("org/a");
105        let b = RepoId::new("org/a");
106        let c = RepoId::new("org/c");
107        assert_eq!(a, b);
108        assert_ne!(a, c);
109    }
110
111    #[test]
112    fn test_execution_order_linear() {
113        let a = RepoId::new("a");
114        let b = RepoId::new("b");
115        let c = RepoId::new("c");
116        let graph = CrossRepoGraph::new(
117            vec![a.clone(), b.clone(), c.clone()],
118            vec![
119                RepoDependency {
120                    dependent: b.clone(),
121                    dependency: a.clone(),
122                },
123                RepoDependency {
124                    dependent: c.clone(),
125                    dependency: b.clone(),
126                },
127            ],
128        );
129        let order = graph.execution_order().expect("no cycle");
130        assert_eq!(order.len(), 3);
131        assert_eq!(order[0].name, "a");
132        assert_eq!(order[1].name, "b");
133        assert_eq!(order[2].name, "c");
134    }
135
136    #[test]
137    fn test_execution_order_diamond() {
138        let a = RepoId::new("a");
139        let b = RepoId::new("b");
140        let c = RepoId::new("c");
141        let d = RepoId::new("d");
142        let graph = CrossRepoGraph::new(
143            vec![a.clone(), b.clone(), c.clone(), d.clone()],
144            vec![
145                RepoDependency {
146                    dependent: b.clone(),
147                    dependency: a.clone(),
148                },
149                RepoDependency {
150                    dependent: c.clone(),
151                    dependency: a.clone(),
152                },
153                RepoDependency {
154                    dependent: d.clone(),
155                    dependency: b.clone(),
156                },
157                RepoDependency {
158                    dependent: d.clone(),
159                    dependency: c.clone(),
160                },
161            ],
162        );
163        let order = graph.execution_order().expect("no cycle");
164        assert_eq!(order.len(), 4);
165        assert_eq!(order[0].name, "a");
166        let pos_b = order.iter().position(|r| r.name == "b").unwrap();
167        let pos_c = order.iter().position(|r| r.name == "c").unwrap();
168        let pos_d = order.iter().position(|r| r.name == "d").unwrap();
169        assert!(pos_b < pos_d && pos_c < pos_d);
170    }
171
172    #[test]
173    fn test_cycle_detected() {
174        let a = RepoId::new("a");
175        let b = RepoId::new("b");
176        let graph = CrossRepoGraph::new(
177            vec![a.clone(), b.clone()],
178            vec![
179                RepoDependency {
180                    dependent: b.clone(),
181                    dependency: a.clone(),
182                },
183                RepoDependency {
184                    dependent: a.clone(),
185                    dependency: b.clone(),
186                },
187            ],
188        );
189        assert!(graph.execution_order().is_err());
190        assert!(graph.has_cycle());
191    }
192
193    #[test]
194    fn test_serde_repo_id() {
195        let id = RepoId::new("stevedores-org/aivcs");
196        let json = serde_json::to_string(&id).expect("serialize");
197        let back: RepoId = serde_json::from_str(&json).expect("deserialize");
198        assert_eq!(id, back);
199    }
200}