Skip to main content

cli/
structure.rs

1//! Structure ingest: the working tree, read as graph.
2//!
3//! [`ingest_git`](crate::ingest_git) builds the *history* half of a codebase
4//! graph — who touched what, when. This module builds the *present* half from
5//! the files on disk: what each file defines, what it imports, what its
6//! definitions call, and what the prose says about it.
7//!
8//! It never writes an edge itself. Every relationship is stored as a list
9//! property and left to a rule, so the engine owns retraction: rewrite a
10//! file's `imports` and the stale `IMPORTS` edges retract in the same commit.
11//!
12//! | Written on | Prop | Derives |
13//! |---|---|---|
14//! | `File` | `imports: [File key]` | `IMPORTS` File → File |
15//! | `File` | `mentions: [File key]` | `MENTIONS` File → File |
16//! | `Symbol` | `calls_to: [Symbol key]` | `CALLS` Symbol → Symbol |
17//! | `Symbol` | `file_id: File key` | `DEFINES` Symbol → File |
18//!
19//! Beside each list sits its evidence: `import_lines` and `call_lines` hold
20//! `"<key>\t<line>"` strings, so a tool can quote the line a link came from.
21//!
22//! # What is read
23//!
24//! Only the working tree. The candidates are the `File` nodes git already put
25//! in the graph — so exclusion patterns have been applied — narrowed to those
26//! that exist on disk right now. A path that lives only in history keeps
27//! whatever git recorded about it and is skipped here.
28//!
29//! # What is written
30//!
31//! Only differences. Each file's stored props are compared field by field
32//! against the freshly extracted ones, and its `Symbol` nodes against the
33//! symbols just found in it. A file whose bytes have not changed produces no
34//! write at all, which is what makes a re-run byte-identical.
35use crate::CliError;
36use code_extract::{
37    extract, resolve_call, resolve_import, resolve_mention, FileFacts, SymbolIndex, MAX_FILE_BYTES,
38};
39use core_api::repograph::rules::{about_rule, concept_sources_rule, ABOUT_LABELS};
40use core_api::{default_max_edges, BatchOp, Predicate, RuleDef, Value};
41use std::collections::{BTreeMap, BTreeSet};
42use std::path::Path;
43
44/// `GraphDb` over the real filesystem, named without spelling out `RealFs` —
45/// which `core-api` does not re-export. Both an open [`core_api::GraphDb`] and
46/// a [`core_api::WriteGuard`] (through its `Deref`) are one of these.
47pub type Db = <core_api::WriteGuard<'static> as std::ops::Deref>::Target;
48
49/// Most `Symbol` nodes kept for one file. A generated or vendored file can
50/// define tens of thousands; past this the file is still hashed and its
51/// imports still resolve, it just stops contributing definitions.
52pub const MAX_SYMBOLS_PER_FILE: usize = 2_000;
53
54/// Files per write batch, and so per WAL commit.
55const BATCH_FILES: usize = 500;
56
57/// Name of the `Symbol.file_id` foreign-key rule. Identical to the name
58/// zero-config FK inference would choose, so the two never both create it —
59/// and declaring it here is what makes the edge `DEFINES` rather than the
60/// `FILE` that inference would derive from the field name.
61pub const DEFINES_RULE: &str = "auto_fk_symbol_file_id";
62
63/// `(label, field)` pairs this module indexes for full-text search.
64///
65/// `Note` and `Concept` are indexed here rather than where they are written
66/// (`remember` and the semantic-pass `ingest_json`) because a store synced
67/// after either wrote is still expected to gain the index — `remember` also
68/// ensures its own `Note.text` pair, in case it is the very first write.
69pub const FULLTEXT: [(&str, &str); 8] = [
70    ("Concept", "name"),
71    ("Concept", "summary"),
72    ("File", "body"),
73    ("File", "headings"),
74    ("File", "path"),
75    ("Note", "text"),
76    ("Symbol", "doc"),
77    ("Symbol", "name"),
78];
79
80/// What one refresh saw. Counts cover every file *scanned*, including those
81/// that needed no write, so the numbers are stable across re-runs.
82#[derive(Debug, Default, Clone, PartialEq, Eq)]
83pub struct StructureReport {
84    /// Files read from disk and extracted.
85    pub files_scanned: usize,
86    /// Symbols found across those files, after the per-file cap.
87    pub symbols: usize,
88    /// Resolved import targets.
89    pub imports: usize,
90    /// Resolved documentation mentions.
91    pub mentions: usize,
92    /// Resolved calls between symbols.
93    pub calls: usize,
94    /// Files reduced to hash, language and line count because they are over
95    /// [`MAX_FILE_BYTES`] or are not text.
96    pub skipped_large: usize,
97    /// Files that hit [`MAX_SYMBOLS_PER_FILE`].
98    pub symbols_capped: usize,
99}
100
101/// Every rule this module declares, in creation order.
102///
103/// Exported so a test — or a store built some other way — can recreate exactly
104/// the rule set these props expect. [`ensure_rules_and_fulltext`] creates an
105/// `about_<label>` rule only when its destination label is present in the
106/// graph; every other rule here is unconditional. The `about_<label>` and
107/// `concept_sources` definitions themselves come from
108/// [`core_api::repograph::rules`], which `remember` also builds them from —
109/// one definition, so a note written by `remember` and one backfilled by a
110/// sync agree on exactly the same rule.
111#[must_use]
112pub fn rules() -> Vec<RuleDef> {
113    let mut out = vec![
114        key_rule(DEFINES_RULE, "Symbol", "File", "file_id", "DEFINES"),
115        key_rule("imports", "File", "File", "imports", "IMPORTS"),
116        key_rule("calls", "Symbol", "Symbol", "calls_to", "CALLS"),
117        key_rule("mentions", "File", "File", "mentions", "MENTIONS"),
118        concept_sources_rule(),
119    ];
120    for label in ABOUT_LABELS {
121        out.push(about_rule(label));
122    }
123    out
124}
125
126/// A `KeyMatch` rule with the engine's default fan-out for the predicate,
127/// stated rather than left implicit — the convention across this crate.
128fn key_rule(name: &str, src: &str, dst: &str, field: &str, edge: &str) -> RuleDef {
129    let predicate = Predicate::KeyMatch {
130        field: field.into(),
131    };
132    let max_edges = Some(default_max_edges(&predicate));
133    RuleDef {
134        name: name.into(),
135        src_label: src.into(),
136        dst_label: dst.into(),
137        predicate,
138        edge_type: edge.into(),
139        weight_prop: None,
140        max_edges,
141        approximate: false,
142        via_label: None,
143        via_edge: None,
144        via_dir: None,
145    }
146}
147
148/// Declare the structure rules and full-text fields that are missing, and
149/// return the names of the rules created.
150///
151/// Idempotent: existence is checked against `rules()` and `fulltext_pairs()`,
152/// so a second call writes nothing. Call it *after* the props are written — a
153/// rule backfills once, on creation, and by then both the `Symbol` label and
154/// the lists it matches on exist.
155pub fn ensure_rules_and_fulltext(w: &mut Db) -> Result<Vec<String>, CliError> {
156    let existing: BTreeSet<String> = w.rules().into_iter().map(|r| r.name).collect();
157    let mut created = Vec::new();
158    for def in rules() {
159        if existing.contains(&def.name) {
160            continue;
161        }
162        // An `about_<label>` rule is only worth declaring once something can
163        // be on the receiving end of it.
164        if def.src_label == "Note" && !label_present(w, &def.dst_label)? {
165            continue;
166        }
167        let name = def.name.clone();
168        w.create_rule(def)?;
169        created.push(name);
170    }
171    for (label, field) in FULLTEXT {
172        if !w
173            .fulltext_pairs()
174            .contains(&(label.to_string(), field.to_string()))
175        {
176            w.enable_fulltext(label, field)?;
177        }
178    }
179    Ok(created)
180}
181
182/// Whether the graph holds at least one node of `label`. `label` is always one
183/// of [`ABOUT_LABELS`], so it is never user input.
184fn label_present(w: &Db, label: &str) -> Result<bool, CliError> {
185    let rs = w.query(
186        &format!("MATCH (n:{label}) RETURN n.id AS id LIMIT 1"),
187        &BTreeMap::new(),
188    )?;
189    Ok(!rs.is_empty())
190}
191
192/// The `File` keys under a key prefix. `""` is the whole graph, `"vendor/lib/"`
193/// one submodule; keys are repository-relative with `/` separators, which is
194/// exactly what `code-extract` resolves against.
195const FILE_KEYS_QUERY: &str = "MATCH (f:File) WHERE startsWith(f.id, $prefix) RETURN f.id AS id";
196
197/// Every `Symbol` node, with the file it belongs to.
198const SYMBOL_QUERY: &str =
199    "MATCH (s:Symbol) RETURN s.id AS id, s.name AS name, s.file_id AS file_id";
200
201/// Every `File` node's link lists, for the stale-key scan.
202const LINK_LISTS_QUERY: &str =
203    "MATCH (f:File) RETURN f.id AS id, f.imports AS imports, f.mentions AS mentions";
204
205/// Refresh every working-tree file under `prefix`.
206///
207/// Writes in batches of [`BATCH_FILES`] files, one WAL commit per batch. Files
208/// whose stored props already match the working tree are left untouched.
209pub fn refresh_all(
210    w: &mut Db,
211    repo: &Path,
212    prefix: &str,
213    with_docs: bool,
214) -> Result<StructureReport, CliError> {
215    refresh(w, repo, prefix, None, with_docs)
216}
217
218/// Refresh exactly `paths` — those of them that are still files on disk.
219///
220/// The incremental counterpart of [`refresh_all`]: the caller passes the paths
221/// this sync touched, plus every file whose link lists named a path that moved
222/// or vanished. See [`importers_of`].
223pub fn refresh_files(
224    w: &mut Db,
225    repo: &Path,
226    prefix: &str,
227    paths: &[String],
228    with_docs: bool,
229) -> Result<StructureReport, CliError> {
230    refresh(w, repo, prefix, Some(paths), with_docs)
231}
232
233/// The `File` keys whose `imports` or `mentions` list still names one of
234/// `keys`.
235///
236/// Renaming a node moves the key its list-derived edges point at, but not the
237/// list that derived them: the importer's `imports` still holds the old key,
238/// so the edge is stale until that file is extracted again. After a rename or
239/// a delete, feed this to [`refresh_files`] alongside the paths that changed
240/// and the lists — and with them the edges — are rewritten.
241pub fn importers_of(w: &Db, keys: &BTreeSet<String>) -> Result<Vec<String>, CliError> {
242    if keys.is_empty() {
243        return Ok(Vec::new());
244    }
245    let rs = w.query(LINK_LISTS_QUERY, &BTreeMap::new())?;
246    let mut out = BTreeSet::new();
247    for i in 0..rs.len() {
248        let Some(Value::Str(id)) = rs.get(i, "id") else {
249            continue;
250        };
251        let names = |field: &str| {
252            matches!(rs.get(i, field), Some(Value::List(l))
253                if l.iter().any(|v| matches!(v, Value::Str(s) if keys.contains(s))))
254        };
255        if names("imports") || names("mentions") {
256            out.insert(id.clone());
257        }
258    }
259    Ok(out.into_iter().collect())
260}
261
262// ── the refresh pass ────────────────────────────────────────────────────────
263
264/// The working tree as the resolvers see it, built once per refresh.
265///
266/// Every lookup `code-extract` needs is answered from this one listing, so a
267/// resolution never touches the filesystem and never names a path that has no
268/// `File` node behind it.
269#[derive(Default)]
270struct Tree {
271    files: BTreeSet<String>,
272    by_dir: BTreeMap<String, Vec<String>>,
273    by_base: BTreeMap<String, Vec<String>>,
274}
275
276impl Tree {
277    fn build(keys: impl IntoIterator<Item = String>) -> Tree {
278        let mut tree = Tree::default();
279        for key in keys {
280            let (dir, base) = match key.rsplit_once('/') {
281                Some((d, b)) => (d.to_string(), b.to_string()),
282                None => (String::new(), key.clone()),
283            };
284            tree.by_base.entry(base).or_default().push(key.clone());
285            tree.by_dir.entry(dir).or_default().push(key.clone());
286            tree.files.insert(key);
287        }
288        tree
289    }
290
291    fn known(&self, path: &str) -> bool {
292        self.files.contains(path)
293    }
294
295    fn files_in(&self, dir: &str) -> Vec<String> {
296        self.by_dir.get(dir).cloned().unwrap_or_default()
297    }
298
299    fn by_basename(&self, name: &str) -> Vec<String> {
300        self.by_base.get(name).cloned().unwrap_or_default()
301    }
302}
303
304/// One symbol, resolved and ready to store.
305struct SymbolWrite {
306    key: String,
307    name: String,
308    kind: &'static str,
309    line_start: u32,
310    line_end: u32,
311    signature: String,
312    doc: String,
313    /// Resolved callee keys, sorted and deduplicated.
314    calls: Vec<String>,
315    /// `"<callee key>\t<line>"`, sorted and deduplicated.
316    call_lines: Vec<String>,
317}
318
319/// One file's resolved facts, ready to diff against what is stored.
320struct FileWrite {
321    path: String,
322    hash: String,
323    lines: u32,
324    lang: &'static str,
325    imports: Vec<String>,
326    import_lines: Vec<String>,
327    mentions: Vec<String>,
328    headings: Vec<String>,
329    body: Option<String>,
330    symbols: Vec<SymbolWrite>,
331}
332
333fn list(items: &[String]) -> Value {
334    Value::List(items.iter().map(|s| Value::Str(s.clone())).collect())
335}
336
337/// A list prop, or `None` when it is empty — an empty list carries no edge, so
338/// the prop is removed rather than stored blank.
339fn some_list(items: &[String]) -> Option<Value> {
340    (!items.is_empty()).then(|| list(items))
341}
342
343impl FileWrite {
344    /// The props this file should carry. `None` means "must not be set", which
345    /// is how a prop — and the edges derived from it — is retracted.
346    fn props(&self) -> Vec<(&'static str, Option<Value>)> {
347        vec![
348            ("hash", Some(Value::Str(self.hash.clone()))),
349            ("lines", Some(Value::Int(i64::from(self.lines)))),
350            ("lang", Some(Value::Str(self.lang.to_string()))),
351            ("symbols_n", Some(Value::Int(self.symbols.len() as i64))),
352            ("imports", some_list(&self.imports)),
353            ("import_lines", some_list(&self.import_lines)),
354            ("mentions", some_list(&self.mentions)),
355            ("headings", some_list(&self.headings)),
356            ("body", self.body.as_ref().map(|b| Value::Str(b.clone()))),
357        ]
358    }
359}
360
361impl SymbolWrite {
362    fn props(&self, file: &str) -> Vec<(&'static str, Option<Value>)> {
363        vec![
364            ("id", Some(Value::Str(self.key.clone()))),
365            ("name", Some(Value::Str(self.name.clone()))),
366            ("kind", Some(Value::Str(self.kind.to_string()))),
367            ("path", Some(Value::Str(file.to_string()))),
368            ("file_id", Some(Value::Str(file.to_string()))),
369            ("line_start", Some(Value::Int(i64::from(self.line_start)))),
370            ("line_end", Some(Value::Int(i64::from(self.line_end)))),
371            ("signature", Some(Value::Str(self.signature.clone()))),
372            ("doc", Some(Value::Str(self.doc.clone()))),
373            ("calls_to", some_list(&self.calls)),
374            ("call_lines", some_list(&self.call_lines)),
375        ]
376    }
377}
378
379/// The symbols one file contributes: the key each is stored under, and its
380/// index into `FileFacts::symbols`. Two definitions that qualify to the same
381/// name would share a key, so the first wins; the rest are dropped.
382fn symbol_keys(path: &str, facts: &FileFacts) -> (Vec<(String, usize)>, bool) {
383    let mut seen = BTreeSet::new();
384    let mut out = Vec::new();
385    let mut capped = false;
386    for (at, sym) in facts.symbols.iter().enumerate() {
387        let key = format!("{path}#{}", sym.name);
388        if !seen.insert(key.clone()) {
389            continue;
390        }
391        if out.len() == MAX_SYMBOLS_PER_FILE {
392            capped = true;
393            break;
394        }
395        out.push((key, at));
396    }
397    (out, capped)
398}
399
400fn refresh(
401    w: &mut Db,
402    repo: &Path,
403    prefix: &str,
404    only: Option<&[String]>,
405    with_docs: bool,
406) -> Result<StructureReport, CliError> {
407    // 1. What the graph believes, narrowed to what is on disk right now.
408    let params = BTreeMap::from([("prefix".to_string(), Value::Str(prefix.to_string()))]);
409    let rs = w.query(FILE_KEYS_QUERY, &params)?;
410    let mut candidates = Vec::new();
411    for i in 0..rs.len() {
412        if let Some(Value::Str(id)) = rs.get(i, "id") {
413            if repo.join(id).is_file() {
414                candidates.push(id.clone());
415            }
416        }
417    }
418    let tree = Tree::build(candidates.iter().cloned());
419
420    // 2. The files this pass is responsible for.
421    let targets: Vec<String> = match only {
422        None => candidates,
423        Some(paths) => {
424            let wanted: BTreeSet<&String> = paths.iter().collect();
425            candidates
426                .into_iter()
427                .filter(|p| wanted.contains(p))
428                .collect()
429        }
430    };
431
432    // 3. Extract. The facts are kept: they are both what gets written and what
433    //    the symbol index is built from.
434    let mut facts: BTreeMap<String, FileFacts> = BTreeMap::new();
435    let mut hash_only: BTreeSet<String> = BTreeSet::new();
436    for path in &targets {
437        let Ok(bytes) = std::fs::read(repo.join(path)) else {
438            continue; // unreadable right now; the next sync tries again
439        };
440        if bytes.len() > MAX_FILE_BYTES || is_binary(&bytes) {
441            hash_only.insert(path.clone());
442        }
443        facts.insert(path.clone(), extract(path, &bytes));
444    }
445
446    // 4. Symbols already in the graph: so a call can reach a file this pass is
447    //    not touching, and so orphans — symbols whose file was renamed away or
448    //    deleted, and whose keys can never be right again — can be swept.
449    let stored = w.query(SYMBOL_QUERY, &BTreeMap::new())?;
450    let mut by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
451    let mut orphans: Vec<String> = Vec::new();
452    let mut index = SymbolIndex::new();
453    for i in 0..stored.len() {
454        let (Some(Value::Str(id)), Some(Value::Str(file))) =
455            (stored.get(i, "id"), stored.get(i, "file_id"))
456        else {
457            continue;
458        };
459        if !w.has_node(file.as_str()) {
460            orphans.push(id.clone());
461            continue;
462        }
463        by_file.entry(file.clone()).or_default().insert(id.clone());
464        if facts.contains_key(file) || !tree.known(file) {
465            continue; // superseded by this pass, or not a working-tree file
466        }
467        if let Some(Value::Str(name)) = stored.get(i, "name") {
468            index.insert(name, id);
469        }
470    }
471
472    // 5. Resolve. Symbol keys first, so every call is looked up in one index
473    //    spanning the whole tree and a callee in an untouched file still hits.
474    let mut report = StructureReport::default();
475    let mut keyed: BTreeMap<&String, Vec<(String, usize)>> = BTreeMap::new();
476    for (path, f) in &facts {
477        let (keys, capped) = symbol_keys(path, f);
478        for (key, at) in &keys {
479            index.insert(&f.symbols[*at].name, key);
480        }
481        report.symbols_capped += usize::from(capped);
482        keyed.insert(path, keys);
483    }
484
485    let mut writes: Vec<FileWrite> = Vec::new();
486    for (path, f) in &facts {
487        let write = resolve_file(path, f, &tree, &index, &keyed[path], with_docs);
488        report.files_scanned += 1;
489        report.symbols += write.symbols.len();
490        report.imports += write.imports.len();
491        report.mentions += write.mentions.len();
492        report.calls += write.symbols.iter().map(|s| s.calls.len()).sum::<usize>();
493        report.skipped_large += usize::from(hash_only.contains(path));
494        writes.push(write);
495    }
496
497    // 6. Write. Orphans go first and in their own commit: their keys must be
498    //    free before a renamed file re-creates its symbols under the new path.
499    if !orphans.is_empty() {
500        let ops = orphans
501            .iter()
502            .map(|key| BatchOp::DeleteNode { key: key.clone() })
503            .collect();
504        commit(w, ops)?;
505    }
506    for chunk in writes.chunks(BATCH_FILES) {
507        let mut ops = Vec::new();
508        for file in chunk {
509            plan_file(w, file, by_file.get(&file.path), &mut ops);
510        }
511        commit(w, ops)?;
512    }
513    Ok(report)
514}
515
516/// Apply one batch as one WAL commit. An empty batch writes nothing.
517fn commit(w: &mut Db, ops: Vec<BatchOp>) -> Result<(), CliError> {
518    if ops.is_empty() {
519        return Ok(());
520    }
521    let (results, sync) = w.commit_group(vec![ops]);
522    for r in results {
523        r?;
524    }
525    match sync {
526        Some(e) => Err(CliError(e.to_string())),
527        None => Ok(()),
528    }
529}
530
531/// Whether the leading bytes look like something other than text. Mirrors the
532/// probe `code-extract` applies, so the count and the extraction agree.
533fn is_binary(bytes: &[u8]) -> bool {
534    bytes[..bytes.len().min(8 * 1024)].contains(&0)
535}
536
537/// Turn one file's raw facts into resolved keys.
538fn resolve_file(
539    path: &str,
540    f: &FileFacts,
541    tree: &Tree,
542    index: &SymbolIndex,
543    keys: &[(String, usize)],
544    with_docs: bool,
545) -> FileWrite {
546    let known = |p: &str| tree.known(p);
547    let files_in = |d: &str| tree.files_in(d);
548    let by_base = |n: &str| tree.by_basename(n);
549
550    let mut imports = BTreeSet::new();
551    let mut import_lines = BTreeSet::new();
552    for imp in &f.imports {
553        for target in resolve_import(f.lang, path, &imp.raw, &known, &files_in) {
554            if target == path {
555                continue;
556            }
557            import_lines.insert(format!("{target}\t{}", imp.line));
558            imports.insert(target);
559        }
560    }
561
562    let mut mentions = BTreeSet::new();
563    if with_docs {
564        for token in &f.mentions {
565            if let Some(target) = resolve_mention(path, token, &known, &by_base) {
566                if target != path {
567                    mentions.insert(target);
568                }
569            }
570        }
571    }
572
573    let mut symbols = Vec::with_capacity(keys.len());
574    for (key, at) in keys {
575        let fact = &f.symbols[*at];
576        let mut calls = BTreeSet::new();
577        let mut call_lines = BTreeSet::new();
578        for (callee, line) in &fact.calls {
579            let Some(target) = resolve_call(path, callee, index) else {
580                continue;
581            };
582            if &target == key {
583                continue; // a definition calling itself is not a graph edge
584            }
585            call_lines.insert(format!("{target}\t{line}"));
586            calls.insert(target);
587        }
588        symbols.push(SymbolWrite {
589            key: key.clone(),
590            name: fact.name.clone(),
591            kind: fact.kind,
592            line_start: fact.line_start,
593            line_end: fact.line_end,
594            signature: fact.signature.clone(),
595            doc: fact.doc.clone(),
596            calls: calls.into_iter().collect(),
597            call_lines: call_lines.into_iter().collect(),
598        });
599    }
600
601    FileWrite {
602        path: path.to_string(),
603        hash: f.hash.clone(),
604        lines: f.lines,
605        lang: f.lang.as_str(),
606        imports: imports.into_iter().collect(),
607        import_lines: import_lines.into_iter().collect(),
608        mentions: mentions.into_iter().collect(),
609        headings: if with_docs {
610            f.headings.clone()
611        } else {
612            Vec::new()
613        },
614        body: if with_docs { f.body.clone() } else { None },
615        symbols,
616    }
617}
618
619/// Queue the ops that make one file's stored state match `file`.
620///
621/// Nothing is queued for a field that already holds the right value, so a file
622/// whose bytes have not changed contributes no ops at all.
623fn plan_file(w: &Db, file: &FileWrite, held: Option<&BTreeSet<String>>, ops: &mut Vec<BatchOp>) {
624    for (field, want) in file.props() {
625        diff_prop(w, &file.path, field, want, ops);
626    }
627
628    let wanted: BTreeSet<&String> = file.symbols.iter().map(|s| &s.key).collect();
629    for key in held.into_iter().flatten() {
630        if !wanted.contains(key) {
631            ops.push(BatchOp::DeleteNode { key: key.clone() });
632        }
633    }
634    for sym in &file.symbols {
635        let props = sym.props(&file.path);
636        match w.node_ref(&sym.key).map(|n| n.label().to_string()) {
637            // A repository may contain a file whose path is literally another
638            // file's symbol key — `#` is a legal character in a path. The node
639            // that got there first keeps it: writing symbol props onto someone
640            // else's `File` node would corrupt it, and there is no second key
641            // to put the symbol under.
642            Some(label) if label != "Symbol" => continue,
643            Some(_) => {
644                for (field, want) in props {
645                    diff_prop(w, &sym.key, field, want, ops);
646                }
647            }
648            None => ops.push(BatchOp::InsertNode {
649                label: "Symbol".into(),
650                key: sym.key.clone(),
651                props: props
652                    .into_iter()
653                    .filter_map(|(f, v)| v.map(|v| (f.to_string(), v)))
654                    .collect(),
655            }),
656        }
657    }
658}
659
660/// Queue a set or a remove for one field, or nothing when it already agrees.
661fn diff_prop(w: &Db, key: &str, field: &str, want: Option<Value>, ops: &mut Vec<BatchOp>) {
662    let current = w.node_ref(key).and_then(|n| n.prop(field));
663    if current == want {
664        return;
665    }
666    match want {
667        Some(value) => ops.push(BatchOp::SetProp {
668            key: key.to_string(),
669            field: field.to_string(),
670            value,
671        }),
672        None => ops.push(BatchOp::RemoveProp {
673            key: key.to_string(),
674            field: field.to_string(),
675        }),
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    #[test]
684    fn rules_cover_every_derived_structure_edge() {
685        let names: Vec<String> = rules().into_iter().map(|r| r.name).collect();
686        for want in [
687            DEFINES_RULE,
688            "imports",
689            "calls",
690            "mentions",
691            "concept_sources",
692            "about_author",
693            "about_concept",
694            "about_file",
695            "about_note",
696            "about_symbol",
697        ] {
698            assert!(names.contains(&want.to_string()), "missing rule {want}");
699        }
700        for def in rules() {
701            assert_eq!(
702                def.max_edges,
703                Some(default_max_edges(&def.predicate)),
704                "{} must state its fan-out",
705                def.name
706            );
707        }
708    }
709
710    #[test]
711    fn the_tree_answers_every_lookup_the_resolvers_need() {
712        let tree = Tree::build([
713            "src/lib.rs".to_string(),
714            "src/net/mod.rs".to_string(),
715            "README.md".to_string(),
716        ]);
717        assert!(tree.known("src/lib.rs"));
718        assert!(!tree.known("src/gone.rs"));
719        assert_eq!(tree.files_in("src"), vec!["src/lib.rs".to_string()]);
720        assert_eq!(tree.files_in("nope"), Vec::<String>::new());
721        assert_eq!(
722            tree.by_basename("mod.rs"),
723            vec!["src/net/mod.rs".to_string()]
724        );
725        assert_eq!(tree.files_in(""), vec!["README.md".to_string()]);
726    }
727
728    #[test]
729    fn binary_probe_matches_the_extractors() {
730        assert!(!is_binary(b"pub fn a() {}"));
731        assert!(is_binary(b"pub fn a() {}\0"));
732        assert!(!is_binary(b""));
733    }
734}