1use std::collections::{BTreeMap, BTreeSet};
18use std::path::Path;
19
20use rusqlite::{Connection, OpenFlags, OptionalExtension};
21
22use crate::store::{Store, StoreError};
23use crate::{EdgeKind, NodeKind};
24
25pub const ORACLE_SCHEMA: &str = "roteiro.oracle/v1";
27
28#[derive(Debug, thiserror::Error)]
30pub enum OracleError {
31 #[error("codegraph sqlite error: {0}")]
33 Sqlite(#[from] rusqlite::Error),
34 #[error(transparent)]
36 Store(#[from] StoreError),
37 #[error("not a codegraph snapshot: {0}")]
39 NotCodegraph(String),
40}
41
42#[derive(Debug, Clone, serde::Serialize)]
46pub struct OracleReport {
47 pub schema: &'static str,
49 pub source_commit: Option<String>,
52 pub symbols_codegraph: usize,
54 pub symbols_roteiro: usize,
56 pub symbols_matched: usize,
58 pub symbols_scope_diff: usize,
62 pub codegraph_only: usize,
65 pub roteiro_only: usize,
67 pub codegraph_only_sample: Vec<String>,
69 pub roteiro_only_sample: Vec<String>,
71 pub constants_codegraph: usize,
74 pub calls_codegraph: usize,
76 pub calls_agree: usize,
78 pub calls_codegraph_only: usize,
81}
82
83const SAMPLE_CAP: usize = 25;
85
86pub fn compare(db_path: &Path, store: &Store) -> Result<OracleReport, OracleError> {
94 let conn = Connection::open_with_flags(
95 db_path,
96 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
97 )?;
98 ensure_codegraph(&conn, db_path)?;
99
100 let source_commit = conn
103 .query_row(
104 "SELECT value FROM meta WHERE key = 'snapshot_source_commit'",
105 [],
106 |r| r.get::<_, String>(0),
107 )
108 .optional()?;
109
110 let cg_symbols = codegraph_symbols(&conn)?;
112 let constants_codegraph = codegraph_constant_count(&conn)?;
113 let cg_calls = codegraph_calls(&conn, &cg_symbols)?;
114
115 let facts = store.export_factset()?;
117 let ro_symbols: BTreeSet<String> = facts
118 .nodes
119 .iter()
120 .filter(|n| is_comparable_kind(&n.kind))
121 .map(|n| n.key.clone())
122 .collect();
123 let ro_calls: BTreeSet<(String, String)> = facts
124 .edges
125 .iter()
126 .filter(|e| e.kind == EdgeKind::Calls)
127 .map(|e| (e.src.clone(), e.dst.clone()))
128 .collect();
129
130 let matched = cg_symbols.intersection(&ro_symbols).count();
131
132 let cg_rest: Vec<&String> = cg_symbols.difference(&ro_symbols).collect();
138 let ro_rest: Vec<&String> = ro_symbols.difference(&cg_symbols).collect();
139 let mut leaf_counts: BTreeMap<(String, String), [usize; 2]> = BTreeMap::new();
140 for k in &cg_rest {
141 leaf_counts.entry(path_leaf(k)).or_default()[0] += 1;
142 }
143 for k in &ro_rest {
144 leaf_counts.entry(path_leaf(k)).or_default()[1] += 1;
145 }
146 let scope_diff: usize = leaf_counts.values().map(|[c, r]| (*c).min(*r)).sum();
147
148 let cg_only = surplus_keys(&cg_rest, &leaf_counts, 0);
151 let ro_only = surplus_keys(&ro_rest, &leaf_counts, 1);
152
153 let calls_agree = cg_calls.iter().filter(|c| ro_calls.contains(*c)).count();
154
155 Ok(OracleReport {
156 schema: ORACLE_SCHEMA,
157 source_commit,
158 symbols_codegraph: cg_symbols.len(),
159 symbols_roteiro: ro_symbols.len(),
160 symbols_matched: matched,
161 symbols_scope_diff: scope_diff,
162 codegraph_only: cg_only.len(),
163 roteiro_only: ro_only.len(),
164 codegraph_only_sample: cg_only.into_iter().take(SAMPLE_CAP).collect(),
165 roteiro_only_sample: ro_only.into_iter().take(SAMPLE_CAP).collect(),
166 constants_codegraph,
167 calls_codegraph: cg_calls.len(),
168 calls_agree,
169 calls_codegraph_only: cg_calls.len() - calls_agree,
170 })
171}
172
173fn is_comparable_kind(kind: &NodeKind) -> bool {
176 matches!(
177 kind,
178 NodeKind::Fn | NodeKind::Struct | NodeKind::Enum | NodeKind::Trait
179 )
180}
181
182fn ensure_codegraph(conn: &Connection, path: &Path) -> Result<(), OracleError> {
186 for table in ["files", "nodes", "edges", "meta"] {
187 let present: Option<i64> = conn
188 .query_row(
189 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
190 [table],
191 |r| r.get(0),
192 )
193 .optional()?;
194 if present.is_none() {
195 return Err(OracleError::NotCodegraph(format!(
196 "{} has no `{table}` table",
197 path.display()
198 )));
199 }
200 }
201 Ok(())
202}
203
204fn symbol_key(path: &str, qualified: &str) -> String {
207 format!("sym:rust:{path}#{}", qualified.replace('.', "::"))
208}
209
210fn surplus_keys(
216 rest: &[&String],
217 counts: &BTreeMap<(String, String), [usize; 2]>,
218 side: usize,
219) -> Vec<String> {
220 let other = 1 - side;
221 let mut budget: BTreeMap<(String, String), usize> = counts
222 .iter()
223 .map(|(k, c)| (k.clone(), c[side].saturating_sub(c[other])))
224 .collect();
225 let mut out = Vec::new();
226 for key in rest {
227 if let Some(remaining) = budget.get_mut(&path_leaf(key))
228 && *remaining > 0
229 {
230 *remaining -= 1;
231 out.push((*key).clone());
232 }
233 }
234 out
235}
236
237fn path_leaf(key: &str) -> (String, String) {
241 let after = key.strip_prefix("sym:rust:").unwrap_or(key);
242 match after.split_once('#') {
243 Some((path, qual)) => (
244 path.to_owned(),
245 qual.rsplit("::").next().unwrap_or(qual).to_owned(),
246 ),
247 None => (after.to_owned(), String::new()),
248 }
249}
250
251fn codegraph_symbols(conn: &Connection) -> Result<BTreeSet<String>, OracleError> {
254 let mut stmt = conn.prepare(
255 "SELECT f.path, COALESCE(n.qualified_name, n.name)
256 FROM nodes n JOIN files f ON f.id = n.file_id
257 WHERE n.type IN ('function','struct','enum','trait')
258 AND f.path LIKE '%.rs'",
259 )?;
260 let rows = stmt.query_map([], |r| {
261 Ok(symbol_key(&r.get::<_, String>(0)?, &r.get::<_, String>(1)?))
262 })?;
263 let mut set = BTreeSet::new();
264 for row in rows {
265 set.insert(row?);
266 }
267 Ok(set)
268}
269
270fn codegraph_constant_count(conn: &Connection) -> Result<usize, OracleError> {
272 let n: i64 = conn.query_row(
273 "SELECT COUNT(*) FROM nodes n JOIN files f ON f.id = n.file_id
274 WHERE n.type = 'constant' AND f.path LIKE '%.rs'",
275 [],
276 |r| r.get(0),
277 )?;
278 Ok(usize::try_from(n).unwrap_or(0))
279}
280
281fn codegraph_calls(
284 conn: &Connection,
285 symbols: &BTreeSet<String>,
286) -> Result<BTreeSet<(String, String)>, OracleError> {
287 let mut stmt = conn.prepare(
288 "SELECT sf.path, COALESCE(s.qualified_name, s.name),
289 tf.path, COALESCE(t.qualified_name, t.name)
290 FROM edges e
291 JOIN nodes s ON s.id = e.source_id JOIN files sf ON sf.id = s.file_id
292 JOIN nodes t ON t.id = e.target_id JOIN files tf ON tf.id = t.file_id
293 WHERE e.relation = 'calls'
294 AND s.type = 'function' AND t.type = 'function'
295 AND sf.path LIKE '%.rs' AND tf.path LIKE '%.rs'",
296 )?;
297 let rows = stmt.query_map([], |r| {
298 let src = symbol_key(&r.get::<_, String>(0)?, &r.get::<_, String>(1)?);
299 let dst = symbol_key(&r.get::<_, String>(2)?, &r.get::<_, String>(3)?);
300 Ok((src, dst))
301 })?;
302 let mut set = BTreeSet::new();
303 for row in rows {
304 let (src, dst) = row?;
305 if symbols.contains(&src) && symbols.contains(&dst) {
307 set.insert((src, dst));
308 }
309 }
310 Ok(set)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::{OracleError, compare};
316 use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
317 use rusqlite::Connection;
318
319 fn write_snapshot(path: &std::path::Path) {
321 let conn = Connection::open(path).expect("open");
322 conn.execute_batch(
323 "CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT);
324 CREATE TABLE nodes (id INTEGER PRIMARY KEY, file_id INTEGER, type TEXT,
325 name TEXT, qualified_name TEXT);
326 CREATE TABLE edges (id INTEGER PRIMARY KEY, source_id INTEGER,
327 target_id INTEGER, relation TEXT);
328 CREATE TABLE meta (key TEXT, value TEXT);
329 INSERT INTO meta VALUES ('snapshot_source_commit', 'abc123');
330 INSERT INTO files VALUES (1, 'src/lib.rs');
331 -- Shared: Store (struct), Store.open (method), helper (fn).
332 INSERT INTO nodes VALUES (1, 1, 'struct', 'Store', 'Store');
333 INSERT INTO nodes VALUES (2, 1, 'function', 'open', 'Store.open');
334 INSERT INTO nodes VALUES (3, 1, 'function', 'helper', 'helper');
335 -- codegraph-only: a constant (Roteiro gap) and an extra fn.
336 INSERT INTO nodes VALUES (4, 1, 'constant', 'MAX', 'MAX');
337 INSERT INTO nodes VALUES (5, 1, 'function', 'only_cg','only_cg');
338 -- scope difference: codegraph keys a test fn bare, Roteiro scopes it.
339 INSERT INTO nodes VALUES (6, 1, 'function', 'foo', 'foo');
340 -- calls: Store.open -> helper.
341 INSERT INTO edges VALUES (1, 2, 3, 'calls');",
342 )
343 .expect("seed");
344 }
345
346 #[test]
347 fn compares_symbols_and_calls() {
348 let dir = std::env::temp_dir().join(format!("roteiro-oracle-{}", std::process::id()));
349 std::fs::create_dir_all(&dir).expect("mkdir");
350 let db = dir.join("cg.db");
351 write_snapshot(&db);
352
353 let mut store = Store::open_in_memory().expect("store");
356 let facts = FactSet::new()
357 .with_node(Node::new(
358 "sym:rust:src/lib.rs#Store",
359 NodeKind::Struct,
360 "Store",
361 ))
362 .with_node(Node::new(
363 "sym:rust:src/lib.rs#Store::open",
364 NodeKind::Fn,
365 "open",
366 ))
367 .with_node(Node::new(
368 "sym:rust:src/lib.rs#helper",
369 NodeKind::Fn,
370 "helper",
371 ))
372 .with_node(Node::new(
373 "sym:rust:src/lib.rs#roteiro_only",
374 NodeKind::Fn,
375 "roteiro_only",
376 ))
377 .with_node(Node::new(
379 "sym:rust:src/lib.rs#tests::foo",
380 NodeKind::Fn,
381 "foo",
382 ))
383 .with_edge(Edge::derived(
384 "sym:rust:src/lib.rs#Store::open",
385 "sym:rust:src/lib.rs#helper",
386 EdgeKind::Calls,
387 ));
388 store.apply_factset(&facts).expect("apply");
389
390 let report = compare(&db, &store).expect("compare");
391 assert_eq!(report.source_commit.as_deref(), Some("abc123"));
392 assert_eq!(report.symbols_codegraph, 5);
394 assert_eq!(report.symbols_roteiro, 5);
395 assert_eq!(report.symbols_matched, 3, "Store, Store::open, helper");
396 assert_eq!(report.symbols_scope_diff, 1, "foo vs tests::foo");
397 assert_eq!(report.codegraph_only, 1, "only_cg (genuine)");
398 assert_eq!(report.roteiro_only, 1, "roteiro_only (genuine)");
399 assert_eq!(
400 report.codegraph_only_sample,
401 vec!["sym:rust:src/lib.rs#only_cg"]
402 );
403 assert_eq!(report.constants_codegraph, 1, "MAX is a Roteiro gap");
404 assert_eq!(report.calls_codegraph, 1);
406 assert_eq!(report.calls_agree, 1);
407 assert_eq!(report.calls_codegraph_only, 0);
408
409 std::fs::remove_dir_all(&dir).ok();
410 }
411
412 #[test]
415 fn multiplicity_surplus_is_a_genuine_gap() {
416 let dir = std::env::temp_dir().join(format!("roteiro-oracle-mult-{}", std::process::id()));
417 std::fs::create_dir_all(&dir).expect("mkdir");
418 let db = dir.join("cg.db");
419 let conn = Connection::open(&db).expect("open");
420 conn.execute_batch(
421 "CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT);
422 CREATE TABLE nodes (id INTEGER PRIMARY KEY, file_id INTEGER, type TEXT,
423 name TEXT, qualified_name TEXT);
424 CREATE TABLE edges (id INTEGER PRIMARY KEY, source_id INTEGER,
425 target_id INTEGER, relation TEXT);
426 CREATE TABLE meta (key TEXT, value TEXT);
427 INSERT INTO files VALUES (1, 'src/lib.rs');
428 -- codegraph: two `run` functions under different (dropped) scopes.
429 INSERT INTO nodes VALUES (1, 1, 'function', 'run', 'a.run');
430 INSERT INTO nodes VALUES (2, 1, 'function', 'run', 'b.run');",
431 )
432 .expect("seed");
433
434 let mut store = Store::open_in_memory().expect("store");
437 store
438 .apply_factset(&FactSet::new().with_node(Node::new(
439 "sym:rust:src/lib.rs#tests::run",
440 NodeKind::Fn,
441 "run",
442 )))
443 .expect("apply");
444
445 let report = compare(&db, &store).expect("compare");
446 assert_eq!(report.symbols_scope_diff, 1);
448 assert_eq!(
449 report.codegraph_only, 1,
450 "the surplus `run` is a genuine gap"
451 );
452 assert_eq!(report.roteiro_only, 0);
453
454 std::fs::remove_dir_all(&dir).ok();
455 }
456
457 #[test]
458 fn rejects_non_codegraph_db() {
459 let dir = std::env::temp_dir().join(format!("roteiro-oracle-bad-{}", std::process::id()));
460 std::fs::create_dir_all(&dir).expect("mkdir");
461 let db = dir.join("plain.db");
462 let conn = Connection::open(&db).expect("open");
463 conn.execute_batch("CREATE TABLE whatever (x INTEGER);")
464 .expect("seed");
465 drop(conn);
466
467 let store = Store::open_in_memory().expect("store");
468 let err = compare(&db, &store).expect_err("should reject");
469 assert!(matches!(err, OracleError::NotCodegraph(_)));
470
471 std::fs::remove_dir_all(&dir).ok();
472 }
473}