Skip to main content

code_ranker_graph/
relativize.rs

1//! Id relativization: rewrite absolute file-path node ids to portable
2//! `{target}/rel` / `{root}/rel` tokens (and follow them through edges, parents,
3//! cycle node lists, and external `path` attributes), so a snapshot is
4//! machine-independent.
5
6use crate::level_graph::LevelGraph;
7use code_ranker_plugin_api::{attrs::AttrValue, edge::Edge, graph::Graph, node::Node};
8use std::cmp::Reverse;
9use std::collections::BTreeMap;
10use std::path::Path;
11
12/// Rewrite a file-based level graph from absolute paths to relativized ids:
13/// - file node ids (absolute paths) → `{target}/rel` or `{root}/rel`;
14/// - edge endpoints, node parents and cycle node lists follow the same map;
15/// - external node `path` attributes are relativized too;
16/// - redundant/empty `path` attributes are dropped (a file node's id IS its
17///   path, so it carries none).
18pub fn relativize_level(level: &mut LevelGraph, target: &Path, roots: &BTreeMap<String, String>) {
19    let id_map = relativize_graph_inner(&mut level.nodes, &mut level.edges, target, roots);
20    for cycle in &mut level.cycles {
21        for n in &mut cycle.nodes {
22            if let Some(nn) = id_map.get(n) {
23                *n = nn.clone();
24            }
25        }
26    }
27}
28
29/// Relativize a structural [`Graph`] in place (before cycles are computed):
30/// file node ids (absolute paths) become `{target}/rel` / `{root}/rel`, edge
31/// endpoints and node parents follow, external `path` attributes are relativized,
32/// and redundant/empty `path` attributes are dropped.
33pub fn relativize_graph(graph: &mut Graph, target: &Path, roots: &BTreeMap<String, String>) {
34    relativize_graph_inner(&mut graph.nodes, &mut graph.edges, target, roots);
35}
36
37fn relativize_graph_inner(
38    nodes: &mut [Node],
39    edges: &mut [Edge],
40    target: &Path,
41    roots: &BTreeMap<String, String>,
42) -> BTreeMap<String, String> {
43    let mut id_map: BTreeMap<String, String> = BTreeMap::new();
44    for node in nodes.iter() {
45        if node.kind == "external" {
46            continue; // external ids (`ext:name`) are already short
47        }
48        let new_id = relativize_path(&node.id, target, roots);
49        if new_id != node.id {
50            id_map.insert(node.id.clone(), new_id);
51        }
52    }
53
54    for node in nodes.iter_mut() {
55        if let Some(new_id) = id_map.get(&node.id) {
56            node.id = new_id.clone();
57        }
58        if let Some(parent) = node.parent.as_mut()
59            && let Some(np) = id_map.get(parent)
60        {
61            *parent = np.clone();
62        }
63        if let Some(AttrValue::Str(p)) = node.attrs.get("path") {
64            let rel = relativize_path(p, target, roots);
65            if rel.is_empty() || rel == node.id {
66                node.attrs.remove("path");
67            } else {
68                node.attrs.insert("path".to_string(), AttrValue::Str(rel));
69            }
70        }
71    }
72    for edge in edges.iter_mut() {
73        if let Some(s) = id_map.get(&edge.source) {
74            edge.source = s.clone();
75        }
76        if let Some(t) = id_map.get(&edge.target) {
77            edge.target = t.clone();
78        }
79    }
80    id_map
81}
82
83fn relativize_path(path: &str, target: &Path, roots: &BTreeMap<String, String>) -> String {
84    if path.is_empty() {
85        return path.to_string();
86    }
87    let p = Path::new(path);
88    if let Ok(rel) = p.strip_prefix(target) {
89        return format!("{{target}}/{}", rel.to_string_lossy());
90    }
91    // Longest root wins.
92    let mut sorted: Vec<_> = roots.iter().collect();
93    sorted.sort_by_key(|(_, root)| Reverse(root.len()));
94    for (name, root) in &sorted {
95        if let Ok(rel) = p.strip_prefix(root.as_str()) {
96            return format!("{{{name}}}/{}", rel.to_string_lossy());
97        }
98    }
99    path.to_string()
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::level_graph::CycleGroup;
106
107    #[test]
108    fn relativize_path_under_target_uses_token() {
109        let got = relativize_path("/p/src/main.rs", Path::new("/p"), &BTreeMap::new());
110        assert_eq!(got, "{target}/src/main.rs");
111    }
112
113    #[test]
114    fn relativize_path_longest_root_wins() {
115        let roots = BTreeMap::from([
116            ("home".to_string(), "/home/u".to_string()),
117            ("registry".to_string(), "/home/u/.cargo".to_string()),
118        ]);
119        let got = relativize_path("/home/u/.cargo/x.rs", Path::new("/p"), &roots);
120        assert_eq!(got, "{registry}/x.rs");
121    }
122
123    #[test]
124    fn relativize_level_rewrites_ids_edges_and_cycles() {
125        use code_ranker_plugin_api::edge::Edge;
126        let mut level = LevelGraph::default();
127        level.nodes.push(Node {
128            id: "/p/src/a.rs".into(),
129            kind: "file".into(),
130            name: "a.rs".into(),
131            parent: None,
132            attrs: Default::default(),
133        });
134        level.nodes.push(Node {
135            id: "ext:serde".into(),
136            kind: "external".into(),
137            name: "serde".into(),
138            parent: None,
139            attrs: Default::default(),
140        });
141        level.edges.push(Edge {
142            source: "/p/src/a.rs".into(),
143            target: "ext:serde".into(),
144            kind: "uses".into(),
145            line: None,
146            attrs: Default::default(),
147        });
148        level.cycles.push(CycleGroup {
149            kind: "mutual".into(),
150            nodes: vec!["/p/src/a.rs".into()],
151        });
152        relativize_level(&mut level, Path::new("/p"), &BTreeMap::new());
153        assert_eq!(level.nodes[0].id, "{target}/src/a.rs");
154        assert_eq!(level.nodes[1].id, "ext:serde");
155        assert_eq!(level.edges[0].source, "{target}/src/a.rs");
156        assert_eq!(level.edges[0].target, "ext:serde");
157        assert_eq!(level.cycles[0].nodes[0], "{target}/src/a.rs");
158    }
159
160    #[test]
161    fn relativize_path_empty_and_unmatched_pass_through() {
162        // empty input is returned as-is; a path under neither target nor any root
163        // falls through unchanged.
164        assert_eq!(relativize_path("", Path::new("/p"), &BTreeMap::new()), "");
165        assert_eq!(
166            relativize_path("/elsewhere/x.rs", Path::new("/p"), &BTreeMap::new()),
167            "/elsewhere/x.rs"
168        );
169    }
170
171    #[test]
172    fn relativize_graph_remaps_parent_and_path_attr() {
173        let roots = BTreeMap::from([("reg".to_string(), "/reg".to_string())]);
174        let mut child_attrs = BTreeMap::new();
175        // a `path` attr under a root → rewritten to `{reg}/…`
176        child_attrs.insert("path".to_string(), AttrValue::Str("/reg/dep/lib.rs".into()));
177        let mut redundant_attrs = BTreeMap::new();
178        // a `path` attr equal to the node's own (relativized) id → dropped
179        redundant_attrs.insert("path".to_string(), AttrValue::Str("/p/b.rs".into()));
180
181        let mut graph = Graph {
182            nodes: vec![
183                Node {
184                    id: "/p/a.rs".into(),
185                    kind: "file".into(),
186                    name: "a.rs".into(),
187                    parent: Some("/p/mod.rs".into()),
188                    attrs: child_attrs,
189                },
190                Node {
191                    id: "/p/mod.rs".into(),
192                    kind: "file".into(),
193                    name: "mod.rs".into(),
194                    parent: None,
195                    attrs: Default::default(),
196                },
197                Node {
198                    id: "/p/b.rs".into(),
199                    kind: "file".into(),
200                    name: "b.rs".into(),
201                    parent: None,
202                    attrs: redundant_attrs,
203                },
204            ],
205            edges: vec![],
206        };
207        relativize_graph(&mut graph, Path::new("/p"), &roots);
208
209        let a = graph.nodes.iter().find(|n| n.name == "a.rs").unwrap();
210        assert_eq!(a.id, "{target}/a.rs");
211        assert_eq!(
212            a.parent.as_deref(),
213            Some("{target}/mod.rs"),
214            "parent remapped"
215        );
216        assert_eq!(
217            a.attrs.get("path"),
218            Some(&AttrValue::Str("{reg}/dep/lib.rs".into())),
219            "path attr relativized against the root"
220        );
221
222        let b = graph.nodes.iter().find(|n| n.name == "b.rs").unwrap();
223        assert!(
224            !b.attrs.contains_key("path"),
225            "a path attr equal to the node's own id is dropped as redundant"
226        );
227    }
228}