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