|
| 1 | +//! Integration tests for the Callgrind `.out` -> call-graph parser. |
| 2 | +//! |
| 3 | +//! The fixture (`data/example.out`) uses runner-style uncompressed Callgrind |
| 4 | +//! syntax (`--compress-strings=no`): full `fn=`/`fl=`/`ob=` names, blank-line |
| 5 | +//! separated function records, and a single `part:` block, fed directly to the |
| 6 | +//! monorepo `callgrind-parser` with no local preprocessing. Every callee also |
| 7 | +//! has its own `fn=` record, since `build_call_graph` only nodes functions that |
| 8 | +//! appear as `fn=` and drops edges to otherwise-unknown callees. |
| 9 | +//! |
| 10 | +//! Topology is asserted through the canonical JSON projection (`to_json`), |
| 11 | +//! which exposes node identity (`name`/`object`/`file`) and indexed edges with |
| 12 | +//! per-thread call counts. Path normalization is exercised through `redact`, |
| 13 | +//! which basenames `file`/`object` before serialization. |
| 14 | +
|
| 15 | +use callgrind_utils::model::CallGraph; |
| 16 | +use serde_json::Value; |
| 17 | +use std::io::Cursor; |
| 18 | + |
| 19 | +const FIXTURE: &str = include_str!("data/example.out"); |
| 20 | + |
| 21 | +fn parse_default() -> CallGraph { |
| 22 | + CallGraph::parse(Cursor::new(FIXTURE)).expect("parse fixture") |
| 23 | +} |
| 24 | + |
| 25 | +fn json(g: &CallGraph) -> Value { |
| 26 | + serde_json::from_str(&g.to_json().expect("to_json")).expect("valid json") |
| 27 | +} |
| 28 | + |
| 29 | +/// The first node named `name` (unique names only). |
| 30 | +fn node<'a>(v: &'a Value, name: &str) -> &'a Value { |
| 31 | + v["nodes"] |
| 32 | + .as_array() |
| 33 | + .expect("nodes array") |
| 34 | + .iter() |
| 35 | + .find(|n| n["name"].as_str() == Some(name)) |
| 36 | + .unwrap_or_else(|| panic!("node {name} not found")) |
| 37 | +} |
| 38 | + |
| 39 | +/// All nodes named `name` (distinct by object/file). |
| 40 | +fn nodes_named<'a>(v: &'a Value, name: &str) -> Vec<&'a Value> { |
| 41 | + v["nodes"] |
| 42 | + .as_array() |
| 43 | + .expect("nodes array") |
| 44 | + .iter() |
| 45 | + .filter(|n| n["name"].as_str() == Some(name)) |
| 46 | + .collect() |
| 47 | +} |
| 48 | + |
| 49 | +/// All edges whose source/target nodes (looked up by name) are `caller`/`callee`. |
| 50 | +fn edges_between<'a>(v: &'a Value, caller: &str, callee: &str) -> Vec<&'a Value> { |
| 51 | + let names: Vec<&str> = v["nodes"] |
| 52 | + .as_array() |
| 53 | + .expect("nodes array") |
| 54 | + .iter() |
| 55 | + .map(|n| n["name"].as_str().unwrap()) |
| 56 | + .collect(); |
| 57 | + v["edges"] |
| 58 | + .as_array() |
| 59 | + .expect("edges array") |
| 60 | + .iter() |
| 61 | + .filter(|e| { |
| 62 | + let s = e["source"].as_u64().unwrap() as usize; |
| 63 | + let t = e["target"].as_u64().unwrap() as usize; |
| 64 | + names.get(s).copied() == Some(caller) && names.get(t).copied() == Some(callee) |
| 65 | + }) |
| 66 | + .collect() |
| 67 | +} |
| 68 | + |
| 69 | +/// Call count of an edge: `counts` is `[[{pid,tid}, count], ...]`; take the |
| 70 | +/// single (pid,tid) entry the fixture produces. |
| 71 | +fn call_count(e: &Value) -> u64 { |
| 72 | + e["counts"] |
| 73 | + .as_array() |
| 74 | + .expect("counts array") |
| 75 | + .first() |
| 76 | + .expect("at least one count entry") |
| 77 | + .as_array() |
| 78 | + .expect("count pair")[1] |
| 79 | + .as_u64() |
| 80 | + .unwrap() |
| 81 | +} |
| 82 | + |
| 83 | +#[test] |
| 84 | +fn parses_basic_callgraph() { |
| 85 | + let v = json(&parse_default()); |
| 86 | + assert_eq!(v["nodes"].as_array().unwrap().len(), 11, "nodes: {v:#?}"); |
| 87 | + assert_eq!(v["edges"].as_array().unwrap().len(), 11, "edges: {v:#?}"); |
| 88 | + |
| 89 | + let e = &edges_between(&v, "main", "func1"); |
| 90 | + assert_eq!(e.len(), 1); |
| 91 | + assert_eq!(call_count(e[0]), 1); |
| 92 | + // No cfi on this call -> callee inherits the caller's file. |
| 93 | + assert_eq!(node(&v, "main")["file"].as_str(), Some("file1.c")); |
| 94 | + assert_eq!(node(&v, "func1")["file"].as_str(), Some("file1.c")); |
| 95 | +} |
| 96 | + |
| 97 | +#[test] |
| 98 | +fn cfl_alias_equals_cfi() { |
| 99 | + // `cfl=cflfile.c` is the historical alias of `cfi=`; the callee node's file |
| 100 | + // must resolve to cflfile.c while the object inherits the caller's. |
| 101 | + let v = json(&parse_default()); |
| 102 | + let cflop = node(&v, "cflop"); |
| 103 | + assert_eq!(cflop["file"].as_str(), Some("cflfile.c")); |
| 104 | + assert_eq!(cflop["object"].as_str(), Some("/path/to/clreq")); |
| 105 | + assert_eq!(edges_between(&v, "main", "cflop").len(), 1); |
| 106 | +} |
| 107 | + |
| 108 | +#[test] |
| 109 | +fn omitted_cfi_inherits_current_file_context() { |
| 110 | + // No `cfi`/`cfl`: the callee inherits the current file context (file1.c). |
| 111 | + let v = json(&parse_default()); |
| 112 | + let nofile = node(&v, "nofile"); |
| 113 | + assert_eq!(nofile["file"].as_str(), Some("file1.c")); |
| 114 | + assert_eq!(edges_between(&v, "main", "nofile").len(), 1); |
| 115 | +} |
| 116 | + |
| 117 | +#[test] |
| 118 | +fn inline_fi_fe_changes_callee_context_not_caller() { |
| 119 | + // `fi=inline.c` moves the current file context, so the callee (inltarget) |
| 120 | + // inherits inline.c. `fe=file1.c` restores the context before the function |
| 121 | + // terminates, so the caller (inlhost) keeps file1.c. |
| 122 | + let v = json(&parse_default()); |
| 123 | + assert_eq!(node(&v, "inlhost")["file"].as_str(), Some("file1.c")); |
| 124 | + assert_eq!(node(&v, "inltarget")["file"].as_str(), Some("inline.c")); |
| 125 | + assert_eq!(edges_between(&v, "inlhost", "inltarget").len(), 1); |
| 126 | +} |
| 127 | + |
| 128 | +#[test] |
| 129 | +fn same_name_different_object_are_distinct() { |
| 130 | + // `helper` exists in liba/fileA.c AND libb/fileB.c -> two distinct nodes, |
| 131 | + // two distinct edges from main. |
| 132 | + let v = json(&parse_default()); |
| 133 | + let helpers = nodes_named(&v, "helper"); |
| 134 | + assert_eq!(helpers.len(), 2, "helpers: {helpers:#?}"); |
| 135 | + |
| 136 | + let mut keys: Vec<(&str, &str)> = helpers |
| 137 | + .iter() |
| 138 | + .map(|n| (n["object"].as_str().unwrap(), n["file"].as_str().unwrap())) |
| 139 | + .collect(); |
| 140 | + keys.sort(); |
| 141 | + assert_eq!(keys, vec![("liba", "fileA.c"), ("libb", "fileB.c")]); |
| 142 | + |
| 143 | + assert_eq!(edges_between(&v, "main", "helper").len(), 2); |
| 144 | +} |
| 145 | + |
| 146 | +#[test] |
| 147 | +fn recursion_becomes_self_edge() { |
| 148 | + let v = json(&parse_default()); |
| 149 | + let rec = edges_between(&v, "rec", "rec"); |
| 150 | + assert_eq!(rec.len(), 1); |
| 151 | + assert_eq!(rec[0]["source"], rec[0]["target"]); |
| 152 | +} |
| 153 | + |
| 154 | +#[test] |
| 155 | +fn cob_overrides_caller_object() { |
| 156 | + // `cob=extlib` with no `cfi`: callee object is extlib, file inherited from |
| 157 | + // the caller context (file1.c); the caller keeps its own object. |
| 158 | + let v = json(&parse_default()); |
| 159 | + let extfn = node(&v, "extfn"); |
| 160 | + assert_eq!(extfn["object"].as_str(), Some("extlib")); |
| 161 | + assert_eq!(extfn["file"].as_str(), Some("file1.c")); |
| 162 | + assert_eq!(node(&v, "main")["object"].as_str(), Some("/path/to/clreq")); |
| 163 | + assert_eq!(edges_between(&v, "main", "extfn").len(), 1); |
| 164 | +} |
| 165 | + |
| 166 | +#[test] |
| 167 | +fn redact_basenames_object_and_file_paths() { |
| 168 | + // Raw JSON keeps the full object path verbatim; `redact` basenames it. |
| 169 | + let raw = json(&parse_default()); |
| 170 | + assert!(raw["nodes"] |
| 171 | + .as_array() |
| 172 | + .unwrap() |
| 173 | + .iter() |
| 174 | + .any(|n| n["object"].as_str() == Some("/path/to/clreq"))); |
| 175 | + |
| 176 | + let redacted = json(&parse_default().redact()); |
| 177 | + assert!(redacted["nodes"] |
| 178 | + .as_array() |
| 179 | + .unwrap() |
| 180 | + .iter() |
| 181 | + .any(|n| n["object"].as_str() == Some("clreq"))); |
| 182 | + assert!( |
| 183 | + !redacted["nodes"] |
| 184 | + .as_array() |
| 185 | + .unwrap() |
| 186 | + .iter() |
| 187 | + .any(|n| n["object"].as_str().unwrap().contains('/')), |
| 188 | + "no object should retain a path separator after redaction" |
| 189 | + ); |
| 190 | +} |
| 191 | + |
| 192 | +#[test] |
| 193 | +fn to_json_is_canonical() { |
| 194 | + let g = parse_default(); |
| 195 | + let v = json(&g); |
| 196 | + |
| 197 | + assert_eq!(v["version"].as_u64(), Some(3)); |
| 198 | + let nodes = v["nodes"].as_array().unwrap(); |
| 199 | + let edges = v["edges"].as_array().unwrap(); |
| 200 | + assert_eq!(nodes.len(), 11); |
| 201 | + assert_eq!(edges.len(), 11); |
| 202 | + |
| 203 | + // Edges reference valid node indices. |
| 204 | + for e in edges { |
| 205 | + let s = e["source"].as_u64().unwrap() as usize; |
| 206 | + let t = e["target"].as_u64().unwrap() as usize; |
| 207 | + assert!(s < nodes.len() && t < nodes.len(), "edge index out of range"); |
| 208 | + } |
| 209 | + |
| 210 | + // Edges are sorted by source index (the serializer's canonical order). |
| 211 | + let sources: Vec<u64> = edges.iter().map(|e| e["source"].as_u64().unwrap()).collect(); |
| 212 | + let mut sorted = sources.clone(); |
| 213 | + sorted.sort(); |
| 214 | + assert_eq!(sources, sorted, "edges must be sorted by source"); |
| 215 | + |
| 216 | + // Output is deterministic: re-parsing yields byte-identical JSON. |
| 217 | + let j1 = parse_default().to_json().unwrap(); |
| 218 | + let j2 = parse_default().to_json().unwrap(); |
| 219 | + assert_eq!(j1, j2, "JSON must be deterministic across parses"); |
| 220 | +} |
0 commit comments