use crate::errors::AppError;
use crate::output;
macro_rules! schema_ids {
($($variant:ident => $id:literal;)*) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemaId {
$(
#[doc = concat!("`", $id, "` → `docs/schemas/", $id, ".schema.json`")]
$variant,
)*
}
impl SchemaId {
pub const ALL: &'static [Self] = &[$(Self::$variant,)*];
pub const fn name(self) -> &'static str {
match self {
$(Self::$variant => $id,)*
}
}
const fn embedded(self) -> &'static str {
match self {
$(Self::$variant => include_str!(
concat!("../docs/schemas/", $id, ".schema.json")
),)*
}
}
pub fn from_id(id: &str) -> Option<Self> {
match id {
$($id => Some(Self::$variant),)*
_ => None,
}
}
}
};
}
schema_ids! {
AgentSurface => "agent-surface";
Backup => "backup";
CleanupOrphans => "cleanup-orphans";
ConfigList => "config-list";
DebugSchema => "debug-schema";
DeepResearch => "deep-research";
DeepResearchOutputAck => "deep-research-output-ack";
DeleteEntity => "delete-entity";
Edit => "edit";
EmbeddingList => "embedding-list";
EmbeddingStatus => "embedding-status";
EnrichItemEvent => "enrich-item-event";
EnrichPhase => "enrich-phase";
EnrichStatus => "enrich-status";
EnrichSummary => "enrich-summary";
EntitiesInput => "entities-input";
ErrorEnvelope => "error-envelope";
ExportMemoryLine => "export-memory-line";
ExportSummary => "export-summary";
Forget => "forget";
FtsCheck => "fts-check";
FtsRebuild => "fts-rebuild";
FtsStats => "fts-stats";
Graph => "graph";
GraphEntities => "graph-entities";
GraphEntityTypes => "graph-entity-types";
GraphInput => "graph-input";
GraphRecomputeDegree => "graph-recompute-degree";
GraphStats => "graph-stats";
GraphTraverse => "graph-traverse";
Health => "health";
History => "history";
HybridSearch => "hybrid-search";
IngestClaudeFileEvent => "ingest-claude-file-event";
IngestClaudePhase => "ingest-claude-phase";
IngestClaudeSummary => "ingest-claude-summary";
IngestFileEvent => "ingest-file-event";
IngestSummary => "ingest-summary";
Init => "init";
Link => "link";
List => "list";
MemoryEntities => "memory-entities";
MemoryEntitiesReverse => "memory-entities-reverse";
MergeEntities => "merge-entities";
Migrate => "migrate";
MigrateRehash => "migrate-rehash";
MigrateToLlmOnly => "migrate-to-llm-only";
NamespaceDetect => "namespace-detect";
NormalizeEntities => "normalize-entities";
Optimize => "optimize";
PruneNer => "prune-ner";
PruneRelations => "prune-relations";
Purge => "purge";
Read => "read";
Recall => "recall";
Reclassify => "reclassify";
ReclassifyRelation => "reclassify-relation";
Related => "related";
RelationshipsInput => "relationships-input";
Remember => "remember";
RememberBatch => "remember-batch";
RememberBatchSummary => "remember-batch-summary";
RememberDryRun => "remember-dry-run";
Rename => "rename";
RenameEntity => "rename-entity";
Restore => "restore";
ShutdownEnvelope => "shutdown-envelope";
SlotsStatus => "slots-status";
SplitBody => "split-body";
Stats => "stats";
SyncSafeCopy => "sync-safe-copy";
Unlink => "unlink";
Vacuum => "vacuum";
VecOrphanList => "vec-orphan-list";
VecPurgeOrphan => "vec-purge-orphan";
VecStats => "vec-stats";
}
const SUGGESTION_THRESHOLD: f64 = 0.7;
impl SchemaId {
pub fn nearest(id: &str) -> Option<&'static str> {
Self::ALL
.iter()
.map(|candidate| {
let score = rapidfuzz::distance::jaro_winkler::normalized_similarity(
id.chars(),
candidate.name().chars(),
);
(candidate.name(), score)
})
.filter(|(_, score)| *score >= SUGGESTION_THRESHOLD)
.max_by(|a, b| a.1.total_cmp(&b.1))
.map(|(candidate, _)| candidate)
}
}
pub fn emit(id: SchemaId) -> Result<(), AppError> {
let value: serde_json::Value = serde_json::from_str(id.embedded()).map_err(|e| {
AppError::Validation(crate::i18n::validation::embedded_schema_invalid_json(
id.name(),
&e,
))
})?;
output::emit_json_compact(&value)
}
#[derive(Debug, clap::Args)]
pub struct SchemaArgs {
#[arg(long, value_name = "ID")]
pub name: Option<String>,
#[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
pub json: bool,
#[command(flatten)]
pub db_noop: crate::cli_db_noop::DbNoopArgs,
}
pub fn run(args: SchemaArgs) -> Result<(), AppError> {
args.db_noop.ignore();
let _ = args.json;
match args.name.as_deref() {
Some(id) => match SchemaId::from_id(id) {
Some(schema) => emit(schema),
None => Err(AppError::NotFound(
crate::i18n::validation::unknown_schema_id(id, SchemaId::nearest(id)),
)),
},
None => {
emit_catalog();
Ok(())
}
}
}
fn emit_catalog() {
for schema in SchemaId::ALL {
let id = schema.name();
output::emit_json_line(&serde_json::json!({
"id": id,
"invoke": format!("sqlite-graphrag schema --name {id}"),
}));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_schemas_parse_as_json_objects() {
for id in SchemaId::ALL {
let v: serde_json::Value = serde_json::from_str(id.embedded())
.unwrap_or_else(|e| panic!("{}: {e}", id.name()));
assert!(v.is_object(), "{} schema must be a JSON object", id.name());
assert!(
v.get("$schema").is_some() || v.get("type").is_some(),
"{} schema must look like a JSON Schema document",
id.name()
);
}
}
#[test]
fn every_id_round_trips_through_from_id() {
for id in SchemaId::ALL {
assert_eq!(SchemaId::from_id(id.name()), Some(*id));
}
assert_eq!(SchemaId::from_id("no-such-schema-at-all"), None);
}
#[test]
fn ids_are_unique_and_sorted() {
let names: Vec<&str> = SchemaId::ALL.iter().map(|id| id.name()).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(names, sorted, "SchemaId::ALL must be sorted and unique");
}
#[test]
fn nearest_suggests_a_close_id_and_nothing_for_gibberish() {
assert_eq!(SchemaId::nearest("enrich-statu"), Some("enrich-status"));
assert_eq!(SchemaId::nearest("zzzzzzzzzzzzzzzz"), None);
}
#[test]
fn schema_ids_cover_every_file_in_docs_schemas() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/schemas");
let entries = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("docs/schemas must be readable: {e}"));
let mut on_disk: Vec<String> = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
name.strip_suffix(".schema.json").map(str::to_string)
})
.collect();
on_disk.sort();
assert!(
!on_disk.is_empty(),
"walk found zero schema files under {} — the walk itself is broken, \
which is exactly how this guard would go silently blind",
dir.display()
);
let declared: std::collections::HashSet<&str> =
SchemaId::ALL.iter().map(|id| id.name()).collect();
let missing: Vec<&String> = on_disk
.iter()
.filter(|id| !declared.contains(id.as_str()))
.collect();
assert!(
missing.is_empty(),
"schema files with no SchemaId variant (unreachable from the CLI): {missing:?}"
);
let on_disk_set: std::collections::HashSet<&str> =
on_disk.iter().map(String::as_str).collect();
let orphaned: Vec<&str> = declared
.iter()
.copied()
.filter(|id| !on_disk_set.contains(id))
.collect();
assert!(
orphaned.is_empty(),
"SchemaId variants with no file under docs/schemas: {orphaned:?}"
);
}
}