Skip to main content

sqlite_graphrag/
print_schema.rs

1//! Agent-native R-AN-01: emit a command's JSON Schema and exit without work.
2//!
3//! Two surfaces share this module:
4//!
5//! * the per-subcommand `--print-schema` flag, which emits the one schema its
6//!   subcommand documents;
7//! * the top-level `schema` subcommand, which lists every contract shipped in
8//!   `docs/schemas/` and emits any of them by id.
9//!
10//! Both write **compact** JSON to stdout and return successfully without
11//! opening the database, calling an LLM, or performing other side effects.
12//!
13//! Schemas are embedded at compile time so the installed binary does not
14//! depend on a source-tree checkout.
15
16use crate::errors::AppError;
17use crate::output;
18
19/// Declares every schema id, its enum variant and its embedded source.
20///
21/// A macro is used because the three projections (`name`, `embedded`, `ALL`)
22/// must stay in lockstep across 74 contracts; writing them by hand is three
23/// chances for an id to drift away from the file it claims to describe.
24macro_rules! schema_ids {
25    ($($variant:ident => $id:literal;)*) => {
26        /// Identifiers for every JSON Schema shipped under `docs/schemas/`.
27        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28        pub enum SchemaId {
29            $(
30                #[doc = concat!("`", $id, "` → `docs/schemas/", $id, ".schema.json`")]
31                $variant,
32            )*
33        }
34
35        impl SchemaId {
36            /// Every declared schema id, in the order written above.
37            pub const ALL: &'static [Self] = &[$(Self::$variant,)*];
38
39            /// Canonical id, identical to the file stem under `docs/schemas/`.
40            pub const fn name(self) -> &'static str {
41                match self {
42                    $(Self::$variant => $id,)*
43                }
44            }
45
46            /// Embedded pretty JSON Schema source (from the repository at build time).
47            const fn embedded(self) -> &'static str {
48                match self {
49                    $(Self::$variant => include_str!(
50                        concat!("../docs/schemas/", $id, ".schema.json")
51                    ),)*
52                }
53            }
54
55            /// Resolves a canonical id to its variant, or `None` when unknown.
56            pub fn from_id(id: &str) -> Option<Self> {
57                match id {
58                    $($id => Some(Self::$variant),)*
59                    _ => None,
60                }
61            }
62        }
63    };
64}
65
66schema_ids! {
67    // GAP-SG-160: the block the agent-native surface injects into every
68    // reshaped envelope. Shared definition, referenced by the per-command
69    // schemas rather than duplicated in each of them.
70    AgentSurface => "agent-surface";
71    Backup => "backup";
72    CleanupOrphans => "cleanup-orphans";
73    ConfigList => "config-list";
74    DebugSchema => "debug-schema";
75    DeepResearch => "deep-research";
76    DeepResearchOutputAck => "deep-research-output-ack";
77    DeleteEntity => "delete-entity";
78    Edit => "edit";
79    EmbeddingList => "embedding-list";
80    EmbeddingStatus => "embedding-status";
81    EnrichItemEvent => "enrich-item-event";
82    EnrichPhase => "enrich-phase";
83    EnrichStatus => "enrich-status";
84    EnrichSummary => "enrich-summary";
85    EntitiesInput => "entities-input";
86    ErrorEnvelope => "error-envelope";
87    ExportMemoryLine => "export-memory-line";
88    ExportSummary => "export-summary";
89    Forget => "forget";
90    FtsCheck => "fts-check";
91    FtsRebuild => "fts-rebuild";
92    FtsStats => "fts-stats";
93    Graph => "graph";
94    GraphEntities => "graph-entities";
95    // v1.2.8: the audit of the entity-type vocabulary actually stored. Opening
96    // the vocabulary removed the one place the valid labels were written down,
97    // so this contract is how a caller learns which labels exist at all before
98    // filtering by one.
99    GraphEntityTypes => "graph-entity-types";
100    // GAP-SG-216: the `{body, entities, relationships}` wire shape that
101    // `remember --graph-stdin` / `--graph-file` accept. INPUT, not output —
102    // published for the same reason `entities-input` is, and eight releases
103    // later, because until v1.2.8 the two commonest failures on this surface
104    // could only be found by triggering them.
105    GraphInput => "graph-input";
106    GraphRecomputeDegree => "graph-recompute-degree";
107    GraphStats => "graph-stats";
108    GraphTraverse => "graph-traverse";
109    Health => "health";
110    History => "history";
111    HybridSearch => "hybrid-search";
112    IngestClaudeFileEvent => "ingest-claude-file-event";
113    IngestClaudePhase => "ingest-claude-phase";
114    IngestClaudeSummary => "ingest-claude-summary";
115    IngestFileEvent => "ingest-file-event";
116    IngestSummary => "ingest-summary";
117    Init => "init";
118    Link => "link";
119    List => "list";
120    MemoryEntities => "memory-entities";
121    MemoryEntitiesReverse => "memory-entities-reverse";
122    MergeEntities => "merge-entities";
123    Migrate => "migrate";
124    MigrateRehash => "migrate-rehash";
125    MigrateToLlmOnly => "migrate-to-llm-only";
126    NamespaceDetect => "namespace-detect";
127    NormalizeEntities => "normalize-entities";
128    Optimize => "optimize";
129    PruneNer => "prune-ner";
130    PruneRelations => "prune-relations";
131    Purge => "purge";
132    Read => "read";
133    Recall => "recall";
134    Reclassify => "reclassify";
135    ReclassifyRelation => "reclassify-relation";
136    Related => "related";
137    RelationshipsInput => "relationships-input";
138    Remember => "remember";
139    RememberBatch => "remember-batch";
140    RememberBatchSummary => "remember-batch-summary";
141    // GAP-SG-216: `remember --dry-run` emits a shape `remember` cannot describe,
142    // since that contract requires fourteen members a run which wrote nothing
143    // has no way to supply. It satisfied no published schema until v1.2.8.
144    RememberDryRun => "remember-dry-run";
145    Rename => "rename";
146    RenameEntity => "rename-entity";
147    Restore => "restore";
148    ShutdownEnvelope => "shutdown-envelope";
149    SlotsStatus => "slots-status";
150    SplitBody => "split-body";
151    Stats => "stats";
152    SyncSafeCopy => "sync-safe-copy";
153    Unlink => "unlink";
154    Vacuum => "vacuum";
155    VecOrphanList => "vec-orphan-list";
156    VecPurgeOrphan => "vec-purge-orphan";
157    VecStats => "vec-stats";
158}
159
160/// Minimum Jaro-Winkler similarity required to suggest a replacement id.
161///
162/// Mirrors the threshold [`crate::config::registry`] uses for setting keys, so
163/// a typo gets the same quality of hint on both surfaces.
164const SUGGESTION_THRESHOLD: f64 = 0.7;
165
166impl SchemaId {
167    /// Returns the closest known id to `id`, when one is similar enough.
168    ///
169    /// Reuses the `rapidfuzz` Jaro-Winkler scorer already used for setting keys
170    /// and entity names rather than introducing a third similarity metric.
171    pub fn nearest(id: &str) -> Option<&'static str> {
172        Self::ALL
173            .iter()
174            .map(|candidate| {
175                let score = rapidfuzz::distance::jaro_winkler::normalized_similarity(
176                    id.chars(),
177                    candidate.name().chars(),
178                );
179                (candidate.name(), score)
180            })
181            .filter(|(_, score)| *score >= SUGGESTION_THRESHOLD)
182            .max_by(|a, b| a.1.total_cmp(&b.1))
183            .map(|(candidate, _)| candidate)
184    }
185}
186
187/// Emit the compact JSON Schema for `id` to stdout and flush.
188///
189/// Stdout contains only the schema document (one compact JSON line) so agents
190/// can pipe the output into a validator without filtering tracing noise.
191///
192/// # Errors
193/// Returns [`AppError::Validation`] if the embedded schema is not valid JSON
194/// (should never happen for the checked-in files) or an I/O error from stdout.
195pub fn emit(id: SchemaId) -> Result<(), AppError> {
196    let value: serde_json::Value = serde_json::from_str(id.embedded()).map_err(|e| {
197        AppError::Validation(crate::i18n::validation::embedded_schema_invalid_json(
198            id.name(),
199            &e,
200        ))
201    })?;
202    // Compact form: strip pretty-print whitespace from the source files.
203    output::emit_json_compact(&value)
204}
205
206/// Arguments for the top-level `schema` subcommand.
207///
208/// Deliberately a subcommand rather than a global flag: clap propagates a
209/// global argument downward only, so four subcommands already defining
210/// `--print-schema` would collide on the same id. A subcommand also matches the
211/// surface the sibling CLIs in this toolchain expose.
212#[derive(Debug, clap::Args)]
213pub struct SchemaArgs {
214    /// Schema id to emit. Omit to list the whole catalogue as NDJSON.
215    #[arg(long, value_name = "ID")]
216    pub name: Option<String>,
217
218    /// No-op; JSON is always emitted on stdout by `schema`.
219    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
220    pub json: bool,
221
222    /// GAP-SG-139: accepted as a no-op for agent uniformity (no graph I/O).
223    #[command(flatten)]
224    pub db_noop: crate::cli_db_noop::DbNoopArgs,
225}
226
227/// Runs the `schema` subcommand: catalogue listing or single-document emit.
228///
229/// Never opens the database and never requires an embedding API key.
230///
231/// # Errors
232/// Returns [`AppError::NotFound`] when `--name` is not a known id, or the
233/// error surfaced by [`emit`] for a known one.
234pub fn run(args: SchemaArgs) -> Result<(), AppError> {
235    args.db_noop.ignore();
236    let _ = args.json;
237    match args.name.as_deref() {
238        Some(id) => match SchemaId::from_id(id) {
239            Some(schema) => emit(schema),
240            None => Err(AppError::NotFound(
241                crate::i18n::validation::unknown_schema_id(id, SchemaId::nearest(id)),
242            )),
243        },
244        None => {
245            emit_catalog();
246            Ok(())
247        }
248    }
249}
250
251/// Writes one NDJSON record per known schema: `{"id": …, "invoke": …}`.
252///
253/// NDJSON rather than a single array so the line count equals the contract
254/// count, which is what the anti-drift gate asserts.
255fn emit_catalog() {
256    for schema in SchemaId::ALL {
257        let id = schema.name();
258        output::emit_json_line(&serde_json::json!({
259            "id": id,
260            "invoke": format!("sqlite-graphrag schema --name {id}"),
261        }));
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn embedded_schemas_parse_as_json_objects() {
271        for id in SchemaId::ALL {
272            let v: serde_json::Value = serde_json::from_str(id.embedded())
273                .unwrap_or_else(|e| panic!("{}: {e}", id.name()));
274            assert!(v.is_object(), "{} schema must be a JSON object", id.name());
275            assert!(
276                v.get("$schema").is_some() || v.get("type").is_some(),
277                "{} schema must look like a JSON Schema document",
278                id.name()
279            );
280        }
281    }
282
283    #[test]
284    fn every_id_round_trips_through_from_id() {
285        for id in SchemaId::ALL {
286            assert_eq!(SchemaId::from_id(id.name()), Some(*id));
287        }
288        assert_eq!(SchemaId::from_id("no-such-schema-at-all"), None);
289    }
290
291    #[test]
292    fn ids_are_unique_and_sorted() {
293        let names: Vec<&str> = SchemaId::ALL.iter().map(|id| id.name()).collect();
294        let mut sorted = names.clone();
295        sorted.sort_unstable();
296        sorted.dedup();
297        assert_eq!(names, sorted, "SchemaId::ALL must be sorted and unique");
298    }
299
300    #[test]
301    fn nearest_suggests_a_close_id_and_nothing_for_gibberish() {
302        assert_eq!(SchemaId::nearest("enrich-statu"), Some("enrich-status"));
303        assert_eq!(SchemaId::nearest("zzzzzzzzzzzzzzzz"), None);
304    }
305
306    /// Anti-drift gate: the enum must cover `docs/schemas/` exactly.
307    ///
308    /// A file added without a variant is unreachable from the CLI — the very
309    /// gap this surface exists to close — and a variant without a file would
310    /// not compile, so only the first direction needs a runtime assertion.
311    /// Both are asserted anyway so a future refactor cannot quietly invert it.
312    #[test]
313    fn schema_ids_cover_every_file_in_docs_schemas() {
314        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/schemas");
315        let entries = std::fs::read_dir(&dir)
316            .unwrap_or_else(|e| panic!("docs/schemas must be readable: {e}"));
317        let mut on_disk: Vec<String> = entries
318            .filter_map(Result::ok)
319            .filter_map(|entry| {
320                let name = entry.file_name().to_string_lossy().into_owned();
321                name.strip_suffix(".schema.json").map(str::to_string)
322            })
323            .collect();
324        on_disk.sort();
325        assert!(
326            !on_disk.is_empty(),
327            "walk found zero schema files under {} — the walk itself is broken, \
328             which is exactly how this guard would go silently blind",
329            dir.display()
330        );
331
332        let declared: std::collections::HashSet<&str> =
333            SchemaId::ALL.iter().map(|id| id.name()).collect();
334        let missing: Vec<&String> = on_disk
335            .iter()
336            .filter(|id| !declared.contains(id.as_str()))
337            .collect();
338        assert!(
339            missing.is_empty(),
340            "schema files with no SchemaId variant (unreachable from the CLI): {missing:?}"
341        );
342
343        let on_disk_set: std::collections::HashSet<&str> =
344            on_disk.iter().map(String::as_str).collect();
345        let orphaned: Vec<&str> = declared
346            .iter()
347            .copied()
348            .filter(|id| !on_disk_set.contains(id))
349            .collect();
350        assert!(
351            orphaned.is_empty(),
352            "SchemaId variants with no file under docs/schemas: {orphaned:?}"
353        );
354    }
355}