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
389 .files
390 .nodes
391 .push(node("crate:foo", NodeKind::Crate));
392 Snapshot::new(
393 "report".into(),
394 "/work".into(),
395 "/work/foo".into(),
396 "rust".into(),
397 None,
398 HashMap::new(),
399 HashMap::new(),
400 None,
401 Vec::new(),
402 graphs,
403 )
404 }
405
406 #[test]
407 fn snapshot_roundtrips_through_json() {
408 let snap = sample_snapshot();
409 let json = serde_json::to_string(&snap).unwrap();
410 let back: Snapshot = serde_json::from_str(&json).unwrap();
411 assert_eq!(back.schema_version, "1");
412 assert_eq!(back.command, "report");
413 assert_eq!(back.plugin, "rust");
414 assert_eq!(back.target, "/work/foo");
415 assert_eq!(back.graphs.files.nodes.len(), 1);
416 assert_eq!(back.graphs.files.nodes[0].id, "crate:foo");
417 assert_eq!(back.generated_at, snap.generated_at);
419 }
420
421 #[test]
422 fn snapshot_omits_absent_optional_fields() {
423 let json = serde_json::to_string(&sample_snapshot()).unwrap();
424 assert!(
425 !json.contains("\"git\""),
426 "None git is not serialized: {json}"
427 );
428 assert!(!json.contains("config_file"), "None config_file is skipped");
429 assert!(!json.contains("timings"), "empty timings is skipped");
430 }
431
432 #[test]
433 fn snapshot_keeps_present_optional_fields() {
434 let mut snap = sample_snapshot();
435 snap.git = Some(GitInfo {
436 branch: "main".into(),
437 commit: "abc".into(),
438 dirty_files: 2,
439 origin: None,
440 });
441 snap.timings.push(StageTime {
442 stage: "parse".into(),
443 ms: 5,
444 detail: String::new(),
445 });
446 let json = serde_json::to_string(&snap).unwrap();
447 let back: Snapshot = serde_json::from_str(&json).unwrap();
448 let git = back.git.unwrap();
449 assert_eq!(git.branch, "main");
450 assert_eq!(git.dirty_files, 2);
451 assert_eq!(back.timings.len(), 1);
452 assert_eq!(back.timings[0].stage, "parse");
453 }
454
455 #[test]
458 fn relativize_path_empty_stays_empty() {
459 assert_eq!(relativize_path("", Path::new("/p"), &HashMap::new()), "");
460 }
461
462 #[test]
463 fn relativize_path_under_target_uses_target_token() {
464 let got = relativize_path("/p/src/main.rs", Path::new("/p"), &HashMap::new());
465 assert_eq!(got, "{target}/src/main.rs");
466 }
467
468 #[test]
469 fn relativize_path_under_named_root_uses_root_token() {
470 let roots = HashMap::from([("cargo".to_string(), "/home/u/.cargo".to_string())]);
471 let got = relativize_path("/home/u/.cargo/registry/foo.rs", Path::new("/p"), &roots);
472 assert_eq!(got, "{cargo}/registry/foo.rs");
473 }
474
475 #[test]
476 fn relativize_path_longest_root_wins() {
477 let roots = HashMap::from([
479 ("home".to_string(), "/home/u".to_string()),
480 ("cargo".to_string(), "/home/u/.cargo".to_string()),
481 ]);
482 let got = relativize_path("/home/u/.cargo/x.rs", Path::new("/p"), &roots);
483 assert_eq!(got, "{cargo}/x.rs");
484 }
485
486 #[test]
487 fn relativize_path_unmatched_is_unchanged() {
488 let got = relativize_path("/elsewhere/x.rs", Path::new("/p"), &HashMap::new());
489 assert_eq!(got, "/elsewhere/x.rs");
490 }
491
492 #[test]
495 fn parse_pkg_repr_registry_path_and_fallback() {
496 let cases = vec![
497 (
498 "registry+https://github.com/rust-lang/crates.io-index#anyhow@1.0.102",
499 ("anyhow", "1.0.102"),
500 ),
501 ("path+file:///path/to/anyhow#1.0.102", ("anyhow", "1.0.102")),
502 ("bare-name-no-hash", ("bare-name-no-hash", "")),
503 ];
504 for (repr, (name, ver)) in cases {
505 let (gn, gv) = parse_pkg_repr(repr);
506 assert_eq!(gn, name, "name for {repr:?}");
507 assert_eq!(gv, ver, "version for {repr:?}");
508 }
509 }
510
511 #[test]
512 fn parse_pkg_repr_git_uses_commit_after_hash() {
513 let (name, ver) = parse_pkg_repr("git+https://github.com/foo/repo?tag=v0.1.0#a3f9c21");
517 assert_eq!(name, "repo");
518 assert_eq!(ver, "a3f9c21");
519 }
520
521 #[test]
524 fn split_version_boundary_splits_after_hash_at_first_path_colons() {
525 let got = split_version_boundary("path+file:///p#0.1.0::mod::sub");
526 assert_eq!(
527 got,
528 Some(("path+file:///p#0.1.0".to_string(), "mod::sub".to_string()))
529 );
530 }
531
532 #[test]
533 fn split_version_boundary_none_without_hash_or_path_colons() {
534 assert_eq!(split_version_boundary("mod::sub"), None, "no '#'");
535 assert_eq!(
536 split_version_boundary("has#hash-but-no-colons"),
537 None,
538 "'#' present but no '::' after it"
539 );
540 }
541
542 #[test]
545 fn rewrite_ids_shortens_single_crate_to_name() {
546 let mut graphs = PluginGraphs::default();
547 graphs
548 .files
549 .nodes
550 .push(node("crate:path+file:///x/anyhow#1.0.102", NodeKind::Crate));
551 rewrite_ids(&mut graphs, Path::new("/x"), &HashMap::new());
552 assert_eq!(graphs.files.nodes[0].id, "crate:anyhow");
553 }
554
555 #[test]
556 fn rewrite_ids_disambiguates_name_conflicts_with_version() {
557 let mut graphs = PluginGraphs::default();
559 graphs
560 .files
561 .nodes
562 .push(node("crate:path+file:///a/foo#1.0.0", NodeKind::Crate));
563 graphs
564 .files
565 .nodes
566 .push(node("crate:path+file:///b/foo#2.0.0", NodeKind::Crate));
567 rewrite_ids(&mut graphs, Path::new("/x"), &HashMap::new());
568 let ids: Vec<&str> = graphs.files.nodes.iter().map(|n| n.id.as_str()).collect();
569 assert!(ids.contains(&"crate:foo@1.0.0"), "got {ids:?}");
570 assert!(ids.contains(&"crate:foo@2.0.0"), "got {ids:?}");
571 }
572
573 #[test]
574 fn rewrite_ids_rewrites_edge_endpoints_and_file_ids() {
575 let mut graphs = PluginGraphs::default();
576 graphs
577 .files
578 .nodes
579 .push(node("crate:path+file:///x/anyhow#1.0.102", NodeKind::Crate));
580 graphs
581 .files
582 .nodes
583 .push(node("file:/x/src/lib.rs", NodeKind::File));
584 graphs.files.edges.push(Edge {
585 from: "crate:path+file:///x/anyhow#1.0.102".into(),
586 to: "file:/x/src/lib.rs".into(),
587 kind: EdgeKind::Contains,
588 unresolved: None,
589 external: None,
590 visibility: None,
591 });
592 rewrite_ids(&mut graphs, Path::new("/x"), &HashMap::new());
593 assert_eq!(graphs.files.nodes[1].id, "file:{target}/src/lib.rs");
595 assert_eq!(graphs.files.edges[0].from, "crate:anyhow");
597 assert_eq!(graphs.files.edges[0].to, "file:{target}/src/lib.rs");
598 }
599}