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        match matches.as_slice() {
107            [] => Err(format!("node not found: {label}")),
108            [(index, _)] => Ok(NodeIndex::new(
109                u32::try_from(*index).map_err(|_| "node index overflow")?,
110            )),
111            _ => Err(format!(
112                "ambiguous node label {label:?}; use one of: {}",
113                matches
114                    .iter()
115                    .take(8)
116                    .map(|(_, node)| node.id.as_str())
117                    .collect::<Vec<_>>()
118                    .join(", ")
119            )),
120        }
121    }
122
123    pub(crate) fn node(&self, index: NodeIndex) -> std::result::Result<&Node, String> {
124        self.graph
125            .node_at(index)
126            .ok_or_else(|| format!("node index out of range: {}", index.index()))
127    }
128}
129
130/// The commit `HEAD` names in the repository at `root`, read from Git's own
131/// files without executing anything. `None` outside a Git checkout or on any
132/// unreadable layout; linked worktrees resolve through `commondir`.
133#[must_use]
134pub fn git_head(root: &Path) -> Option<String> {
135    let mut git_dir = root.join(".git");
136    if git_dir.is_file() {
137        let text = std::fs::read_to_string(&git_dir).ok()?;
138        let relative = text.strip_prefix("gitdir:")?.trim();
139        let candidate = Path::new(relative);
140        git_dir = if candidate.is_absolute() {
141            candidate.to_path_buf()
142        } else {
143            root.join(candidate)
144        };
145    }
146    let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
147    let head = head.trim();
148    let Some(reference) = head.strip_prefix("ref:") else {
149        return Some(head.to_owned());
150    };
151    let reference = reference.trim();
152    let common = std::fs::read_to_string(git_dir.join("commondir"))
153        .map_or_else(|_| git_dir.clone(), |dir| git_dir.join(dir.trim()));
154    for base in [&git_dir, &common] {
155        if let Ok(hash) = std::fs::read_to_string(base.join(reference)) {
156            return Some(hash.trim().to_owned());
157        }
158    }
159    let packed = std::fs::read_to_string(common.join("packed-refs")).ok()?;
160    packed
161        .lines()
162        .filter_map(|line| line.split_once(' '))
163        .find(|(_, name)| *name == reference)
164        .map(|(hash, _)| hash.trim().to_owned())
165}
166
167/// Connected components over coupling evidence only, largest first.
168/// Containment, method membership and shared external packages connect
169/// everything to everything, so they cannot define a community; singleton
170/// components carry no coupling and are dropped.
171fn coupled_components(graph: &Graph) -> Vec<Vec<NodeIndex>> {
172    fn find(parent: &mut [usize], node: usize) -> usize {
173        let mut root = node;
174        while parent[root] != root {
175            root = parent[root];
176        }
177        let mut current = node;
178        while parent[current] != root {
179            let next = parent[current];
180            parent[current] = root;
181            current = next;
182        }
183        root
184    }
185    let nodes = graph.nodes();
186    let mut parent = (0..nodes.len()).collect::<Vec<_>>();
187    let is_package = |slot: usize| {
188        nodes
189            .get(slot)
190            .is_some_and(|node| node.kind == NodeKind::Package)
191    };
192    for slot in 0..nodes.len() {
193        let index = NodeIndex::new(u32::try_from(slot).unwrap_or(u32::MAX));
194        for edge in graph.outgoing_at(index) {
195            if matches!(edge.kind, EdgeKind::Contains | EdgeKind::Method) {
196                continue;
197            }
198            let Some(target) = graph.node_index(edge.target.as_str()) else {
199                continue;
200            };
201            if is_package(slot) || is_package(target.index()) {
202                continue;
203            }
204            let left = find(&mut parent, slot);
205            let right = find(&mut parent, target.index());
206            if left != right {
207                parent[left.max(right)] = left.min(right);
208            }
209        }
210    }
211    let mut groups = std::collections::BTreeMap::<usize, Vec<NodeIndex>>::new();
212    for slot in 0..nodes.len() {
213        let root = find(&mut parent, slot);
214        groups
215            .entry(root)
216            .or_default()
217            .push(NodeIndex::new(u32::try_from(slot).unwrap_or(u32::MAX)));
218    }
219    let mut components = groups
220        .into_values()
221        .filter(|members| members.len() > 1)
222        .collect::<Vec<_>>();
223    components.sort_by_key(|members| {
224        (
225            std::cmp::Reverse(members.len()),
226            members.first().map_or(0, |index| index.index()),
227        )
228    });
229    components
230}