Skip to main content

weavatrix_rust/engine/
repository_state.rs

1use super::RepositoryState;
2use crate::analyzer::Analyzer;
3use crate::model::{Error, Result, Snapshot};
4use std::path::Path;
5use std::sync::{Arc, OnceLock};
6use std::time::{Duration, Instant};
7use weavatrix_graph::{EdgeKind, Graph, Node, NodeIndex, NodeKind};
8use weavatrix_scan::ScanReport;
9
10impl RepositoryState {
11    pub(crate) fn build(analyzer: &Analyzer, root: impl AsRef<Path>) -> Result<Self> {
12        let started = Instant::now();
13        let (snapshot, scan) = analyzer.analyze_with_report(root)?;
14        let graph = Graph::try_from_sorted_parts(snapshot.nodes.clone(), snapshot.edges.clone())?;
15        let snapshot_root = std::path::PathBuf::from(&snapshot.repository);
16        let root = snapshot_root
17            .canonicalize()
18            .map_err(|source| Error::io(&snapshot_root, source))?;
19        Ok(Self {
20            root,
21            snapshot,
22            graph: Arc::new(graph),
23            scan,
24            build_time: started.elapsed(),
25            built_at: Instant::now(),
26            weak_components: Arc::new(OnceLock::new()),
27        })
28    }
29
30    pub(super) fn from_scan(analyzer: &Analyzer, root: &Path, scan: ScanReport) -> Result<Self> {
31        let started = Instant::now();
32        let snapshot = analyzer.analyze_report(root, &scan)?;
33        let graph = Graph::try_from_sorted_parts(snapshot.nodes.clone(), snapshot.edges.clone())?;
34        Ok(Self {
35            root: root.to_path_buf(),
36            snapshot,
37            graph: Arc::new(graph),
38            scan,
39            build_time: started.elapsed(),
40            built_at: Instant::now(),
41            weak_components: Arc::new(OnceLock::new()),
42        })
43    }
44
45    /// Age of this in-memory graph: seconds since it was built from disk.
46    #[must_use]
47    pub fn graph_age_seconds(&self) -> u64 {
48        self.built_at.elapsed().as_secs()
49    }
50
51    #[must_use]
52    pub fn root(&self) -> &Path {
53        &self.root
54    }
55
56    #[must_use]
57    pub const fn snapshot(&self) -> &Snapshot {
58        &self.snapshot
59    }
60
61    #[must_use]
62    pub fn graph(&self) -> &Graph {
63        self.graph.as_ref()
64    }
65
66    #[must_use]
67    pub const fn build_time(&self) -> Duration {
68        self.build_time
69    }
70
71    #[must_use]
72    pub const fn scan_report(&self) -> &ScanReport {
73        &self.scan
74    }
75
76    pub(crate) fn coupled_components(&self) -> &[Vec<NodeIndex>] {
77        self.weak_components
78            .get_or_init(|| coupled_components(self.graph.as_ref()))
79            .as_slice()
80    }
81
82    /// Warms the cached repository communities in a background thread.
83    pub fn warm_communities(&self) {
84        if self.weak_components.get().is_some() {
85            return;
86        }
87        let graph = Arc::clone(&self.graph);
88        let destination = Arc::clone(&self.weak_components);
89        std::thread::spawn(move || {
90            std::thread::sleep(Duration::from_millis(10));
91            destination.get_or_init(|| coupled_components(graph.as_ref()));
92        });
93    }
94
95    pub(crate) fn resolve_node(&self, label: &str) -> std::result::Result<NodeIndex, String> {
96        if let Some(index) = self.graph.node_index(label) {
97            return Ok(index);
98        }
99        let matches = self
100            .graph
101            .nodes()
102            .iter()
103            .enumerate()
104            .filter(|(_, node)| node.label == label)
105            .collect::<Vec<_>>();
106        let candidates = prefer_non_test_only(&matches);
107        match candidates.as_slice() {
108            [] => Err(format!("node not found: {label}")),
109            [(index, _)] => Ok(NodeIndex::new(
110                u32::try_from(*index).map_err(|_| "node index overflow")?,
111            )),
112            _ => Err(format!(
113                "ambiguous node label {label:?}; use one of: {}",
114                candidates
115                    .iter()
116                    .take(8)
117                    .map(|(_, node)| node.id.as_str())
118                    .collect::<Vec<_>>()
119                    .join(", ")
120            )),
121        }
122    }
123
124    pub(crate) fn node(&self, index: NodeIndex) -> std::result::Result<&Node, String> {
125        self.graph
126            .node_at(index)
127            .ok_or_else(|| format!("node index out of range: {}", index.index()))
128    }
129}
130
131fn prefer_non_test_only<'a>(matches: &'a [(usize, &'a Node)]) -> Vec<(usize, &'a Node)> {
132    if matches.len() <= 1 {
133        return matches.to_vec();
134    }
135    let production = matches
136        .iter()
137        .copied()
138        .filter(|(_, node)| {
139            !matches!(
140                node.attributes.get("test_only"),
141                Some(weavatrix_graph::AttributeValue::Bool(true))
142            )
143        })
144        .collect::<Vec<_>>();
145    if production.len() == 1 {
146        production
147    } else if production.is_empty() {
148        matches.to_vec()
149    } else {
150        production
151    }
152}
153
154/// The commit `HEAD` names in the repository at `root`, read from Git's own
155/// files without executing anything. `None` outside a Git checkout or on any
156/// unreadable layout; linked worktrees resolve through `commondir`.
157#[must_use]
158pub fn git_head(root: &Path) -> Option<String> {
159    let mut git_dir = root.join(".git");
160    if git_dir.is_file() {
161        let text = std::fs::read_to_string(&git_dir).ok()?;
162        let relative = text.strip_prefix("gitdir:")?.trim();
163        let candidate = Path::new(relative);
164        git_dir = if candidate.is_absolute() {
165            candidate.to_path_buf()
166        } else {
167            root.join(candidate)
168        };
169    }
170    let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
171    let head = head.trim();
172    let Some(reference) = head.strip_prefix("ref:") else {
173        return Some(head.to_owned());
174    };
175    let reference = reference.trim();
176    let common = std::fs::read_to_string(git_dir.join("commondir"))
177        .map_or_else(|_| git_dir.clone(), |dir| git_dir.join(dir.trim()));
178    for base in [&git_dir, &common] {
179        if let Ok(hash) = std::fs::read_to_string(base.join(reference)) {
180            return Some(hash.trim().to_owned());
181        }
182    }
183    let packed = std::fs::read_to_string(common.join("packed-refs")).ok()?;
184    packed
185        .lines()
186        .filter_map(|line| line.split_once(' '))
187        .find(|(_, name)| *name == reference)
188        .map(|(hash, _)| hash.trim().to_owned())
189}
190
191/// Connected components over coupling evidence only, largest first.
192/// Containment, method membership and shared external packages connect
193/// everything to everything, so they cannot define a community; singleton
194/// components carry no coupling and are dropped.
195fn coupled_components(graph: &Graph) -> Vec<Vec<NodeIndex>> {
196    fn find(parent: &mut [usize], node: usize) -> usize {
197        let mut root = node;
198        while parent[root] != root {
199            root = parent[root];
200        }
201        let mut current = node;
202        while parent[current] != root {
203            let next = parent[current];
204            parent[current] = root;
205            current = next;
206        }
207        root
208    }
209    let nodes = graph.nodes();
210    let mut parent = (0..nodes.len()).collect::<Vec<_>>();
211    let is_package = |slot: usize| {
212        nodes
213            .get(slot)
214            .is_some_and(|node| node.kind == NodeKind::Package)
215    };
216    for slot in 0..nodes.len() {
217        let index = NodeIndex::new(u32::try_from(slot).unwrap_or(u32::MAX));
218        for edge in graph.outgoing_at(index) {
219            if matches!(edge.kind, EdgeKind::Contains | EdgeKind::Method) {
220                continue;
221            }
222            let Some(target) = graph.node_index(edge.target.as_str()) else {
223                continue;
224            };
225            if is_package(slot) || is_package(target.index()) {
226                continue;
227            }
228            let left = find(&mut parent, slot);
229            let right = find(&mut parent, target.index());
230            if left != right {
231                parent[left.max(right)] = left.min(right);
232            }
233        }
234    }
235    let mut groups = std::collections::BTreeMap::<usize, Vec<NodeIndex>>::new();
236    for slot in 0..nodes.len() {
237        let root = find(&mut parent, slot);
238        groups
239            .entry(root)
240            .or_default()
241            .push(NodeIndex::new(u32::try_from(slot).unwrap_or(u32::MAX)));
242    }
243    let mut components = groups
244        .into_values()
245        .filter(|members| members.len() > 1)
246        .collect::<Vec<_>>();
247    components.sort_by_key(|members| {
248        (
249            std::cmp::Reverse(members.len()),
250            members.first().map_or(0, |index| index.index()),
251        )
252    });
253    components
254}