1use crate::graph::{Graph, NodeKind};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::cmp::Reverse;
5use std::collections::{HashMap, HashSet};
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct StageTime {
10 pub stage: String,
11 pub ms: u64,
12 #[serde(default, skip_serializing_if = "String::is_empty")]
13 pub detail: String,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Snapshot {
18 pub schema_version: String,
19 pub generated_at: DateTime<Utc>,
20 pub command: String,
21 pub workspace: String,
23 pub target: String,
25 pub plugin: String,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub config_file: Option<String>,
29 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
30 pub local_only: bool,
31 pub versions: HashMap<String, String>,
32 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
34 pub roots: HashMap<String, String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub git: Option<GitInfo>,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub timings: Vec<StageTime>,
40 pub graphs: PluginGraphs,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct GitInfo {
45 pub branch: String,
46 pub commit: String,
47 pub dirty_files: u32,
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51pub struct PluginGraphs {
52 pub modules: Graph,
53 #[serde(default, skip_serializing_if = "Graph::is_empty")]
55 pub files: Graph,
56 pub functions: Graph,
57}
58
59impl Snapshot {
60 #[allow(clippy::too_many_arguments)]
61 pub fn new(
62 command: String,
63 workspace: String,
64 target: String,
65 plugin: String,
66 config_file: Option<String>,
67 local_only: bool,
68 versions: HashMap<String, String>,
69 roots: HashMap<String, String>,
70 git: Option<GitInfo>,
71 timings: Vec<StageTime>,
72 graphs: PluginGraphs,
73 ) -> Self {
74 Self {
75 schema_version: "1".to_string(),
76 generated_at: Utc::now(),
77 command,
78 workspace,
79 target,
80 plugin,
81 config_file,
82 local_only,
83 versions,
84 roots,
85 git,
86 timings,
87 graphs,
88 }
89 }
90}
91
92pub fn relativize_graphs(
101 graphs: &mut PluginGraphs,
102 target: &Path,
103 roots: &HashMap<String, String>,
104) {
105 for graph in [
106 &mut graphs.modules,
107 &mut graphs.files,
108 &mut graphs.functions,
109 ] {
110 for node in &mut graph.nodes {
111 node.path = relativize_path(&node.path, target, roots);
112 }
113 }
114}
115
116pub(crate) fn relativize_path(
117 path: &str,
118 target: &Path,
119 roots: &HashMap<String, String>,
120) -> String {
121 if path.is_empty() {
122 return path.to_string();
123 }
124 let p = Path::new(path);
125 if let Ok(rel) = p.strip_prefix(target) {
127 return format!("{{target}}/{}", rel.to_string_lossy());
128 }
129 let mut sorted: Vec<_> = roots.iter().collect();
131 sorted.sort_by_key(|(_, root)| Reverse(root.len()));
132 for (name, root) in &sorted {
133 if let Ok(rel) = p.strip_prefix(root.as_str()) {
134 return format!("{{{name}}}/{}", rel.to_string_lossy());
135 }
136 }
137 path.to_string()
138}
139
140pub fn rewrite_ids(graphs: &mut PluginGraphs, target: &Path, roots: &HashMap<String, String>) {
153 let mut pkg_info: HashMap<String, (String, String)> = HashMap::new();
155 for node in graphs
156 .modules
157 .nodes
158 .iter()
159 .chain(graphs.files.nodes.iter())
160 .chain(graphs.functions.nodes.iter())
161 {
162 if node.kind == NodeKind::Crate
163 && let Some(pkg_repr) = node.id.strip_prefix("crate:")
164 {
165 pkg_info
166 .entry(pkg_repr.to_string())
167 .or_insert_with(|| parse_pkg_repr(pkg_repr));
168 }
169 }
170
171 let mut name_versions: HashMap<String, HashSet<String>> = HashMap::new();
173 for (name, version) in pkg_info.values() {
174 name_versions
175 .entry(name.clone())
176 .or_default()
177 .insert(version.clone());
178 }
179
180 let crate_map: HashMap<String, String> = pkg_info
182 .iter()
183 .map(|(repr, (name, version))| {
184 let conflict = name_versions.get(name).is_some_and(|v| v.len() > 1);
185 let short = if conflict && !version.is_empty() {
186 format!("{name}@{version}")
187 } else {
188 name.clone()
189 };
190 (repr.clone(), short)
191 })
192 .collect();
193
194 let mut id_map: HashMap<String, String> = HashMap::new();
196 for node in graphs
197 .modules
198 .nodes
199 .iter()
200 .chain(graphs.files.nodes.iter())
201 .chain(graphs.functions.nodes.iter())
202 {
203 let new_id = rewrite_node_id(&node.id, &crate_map, target, roots);
204 if new_id != node.id {
205 id_map.insert(node.id.clone(), new_id);
206 }
207 }
208
209 for graph in [
211 &mut graphs.modules,
212 &mut graphs.files,
213 &mut graphs.functions,
214 ] {
215 for node in &mut graph.nodes {
216 if let Some(new_id) = id_map.get(&node.id) {
217 node.id = new_id.clone();
218 }
219 if let Some(parent) = node.parent.as_mut() {
220 if let Some(new_parent) = id_map.get(parent.as_str()) {
221 *parent = new_parent.clone();
222 } else {
223 let rewritten = rewrite_node_id(parent, &crate_map, target, roots);
226 if rewritten != *parent {
227 *parent = rewritten;
228 }
229 }
230 }
231 }
232 for edge in &mut graph.edges {
233 if let Some(v) = id_map.get(&edge.from) {
234 edge.from = v.clone();
235 }
236 if let Some(v) = id_map.get(&edge.to) {
237 edge.to = v.clone();
238 }
239 }
240 }
241}
242
243fn rewrite_node_id(
244 id: &str,
245 crate_map: &HashMap<String, String>,
246 target: &Path,
247 roots: &HashMap<String, String>,
248) -> String {
249 if let Some(pkg_repr) = id.strip_prefix("crate:") {
251 let short = crate_map
252 .get(pkg_repr)
253 .cloned()
254 .unwrap_or_else(|| parse_pkg_repr(pkg_repr).0);
255 return format!("crate:{short}");
256 }
257 for kind in ["mod", "trait", "fn", "method"] {
259 let prefix = format!("{kind}:");
260 if let Some(rest) = id.strip_prefix(&prefix)
261 && let Some((pkg_repr, path_part)) = split_version_boundary(rest)
262 {
263 let short = crate_map
264 .get(&pkg_repr)
265 .cloned()
266 .unwrap_or_else(|| parse_pkg_repr(&pkg_repr).0);
267 let trimmed = path_part
269 .strip_prefix(&format!("{short}::"))
270 .unwrap_or(&path_part)
271 .to_string();
272 return format!("{kind}:{short}::{trimmed}");
273 }
274 }
275 if let Some(abs_path) = id.strip_prefix("file:") {
277 let rel = relativize_path(abs_path, target, roots);
278 return format!("file:{rel}");
279 }
280 id.to_string()
281}
282
283fn split_version_boundary(s: &str) -> Option<(String, String)> {
286 let hash_pos = s.find('#')?;
287 let after_hash = &s[hash_pos + 1..];
288 let colon_pos = after_hash.find("::")?;
289 let pkg_repr = s[..hash_pos + 1 + colon_pos].to_string();
290 let path_part = after_hash[colon_pos + 2..].to_string();
291 Some((pkg_repr, path_part))
292}
293
294fn parse_pkg_repr(repr: &str) -> (String, String) {
301 if let Some(hash_pos) = repr.rfind('#') {
302 let after = &repr[hash_pos + 1..];
303 if let Some((name, ver)) = after.split_once('@') {
305 return (name.to_string(), ver.to_string());
306 }
307 let version = after.to_string();
309 let before = &repr[..hash_pos];
310 let before = before.split('?').next().unwrap_or(before);
312 let name = before
313 .split('/')
314 .next_back()
315 .unwrap_or("unknown")
316 .to_string();
317 return (name, version);
318 }
319 (repr.to_string(), String::new())
321}