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 pub versions: HashMap<String, String>,
30 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
32 pub roots: HashMap<String, String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub git: Option<GitInfo>,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub timings: Vec<StageTime>,
38 pub graphs: PluginGraphs,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct GitInfo {
43 pub branch: String,
44 pub commit: String,
45 pub dirty_files: u32,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub origin: Option<String>,
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct PluginGraphs {
54 pub files: Graph,
58}
59
60impl Snapshot {
61 #[allow(clippy::too_many_arguments)]
62 pub fn new(
63 command: String,
64 workspace: String,
65 target: String,
66 plugin: String,
67 config_file: Option<String>,
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 versions,
83 roots,
84 git,
85 timings,
86 graphs,
87 }
88 }
89}
90
91pub fn to_canonical_string_pretty<T: Serialize>(value: &T) -> serde_json::Result<String> {
104 let mut v = serde_json::to_value(value)?;
105 canonicalize_value(&mut v);
106 serde_json::to_string_pretty(&v)
107}
108
109pub fn to_canonical_string<T: Serialize>(value: &T) -> serde_json::Result<String> {
111 let mut v = serde_json::to_value(value)?;
112 canonicalize_value(&mut v);
113 serde_json::to_string(&v)
114}
115
116fn canonicalize_value(v: &mut serde_json::Value) {
117 match v {
118 serde_json::Value::Array(arr) => {
119 for item in arr.iter_mut() {
120 canonicalize_value(item);
121 }
122 }
123 serde_json::Value::Object(map) => {
124 for val in map.values_mut() {
125 canonicalize_value(val);
126 }
127 if let Some(serde_json::Value::Array(nodes)) = map.get_mut("nodes") {
130 nodes.sort_by_key(|a| json_str(a, "id"));
131 }
132 if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
133 edges.sort_by(|a, b| {
134 json_str(a, "from")
135 .cmp(&json_str(b, "from"))
136 .then_with(|| json_str(a, "to").cmp(&json_str(b, "to")))
137 .then_with(|| json_str(a, "kind").cmp(&json_str(b, "kind")))
138 });
139 }
140 }
141 _ => {}
142 }
143}
144
145fn json_str(v: &serde_json::Value, key: &str) -> String {
146 v.get(key)
147 .and_then(|x| x.as_str())
148 .unwrap_or_default()
149 .to_string()
150}
151
152pub fn relativize_graphs(
161 graphs: &mut PluginGraphs,
162 target: &Path,
163 roots: &HashMap<String, String>,
164) {
165 for node in &mut graphs.files.nodes {
166 node.path = relativize_path(&node.path, target, roots);
167 }
168}
169
170pub(crate) fn relativize_path(
171 path: &str,
172 target: &Path,
173 roots: &HashMap<String, String>,
174) -> String {
175 if path.is_empty() {
176 return path.to_string();
177 }
178 let p = Path::new(path);
179 if let Ok(rel) = p.strip_prefix(target) {
181 return format!("{{target}}/{}", rel.to_string_lossy());
182 }
183 let mut sorted: Vec<_> = roots.iter().collect();
185 sorted.sort_by_key(|(_, root)| Reverse(root.len()));
186 for (name, root) in &sorted {
187 if let Ok(rel) = p.strip_prefix(root.as_str()) {
188 return format!("{{{name}}}/{}", rel.to_string_lossy());
189 }
190 }
191 path.to_string()
192}
193
194pub fn rewrite_ids(graphs: &mut PluginGraphs, target: &Path, roots: &HashMap<String, String>) {
207 let mut pkg_info: HashMap<String, (String, String)> = HashMap::new();
209 for node in graphs.files.nodes.iter() {
210 if node.kind == NodeKind::Crate
211 && let Some(pkg_repr) = node.id.strip_prefix("crate:")
212 {
213 pkg_info
214 .entry(pkg_repr.to_string())
215 .or_insert_with(|| parse_pkg_repr(pkg_repr));
216 }
217 }
218
219 let mut name_versions: HashMap<String, HashSet<String>> = HashMap::new();
221 for (name, version) in pkg_info.values() {
222 name_versions
223 .entry(name.clone())
224 .or_default()
225 .insert(version.clone());
226 }
227
228 let crate_map: HashMap<String, String> = pkg_info
230 .iter()
231 .map(|(repr, (name, version))| {
232 let conflict = name_versions.get(name).is_some_and(|v| v.len() > 1);
233 let short = if conflict && !version.is_empty() {
234 format!("{name}@{version}")
235 } else {
236 name.clone()
237 };
238 (repr.clone(), short)
239 })
240 .collect();
241
242 let mut id_map: HashMap<String, String> = HashMap::new();
244 for node in graphs.files.nodes.iter() {
245 let new_id = rewrite_node_id(&node.id, &crate_map, target, roots);
246 if new_id != node.id {
247 id_map.insert(node.id.clone(), new_id);
248 }
249 }
250
251 let graph = &mut graphs.files;
253 for node in &mut graph.nodes {
254 if let Some(new_id) = id_map.get(&node.id) {
255 node.id = new_id.clone();
256 }
257 if let Some(parent) = node.parent.as_mut() {
258 if let Some(new_parent) = id_map.get(parent.as_str()) {
259 *parent = new_parent.clone();
260 } else {
261 let rewritten = rewrite_node_id(parent, &crate_map, target, roots);
264 if rewritten != *parent {
265 *parent = rewritten;
266 }
267 }
268 }
269 }
270 for edge in &mut graph.edges {
271 if let Some(v) = id_map.get(&edge.from) {
272 edge.from = v.clone();
273 }
274 if let Some(v) = id_map.get(&edge.to) {
275 edge.to = v.clone();
276 }
277 }
278}
279
280fn rewrite_node_id(
281 id: &str,
282 crate_map: &HashMap<String, String>,
283 target: &Path,
284 roots: &HashMap<String, String>,
285) -> String {
286 if let Some(pkg_repr) = id.strip_prefix("crate:") {
288 let short = crate_map
289 .get(pkg_repr)
290 .cloned()
291 .unwrap_or_else(|| parse_pkg_repr(pkg_repr).0);
292 return format!("crate:{short}");
293 }
294 for kind in ["mod", "trait", "fn", "method"] {
296 let prefix = format!("{kind}:");
297 if let Some(rest) = id.strip_prefix(&prefix)
298 && let Some((pkg_repr, path_part)) = split_version_boundary(rest)
299 {
300 let short = crate_map
301 .get(&pkg_repr)
302 .cloned()
303 .unwrap_or_else(|| parse_pkg_repr(&pkg_repr).0);
304 let trimmed = path_part
306 .strip_prefix(&format!("{short}::"))
307 .unwrap_or(&path_part)
308 .to_string();
309 return format!("{kind}:{short}::{trimmed}");
310 }
311 }
312 if let Some(abs_path) = id.strip_prefix("file:") {
314 let rel = relativize_path(abs_path, target, roots);
315 return format!("file:{rel}");
316 }
317 id.to_string()
318}
319
320fn split_version_boundary(s: &str) -> Option<(String, String)> {
323 let hash_pos = s.find('#')?;
324 let after_hash = &s[hash_pos + 1..];
325 let colon_pos = after_hash.find("::")?;
326 let pkg_repr = s[..hash_pos + 1 + colon_pos].to_string();
327 let path_part = after_hash[colon_pos + 2..].to_string();
328 Some((pkg_repr, path_part))
329}
330
331fn parse_pkg_repr(repr: &str) -> (String, String) {
338 if let Some(hash_pos) = repr.rfind('#') {
339 let after = &repr[hash_pos + 1..];
340 if let Some((name, ver)) = after.split_once('@') {
342 return (name.to_string(), ver.to_string());
343 }
344 let version = after.to_string();
346 let before = &repr[..hash_pos];
347 let before = before.split('?').next().unwrap_or(before);
349 let name = before
350 .split('/')
351 .next_back()
352 .unwrap_or("unknown")
353 .to_string();
354 return (name, version);
355 }
356 (repr.to_string(), String::new())
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::graph::{Edge, EdgeKind, Node};
364
365 fn node(id: &str, kind: NodeKind) -> Node {
366 Node {
367 id: id.into(),
368 kind,
369 name: id.into(),
370 path: String::new(),
371 parent: None,
372 external: None,
373 version: None,
374 visibility: None,
375 loc: None,
376 line: None,
377 item_count: None,
378 method_count: None,
379 complexity: None,
380 cycle_kind: None,
381 }
382 }
383
384 fn sample_snapshot() -> Snapshot {
387 let mut graphs = PluginGraphs::default();
388 graphs.files.nodes.push(node("crate:foo", NodeKind::Crate));
389 Snapshot::new(
390 "report".into(),
391 "/work".into(),
392 "/work/foo".into(),
393 "rust".into(),
394 None,
395 HashMap::new(),
396 HashMap::new(),
397 None,
398 Vec::new(),
399 graphs,
400 )
401 }
402
403 #[test]
404 fn snapshot_roundtrips_through_json() {
405 let snap = sample_snapshot();
406 let json = serde_json::to_string(&snap).unwrap();
407 let back: Snapshot = serde_json::from_str(&json).unwrap();
408 assert_eq!(back.schema_version, "1");
409 assert_eq!(back.command, "report");
410 assert_eq!(back.plugin, "rust");
411 assert_eq!(back.target, "/work/foo");
412 assert_eq!(back.graphs.files.nodes.len(), 1);
413 assert_eq!(back.graphs.files.nodes[0].id, "crate:foo");
414 assert_eq!(back.generated_at, snap.generated_at);
416 }
417
418 #[test]
419 fn snapshot_omits_absent_optional_fields() {
420 let json = serde_json::to_string(&sample_snapshot()).unwrap();
421 assert!(
422 !json.contains("\"git\""),
423 "None git is not serialized: {json}"
424 );
425 assert!(!json.contains("config_file"), "None config_file is skipped");
426 assert!(!json.contains("timings"), "empty timings is skipped");
427 }
428
429 #[test]
430 fn snapshot_keeps_present_optional_fields() {
431 let mut snap = sample_snapshot();
432 snap.git = Some(GitInfo {
433 branch: "main".into(),
434 commit: "abc".into(),
435 dirty_files: 2,
436 origin: None,
437 });
438 snap.timings.push(StageTime {
439 stage: "parse".into(),
440 ms: 5,
441 detail: String::new(),
442 });
443 let json = serde_json::to_string(&snap).unwrap();
444 let back: Snapshot = serde_json::from_str(&json).unwrap();
445 let git = back.git.unwrap();
446 assert_eq!(git.branch, "main");
447 assert_eq!(git.dirty_files, 2);
448 assert_eq!(back.timings.len(), 1);
449 assert_eq!(back.timings[0].stage, "parse");
450 }
451
452 #[test]
455 fn relativize_path_empty_stays_empty() {
456 assert_eq!(relativize_path("", Path::new("/p"), &HashMap::new()), "");
457 }
458
459 #[test]
460 fn relativize_path_under_target_uses_target_token() {
461 let got = relativize_path("/p/src/main.rs", Path::new("/p"), &HashMap::new());
462 assert_eq!(got, "{target}/src/main.rs");
463 }
464
465 #[test]
466 fn relativize_path_under_named_root_uses_root_token() {
467 let roots = HashMap::from([("cargo".to_string(), "/home/u/.cargo".to_string())]);
468 let got = relativize_path("/home/u/.cargo/registry/foo.rs", Path::new("/p"), &roots);
469 assert_eq!(got, "{cargo}/registry/foo.rs");
470 }
471
472 #[test]
473 fn relativize_path_longest_root_wins() {
474 let roots = HashMap::from([
476 ("home".to_string(), "/home/u".to_string()),
477 ("cargo".to_string(), "/home/u/.cargo".to_string()),
478 ]);
479 let got = relativize_path("/home/u/.cargo/x.rs", Path::new("/p"), &roots);
480 assert_eq!(got, "{cargo}/x.rs");
481 }
482
483 #[test]
484 fn relativize_path_unmatched_is_unchanged() {
485 let got = relativize_path("/elsewhere/x.rs", Path::new("/p"), &HashMap::new());
486 assert_eq!(got, "/elsewhere/x.rs");
487 }
488
489 #[test]
492 fn parse_pkg_repr_registry_path_and_fallback() {
493 let cases = vec![
494 (
495 "registry+https://github.com/rust-lang/crates.io-index#anyhow@1.0.102",
496 ("anyhow", "1.0.102"),
497 ),
498 ("path+file:///path/to/anyhow#1.0.102", ("anyhow", "1.0.102")),
499 ("bare-name-no-hash", ("bare-name-no-hash", "")),
500 ];
501 for (repr, (name, ver)) in cases {
502 let (gn, gv) = parse_pkg_repr(repr);
503 assert_eq!(gn, name, "name for {repr:?}");
504 assert_eq!(gv, ver, "version for {repr:?}");
505 }
506 }
507
508 #[test]
509 fn parse_pkg_repr_git_uses_commit_after_hash() {
510 let (name, ver) = parse_pkg_repr("git+https://github.com/foo/repo?tag=v0.1.0#a3f9c21");
514 assert_eq!(name, "repo");
515 assert_eq!(ver, "a3f9c21");
516 }
517
518 #[test]
521 fn split_version_boundary_splits_after_hash_at_first_path_colons() {
522 let got = split_version_boundary("path+file:///p#0.1.0::mod::sub");
523 assert_eq!(
524 got,
525 Some(("path+file:///p#0.1.0".to_string(), "mod::sub".to_string()))
526 );
527 }
528
529 #[test]
530 fn split_version_boundary_none_without_hash_or_path_colons() {
531 assert_eq!(split_version_boundary("mod::sub"), None, "no '#'");
532 assert_eq!(
533 split_version_boundary("has#hash-but-no-colons"),
534 None,
535 "'#' present but no '::' after it"
536 );
537 }
538
539 #[test]
542 fn rewrite_ids_shortens_single_crate_to_name() {
543 let mut graphs = PluginGraphs::default();
544 graphs
545 .files
546 .nodes
547 .push(node("crate:path+file:///x/anyhow#1.0.102", NodeKind::Crate));
548 rewrite_ids(&mut graphs, Path::new("/x"), &HashMap::new());
549 assert_eq!(graphs.files.nodes[0].id, "crate:anyhow");
550 }
551
552 #[test]
553 fn rewrite_ids_disambiguates_name_conflicts_with_version() {
554 let mut graphs = PluginGraphs::default();
556 graphs
557 .files
558 .nodes
559 .push(node("crate:path+file:///a/foo#1.0.0", NodeKind::Crate));
560 graphs
561 .files
562 .nodes
563 .push(node("crate:path+file:///b/foo#2.0.0", NodeKind::Crate));
564 rewrite_ids(&mut graphs, Path::new("/x"), &HashMap::new());
565 let ids: Vec<&str> = graphs.files.nodes.iter().map(|n| n.id.as_str()).collect();
566 assert!(ids.contains(&"crate:foo@1.0.0"), "got {ids:?}");
567 assert!(ids.contains(&"crate:foo@2.0.0"), "got {ids:?}");
568 }
569
570 #[test]
571 fn rewrite_ids_rewrites_edge_endpoints_and_file_ids() {
572 let mut graphs = PluginGraphs::default();
573 graphs
574 .files
575 .nodes
576 .push(node("crate:path+file:///x/anyhow#1.0.102", NodeKind::Crate));
577 graphs
578 .files
579 .nodes
580 .push(node("file:/x/src/lib.rs", NodeKind::File));
581 graphs.files.edges.push(Edge {
582 from: "crate:path+file:///x/anyhow#1.0.102".into(),
583 to: "file:/x/src/lib.rs".into(),
584 kind: EdgeKind::Contains,
585 unresolved: None,
586 external: None,
587 visibility: None,
588 });
589 rewrite_ids(&mut graphs, Path::new("/x"), &HashMap::new());
590 assert_eq!(graphs.files.nodes[1].id, "file:{target}/src/lib.rs");
592 assert_eq!(graphs.files.edges[0].from, "crate:anyhow");
594 assert_eq!(graphs.files.edges[0].to, "file:{target}/src/lib.rs");
595 }
596}