sqlite_graphrag/
print_schema.rs1use crate::errors::AppError;
17use crate::output;
18
19macro_rules! schema_ids {
25 ($($variant:ident => $id:literal;)*) => {
26 #[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 pub const ALL: &'static [Self] = &[$(Self::$variant,)*];
38
39 pub const fn name(self) -> &'static str {
41 match self {
42 $(Self::$variant => $id,)*
43 }
44 }
45
46 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 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 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 GraphEntityTypes => "graph-entity-types";
100 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 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
160const SUGGESTION_THRESHOLD: f64 = 0.7;
165
166impl SchemaId {
167 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
187pub 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 output::emit_json_compact(&value)
204}
205
206#[derive(Debug, clap::Args)]
213pub struct SchemaArgs {
214 #[arg(long, value_name = "ID")]
216 pub name: Option<String>,
217
218 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
220 pub json: bool,
221
222 #[command(flatten)]
224 pub db_noop: crate::cli_db_noop::DbNoopArgs,
225}
226
227pub 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
251fn 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 #[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}