use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join("fixtures/valid/minimal")
}
fn knowledge_base_command() -> Command {
Command::new(env!("CARGO_BIN_EXE_knowledge-base"))
}
fn copied_fixture() -> tempfile::TempDir {
let destination = tempfile::tempdir().expect("temporary knowledge base");
for directory in ["entities", "entity_types", "properties", "references", "entity_context"] {
let source_directory = fixture().join(directory);
if !source_directory.exists() {
continue;
}
fs::create_dir(destination.path().join(directory)).expect("create fixture directory");
for entry in fs::read_dir(source_directory).expect("read fixture directory") {
let entry = entry.expect("read fixture entry");
fs::copy(entry.path(), destination.path().join(directory).join(entry.file_name())).expect("copy fixture file");
}
}
fs::copy(fixture().join("id_allocation.yaml"), destination.path().join("id_allocation.yaml")).expect("copy allocation");
destination
}
fn write_manifest(root: &Path, source: &str) -> PathBuf {
let path = root.join("statement-manifest.yaml");
fs::write(&path, source).expect("write statement manifest");
path
}
#[test]
fn read_commands_print_files_exactly_as_stored() {
let root = fixture();
let cases = [
(["entity", "read", "Q1"], "entities/Q1.yaml"),
(["entity-type", "read", "T1"], "entity_types/T1.yaml"),
(["property", "read", "P1"], "properties/P1.yaml"),
(["reference", "read", "R1"], "references/R1.yaml"),
(["entity-context", "read", "Q1"], "entity_context/Q1.md"),
];
for (arguments, relative_path) in cases {
let output = knowledge_base_command()
.args(arguments)
.env("KNOWLEDGE_BASE_PATH", &root)
.output()
.expect("run knowledge-base command");
assert!(output.status.success(), "{}: {}", relative_path, String::from_utf8_lossy(&output.stderr));
assert_eq!(output.stdout, fs::read(root.join(relative_path)).expect("read expected file"));
assert!(output.stderr.is_empty());
}
}
#[test]
fn read_preserves_a_missing_trailing_newline_without_validating_the_repository() {
let root = tempfile::tempdir().expect("temporary knowledge base");
fs::create_dir(root.path().join("entities")).expect("create entity directory");
fs::write(root.path().join("entities/Q1.yaml"), b"id: Q1").expect("write entity");
let output = knowledge_base_command()
.args(["entity", "read", "Q1"])
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("run entity read");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert_eq!(output.stdout, b"id: Q1");
}
#[test]
fn validate_uses_the_environment_configured_root() {
let root = fixture();
let output = knowledge_base_command().arg("validate").env("KNOWLEDGE_BASE_PATH", &root).output().expect("run validation");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert_eq!(String::from_utf8(output.stdout).unwrap(), format!("valid knowledge base: {}\n", root.display()));
}
#[test]
fn configured_root_is_required_for_executable_commands() {
for value in [None, Some("")] {
let mut command = knowledge_base_command();
command.arg("validate").env_remove("KNOWLEDGE_BASE_PATH");
if let Some(value) = value {
command.env("KNOWLEDGE_BASE_PATH", value);
}
let output = command.output().expect("run validation");
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("KNOWLEDGE_BASE_PATH must be set"));
}
}
#[test]
fn help_and_version_do_not_require_configuration() {
for argument in ["--help", "--version"] {
let output = knowledge_base_command()
.arg(argument)
.env_remove("KNOWLEDGE_BASE_PATH")
.output()
.expect("run informational command");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
}
}
#[test]
fn malformed_or_wrong_prefix_identifiers_are_usage_errors() {
for identifier in ["P1", "../Q1", "Q1.yaml"] {
let output = knowledge_base_command()
.args(["entity", "read", identifier])
.env("KNOWLEDGE_BASE_PATH", fixture())
.output()
.expect("run entity read");
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("invalid identifier"));
}
}
#[test]
fn missing_records_and_optional_context_are_read_errors() {
for arguments in [["entity", "read", "Q999"], ["entity-context", "read", "Q2"]] {
let output = knowledge_base_command()
.args(arguments)
.env("KNOWLEDGE_BASE_PATH", fixture())
.output()
.expect("run read command");
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("cannot read"));
assert!(output.stdout.is_empty());
}
}
#[test]
fn relationships_show_direct_incoming_and_outgoing_edges() {
let cases = [
(
"Q2",
"entity: Q2\noffset: 0\nlimit: 100\ntotal: 1\nrelationships:\n- direction: incoming\n entity:\n id: Q1\n labels:\n tr: BİLECİK\n property: P3\n statement: S3\n",
),
(
"Q1",
"entity: Q1\noffset: 0\nlimit: 100\ntotal: 1\nrelationships:\n- direction: outgoing\n entity:\n id: Q2\n labels:\n en: Türkiye\n property: P3\n statement: S3\n",
),
];
for (id, expected) in cases {
let output = knowledge_base_command()
.args(["entity", "relationships", id])
.env("KNOWLEDGE_BASE_PATH", fixture())
.output()
.expect("run entity relationships");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert_eq!(String::from_utf8(output.stdout).unwrap(), expected);
assert!(output.stderr.is_empty());
}
}
#[test]
fn entity_query_returns_full_entities_with_filter_and_pagination_metadata() {
let output = knowledge_base_command()
.args(["entity", "query", "--filter", "P3=Q2"])
.env("KNOWLEDGE_BASE_PATH", fixture())
.output()
.expect("run entity query");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let page: serde_yaml::Value = serde_yaml::from_slice(&output.stdout).unwrap();
assert_eq!(page["filters"][0]["property"], "P3");
assert_eq!(page["filters"][0]["value"]["type"], "entity");
assert_eq!(page["filters"][0]["value"]["value"], "Q2");
assert_eq!(page["offset"], 0);
assert_eq!(page["limit"], 100);
assert_eq!(page["total"], 1);
assert!(page.get("next_offset").is_none());
assert_eq!(page["entities"][0]["id"], "Q1");
assert_eq!(page["entities"][0]["statements"][2]["id"], "S3");
assert!(output.stderr.is_empty());
}
#[test]
fn entity_query_ands_filters_and_returns_empty_pages() {
let matching = knowledge_base_command()
.args(["entity", "query", "--filter", "P3=Q2", "--filter", "P1=228334", "--limit", "1"])
.env("KNOWLEDGE_BASE_PATH", fixture())
.output()
.expect("run matching entity query");
assert!(matching.status.success(), "{}", String::from_utf8_lossy(&matching.stderr));
let matching: serde_yaml::Value = serde_yaml::from_slice(&matching.stdout).unwrap();
assert_eq!(matching["total"], 1);
assert_eq!(matching["entities"][0]["id"], "Q1");
let empty = knowledge_base_command()
.args(["entity", "query", "--filter", "P3=Q2", "--offset", "99"])
.env("KNOWLEDGE_BASE_PATH", fixture())
.output()
.expect("run empty entity query page");
assert!(empty.status.success(), "{}", String::from_utf8_lossy(&empty.stderr));
let empty: serde_yaml::Value = serde_yaml::from_slice(&empty.stdout).unwrap();
assert_eq!(empty["total"], 1);
assert_eq!(empty["entities"], serde_yaml::Value::Sequence(Vec::new()));
}
#[test]
fn invalid_entity_queries_fail_without_output() {
let cases = [
vec!["entity", "query"],
vec!["entity", "query", "--filter", "P3"],
vec!["entity", "query", "--filter", "P3="],
vec!["entity", "query", "--filter", "P3=P1"],
vec!["entity", "query", "--filter", "P999=Q2"],
vec!["entity", "query", "--filter", "P3=Q2", "--limit", "0"],
];
for arguments in cases {
let output = knowledge_base_command()
.args(arguments)
.env("KNOWLEDGE_BASE_PATH", fixture())
.output()
.expect("run invalid entity query");
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(!output.stderr.is_empty());
}
}
#[test]
fn relationships_are_one_hop_and_paginated_after_canonical_sorting() {
let root = copied_fixture();
fs::write(
root.path().join("entities/Q3.yaml"),
"id: Q3\nlabels:\n en:\n text: District\n references: [R1]\nentity_types:\n - value: T1\n references: [R1]\nstatements:\n - id: S1\n property: P3\n value:\n type: entity\n value: Q1\n references: [R1]\n",
)
.unwrap();
fs::write(
root.path().join("entities/Q4.yaml"),
"id: Q4\nlabels:\n en:\n text: Ankara\n references: [R1]\nentity_types:\n - value: T1\n references: [R1]\nstatements:\n - id: S1\n property: P3\n value:\n type: entity\n value: Q2\n references: [R1]\n",
)
.unwrap();
let first = knowledge_base_command()
.args(["entity", "relationships", "Q2", "--limit", "1"])
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("run first relationship page");
assert!(first.status.success(), "{}", String::from_utf8_lossy(&first.stderr));
let first = String::from_utf8(first.stdout).unwrap();
assert!(first.contains("total: 2\nnext_offset: 1\n"));
assert!(first.contains("id: Q1"));
assert!(!first.contains("id: Q3"));
assert!(!first.contains("id: Q4"));
let second = knowledge_base_command()
.args(["entity", "relationships", "Q2", "--limit", "1", "--offset", "1"])
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("run second relationship page");
assert!(second.status.success(), "{}", String::from_utf8_lossy(&second.stderr));
let second = String::from_utf8(second.stdout).unwrap();
assert!(second.contains("total: 2\nrelationships:"));
assert!(!second.contains("next_offset"));
assert!(second.contains("id: Q4"));
assert!(!second.contains("id: Q3"));
let empty = knowledge_base_command()
.args(["entity", "relationships", "Q2", "--offset", "99"])
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("run empty relationship page");
assert!(empty.status.success(), "{}", String::from_utf8_lossy(&empty.stderr));
assert!(String::from_utf8(empty.stdout).unwrap().ends_with("total: 2\nrelationships: []\n"));
}
#[test]
fn invalid_relationship_queries_fail_without_output() {
let malformed_root = copied_fixture();
fs::write(malformed_root.path().join("entities/Q2.yaml"), "not: an entity\n").unwrap();
let fixture_root = fixture();
let cases = [
(vec!["entity", "relationships", "Q999"], fixture_root.as_path()),
(vec!["entity", "relationships", "Q2"], malformed_root.path()),
(vec!["entity", "relationships", "Q2", "--limit", "0"], fixture_root.as_path()),
];
for (arguments, root) in cases {
let output = knowledge_base_command()
.args(arguments)
.env("KNOWLEDGE_BASE_PATH", root)
.output()
.expect("run invalid relationship query");
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(!output.stderr.is_empty());
}
}
#[test]
fn non_utf8_resources_are_read_errors() {
let root = tempfile::tempdir().expect("temporary knowledge base");
fs::create_dir(root.path().join("entities")).expect("create entity directory");
fs::write(root.path().join("entities/Q1.yaml"), [0xff]).expect("write invalid UTF-8");
let output = knowledge_base_command()
.args(["entity", "read", "Q1"])
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("run entity read");
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("cannot read"));
assert!(output.stdout.is_empty());
}
#[test]
fn statement_apply_dry_run_reports_planned_addition_without_changing_entity() {
let root = copied_fixture();
let manifest = write_manifest(
root.path(),
"statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 123456789 }\n references: [R1]\n",
);
let entity_path = root.path().join("entities/Q1.yaml");
let before = fs::read(&entity_path).unwrap();
let output = knowledge_base_command()
.args(["entity", "statement", "apply"])
.arg(&manifest)
.arg("--dry-run")
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("run statement dry-run");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
"outcome: previewed\nresults:\n- index: 1\n entity: Q1\n property: P1\n statement: S4\n status: would_add\n"
);
assert_eq!(fs::read(entity_path).unwrap(), before);
}
#[test]
fn statement_apply_preserves_text_and_repeated_apply_is_rejected() {
let root = copied_fixture();
let manifest_source = "statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 123456789 }\n references: [R1]\n";
let manifest = write_manifest(root.path(), manifest_source);
let entity_path = root.path().join("entities/Q1.yaml");
let source = fs::read_to_string(&entity_path).unwrap().replace("statements:\n", "# retained comment\nstatements:\n");
fs::write(&entity_path, &source).unwrap();
let applied = knowledge_base_command()
.args(["entity", "statement", "apply"])
.arg(&manifest)
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("apply statement manifest");
assert!(applied.status.success(), "{}", String::from_utf8_lossy(&applied.stderr));
let changed = fs::read_to_string(&entity_path).unwrap();
assert!(changed.starts_with(&source));
assert!(changed.contains(" - id: S4\n property: P1\n"));
assert!(changed.contains("# retained comment"));
assert_eq!(fs::read_to_string(&manifest).unwrap(), manifest_source);
let repeated = knowledge_base_command()
.args(["entity", "statement", "apply"])
.arg(&manifest)
.arg("--dry-run")
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("repeat statement manifest");
assert!(!repeated.status.success());
assert!(String::from_utf8_lossy(&repeated.stdout).contains("status: already_present"));
assert_eq!(fs::read_to_string(&entity_path).unwrap(), changed);
}
#[test]
fn duplicate_manifest_rows_are_reported_and_never_written() {
let root = copied_fixture();
let manifest = write_manifest(
root.path(),
"statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 42 }\n references: [R1]\n - entity: Q1\n property: P1\n value: { type: integer, value: 42 }\n references: [R1]\n",
);
let entity_path = root.path().join("entities/Q1.yaml");
let before = fs::read(&entity_path).unwrap();
let output = knowledge_base_command()
.args(["entity", "statement", "apply"])
.arg(&manifest)
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("apply duplicate statement manifest");
assert!(!output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("statement: S4\n status: would_add"));
assert!(stdout.contains("statement: S4\n status: already_present"));
assert!(stdout.starts_with("outcome: not_applied\n"));
assert_eq!(fs::read(entity_path).unwrap(), before);
}
#[test]
fn invalid_statement_manifests_fail_without_results_or_writes() {
let cases = [
(
"statements:\n - entity: Q1\n property: P1\n value: { type: string, value: wrong-type }\n references: [R1]\n",
"requires integer values",
),
(
"statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 42 }\n references: []\n",
"references must not be empty",
),
(
"statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 42 }\n references: [R1]\n qualifiers: []\n",
"unknown field `qualifiers`",
),
];
for (manifest_source, expected_error) in cases {
let root = copied_fixture();
let manifest = write_manifest(root.path(), manifest_source);
let entity_path = root.path().join("entities/Q1.yaml");
let before = fs::read(&entity_path).unwrap();
let output = knowledge_base_command()
.args(["entity", "statement", "apply"])
.arg(&manifest)
.arg("--dry-run")
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("run invalid statement manifest");
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains(expected_error));
assert_eq!(fs::read(entity_path).unwrap(), before);
}
}
#[test]
fn statement_apply_commits_multiple_entities_after_complete_validation() {
let root = copied_fixture();
fs::write(
root.path().join("properties/P4.yaml"),
"id: P4\nlabels:\n en:\n text: external identifier\n references: [R1]\nsubject_types: [T2]\nvalue_type: string\ncardinality: one\n",
)
.unwrap();
fs::write(
root.path().join("id_allocation.yaml"),
"version: 1\nnext:\n entity: 3\n property: 5\n reference: 2\n entity_type: 3\n",
)
.unwrap();
let manifest = write_manifest(
root.path(),
"statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 42 }\n references: [R1]\n - entity: Q2\n property: P4\n value: { type: string, value: Q987654 }\n references: [R1]\n",
);
let output = knowledge_base_command()
.args(["entity", "statement", "apply"])
.arg(&manifest)
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("apply multi-entity statement manifest");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("entity: Q1\n property: P1\n statement: S4\n status: added"));
assert!(stdout.contains("entity: Q2\n property: P4\n statement: S1\n status: added"));
assert!(stdout.starts_with("outcome: applied\n"));
assert!(fs::read_to_string(root.path().join("entities/Q1.yaml")).unwrap().contains("value: 42"));
assert!(fs::read_to_string(root.path().join("entities/Q2.yaml")).unwrap().contains("value: Q987654"));
let validation = knowledge_base_command()
.arg("validate")
.env("KNOWLEDGE_BASE_PATH", root.path())
.output()
.expect("validate applied repository");
assert!(validation.status.success(), "{}", String::from_utf8_lossy(&validation.stderr));
}