Skip to main content

code_split_core/
snapshot.rs

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    /// Directory from which `code-split` was invoked.
22    pub workspace: String,
23    /// The analyzed project directory (absolute path, stored once here).
24    pub target: String,
25    pub plugin: String,
26    /// Config file used for this analysis, if any was found.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub config_file: Option<String>,
29    pub versions: HashMap<String, String>,
30    /// Named system roots used to shorten node paths (e.g. `{cargo}`, `{rustup}`).
31    #[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    /// Per-stage timing in milliseconds, in execution order.
36    #[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    /// Remote `origin` URL (raw, e.g. `git@gitlab.example.com:group/proj.git`).
47    /// Used by the HTML report to build "open in GitLab/GitHub" source links.
48    #[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    /// The single file-level graph: `File` nodes + `External` library nodes,
55    /// connected by file→file `Uses`/`Reexports` edges and file→library
56    /// `Uses {external}` edges.
57    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
91// ---------------------------------------------------------------------------
92// Canonical (deterministic) JSON serialization
93// ---------------------------------------------------------------------------
94
95/// Serialize to canonical pretty JSON: every object key is emitted in
96/// alphabetical order and the graph `nodes` / `edges` arrays are sorted by a
97/// stable key (`id` for nodes; `from`, `to`, `kind` for edges). This makes the
98/// output byte-stable for unchanged input — re-running the analysis never
99/// reorders keys (e.g. from `HashMap` iteration order) or array entries.
100///
101/// `serde_json::Value` is backed by a `BTreeMap`, so round-tripping through it
102/// yields alphabetical keys for free; we only sort the data arrays explicitly.
103pub 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
109/// Compact counterpart of [`to_canonical_string_pretty`] (no indentation).
110pub 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            // Data arrays get a stable order so two snapshots of unchanged code
128            // are byte-identical regardless of plugin emission order.
129            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
152// ---------------------------------------------------------------------------
153// Path relativization
154// ---------------------------------------------------------------------------
155
156/// Rewrite all node `path` fields to be relative:
157/// - paths under `target` → plain relative path (`src/main.rs`)
158/// - paths under a named root → `{name}/rest/of/path`
159/// - anything else → left as-is
160pub 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    // target first — local paths become {target}/src/...
180    if let Ok(rel) = p.strip_prefix(target) {
181        return format!("{{target}}/{}", rel.to_string_lossy());
182    }
183    // Longest root wins.
184    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
194// ---------------------------------------------------------------------------
195// ID rewriting  (Variant A: {crate_name}::{path}, version only on conflict)
196// ---------------------------------------------------------------------------
197
198/// Rewrite all node `id`, `parent` and edge `from`/`to` fields from the raw
199/// cargo-based IDs to short human-readable IDs.
200///
201/// Scheme:
202/// - `crate:{pkg_repr}` → `crate:anyhow` / `crate:anyhow@1.0.102` (conflict)
203/// - `mod:{pkg_repr}::{path}` → `mod:anyhow::{path}`
204/// - `trait:{pkg_repr}::{path}` → `trait:anyhow::{path}`
205/// - `file:{abs_path}` → `file:{rel_path}` (relativized via roots)
206pub fn rewrite_ids(graphs: &mut PluginGraphs, target: &Path, roots: &HashMap<String, String>) {
207    // Step 1: collect (pkg_repr → (name, version)) from crate nodes.
208    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    // Step 2: detect name conflicts (same name, different versions).
220    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    // Step 3: pkg_repr → short crate identifier.
229    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    // Step 4: build old_id → new_id for every node across all graphs.
243    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    // Step 5: apply mapping to nodes and edges.
252    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                // parent references a node not in any graph (e.g. stdlib file node);
262                // rewrite it directly instead of relying on id_map lookup.
263                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    // crate:
287    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    // mod: / trait: / fn: / method:
295    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            // Strip redundant `{crate_name}::` prefix from path when target == crate.
305            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    // file:
313    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
320/// Split `path+file:///path#0.1.0::mod::sub` into
321/// (`path+file:///path#0.1.0`, `mod::sub`).
322fn 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
331/// Extract `(name, version)` from a raw cargo package repr.
332///
333/// Examples:
334/// - `path+file:///path/to/anyhow#1.0.102`   → (`anyhow`, `1.0.102`)
335/// - `registry+https://...#anyhow@1.0.102`   → (`anyhow`, `1.0.102`)
336/// - `git+https://...?tag=v0.1.0#a3f9c21`    → (`repo-name`, `v0.1.0`)
337fn parse_pkg_repr(repr: &str) -> (String, String) {
338    if let Some(hash_pos) = repr.rfind('#') {
339        let after = &repr[hash_pos + 1..];
340        // registry style: name@version after #
341        if let Some((name, ver)) = after.split_once('@') {
342            return (name.to_string(), ver.to_string());
343        }
344        // path style: version is after #, name is last component before #
345        let version = after.to_string();
346        let before = &repr[..hash_pos];
347        // strip query string (?tag=...) if present
348        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    // Fallback: use the whole thing as name
357    (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    // ── serde round-trip of the public artifact (P1) ────────────────────────
385
386    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        // generated_at survives the RFC3339 round-trip to the same instant.
415        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    // ── relativize_path ─────────────────────────────────────────────────────
453
454    #[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        // Both roots are prefixes of the path; the longer one (`cargo`) wins.
475        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    // ── parse_pkg_repr ──────────────────────────────────────────────────────
490
491    #[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        // Cargo git source ids carry the commit sha after `#`; the `?tag=...`
511        // query is stripped and the repo name is the last path segment.
512        // NB: the returned "version" is the commit, not the tag.
513        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    // ── split_version_boundary ──────────────────────────────────────────────
519
520    #[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    // ── rewrite_ids ─────────────────────────────────────────────────────────
540
541    #[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        // Same crate name at two versions → both keep `@version` suffixes.
555        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        // file id is relativized against the target.
591        assert_eq!(graphs.files.nodes[1].id, "file:{target}/src/lib.rs");
592        // edge endpoints follow the node-id rewrite.
593        assert_eq!(graphs.files.edges[0].from, "crate:anyhow");
594        assert_eq!(graphs.files.edges[0].to, "file:{target}/src/lib.rs");
595    }
596}