Skip to main content

sqlite_graphrag/config/
registry.rs

1//! Canonical registry of operational setting keys.
2//!
3//! Owns the single source of truth for which dotted keys `config set` accepts,
4//! which retired aliases still map onto a live key, and the drift test that
5//! keeps the registry honest against the readers scattered across the crate.
6
7use super::{SettingKey, ValueKind};
8
9/// Canonical registry of operational setting keys accepted by `config set`.
10///
11/// Single source of truth shared by `set_setting` validation and the
12/// `config doctor` knob listing. Keeping exactly one list prevents the
13/// divergence class recorded as `GAP-SG-79`, where help text advertised
14/// `db.default_path` while [`crate::paths::AppPaths::resolve`] has always
15/// read `db.path`.
16///
17/// Every entry MUST have a matching `get_setting` reader somewhere in the
18/// crate. Adding a key here without a reader recreates the silent no-op this
19/// registry exists to prevent; `GAP-SG-90` adds the test that enforces it.
20///
21/// Literal defaults that mirror a constant are asserted against that constant
22/// in this module's tests, so the two cannot drift apart in silence.
23///
24/// Kept sorted so the emitted diagnostics are stable across runs.
25pub const SETTING_KEYS: &[SettingKey] = &[
26    SettingKey {
27        key: "agent_surface.max_items",
28        default: Some("0"),
29        kind: ValueKind::Unsigned,
30    },
31    SettingKey {
32        key: "agent_surface.max_output_bytes",
33        default: Some("0"),
34        kind: ValueKind::Unsigned,
35    },
36    SettingKey {
37        key: "agent_surface.truncate_content",
38        default: Some("0"),
39        kind: ValueKind::Unsigned,
40    },
41    SettingKey {
42        key: "cache.dir",
43        default: None,
44        kind: ValueKind::Path,
45    },
46    SettingKey {
47        key: "cli.max_instances",
48        default: None,
49        kind: ValueKind::Unsigned,
50    },
51    SettingKey {
52        key: "cli.no_input",
53        default: Some("false"),
54        kind: ValueKind::Bool,
55    },
56    SettingKey {
57        key: "cli.stdin_timeout_secs",
58        default: Some("60"),
59        kind: ValueKind::Unsigned,
60    },
61    SettingKey {
62        key: "db.busy_base_delay_ms",
63        default: Some("300"),
64        kind: ValueKind::Unsigned,
65    },
66    SettingKey {
67        key: "db.busy_retries",
68        default: Some("5"),
69        kind: ValueKind::Unsigned,
70    },
71    SettingKey {
72        key: "db.path",
73        default: None,
74        kind: ValueKind::Path,
75    },
76    SettingKey {
77        key: "db.query_timeout_ms",
78        default: Some("5000"),
79        kind: ValueKind::Unsigned,
80    },
81    SettingKey {
82        key: "display.tz",
83        default: Some("UTC"),
84        kind: ValueKind::Tz,
85    },
86    SettingKey {
87        key: "embedding.batch_size",
88        default: Some("32"),
89        kind: ValueKind::Unsigned,
90    },
91    SettingKey {
92        key: "embedding.dim",
93        default: Some("1024"),
94        kind: ValueKind::Unsigned,
95    },
96    SettingKey {
97        // OpenRouter embedding model. `--embedding-model` promised this key as
98        // its XDG fallback since v1.0.93 while nothing ever read it, so an
99        // operator who ran `config set embedding.model` still got exit 78 for a
100        // missing flag. Deliberately unset, because an absent value means
101        // "operator passed nothing" — the state `--embedding-backend
102        // openrouter` rejects.
103        key: "embedding.model",
104        default: None,
105        kind: ValueKind::Text,
106    },
107    SettingKey {
108        // Embedding backend selector. Same defect GAP-SG-192 fixed for
109        // `embedding.model`, left behind on its sibling: `--embedding-backend`
110        // has promised "optional XDG `config set embedding.backend`" in its
111        // own `--help` while the key was absent from this registry, so the
112        // documented command answered exit 1, `unknown config key`.
113        // Unset by default so an omitted value stays distinguishable from an
114        // explicit one — that is what lets flag > XDG > clap default resolve
115        // instead of the XDG layer always winning with a value nobody chose.
116        key: "embedding.backend",
117        default: None,
118        kind: ValueKind::OneOf(&["auto", "openrouter", "open-router"]),
119    },
120    SettingKey {
121        // LLM backend for embedding. `--llm-backend` promises "optional XDG
122        // `llm.backend` via `config set`" and the key was never registered.
123        // Unset for the same reason as `embedding.backend` above: the clap
124        // default (`open-router`) must keep winning when nobody set the key.
125        key: "llm.backend",
126        default: None,
127        kind: ValueKind::OneOf(&["none", "openrouter", "open-router"]),
128    },
129    SettingKey {
130        // Graph-match ceiling for `hybrid-search --with-graph`. `0` opts back
131        // into the unbounded pre-v1.2.2 envelope, which reached 1.1 MB from a
132        // `--k 3` query.
133        key: "search.hybrid.max_graph_results",
134        default: Some("50"),
135        kind: ValueKind::Unsigned,
136    },
137    SettingKey {
138        key: "embedding.entity_cache_max_entries",
139        default: Some("10000"),
140        kind: ValueKind::Unsigned,
141    },
142    SettingKey {
143        key: "embedding.entity_cache_ttl_secs",
144        default: Some("3600"),
145        kind: ValueKind::Unsigned,
146    },
147    SettingKey {
148        key: "embedding.timeout_secs",
149        default: Some("300"),
150        kind: ValueKind::Unsigned,
151    },
152    SettingKey {
153        key: "enrich.circuit_breaker_reset_secs",
154        default: Some("60"),
155        kind: ValueKind::Unsigned,
156    },
157    SettingKey {
158        key: "enrich.entity_connect.default_limit",
159        default: Some("100"),
160        kind: ValueKind::Unsigned,
161    },
162    SettingKey {
163        key: "enrich.entity_connect.large_ns_limit",
164        default: Some("25"),
165        kind: ValueKind::Unsigned,
166    },
167    SettingKey {
168        key: "enrich.entity_description.corpus_top_k",
169        default: Some("8"),
170        kind: ValueKind::Unsigned,
171    },
172    SettingKey {
173        key: "enrich.entity_description.domain",
174        default: Some("auto"),
175        kind: ValueKind::Text,
176    },
177    SettingKey {
178        key: "enrich.entity_description.grounding_threshold",
179        default: Some("0.30"),
180        kind: ValueKind::Float,
181    },
182    SettingKey {
183        key: "enrich.entity_description.neighbour_top_k",
184        default: Some("12"),
185        kind: ValueKind::Unsigned,
186    },
187    SettingKey {
188        key: "enrich.entity_description.min_corpus_chars",
189        default: Some("40"),
190        kind: ValueKind::Unsigned,
191    },
192    SettingKey {
193        key: "enrich.entity_description.quality_sample",
194        default: Some("50"),
195        kind: ValueKind::Unsigned,
196    },
197    SettingKey {
198        key: "enrich.entity_description.snippet_chars",
199        default: Some("2000"),
200        kind: ValueKind::Unsigned,
201    },
202    // GAP-SG-279: `entity-type-validate` decided an entity's type from two
203    // lines of input — the name and the label under dispute — while the sibling
204    // operation next to it had four keys for gathering evidence before writing
205    // a single sentence. The asymmetry was the whole defect: the operation with
206    // the weakest input was the one asked to repair the 10902 entities the
207    // closed vocabulary had collapsed into `concept`. These four keys are the
208    // same four the description path already has, so the two operations tune
209    // alike instead of one of them being untunable.
210    // GAP-SG-283: the vocabulary policy `enrich` lacked. `remember` has
211    // `--strict-entity-types` and `link` has `--strict-relations`; the channel
212    // that writes type labels in VOLUME had neither, so a model string reached
213    // `UPDATE entities SET type` with nothing between it and the column once
214    // V017 removed the SQL CHECK. Read by
215    // `src/commands/enrich/events/entity_type_policy.rs`.
216    SettingKey {
217        key: "enrich.entity_type.allowed_types",
218        default: None,
219        kind: ValueKind::Text,
220    },
221    SettingKey {
222        key: "enrich.entity_type.on_unknown_type",
223        default: Some("keep"),
224        kind: ValueKind::Text,
225    },
226    SettingKey {
227        key: "enrich.entity_type_validate.corpus_top_k",
228        default: Some("8"),
229        kind: ValueKind::Unsigned,
230    },
231    SettingKey {
232        key: "enrich.entity_type_validate.min_corpus_chars",
233        default: Some("40"),
234        kind: ValueKind::Unsigned,
235    },
236    SettingKey {
237        key: "enrich.entity_type_validate.neighbour_top_k",
238        default: Some("12"),
239        kind: ValueKind::Unsigned,
240    },
241    SettingKey {
242        key: "enrich.entity_type_validate.snippet_chars",
243        default: Some("2000"),
244        kind: ValueKind::Unsigned,
245    },
246    SettingKey {
247        key: "enrich.rate_limit_deadline_secs",
248        default: Some("3600"),
249        kind: ValueKind::Unsigned,
250    },
251    SettingKey {
252        key: "enrich.reembed_claim_batch",
253        default: Some("32"),
254        kind: ValueKind::Unsigned,
255    },
256    SettingKey {
257        key: "enrich.scan_page_size",
258        default: Some("512"),
259        kind: ValueKind::Unsigned,
260    },
261    SettingKey {
262        key: "enrich.yield_every_n_items",
263        default: Some("10"),
264        kind: ValueKind::Unsigned,
265    },
266    SettingKey {
267        key: "i18n.lang",
268        default: Some("en"),
269        // Mirrors the clap aliases on `Language` in `src/i18n/mod.rs`.
270        kind: ValueKind::OneOf(&[
271            "en",
272            "EN",
273            "english",
274            "pt",
275            "PT",
276            "pt-BR",
277            "pt-br",
278            "portugues",
279            "portuguese",
280        ]),
281    },
282    SettingKey {
283        key: "ingest.low_memory",
284        default: Some("false"),
285        kind: ValueKind::Bool,
286    },
287    SettingKey {
288        key: "limits.max_entities_per_memory",
289        default: Some("50"),
290        kind: ValueKind::Unsigned,
291    },
292    SettingKey {
293        key: "limits.max_relations_per_memory",
294        default: Some("50"),
295        kind: ValueKind::Unsigned,
296    },
297    SettingKey {
298        key: "llm.fallback",
299        default: Some("none"),
300        // v1.2.0 removed the subprocess backends; `none` is all that is left.
301        kind: ValueKind::OneOf(&["none"]),
302    },
303    SettingKey {
304        key: "llm.max_host_concurrency",
305        default: None,
306        kind: ValueKind::Unsigned,
307    },
308    SettingKey {
309        key: "llm.model",
310        default: None,
311        kind: ValueKind::Text,
312    },
313    SettingKey {
314        key: "llm.openrouter_timeout_secs",
315        default: Some("600"),
316        kind: ValueKind::Unsigned,
317    },
318    SettingKey {
319        key: "llm.probe_timeout_ms",
320        default: Some("800"),
321        kind: ValueKind::Unsigned,
322    },
323    SettingKey {
324        key: "llm.skip_embedding_on_failure",
325        default: Some("false"),
326        kind: ValueKind::Bool,
327    },
328    SettingKey {
329        key: "llm.slot_no_wait",
330        default: Some("false"),
331        kind: ValueKind::Bool,
332    },
333    SettingKey {
334        key: "llm.slot_wait_secs",
335        default: Some("300"),
336        kind: ValueKind::Unsigned,
337    },
338    SettingKey {
339        key: "llm.worker_rss_mb",
340        default: Some("350"),
341        kind: ValueKind::Unsigned,
342    },
343    SettingKey {
344        key: "log.format",
345        default: Some("pretty"),
346        kind: ValueKind::OneOf(&["pretty", "json"]),
347    },
348    SettingKey {
349        key: "log.level",
350        default: Some("warn"),
351        kind: ValueKind::LogDirective,
352    },
353    SettingKey {
354        key: "log.retention_days",
355        default: Some("7"),
356        kind: ValueKind::Unsigned,
357    },
358    SettingKey {
359        key: "log.rotation",
360        default: Some("daily"),
361        kind: ValueKind::OneOf(&["daily", "hourly", "never"]),
362    },
363    SettingKey {
364        key: "log.to_file",
365        default: Some("false"),
366        kind: ValueKind::Bool,
367    },
368    SettingKey {
369        key: "namespace.default",
370        default: Some("global"),
371        kind: ValueKind::Text,
372    },
373    SettingKey {
374        key: "network.chat_url",
375        default: None,
376        kind: ValueKind::Url,
377    },
378    SettingKey {
379        key: "network.embed_url",
380        default: None,
381        kind: ValueKind::Url,
382    },
383    SettingKey {
384        key: "network.openrouter.chat_url",
385        default: Some(crate::constants::DEFAULT_OPENROUTER_CHAT_URL),
386        kind: ValueKind::Url,
387    },
388    SettingKey {
389        key: "network.openrouter.embeddings_url",
390        default: Some(crate::constants::DEFAULT_OPENROUTER_EMBEDDINGS_URL),
391        kind: ValueKind::Url,
392    },
393    SettingKey {
394        key: "parallelism.embed_runtime_threads",
395        default: None,
396        kind: ValueKind::Unsigned,
397    },
398    SettingKey {
399        key: "parallelism.max_total_workers",
400        default: Some("64"),
401        kind: ValueKind::Unsigned,
402    },
403    SettingKey {
404        key: "parallelism.rayon_threads",
405        default: None,
406        kind: ValueKind::Unsigned,
407    },
408    SettingKey {
409        key: "retry.disable",
410        default: Some("false"),
411        kind: ValueKind::Bool,
412    },
413    SettingKey {
414        key: "shutdown.ignore",
415        default: Some("false"),
416        kind: ValueKind::Bool,
417    },
418    SettingKey {
419        key: "system.max_load_per_ncpu",
420        default: Some("2.0"),
421        kind: ValueKind::Float,
422    },
423];
424
425/// Iterates the registry key names in registration (sorted) order.
426///
427/// Callers that only need names use this instead of reaching into the struct,
428/// so adding a field to [`SettingKey`] does not ripple through the crate.
429pub fn setting_key_names() -> impl Iterator<Item = &'static str> {
430    SETTING_KEYS.iter().map(|entry| entry.key)
431}
432
433/// Setting keys that were advertised historically but never had a reader.
434///
435/// Each tuple maps the obsolete key to its replacement. Present so
436/// [`load_config`] can warn instead of ignoring the value in silence, which
437/// is the failure mode `GAP-SG-79` documents.
438///
439/// These keys are rejected by `set_setting`; the warning covers configs
440/// written before the validation existed.
441// GAP-SG-79 / GAP-SG-122: legacy aliases map to the single canonical key.
442// `db.default_path` never took effect (paths always read `db.path`).
443// `paths.cache` was a parallel name for `cache.dir`.
444pub(super) const LEGACY_SETTING_KEYS: &[(&str, &str)] =
445    &[("db.default_path", "db.path"), ("paths.cache", "cache.dir")];
446
447/// Minimum Jaro-Winkler similarity required to suggest a replacement key.
448///
449/// Below this a suggestion is more confusing than helpful, so the error lists
450/// nothing rather than pointing at an unrelated key.
451const SUGGESTION_THRESHOLD: f64 = 0.7;
452
453/// Returns `true` when `key` belongs to [`SETTING_KEYS`].
454pub fn is_known_setting(key: &str) -> bool {
455    setting_key_names().any(|known| known == key)
456}
457
458/// Returns the closest known key to `key`, when one is similar enough.
459///
460/// Reuses `rapidfuzz` Jaro-Winkler, the same scorer already used for entity
461/// name resolution in [`crate::storage::entities`], instead of introducing a
462/// second similarity implementation.
463pub fn nearest_setting_key(key: &str) -> Option<&'static str> {
464    setting_key_names()
465        .map(|candidate| {
466            let score = rapidfuzz::distance::jaro_winkler::normalized_similarity(
467                key.chars(),
468                candidate.chars(),
469            );
470            (candidate, score)
471        })
472        .filter(|(_, score)| *score >= SUGGESTION_THRESHOLD)
473        .max_by(|a, b| a.1.total_cmp(&b.1))
474        .map(|(candidate, _)| candidate)
475}
476/// GAP-SG-90: every literal key read in production must appear in
477/// [`SETTING_KEYS`]. These tests scan the crate source for the call shapes that
478/// carry a literal key, and assert the source list itself stays complete.
479#[cfg(test)]
480mod setting_keys_drift_tests {
481    use super::SETTING_KEYS;
482
483    /// Every module holding a literal setting key, whether it reads it through
484    /// `get_setting("…")` or through a `runtime_config::resolve_*` helper.
485    ///
486    /// A fixed list is used because a full-tree walk is flaky in package builds.
487    /// Paths are relative to THIS file, so they climb out of `src/config/` with
488    /// `../` (GAP-SG-146 moved the module into a directory).
489    ///
490    /// The `config` module itself is excluded: the prose right here mentions
491    /// `get_setting("…")` and a placeholder would be scanned as a real key.
492    /// `runtime_config.rs` is excluded because its own unit tests assert a
493    /// deliberately unregistered key; its production literals are covered by
494    /// those tests.
495    const SCANNED_SOURCES: &[&str] = &[
496        include_str!("../commands/enrich/extraction_descriptions.rs"),
497        // GAP-SG-279 put a `resolve_usize` here: `entity-type-validate` now
498        // reads `enrich.entity_type_validate.min_corpus_chars` before deciding
499        // whether an entity has enough evidence to judge its type from.
500        include_str!("../commands/enrich/extraction_graph.rs"),
501        // GAP-SG-283: the entity type vocabulary policy reads its two keys
502        // here, one layer above the `call_*` helper that writes the column.
503        include_str!("../commands/enrich/events/entity_type_policy.rs"),
504        include_str!("../commands/enrich/prompts.rs"),
505        include_str!("../commands/enrich/quality_sample.rs"),
506        include_str!("../commands/enrich/scheduler.rs"),
507        include_str!("../commands/enrich/status.rs"),
508        include_str!("../commands/ingest/args.rs"),
509        // v1.2.5 split the former single-file `constants.rs` into themed
510        // submodules; every one is scanned so the coverage this guard provides
511        // does not shrink with the refactor.
512        include_str!("../constants/mod.rs"),
513        include_str!("../constants/identity.rs"),
514        include_str!("../constants/network.rs"),
515        include_str!("../constants/exit_codes.rs"),
516        include_str!("../constants/storage.rs"),
517        include_str!("../constants/embedding.rs"),
518        include_str!("../constants/enrich.rs"),
519        include_str!("../constants/search.rs"),
520        include_str!("../constants/runtime.rs"),
521        include_str!("../constants/limits.rs"),
522        include_str!("../embedder/getters.rs"),
523        include_str!("../i18n/mod.rs"),
524        include_str!("../lib.rs"),
525        include_str!("../llm_slots.rs"),
526        include_str!("../lock.rs"),
527        include_str!("../main.rs"),
528        include_str!("../namespace.rs"),
529        include_str!("../paths.rs"),
530        include_str!("../retry.rs"),
531        include_str!("../system_load.rs"),
532        include_str!("../tracing_init.rs"),
533        include_str!("../tz.rs"),
534    ];
535
536    /// Call shapes that carry a literal setting key as an argument.
537    ///
538    /// `resolve_*` takes the key as its SECOND argument, after an explicit
539    /// `None` override, so the marker includes that `None` to avoid matching a
540    /// call that forwards a non-literal key.
541    const KEY_CALL_MARKERS: &[&str] = &["get_setting(\""];
542
543    /// `runtime_config` resolvers that take the key as their SECOND argument.
544    ///
545    /// The first argument is the precedence override (a CLI flag or `None`) and
546    /// says nothing about whether the key exists, so it is skipped rather than
547    /// matched. An earlier version required a literal `None` there to avoid
548    /// matching calls that forward a non-literal key — that bought precision and
549    /// paid with blindness on 22 call sites of the form
550    /// `resolve_*(args.flag, "key", default)`, including `embedding.timeout_secs`.
551    ///
552    /// In a conformance guard, prefer a false positive to a false negative: a
553    /// false positive shows up as a red test and someone looks, while a false
554    /// negative is an orphaned key nobody sees.
555    const KEY_RESOLVER_MARKERS: &[&str] = &[
556        "resolve_u64(",
557        "resolve_usize(",
558        "resolve_bool(",
559        "resolve_string(",
560    ];
561
562    /// Drops whole-line comments and all whitespace.
563    ///
564    /// Whole-line comments are removed because doc prose in this crate quotes
565    /// `get_setting("…")` with a placeholder that is not a real key. Whitespace
566    /// is removed because `resolve_*` calls are routinely wrapped by rustfmt
567    /// across several lines, which would defeat a literal marker match.
568    fn normalize(src: &str) -> String {
569        src.lines()
570            .filter(|line| !line.trim_start().starts_with("//"))
571            .flat_map(|line| line.chars().filter(|c| !c.is_whitespace()))
572            .collect()
573    }
574
575    /// Extracts every literal setting key read by `src`.
576    ///
577    /// Covers both call shapes: [`KEY_CALL_MARKERS`], where the key is the FIRST
578    /// argument, and [`KEY_RESOLVER_MARKERS`], where it is the second.
579    pub(super) fn scan_keys(src: &str) -> Vec<String> {
580        let normalized = normalize(src);
581        let mut found = Vec::new();
582        for marker in KEY_CALL_MARKERS {
583            for cap in normalized.split(marker).skip(1) {
584                if let Some(end) = cap.find('"') {
585                    if !cap[..end].is_empty() {
586                        found.push(cap[..end].to_string());
587                    }
588                }
589            }
590        }
591        for marker in KEY_RESOLVER_MARKERS {
592            for cap in normalized.split(marker).skip(1) {
593                // Skip the precedence argument and take the literal that follows
594                // it. Bail out at the closing paren so a call with no literal
595                // key cannot reach forward into an unrelated string.
596                let Some(comma) = cap.find(',') else { continue };
597                let close = cap.find(')').unwrap_or(usize::MAX);
598                if comma > close {
599                    continue;
600                }
601                let rest = &cap[comma + 1..];
602                if !rest.starts_with('"') {
603                    continue;
604                }
605                if let Some(end) = rest[1..].find('"') {
606                    if end > 0 {
607                        found.push(rest[1..1 + end].to_string());
608                    }
609                }
610            }
611        }
612        found
613    }
614
615    #[test]
616    fn setting_keys_covers_every_literal_key_in_src() {
617        let registered: std::collections::HashSet<&str> =
618            SETTING_KEYS.iter().map(|e| e.key).collect();
619        let mut missing: Vec<String> = SCANNED_SOURCES
620            .iter()
621            .flat_map(|src| scan_keys(src))
622            .filter(|key| !registered.contains(key.as_str()))
623            .collect();
624        missing.sort();
625        missing.dedup();
626        assert!(
627            missing.is_empty(),
628            "setting keys read at runtime but missing from SETTING_KEYS: {missing:?}"
629        );
630    }
631
632    /// Modules whose literals are NOT production reads of a setting key.
633    ///
634    /// `config` holds the registry itself and quotes `get_setting("…")` in prose;
635    /// `runtime_config` is the resolver, and its own unit tests assert a
636    /// deliberately unregistered key.
637    const COMPLETENESS_EXEMPT_DIRS: &[&str] = &["config"];
638    const COMPLETENESS_EXEMPT_FILES: &[&str] = &["runtime_config.rs"];
639
640    #[test]
641    fn scanned_sources_lists_every_file_that_reads_a_setting_key() {
642        // GAP-SG-90 follow-up. `SCANNED_SOURCES` has TWO failure modes and only
643        // one is loud: a RENAMED file breaks the build through `include_str!`,
644        // but a NEW file that reads a key and is never added stays invisible and
645        // the guard silently stops covering it. This walk closes that half.
646        //
647        // It deliberately does NOT scan keys — `include_str!` still does the real
648        // work. It only asserts the list is COMPLETE, so the objection against a
649        // full-tree content walk does not apply.
650        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
651        if !root.is_dir() {
652            // Never fail on environment: a test that goes red because the source
653            // tree is absent is worse than no test at all.
654            eprintln!("skipping: {} is not a directory", root.display());
655            return;
656        }
657
658        let mut rust_files = Vec::new();
659        collect_rust_files(&root, &mut rust_files);
660        assert!(
661            !rust_files.is_empty(),
662            "walk found zero .rs files under {} — the walk itself is broken, \
663             which is exactly how this guard would go silently blind",
664            root.display()
665        );
666
667        let listed: String = SCANNED_SOURCES.concat();
668        let mut unlisted = Vec::new();
669        for path in &rust_files {
670            let Ok(rel) = path.strip_prefix(&root) else {
671                continue;
672            };
673            let exempt_dir = rel.components().any(|c| {
674                COMPLETENESS_EXEMPT_DIRS.contains(&c.as_os_str().to_string_lossy().as_ref())
675            });
676            let exempt_file = rel
677                .file_name()
678                .is_some_and(|f| COMPLETENESS_EXEMPT_FILES.contains(&f.to_string_lossy().as_ref()));
679            if exempt_dir || exempt_file {
680                continue;
681            }
682            let Ok(body) = std::fs::read_to_string(path) else {
683                continue;
684            };
685            if scan_keys(&body).is_empty() {
686                continue;
687            }
688            // `SCANNED_SOURCES` holds file CONTENTS, not paths, so membership is
689            // tested by content identity rather than by name.
690            if !listed.contains(body.as_str()) {
691                unlisted.push(rel.display().to_string());
692            }
693        }
694        unlisted.sort();
695        assert!(
696            unlisted.is_empty(),
697            "these files read a setting key but are absent from SCANNED_SOURCES: {unlisted:?}"
698        );
699    }
700
701    /// Recursively collects `.rs` files under `dir`.
702    fn collect_rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
703        let Ok(entries) = std::fs::read_dir(dir) else {
704            return;
705        };
706        for entry in entries.flatten() {
707            let path = entry.path();
708            if path.is_dir() {
709                collect_rust_files(&path, out);
710            } else if path.extension().is_some_and(|e| e == "rs") {
711                out.push(path);
712            }
713        }
714    }
715
716    #[test]
717    fn scanner_sees_resolve_calls_wrapped_across_lines() {
718        // Guards the blind spot this scanner was rewritten to close: keys that
719        // only ever reach the config layer through a multi-line `resolve_*`.
720        let src = r#"
721            let v = crate::runtime_config::resolve_u64(
722                None,
723                "made.up.key",
724                7,
725            );
726        "#;
727        assert_eq!(scan_keys(src), vec!["made.up.key".to_string()]);
728    }
729
730    #[test]
731    fn scanner_ignores_keys_quoted_inside_whole_line_comments() {
732        // Guards the trap in this very module's prose.
733        let src = "        /// scans for get_setting(\"placeholder\") call sites\n";
734        assert!(scan_keys(src).is_empty());
735    }
736}