use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use rusqlite::{Connection, OpenFlags, OptionalExtension};
use crate::store::{Store, StoreError};
use crate::{EdgeKind, NodeKind};
pub const ORACLE_SCHEMA: &str = "roteiro.oracle/v1";
#[derive(Debug, thiserror::Error)]
pub enum OracleError {
#[error("codegraph sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error(transparent)]
Store(#[from] StoreError),
#[error("not a codegraph snapshot: {0}")]
NotCodegraph(String),
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct OracleReport {
pub schema: &'static str,
pub source_commit: Option<String>,
pub symbols_codegraph: usize,
pub symbols_roteiro: usize,
pub symbols_matched: usize,
pub symbols_scope_diff: usize,
pub codegraph_only: usize,
pub roteiro_only: usize,
pub codegraph_only_sample: Vec<String>,
pub roteiro_only_sample: Vec<String>,
pub constants_codegraph: usize,
pub calls_codegraph: usize,
pub calls_agree: usize,
pub calls_codegraph_only: usize,
}
const SAMPLE_CAP: usize = 25;
pub fn compare(db_path: &Path, store: &Store) -> Result<OracleReport, OracleError> {
let conn = Connection::open_with_flags(
db_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)?;
ensure_codegraph(&conn, db_path)?;
let source_commit = conn
.query_row(
"SELECT value FROM meta WHERE key = 'snapshot_source_commit'",
[],
|r| r.get::<_, String>(0),
)
.optional()?;
let cg_symbols = codegraph_symbols(&conn)?;
let constants_codegraph = codegraph_constant_count(&conn)?;
let cg_calls = codegraph_calls(&conn, &cg_symbols)?;
let facts = store.export_factset()?;
let ro_symbols: BTreeSet<String> = facts
.nodes
.iter()
.filter(|n| is_comparable_kind(&n.kind))
.map(|n| n.key.clone())
.collect();
let ro_calls: BTreeSet<(String, String)> = facts
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls)
.map(|e| (e.src.clone(), e.dst.clone()))
.collect();
let matched = cg_symbols.intersection(&ro_symbols).count();
let cg_rest: Vec<&String> = cg_symbols.difference(&ro_symbols).collect();
let ro_rest: Vec<&String> = ro_symbols.difference(&cg_symbols).collect();
let mut leaf_counts: BTreeMap<(String, String), [usize; 2]> = BTreeMap::new();
for k in &cg_rest {
leaf_counts.entry(path_leaf(k)).or_default()[0] += 1;
}
for k in &ro_rest {
leaf_counts.entry(path_leaf(k)).or_default()[1] += 1;
}
let scope_diff: usize = leaf_counts.values().map(|[c, r]| (*c).min(*r)).sum();
let cg_only = surplus_keys(&cg_rest, &leaf_counts, 0);
let ro_only = surplus_keys(&ro_rest, &leaf_counts, 1);
let calls_agree = cg_calls.iter().filter(|c| ro_calls.contains(*c)).count();
Ok(OracleReport {
schema: ORACLE_SCHEMA,
source_commit,
symbols_codegraph: cg_symbols.len(),
symbols_roteiro: ro_symbols.len(),
symbols_matched: matched,
symbols_scope_diff: scope_diff,
codegraph_only: cg_only.len(),
roteiro_only: ro_only.len(),
codegraph_only_sample: cg_only.into_iter().take(SAMPLE_CAP).collect(),
roteiro_only_sample: ro_only.into_iter().take(SAMPLE_CAP).collect(),
constants_codegraph,
calls_codegraph: cg_calls.len(),
calls_agree,
calls_codegraph_only: cg_calls.len() - calls_agree,
})
}
fn is_comparable_kind(kind: &NodeKind) -> bool {
matches!(
kind,
NodeKind::Fn | NodeKind::Struct | NodeKind::Enum | NodeKind::Trait
)
}
fn ensure_codegraph(conn: &Connection, path: &Path) -> Result<(), OracleError> {
for table in ["files", "nodes", "edges", "meta"] {
let present: Option<i64> = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
[table],
|r| r.get(0),
)
.optional()?;
if present.is_none() {
return Err(OracleError::NotCodegraph(format!(
"{} has no `{table}` table",
path.display()
)));
}
}
Ok(())
}
fn symbol_key(path: &str, qualified: &str) -> String {
format!("sym:rust:{path}#{}", qualified.replace('.', "::"))
}
fn surplus_keys(
rest: &[&String],
counts: &BTreeMap<(String, String), [usize; 2]>,
side: usize,
) -> Vec<String> {
let other = 1 - side;
let mut budget: BTreeMap<(String, String), usize> = counts
.iter()
.map(|(k, c)| (k.clone(), c[side].saturating_sub(c[other])))
.collect();
let mut out = Vec::new();
for key in rest {
if let Some(remaining) = budget.get_mut(&path_leaf(key))
&& *remaining > 0
{
*remaining -= 1;
out.push((*key).clone());
}
}
out
}
fn path_leaf(key: &str) -> (String, String) {
let after = key.strip_prefix("sym:rust:").unwrap_or(key);
match after.split_once('#') {
Some((path, qual)) => (
path.to_owned(),
qual.rsplit("::").next().unwrap_or(qual).to_owned(),
),
None => (after.to_owned(), String::new()),
}
}
fn codegraph_symbols(conn: &Connection) -> Result<BTreeSet<String>, OracleError> {
let mut stmt = conn.prepare(
"SELECT f.path, COALESCE(n.qualified_name, n.name)
FROM nodes n JOIN files f ON f.id = n.file_id
WHERE n.type IN ('function','struct','enum','trait')
AND f.path LIKE '%.rs'",
)?;
let rows = stmt.query_map([], |r| {
Ok(symbol_key(&r.get::<_, String>(0)?, &r.get::<_, String>(1)?))
})?;
let mut set = BTreeSet::new();
for row in rows {
set.insert(row?);
}
Ok(set)
}
fn codegraph_constant_count(conn: &Connection) -> Result<usize, OracleError> {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM nodes n JOIN files f ON f.id = n.file_id
WHERE n.type = 'constant' AND f.path LIKE '%.rs'",
[],
|r| r.get(0),
)?;
Ok(usize::try_from(n).unwrap_or(0))
}
fn codegraph_calls(
conn: &Connection,
symbols: &BTreeSet<String>,
) -> Result<BTreeSet<(String, String)>, OracleError> {
let mut stmt = conn.prepare(
"SELECT sf.path, COALESCE(s.qualified_name, s.name),
tf.path, COALESCE(t.qualified_name, t.name)
FROM edges e
JOIN nodes s ON s.id = e.source_id JOIN files sf ON sf.id = s.file_id
JOIN nodes t ON t.id = e.target_id JOIN files tf ON tf.id = t.file_id
WHERE e.relation = 'calls'
AND s.type = 'function' AND t.type = 'function'
AND sf.path LIKE '%.rs' AND tf.path LIKE '%.rs'",
)?;
let rows = stmt.query_map([], |r| {
let src = symbol_key(&r.get::<_, String>(0)?, &r.get::<_, String>(1)?);
let dst = symbol_key(&r.get::<_, String>(2)?, &r.get::<_, String>(3)?);
Ok((src, dst))
})?;
let mut set = BTreeSet::new();
for row in rows {
let (src, dst) = row?;
if symbols.contains(&src) && symbols.contains(&dst) {
set.insert((src, dst));
}
}
Ok(set)
}
#[cfg(test)]
mod tests {
use super::{OracleError, compare};
use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
use rusqlite::Connection;
fn write_snapshot(path: &std::path::Path) {
let conn = Connection::open(path).expect("open");
conn.execute_batch(
"CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT);
CREATE TABLE nodes (id INTEGER PRIMARY KEY, file_id INTEGER, type TEXT,
name TEXT, qualified_name TEXT);
CREATE TABLE edges (id INTEGER PRIMARY KEY, source_id INTEGER,
target_id INTEGER, relation TEXT);
CREATE TABLE meta (key TEXT, value TEXT);
INSERT INTO meta VALUES ('snapshot_source_commit', 'abc123');
INSERT INTO files VALUES (1, 'src/lib.rs');
-- Shared: Store (struct), Store.open (method), helper (fn).
INSERT INTO nodes VALUES (1, 1, 'struct', 'Store', 'Store');
INSERT INTO nodes VALUES (2, 1, 'function', 'open', 'Store.open');
INSERT INTO nodes VALUES (3, 1, 'function', 'helper', 'helper');
-- codegraph-only: a constant (Roteiro gap) and an extra fn.
INSERT INTO nodes VALUES (4, 1, 'constant', 'MAX', 'MAX');
INSERT INTO nodes VALUES (5, 1, 'function', 'only_cg','only_cg');
-- scope difference: codegraph keys a test fn bare, Roteiro scopes it.
INSERT INTO nodes VALUES (6, 1, 'function', 'foo', 'foo');
-- calls: Store.open -> helper.
INSERT INTO edges VALUES (1, 2, 3, 'calls');",
)
.expect("seed");
}
#[test]
fn compares_symbols_and_calls() {
let dir = std::env::temp_dir().join(format!("roteiro-oracle-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let db = dir.join("cg.db");
write_snapshot(&db);
let mut store = Store::open_in_memory().expect("store");
let facts = FactSet::new()
.with_node(Node::new(
"sym:rust:src/lib.rs#Store",
NodeKind::Struct,
"Store",
))
.with_node(Node::new(
"sym:rust:src/lib.rs#Store::open",
NodeKind::Fn,
"open",
))
.with_node(Node::new(
"sym:rust:src/lib.rs#helper",
NodeKind::Fn,
"helper",
))
.with_node(Node::new(
"sym:rust:src/lib.rs#roteiro_only",
NodeKind::Fn,
"roteiro_only",
))
.with_node(Node::new(
"sym:rust:src/lib.rs#tests::foo",
NodeKind::Fn,
"foo",
))
.with_edge(Edge::derived(
"sym:rust:src/lib.rs#Store::open",
"sym:rust:src/lib.rs#helper",
EdgeKind::Calls,
));
store.apply_factset(&facts).expect("apply");
let report = compare(&db, &store).expect("compare");
assert_eq!(report.source_commit.as_deref(), Some("abc123"));
assert_eq!(report.symbols_codegraph, 5);
assert_eq!(report.symbols_roteiro, 5);
assert_eq!(report.symbols_matched, 3, "Store, Store::open, helper");
assert_eq!(report.symbols_scope_diff, 1, "foo vs tests::foo");
assert_eq!(report.codegraph_only, 1, "only_cg (genuine)");
assert_eq!(report.roteiro_only, 1, "roteiro_only (genuine)");
assert_eq!(
report.codegraph_only_sample,
vec!["sym:rust:src/lib.rs#only_cg"]
);
assert_eq!(report.constants_codegraph, 1, "MAX is a Roteiro gap");
assert_eq!(report.calls_codegraph, 1);
assert_eq!(report.calls_agree, 1);
assert_eq!(report.calls_codegraph_only, 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn multiplicity_surplus_is_a_genuine_gap() {
let dir = std::env::temp_dir().join(format!("roteiro-oracle-mult-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let db = dir.join("cg.db");
let conn = Connection::open(&db).expect("open");
conn.execute_batch(
"CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT);
CREATE TABLE nodes (id INTEGER PRIMARY KEY, file_id INTEGER, type TEXT,
name TEXT, qualified_name TEXT);
CREATE TABLE edges (id INTEGER PRIMARY KEY, source_id INTEGER,
target_id INTEGER, relation TEXT);
CREATE TABLE meta (key TEXT, value TEXT);
INSERT INTO files VALUES (1, 'src/lib.rs');
-- codegraph: two `run` functions under different (dropped) scopes.
INSERT INTO nodes VALUES (1, 1, 'function', 'run', 'a.run');
INSERT INTO nodes VALUES (2, 1, 'function', 'run', 'b.run');",
)
.expect("seed");
let mut store = Store::open_in_memory().expect("store");
store
.apply_factset(&FactSet::new().with_node(Node::new(
"sym:rust:src/lib.rs#tests::run",
NodeKind::Fn,
"run",
)))
.expect("apply");
let report = compare(&db, &store).expect("compare");
assert_eq!(report.symbols_scope_diff, 1);
assert_eq!(
report.codegraph_only, 1,
"the surplus `run` is a genuine gap"
);
assert_eq!(report.roteiro_only, 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_non_codegraph_db() {
let dir = std::env::temp_dir().join(format!("roteiro-oracle-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let db = dir.join("plain.db");
let conn = Connection::open(&db).expect("open");
conn.execute_batch("CREATE TABLE whatever (x INTEGER);")
.expect("seed");
drop(conn);
let store = Store::open_in_memory().expect("store");
let err = compare(&db, &store).expect_err("should reject");
assert!(matches!(err, OracleError::NotCodegraph(_)));
std::fs::remove_dir_all(&dir).ok();
}
}