#![allow(clippy::print_stdout, clippy::print_stderr)]
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use mif_problem::{OutputFormat, ProblemMeta, ToProblem};
const DEFAULT_DB_PATH: &str = ".mif/vectors.db";
#[derive(Parser)]
#[command(
name = "mif-cli",
version,
about = "CLI for the MIF (Modeled Information Format) ecosystem"
)]
struct Cli {
#[arg(long, global = true, value_parser = ["pretty", "json"])]
format: Option<String>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Validate {
file: PathBuf,
#[arg(long, default_value_t = 1)]
level: u8,
},
Ontology {
#[command(subcommand)]
command: OntologyCommand,
},
Ingest {
file: PathBuf,
#[arg(long)]
db_path: Option<PathBuf>,
},
Search {
#[arg(allow_hyphen_values = true)]
query: String,
#[arg(long)]
db_path: Option<PathBuf>,
#[arg(long, default_value_t = 10)]
limit: usize,
},
FindSimilar {
id: String,
#[arg(long)]
db_path: Option<PathBuf>,
#[arg(long, default_value_t = 10)]
limit: usize,
},
CorpusStats {
#[arg(long)]
db_path: Option<PathBuf>,
},
Roundtrip {
file: PathBuf,
#[arg(long, value_parser = ["v1-canonical", "pre-projected"], default_value = "v1-canonical")]
shape: String,
},
EmitJsonld {
file: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long, value_parser = ["v1-canonical", "pre-projected"], default_value = "v1-canonical")]
shape: String,
},
EmitMarkdown {
file: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long, value_parser = ["v1-canonical", "pre-projected"], default_value = "v1-canonical")]
shape: String,
},
}
#[derive(Subcommand)]
enum OntologyCommand {
Resolve {
id: String,
#[arg(long)]
ontologies_dir: PathBuf,
},
}
#[derive(Debug, thiserror::Error)]
enum CliError {
#[error("failed to read {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to parse {path} as JSON: {source}")]
Json {
path: String,
#[source]
source: serde_json::Error,
},
#[error(transparent)]
Schema(#[from] mif_schema::MifSchemaError),
#[error(transparent)]
Ontology(#[from] mif_ontology::OntologyError),
#[error(transparent)]
Frontmatter(#[from] mif_frontmatter::FrontmatterError),
#[error(transparent)]
Embed(#[from] mif_embed::EmbedError),
#[error(transparent)]
Store(#[from] mif_store::StoreError),
#[error("no document with id '{0}' has been ingested into this vector store")]
DocumentNotFound(String),
#[error("failed to serialize JSON-LD: {source}")]
JsonSerialize {
#[source]
source: serde_json::Error,
},
}
impl CliError {
const fn meta(&self) -> ProblemMeta {
match self {
Self::Io { .. } => ProblemMeta {
slug: "mif-cli-io",
version: "v1",
title: "Failed to read an input file",
status: 500,
exit_code: 1,
},
Self::Json { .. } => ProblemMeta {
slug: "mif-cli-invalid-json",
version: "v1",
title: "Input file is not valid JSON",
status: 400,
exit_code: 2,
},
Self::DocumentNotFound(_) => ProblemMeta {
slug: "mif-cli-document-not-found",
version: "v1",
title: "No document with the given id has been ingested",
status: 404,
exit_code: 3,
},
Self::JsonSerialize { .. } => ProblemMeta {
slug: "mif-cli-json-serialize-failure",
version: "v1",
title: "Failed to serialize JSON-LD to text",
status: 500,
exit_code: 1,
},
Self::Schema(_)
| Self::Ontology(_)
| Self::Frontmatter(_)
| Self::Embed(_)
| Self::Store(_) => ProblemMeta {
slug: "delegated",
version: "v1",
title: "Delegated error",
status: 500,
exit_code: 1,
},
}
}
}
impl ToProblem for CliError {
fn to_problem(&self) -> mif_problem::ProblemDetails {
match self {
Self::Schema(inner) => inner.to_problem(),
Self::Ontology(inner) => inner.to_problem(),
Self::Frontmatter(inner) => inner.to_problem(),
Self::Embed(inner) => inner.to_problem(),
Self::Store(inner) => inner.to_problem(),
Self::Io { source, .. } => {
let (status, fix, action) = mif_problem::classify_io_error(source);
let mut problem = self
.meta()
.into_details(env!("CARGO_PKG_NAME"), self.to_string());
problem.status = status;
problem.with_suggested_fix(fix).with_code_action(action)
},
Self::DocumentNotFound(_) => self
.meta()
.into_details(env!("CARGO_PKG_NAME"), self.to_string())
.with_suggested_fix(mif_problem::SuggestedFix::new(
"Ingest the document first with `mif-cli ingest`, or check the id for a \
typo.",
mif_problem::Applicability::MaybeIncorrect,
))
.with_code_action(mif_problem::CodeAction::new(
"Ingest the document before searching for similar ones",
"quickfix",
mif_problem::Applicability::MaybeIncorrect,
)),
Self::Json { .. } | Self::JsonSerialize { .. } => self
.meta()
.into_details(env!("CARGO_PKG_NAME"), self.to_string()),
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
let format = OutputFormat::select(cli.format.as_deref(), std::io::stderr().is_terminal());
match run(&cli.command) {
Ok(message) => {
println!("{message}");
ExitCode::SUCCESS
},
Err(error) => {
eprintln!("{}", error.render(format));
ExitCode::from(error.to_problem().exit_code.unwrap_or(1))
},
}
}
fn run(command: &Command) -> Result<String, CliError> {
match command {
Command::Validate { file, level } => validate(file, *level),
Command::Ontology { command } => match command {
OntologyCommand::Resolve { id, ontologies_dir } => resolve(id, ontologies_dir),
},
Command::Ingest { file, db_path } => ingest(file, db_path.as_deref()),
Command::Search {
query,
db_path,
limit,
} => search(query, db_path.as_deref(), *limit),
Command::FindSimilar { id, db_path, limit } => find_similar(id, db_path.as_deref(), *limit),
Command::CorpusStats { db_path } => corpus_stats(db_path.as_deref()),
Command::Roundtrip { file, shape } => roundtrip(
file,
mif_frontmatter::FrontmatterShape::try_from(shape.as_str())?,
),
Command::EmitJsonld { file, out, shape } => emit_jsonld(
file,
out.as_deref(),
mif_frontmatter::FrontmatterShape::try_from(shape.as_str())?,
),
Command::EmitMarkdown { file, out, shape } => emit_markdown(
file,
out.as_deref(),
mif_frontmatter::FrontmatterShape::try_from(shape.as_str())?,
),
}
}
fn resolve_db_path(db_path: Option<&Path>) -> PathBuf {
db_path.map_or_else(|| PathBuf::from(DEFAULT_DB_PATH), Path::to_path_buf)
}
fn format_matches(matches: &[mif_store::SimilarityMatch]) -> String {
if matches.is_empty() {
return "(no matches)".to_string();
}
matches
.iter()
.map(|m| format!("{:.4} {}", m.score, m.id))
.collect::<Vec<_>>()
.join("\n")
}
fn validate(file: &Path, level: u8) -> Result<String, CliError> {
let contents = std::fs::read_to_string(file).map_err(|source| CliError::Io {
path: file.display().to_string(),
source,
})?;
let jsonld = project_to_jsonld(
file,
&contents,
mif_frontmatter::FrontmatterShape::V1Canonical,
)?;
let level = mif_schema::Level::try_from(level)?;
mif_schema::validate_level(&jsonld, level)?;
Ok(format!("{}: valid", file.display()))
}
fn resolve(id: &str, ontologies_dir: &Path) -> Result<String, CliError> {
let corpus = mif_ontology::load_corpus_from_dir(ontologies_dir)?;
let chain = mif_ontology::resolve_chain(id, &corpus)?;
Ok(chain
.iter()
.map(|ontology| format!("{} ({})", ontology.id, ontology.version))
.collect::<Vec<_>>()
.join(" -> "))
}
fn project_to_jsonld(
path: &Path,
contents: &str,
shape: mif_frontmatter::FrontmatterShape,
) -> Result<serde_json::Value, CliError> {
let contents = contents.strip_prefix('\u{feff}').unwrap_or(contents);
if contents.trim_start().starts_with("---") {
mif_frontmatter::roundtrip_lossless(contents)?;
let (frontmatter, body) = mif_frontmatter::parse_markdown(contents)?;
Ok(mif_frontmatter::md_to_jsonld(&frontmatter, &body)?)
} else {
let jsonld: serde_json::Value =
serde_json::from_str(contents).map_err(|source| CliError::Json {
path: path.display().to_string(),
source,
})?;
mif_frontmatter::jsonld_roundtrip_lossless(&jsonld, shape)?;
Ok(jsonld)
}
}
fn content_hash(contents: &str) -> String {
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = FNV_OFFSET_BASIS;
for byte in contents.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("{hash:016x}")
}
fn ingest(file: &Path, db_path: Option<&Path>) -> Result<String, CliError> {
let contents = std::fs::read_to_string(file).map_err(|source| CliError::Io {
path: file.display().to_string(),
source,
})?;
let jsonld = project_to_jsonld(
file,
&contents,
mif_frontmatter::FrontmatterShape::V1Canonical,
)?;
mif_schema::validate_document(&jsonld)?;
let id = jsonld
.get("@id")
.and_then(serde_json::Value::as_str)
.map_or_else(|| file.display().to_string(), ToString::to_string);
let content_text = jsonld
.get("content")
.and_then(serde_json::Value::as_str)
.unwrap_or(&contents);
let embedder = mif_embed::Embedder::load()?;
let vector = embedder.embed(content_text)?;
let db_path = db_path.map_or_else(|| PathBuf::from(DEFAULT_DB_PATH), Path::to_path_buf);
if let Some(parent) = db_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|source| CliError::Io {
path: parent.display().to_string(),
source,
})?;
}
let store = mif_store::VectorStore::open(&db_path)?;
let hash = content_hash(&contents);
let updated_at = chrono::Utc::now().to_rfc3339();
store.upsert(&id, &vector, &hash, &updated_at)?;
Ok(format!(
"{}: lint=ok validate=ok roundtrip=lossless embedding_dim={} stored=true (id={id}, db={})",
file.display(),
vector.len(),
db_path.display()
))
}
fn search(query: &str, db_path: Option<&Path>, limit: usize) -> Result<String, CliError> {
let embedder = mif_embed::Embedder::load()?;
let vector = embedder.embed(query)?;
let db_path = resolve_db_path(db_path);
let store = mif_store::VectorStore::open(&db_path)?;
let matches = store.top_k_similar(&vector, limit)?;
Ok(format_matches(&matches))
}
fn find_similar(id: &str, db_path: Option<&Path>, limit: usize) -> Result<String, CliError> {
let db_path = resolve_db_path(db_path);
let store = mif_store::VectorStore::open(&db_path)?;
let anchor = store
.get(id)?
.ok_or_else(|| CliError::DocumentNotFound(id.to_string()))?;
let matches: Vec<_> = store
.top_k_similar(&anchor.vector, limit.saturating_add(1))?
.into_iter()
.filter(|m| m.id != id)
.take(limit)
.collect();
Ok(format_matches(&matches))
}
fn corpus_stats(db_path: Option<&Path>) -> Result<String, CliError> {
let db_path = resolve_db_path(db_path);
let store = mif_store::VectorStore::open(&db_path)?;
let stats = store.stats()?;
Ok(stats.dim.map_or_else(
|| format!("count=0 db={}", db_path.display()),
|dim| format!("count={} dim={dim} db={}", stats.count, db_path.display()),
))
}
fn roundtrip(file: &Path, shape: mif_frontmatter::FrontmatterShape) -> Result<String, CliError> {
let contents = std::fs::read_to_string(file).map_err(|source| CliError::Io {
path: file.display().to_string(),
source,
})?;
project_to_jsonld(file, &contents, shape)?;
Ok(format!("{}: roundtrip lossless", file.display()))
}
fn emit_jsonld(
file: &Path,
out: Option<&Path>,
shape: mif_frontmatter::FrontmatterShape,
) -> Result<String, CliError> {
let contents = std::fs::read_to_string(file).map_err(|source| CliError::Io {
path: file.display().to_string(),
source,
})?;
let jsonld = project_to_jsonld(file, &contents, shape)?;
let pretty = serde_json::to_string_pretty(&jsonld)
.map_err(|source| CliError::JsonSerialize { source })?;
if let Some(out) = out {
std::fs::write(out, format!("{pretty}\n")).map_err(|source| CliError::Io {
path: out.display().to_string(),
source,
})?;
Ok(format!(
"{}: wrote JSON-LD to {}",
file.display(),
out.display()
))
} else {
Ok(pretty)
}
}
fn emit_markdown(
file: &Path,
out: Option<&Path>,
shape: mif_frontmatter::FrontmatterShape,
) -> Result<String, CliError> {
let contents = std::fs::read_to_string(file).map_err(|source| CliError::Io {
path: file.display().to_string(),
source,
})?;
let contents = contents.strip_prefix('\u{feff}').unwrap_or(&contents);
let jsonld: serde_json::Value =
serde_json::from_str(contents).map_err(|source| CliError::Json {
path: file.display().to_string(),
source,
})?;
let (frontmatter, body) = mif_frontmatter::jsonld_roundtrip_lossless(&jsonld, shape)?;
let markdown = mif_frontmatter::serialize_markdown(&frontmatter, &body)?;
if let Some(out) = out {
std::fs::write(out, &markdown).map_err(|source| CliError::Io {
path: out.display().to_string(),
source,
})?;
Ok(format!(
"{}: wrote markdown to {}",
file.display(),
out.display()
))
} else {
Ok(markdown.trim_end_matches('\n').to_string())
}
}
#[cfg(test)]
mod tests {
use std::fs;
use mif_problem::{OutputFormat, ToProblem};
use mif_frontmatter::FrontmatterShape;
use super::{
CliError, Command, OntologyCommand, corpus_stats, emit_jsonld, emit_markdown, find_similar,
ingest, resolve, roundtrip, run, search, validate,
};
fn write_temp_file(contents: &str) -> tempfile::NamedTempFile {
let file = tempfile::NamedTempFile::new().unwrap();
fs::write(file.path(), contents).unwrap();
file
}
fn warm_embedding_model_cache() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let _ = mif_embed::Embedder::load();
});
}
#[test]
fn validate_accepts_a_conformant_document() {
let file = write_temp_file(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:test-001",
"conceptType": "semantic",
"content": "Test content.",
"created": "2026-07-02T00:00:00Z"
}"#,
);
assert_eq!(
validate(file.path(), 1).unwrap(),
format!("{}: valid", file.path().display())
);
}
#[test]
fn validate_accepts_a_conformant_markdown_document() {
let file = write_temp_file(VALID_MARKDOWN_FIXTURE);
assert_eq!(
validate(file.path(), 1).unwrap(),
format!("{}: valid", file.path().display())
);
}
#[test]
fn validate_accepts_a_markdown_document_with_a_leading_byte_order_mark() {
let file = write_temp_file(&format!("\u{feff}{VALID_MARKDOWN_FIXTURE}"));
assert_eq!(
validate(file.path(), 1).unwrap(),
format!("{}: valid", file.path().display())
);
}
#[test]
fn validate_rejects_a_non_conformant_document() {
let file = write_temp_file(r#"{"content": "missing required fields"}"#);
let error = validate(file.path(), 1).unwrap_err();
assert!(error.to_string().contains("failed schema validation"));
}
#[test]
fn validate_reports_invalid_json() {
let file = write_temp_file("not json");
let error = validate(file.path(), 1).unwrap_err();
assert!(error.to_string().contains("failed to parse"));
}
#[test]
fn validate_reports_missing_file() {
let missing = std::path::Path::new("/nonexistent/mif-cli-test-fixture.json");
let error = validate(missing, 1).unwrap_err();
assert!(error.to_string().contains("failed to read"));
}
#[test]
fn validate_l2_rejects_a_document_missing_the_l2_floor_fields() {
let file = write_temp_file(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:test-001",
"conceptType": "semantic",
"content": "Test content.",
"created": "2026-07-02T00:00:00Z"
}"#,
);
let error = validate(file.path(), 2).unwrap_err();
assert!(error.to_string().contains("L2 level floor"));
assert!(error.to_string().contains("namespace"));
}
#[test]
fn validate_l2_accepts_a_document_with_the_l2_floor_fields() {
let file = write_temp_file(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:test-001",
"conceptType": "semantic",
"content": "Test content.",
"created": "2026-07-02T00:00:00Z",
"namespace": "test",
"modified": "2026-07-02T00:00:00Z",
"temporal": {}
}"#,
);
assert_eq!(
validate(file.path(), 2).unwrap(),
format!("{}: valid", file.path().display())
);
}
#[test]
fn validate_rejects_an_out_of_range_level() {
let file = write_temp_file(VALID_MARKDOWN_FIXTURE);
let error = validate(file.path(), 9).unwrap_err();
assert!(error.to_string().contains("unsupported MIF level"));
}
#[test]
fn resolve_prints_the_extends_chain() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("mif-base.yaml"),
"ontology:\n id: mif-base\n version: 1.0.0\n",
)
.unwrap();
fs::write(
dir.path().join("domain.yaml"),
"ontology:\n id: domain\n version: 1.0.0\n extends: [mif-base]\n",
)
.unwrap();
assert_eq!(
resolve("domain", dir.path()).unwrap(),
"mif-base (1.0.0) -> domain (1.0.0)"
);
}
#[test]
fn resolve_reports_unknown_ontology() {
let dir = tempfile::tempdir().unwrap();
let error = resolve("missing", dir.path()).unwrap_err();
assert!(error.to_string().contains("not found"));
}
#[test]
fn invalid_document_error_renders_as_problem_json() {
let file = write_temp_file(r#"{"content": "missing required fields"}"#);
let error = validate(file.path(), 1).unwrap_err();
let json = error.render(OutputFormat::Json);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
for member in [
"type",
"title",
"status",
"detail",
"instance",
"retry_after",
] {
assert!(value.get(member).is_some(), "missing {member}");
}
assert_eq!(
value["type"],
"https://modeled-information-format.github.io/mif-rs/references/errors/invalid-document/v1"
);
}
#[test]
fn missing_file_io_error_classifies_as_a_404_maybe_incorrect_problem() {
let error =
validate(std::path::Path::new("/nonexistent/mif-cli-fixture.json"), 1).unwrap_err();
let problem = error.to_problem();
assert_eq!(problem.status, 404);
assert_eq!(
problem.suggested_fix.unwrap().applicability,
mif_problem::Applicability::MaybeIncorrect
);
}
#[test]
fn directory_io_error_classifies_as_a_500_unspecified_problem() {
let dir = tempfile::tempdir().unwrap();
let error = validate(dir.path(), 1).unwrap_err();
let problem = error.to_problem();
#[cfg(not(windows))]
{
assert_eq!(problem.status, 500);
assert_eq!(
problem.suggested_fix.unwrap().applicability,
mif_problem::Applicability::Unspecified
);
}
#[cfg(windows)]
{
assert_eq!(problem.status, 403);
assert_eq!(
problem.suggested_fix.unwrap().applicability,
mif_problem::Applicability::MaybeIncorrect
);
}
}
#[test]
fn unknown_ontology_error_renders_as_problem_json() {
let dir = tempfile::tempdir().unwrap();
let error = resolve("missing", dir.path()).unwrap_err();
let problem = error.to_problem();
assert_eq!(
problem.problem_type,
"https://modeled-information-format.github.io/mif-rs/references/errors/ontology-not-found/v1"
);
assert_eq!(problem.status, 404);
}
#[test]
fn pretty_render_matches_error_prefixed_display() {
let error =
validate(std::path::Path::new("/nonexistent/mif-cli-fixture.json"), 1).unwrap_err();
assert_eq!(
error.render(OutputFormat::Pretty),
format!("Error: {error}")
);
}
const VALID_MARKDOWN_FIXTURE: &str = "---
id: memory:test-001
type: semantic
created: 2026-07-02T00:00:00Z
---
Test content.
";
const DRIFTING_MARKDOWN_FIXTURE: &str =
"---\nid: x\ntype: semantic\n123: orphaned-value\n---\n\nBody.\n";
#[test]
fn roundtrip_accepts_a_conformant_markdown_document() {
let file = write_temp_file(VALID_MARKDOWN_FIXTURE);
assert_eq!(
roundtrip(file.path(), FrontmatterShape::V1Canonical).unwrap(),
format!("{}: roundtrip lossless", file.path().display())
);
}
#[test]
fn roundtrip_accepts_a_conformant_jsonld_document() {
let file = write_temp_file(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:test-001",
"conceptType": "semantic",
"content": "Test content.",
"created": "2026-07-02T00:00:00Z"
}"#,
);
assert_eq!(
roundtrip(file.path(), FrontmatterShape::V1Canonical).unwrap(),
format!("{}: roundtrip lossless", file.path().display())
);
}
#[test]
fn roundtrip_rejects_a_drifting_document() {
let file = write_temp_file(DRIFTING_MARKDOWN_FIXTURE);
let error = roundtrip(file.path(), FrontmatterShape::V1Canonical).unwrap_err();
assert!(matches!(error, CliError::Frontmatter(_)));
}
#[test]
fn roundtrip_rejects_json_ld_input_whose_fields_do_not_survive_the_trip() {
let file = write_temp_file(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:timestamp-loss-test",
"conceptType": "semantic",
"content": "Test content.",
"timestamp": "2026-01-01T00:00:00Z"
}"#,
);
let error = roundtrip(file.path(), FrontmatterShape::V1Canonical).unwrap_err();
assert!(matches!(error, CliError::Frontmatter(_)));
}
#[test]
fn roundtrip_accepts_json_ld_input_whose_timestamp_is_consistently_derived() {
let file = write_temp_file(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:timestamp-consistent-test",
"conceptType": "semantic",
"content": "Test content.",
"created": "2026-01-01T00:00:00Z",
"timestamp": "2026-01-01T00:00:00Z"
}"#,
);
assert_eq!(
roundtrip(file.path(), FrontmatterShape::V1Canonical).unwrap(),
format!("{}: roundtrip lossless", file.path().display())
);
}
#[test]
fn emit_jsonld_rejects_json_ld_input_whose_fields_do_not_survive_the_trip() {
let file = write_temp_file(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:timestamp-loss-test-2",
"conceptType": "semantic",
"content": "Test content.",
"timestamp": "2026-01-01T00:00:00Z"
}"#,
);
let error = emit_jsonld(file.path(), None, FrontmatterShape::V1Canonical).unwrap_err();
assert!(matches!(error, CliError::Frontmatter(_)));
}
#[test]
fn emit_jsonld_prints_the_projection_to_stdout_by_default() {
let file = write_temp_file(VALID_MARKDOWN_FIXTURE);
let output = emit_jsonld(file.path(), None, FrontmatterShape::V1Canonical).unwrap();
let value: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["@id"], "urn:mif:memory:test-001");
assert_eq!(value["conceptType"], "semantic");
}
#[test]
fn emit_jsonld_writes_to_the_out_path_when_given() {
let file = write_temp_file(VALID_MARKDOWN_FIXTURE);
let out_dir = tempfile::tempdir().unwrap();
let out_path = out_dir.path().join("out.json");
let message =
emit_jsonld(file.path(), Some(&out_path), FrontmatterShape::V1Canonical).unwrap();
assert!(message.contains("wrote JSON-LD to"));
let written = fs::read_to_string(&out_path).unwrap();
let value: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(value["@id"], "urn:mif:memory:test-001");
}
#[test]
fn emit_jsonld_rejects_a_drifting_document() {
let file = write_temp_file(DRIFTING_MARKDOWN_FIXTURE);
let error = emit_jsonld(file.path(), None, FrontmatterShape::V1Canonical).unwrap_err();
assert!(matches!(error, CliError::Frontmatter(_)));
}
#[test]
fn emit_markdown_prints_the_projection_to_stdout_by_default() {
let file = write_temp_file(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:test-001",
"conceptType": "semantic",
"content": "Test content.",
"created": "2026-07-02T00:00:00Z"
}"#,
);
let output = emit_markdown(
file.path(),
None,
mif_frontmatter::FrontmatterShape::V1Canonical,
)
.unwrap();
assert!(output.starts_with("---\n"));
assert!(output.contains("Test content."));
}
#[test]
fn emit_markdown_accepts_json_ld_with_a_leading_byte_order_mark() {
let mut contents = "\u{feff}".to_string();
contents.push_str(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:bom-test",
"conceptType": "semantic",
"content": "Test content.",
"created": "2026-07-02T00:00:00Z"
}"#,
);
let file = write_temp_file(&contents);
let output = emit_markdown(
file.path(),
None,
mif_frontmatter::FrontmatterShape::V1Canonical,
)
.unwrap();
assert!(output.starts_with("---\n"));
}
#[test]
fn emit_markdown_writes_to_the_out_path_when_given() {
let file = write_temp_file(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:test-001",
"conceptType": "semantic",
"content": "Test content.",
"created": "2026-07-02T00:00:00Z"
}"#,
);
let out_dir = tempfile::tempdir().unwrap();
let out_path = out_dir.path().join("out.md");
let message = emit_markdown(
file.path(),
Some(&out_path),
mif_frontmatter::FrontmatterShape::V1Canonical,
)
.unwrap();
assert!(message.contains("wrote markdown to"));
let written = fs::read_to_string(&out_path).unwrap();
assert!(written.starts_with("---\n"));
}
#[test]
fn emit_markdown_rejects_invalid_json() {
let file = write_temp_file("not json");
let error = emit_markdown(
file.path(),
None,
mif_frontmatter::FrontmatterShape::V1Canonical,
)
.unwrap_err();
assert!(matches!(error, CliError::Json { .. }));
}
#[test]
fn emit_markdown_rejects_json_ld_input_whose_fields_do_not_survive_the_trip() {
let file = write_temp_file(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:emit-md-timestamp-loss-test",
"conceptType": "semantic",
"content": "Test content.",
"timestamp": "2026-01-01T00:00:00Z"
}"#,
);
let error = emit_markdown(
file.path(),
None,
mif_frontmatter::FrontmatterShape::V1Canonical,
)
.unwrap_err();
assert!(matches!(error, CliError::Frontmatter(_)));
}
#[test]
fn json_serialize_error_maps_to_a_versioned_problem_type() {
let source = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
let error = CliError::JsonSerialize { source };
let problem = error.to_problem();
assert_eq!(
problem.problem_type,
"https://modeled-information-format.github.io/mif-rs/references/errors/mif-cli-json-serialize-failure/v1"
);
assert_eq!(problem.status, 500);
assert_eq!(problem.exit_code, Some(1));
}
#[test]
fn frontmatter_shape_try_from_rejects_an_unknown_shape_name() {
let error = mif_frontmatter::FrontmatterShape::try_from("PreProjected").unwrap_err();
assert!(matches!(
error,
mif_frontmatter::FrontmatterError::UnknownShape(_)
));
}
#[test]
fn ingest_accepts_a_conformant_markdown_document_and_stores_it() {
warm_embedding_model_cache();
let file = write_temp_file(VALID_MARKDOWN_FIXTURE);
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
let message = ingest(file.path(), Some(&db_path)).unwrap();
assert!(message.contains("lint=ok"));
assert!(message.contains("validate=ok"));
assert!(message.contains("roundtrip=lossless"));
assert!(message.contains("embedding_dim=384"));
assert!(message.contains("stored=true"));
let store = mif_store::VectorStore::open(&db_path).unwrap();
assert_eq!(store.count().unwrap(), 1);
let stored = store.get("urn:mif:memory:test-001").unwrap().unwrap();
assert_eq!(stored.dim, 384);
}
#[test]
fn ingest_accepts_a_conformant_jsonld_document() {
warm_embedding_model_cache();
let file = write_temp_file(
r#"{
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": "urn:mif:memory:test-002",
"conceptType": "semantic",
"content": "Other content.",
"created": "2026-07-02T00:00:00Z"
}"#,
);
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
let message = ingest(file.path(), Some(&db_path)).unwrap();
assert!(message.contains("embedding_dim=384"));
let store = mif_store::VectorStore::open(&db_path).unwrap();
assert!(store.get("urn:mif:memory:test-002").unwrap().is_some());
}
#[test]
fn ingest_rejects_invalid_document_and_writes_no_row() {
warm_embedding_model_cache();
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
let valid_file = write_temp_file(VALID_MARKDOWN_FIXTURE);
ingest(valid_file.path(), Some(&db_path)).unwrap();
let store = mif_store::VectorStore::open(&db_path).unwrap();
assert_eq!(store.count().unwrap(), 1);
let invalid_file = write_temp_file(
"---
id: memory:test-003
created: 2026-07-02T00:00:00Z
---
No type field.
",
);
let error = ingest(invalid_file.path(), Some(&db_path)).unwrap_err();
assert!(error.to_string().contains("failed schema validation"));
let store = mif_store::VectorStore::open(&db_path).unwrap();
assert_eq!(store.count().unwrap(), 1);
}
#[test]
fn ingest_invalid_document_renders_as_problem_json() {
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
let invalid_file = write_temp_file(
"---
id: memory:test-004
created: 2026-07-02T00:00:00Z
---
No type field.
",
);
let error = ingest(invalid_file.path(), Some(&db_path)).unwrap_err();
let json = error.render(OutputFormat::Json);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
for member in [
"type",
"title",
"status",
"detail",
"instance",
"retry_after",
"suggested_fix",
"code_actions",
] {
assert!(value.get(member).is_some(), "missing {member}");
}
assert_eq!(
value["type"],
"https://modeled-information-format.github.io/mif-rs/references/errors/invalid-document/v1"
);
}
#[test]
fn ingest_missing_file_classifies_as_a_404_maybe_incorrect_problem() {
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
let error = ingest(
std::path::Path::new("/nonexistent/mif-cli-fixture.json"),
Some(&db_path),
)
.unwrap_err();
assert_eq!(error.to_problem().status, 404);
}
#[test]
fn ingest_reports_an_io_error_when_the_db_parent_directory_cannot_be_created() {
warm_embedding_model_cache();
let file = write_temp_file(VALID_MARKDOWN_FIXTURE);
let parent_dir = tempfile::tempdir().unwrap();
let blocker = parent_dir.path().join("blocker");
fs::write(&blocker, "not a directory").unwrap();
let db_path = blocker.join("subdir").join("vectors.db");
let error = ingest(file.path(), Some(&db_path)).unwrap_err();
assert_eq!(error.to_problem().status, 500);
}
#[test]
fn ingest_reports_the_real_file_path_on_a_json_ld_parse_error() {
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
let file = write_temp_file("not valid json");
let error = ingest(file.path(), Some(&db_path)).unwrap_err();
let message = error.to_string();
assert!(
message.contains(&file.path().display().to_string()),
"expected the real file path in {message:?}, not the ingest-input placeholder"
);
}
#[test]
fn exit_code_reflects_the_mapped_problem_type_not_a_flat_failure() {
let io_error = CliError::Io {
path: "x".to_string(),
source: std::io::Error::from(std::io::ErrorKind::NotFound),
};
assert_eq!(io_error.to_problem().exit_code, Some(1));
let json_error = CliError::Json {
path: "x".to_string(),
source: serde_json::from_str::<serde_json::Value>("not json").unwrap_err(),
};
assert_eq!(json_error.to_problem().exit_code, Some(2));
let not_found = CliError::DocumentNotFound("urn:mif:memory:missing".to_string());
assert_eq!(not_found.to_problem().exit_code, Some(3));
}
#[test]
fn delegated_error_variants_render_a_sane_problem_if_ever_directly_matched() {
for error in [
CliError::Frontmatter(mif_frontmatter::FrontmatterError::MissingFrontmatter),
CliError::Embed(mif_embed::EmbedError::NoCacheDir { model: "test" }),
CliError::Store(mif_store::StoreError::MissingParentDir {
path: "test".to_string(),
}),
] {
let problem = error.to_problem();
assert!(problem.status >= 400, "status was {}", problem.status);
let meta = error.meta();
assert_eq!(meta.status, 500);
}
}
#[test]
fn run_dispatches_every_subcommand_to_its_handler() {
let ontologies_dir = tempfile::tempdir().unwrap();
fs::write(
ontologies_dir.path().join("base.yaml"),
"ontology:\n id: base\n version: 1.0.0\n",
)
.unwrap();
let resolve_result = run(&Command::Ontology {
command: OntologyCommand::Resolve {
id: "base".to_string(),
ontologies_dir: ontologies_dir.path().to_path_buf(),
},
});
assert!(resolve_result.is_ok());
let missing_file = tempfile::tempdir().unwrap().path().join("missing.json");
let validate_result = run(&Command::Validate {
file: missing_file,
level: 1,
});
assert!(validate_result.is_err());
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
warm_embedding_model_cache();
let doc_file = write_temp_file(VALID_MARKDOWN_FIXTURE);
let ingest_result = run(&Command::Ingest {
file: doc_file.path().to_path_buf(),
db_path: Some(db_path.clone()),
});
assert!(ingest_result.is_ok());
let search_result = run(&Command::Search {
query: "test content".to_string(),
db_path: Some(db_path.clone()),
limit: 5,
});
assert!(search_result.is_ok());
let find_similar_result = run(&Command::FindSimilar {
id: "urn:mif:memory:test-001".to_string(),
db_path: Some(db_path.clone()),
limit: 5,
});
assert!(find_similar_result.is_ok());
let corpus_stats_result = run(&Command::CorpusStats {
db_path: Some(db_path),
});
assert!(corpus_stats_result.is_ok());
}
fn ingest_fixture(db_path: &std::path::Path, id: &str, content: &str) {
warm_embedding_model_cache();
let file = write_temp_file(&format!(
"---\nid: {id}\ntype: semantic\ncreated: 2026-07-02T00:00:00Z\n---\n\n{content}\n"
));
ingest(file.path(), Some(db_path)).unwrap();
}
#[test]
fn search_ranks_ingested_documents_by_relevance() {
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
ingest_fixture(
&db_path,
"memory:cats",
"Cats are small domesticated felines.",
);
ingest_fixture(
&db_path,
"memory:finance",
"Quarterly revenue exceeded analyst expectations.",
);
let result = search("A furry pet cat", Some(&db_path), 10).unwrap();
let first_line = result.lines().next().unwrap();
assert!(first_line.ends_with("urn:mif:memory:cats"));
}
#[test]
fn search_reports_no_matches_on_an_empty_store() {
warm_embedding_model_cache();
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
mif_store::VectorStore::open(&db_path).unwrap();
let result = search("anything", Some(&db_path), 10).unwrap();
assert_eq!(result, "(no matches)");
}
#[test]
fn find_similar_excludes_the_anchor_document_itself() {
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
ingest_fixture(&db_path, "memory:a", "Cats are small domesticated felines.");
ingest_fixture(&db_path, "memory:b", "Dogs are loyal domesticated canines.");
let result = find_similar("urn:mif:memory:a", Some(&db_path), 10).unwrap();
assert!(!result.contains("memory:a"));
assert!(result.contains("memory:b"));
}
#[test]
fn find_similar_reports_document_not_found() {
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
mif_store::VectorStore::open(&db_path).unwrap();
let error = find_similar("urn:mif:memory:missing", Some(&db_path), 10).unwrap_err();
let problem = error.to_problem();
assert_eq!(
problem.problem_type,
"https://modeled-information-format.github.io/mif-rs/references/errors/mif-cli-document-not-found/v1"
);
assert_eq!(problem.status, 404);
}
#[test]
fn corpus_stats_reports_count_and_dim() {
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("vectors.db");
assert_eq!(
corpus_stats(Some(&db_path)).unwrap(),
format!("count=0 db={}", db_path.display())
);
ingest_fixture(&db_path, "memory:one", "Some content.");
let result = corpus_stats(Some(&db_path)).unwrap();
assert!(result.contains("count=1"));
assert!(result.contains("dim=384"));
}
}