use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::dtoml;
use crate::entity::{self, Artifact, Fileset, Inputs, Kind, MaterialiseRequest, ScaffoldCtx};
use crate::listing::{self, Format, ListArgs};
use crate::tomlfmt::toml_string;
#[cfg(test)]
use crate::test_support::SCHEMA_KNOWLEDGE;
#[cfg(test)]
use crate::tomlfmt::toml_array_inner;
const RECORD_STEM: &str = "record";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum RecordKind {
Assumption,
Decision,
Question,
Constraint,
Evidence,
Hypothesis,
}
pub(crate) const ASSUMPTION_KIND: Kind = Kind {
dir: ".doctrine/knowledge/assumption",
prefix: crate::kinds::ASM,
stem: "record",
scaffold: |c| record_scaffold(RecordKind::Assumption, c),
};
pub(crate) const DECISION_KIND: Kind = Kind {
dir: ".doctrine/knowledge/decision",
prefix: crate::kinds::DEC,
stem: "record",
scaffold: |c| record_scaffold(RecordKind::Decision, c),
};
pub(crate) const QUESTION_KIND: Kind = Kind {
dir: ".doctrine/knowledge/question",
prefix: crate::kinds::QUE,
stem: "record",
scaffold: |c| record_scaffold(RecordKind::Question, c),
};
pub(crate) const CONSTRAINT_KIND: Kind = Kind {
dir: ".doctrine/knowledge/constraint",
prefix: crate::kinds::CON,
stem: "record",
scaffold: |c| record_scaffold(RecordKind::Constraint, c),
};
pub(crate) const EVIDENCE_KIND: Kind = Kind {
dir: ".doctrine/knowledge/evidence",
prefix: crate::kinds::EVD,
stem: "record",
scaffold: |c| record_scaffold(RecordKind::Evidence, c),
};
pub(crate) const HYPOTHESIS_KIND: Kind = Kind {
dir: ".doctrine/knowledge/hypothesis",
prefix: crate::kinds::HYP,
stem: "record",
scaffold: |c| record_scaffold(RecordKind::Hypothesis, c),
};
impl RecordKind {
pub(crate) const fn kind(self) -> &'static Kind {
match self {
RecordKind::Assumption => &ASSUMPTION_KIND,
RecordKind::Decision => &DECISION_KIND,
RecordKind::Question => &QUESTION_KIND,
RecordKind::Constraint => &CONSTRAINT_KIND,
RecordKind::Evidence => &EVIDENCE_KIND,
RecordKind::Hypothesis => &HYPOTHESIS_KIND,
}
}
pub(crate) const fn prefix(self) -> &'static str {
self.kind().prefix
}
pub(crate) const fn as_str(self) -> &'static str {
match self {
RecordKind::Assumption => "assumption",
RecordKind::Decision => "decision",
RecordKind::Question => "question",
RecordKind::Constraint => "constraint",
RecordKind::Evidence => "evidence",
RecordKind::Hypothesis => "hypothesis",
}
}
pub(crate) fn canonical_id(self, id: u32) -> String {
listing::canonical_id(self.prefix(), id)
}
pub(crate) fn from_prefix(prefix: &str) -> Option<Self> {
RecordKind::ALL.into_iter().find(|k| k.prefix() == prefix)
}
#[cfg(test)]
pub(crate) fn default_status(self) -> &'static str {
statuses(self).first().copied().unwrap_or_default()
}
pub(crate) fn is_terminal(self, status: &str) -> bool {
terminal(self).contains(&status)
}
pub(crate) const ALL: [RecordKind; 6] = [
RecordKind::Assumption,
RecordKind::Decision,
RecordKind::Question,
RecordKind::Constraint,
RecordKind::Evidence,
RecordKind::Hypothesis,
];
}
pub(crate) const ASSUMPTION_STATUSES: &[&str] =
&["held", "testing", "validated", "invalidated", "obsolete"];
pub(crate) const DECISION_STATUSES: &[&str] = &["proposed", "accepted", "rejected", "superseded"];
pub(crate) const QUESTION_STATUSES: &[&str] = &["open", "answered", "obsolete"];
pub(crate) const CONSTRAINT_STATUSES: &[&str] = &["active", "waived", "superseded", "retired"];
pub(crate) const EVIDENCE_STATUSES: &[&str] = &[
"captured",
"disputed",
"confirmed",
"retracted",
"superseded",
];
pub(crate) const HYPOTHESIS_STATUSES: &[&str] = &["proposed", "confirmed", "refuted"];
const ASSUMPTION_HIDDEN: &[&str] = &["validated", "invalidated", "obsolete"];
const DECISION_HIDDEN: &[&str] = &["rejected", "superseded"];
const QUESTION_HIDDEN: &[&str] = &["answered", "obsolete"];
const CONSTRAINT_HIDDEN: &[&str] = &["waived", "superseded", "retired"];
const EVIDENCE_HIDDEN: &[&str] = &["retracted", "superseded"];
const HYPOTHESIS_HIDDEN: &[&str] = &["confirmed", "refuted"];
const ASSUMPTION_TERMINAL: &[&str] = &["validated", "invalidated", "obsolete"];
const DECISION_TERMINAL: &[&str] = &["accepted", "rejected", "superseded"];
const QUESTION_TERMINAL: &[&str] = &["answered", "obsolete"];
const CONSTRAINT_TERMINAL: &[&str] = &["waived", "superseded", "retired"];
const EVIDENCE_TERMINAL: &[&str] = &["retracted", "superseded"];
const HYPOTHESIS_TERMINAL: &[&str] = &["confirmed", "refuted"];
pub(crate) fn statuses(k: RecordKind) -> &'static [&'static str] {
match k {
RecordKind::Assumption => ASSUMPTION_STATUSES,
RecordKind::Decision => DECISION_STATUSES,
RecordKind::Question => QUESTION_STATUSES,
RecordKind::Constraint => CONSTRAINT_STATUSES,
RecordKind::Evidence => EVIDENCE_STATUSES,
RecordKind::Hypothesis => HYPOTHESIS_STATUSES,
}
}
pub(crate) fn is_hidden(k: RecordKind, status: &str) -> bool {
hidden(k).contains(&status)
}
const fn hidden(k: RecordKind) -> &'static [&'static str] {
match k {
RecordKind::Assumption => ASSUMPTION_HIDDEN,
RecordKind::Decision => DECISION_HIDDEN,
RecordKind::Question => QUESTION_HIDDEN,
RecordKind::Constraint => CONSTRAINT_HIDDEN,
RecordKind::Evidence => EVIDENCE_HIDDEN,
RecordKind::Hypothesis => HYPOTHESIS_HIDDEN,
}
}
const fn terminal(k: RecordKind) -> &'static [&'static str] {
match k {
RecordKind::Assumption => ASSUMPTION_TERMINAL,
RecordKind::Decision => DECISION_TERMINAL,
RecordKind::Question => QUESTION_TERMINAL,
RecordKind::Constraint => CONSTRAINT_TERMINAL,
RecordKind::Evidence => EVIDENCE_TERMINAL,
RecordKind::Hypothesis => HYPOTHESIS_TERMINAL,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Confidence {
Low,
Medium,
High,
}
impl Confidence {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Confidence::Low => "low",
Confidence::Medium => "medium",
Confidence::High => "high",
}
}
#[cfg(test)]
pub(crate) const KNOWN: &'static [&'static str] = &["low", "medium", "high"];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Provenance {
Inspection,
Experiment,
Reproduction,
Citation,
}
impl Provenance {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Provenance::Inspection => "inspection",
Provenance::Experiment => "experiment",
Provenance::Reproduction => "reproduction",
Provenance::Citation => "citation",
}
}
#[cfg(test)]
pub(crate) const KNOWN: &'static [&'static str] =
&["inspection", "experiment", "reproduction", "citation"];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Basis {
Observation,
PriorArt,
DesignInference,
ExternalSource,
OperatorJudgement,
}
impl Basis {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Basis::Observation => "observation",
Basis::PriorArt => "prior-art",
Basis::DesignInference => "design-inference",
Basis::ExternalSource => "external-source",
Basis::OperatorJudgement => "operator-judgement",
}
}
#[cfg(test)]
pub(crate) const KNOWN: &'static [&'static str] = &[
"observation",
"prior-art",
"design-inference",
"external-source",
"operator-judgement",
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ConstraintSource {
Canon,
Adr,
External,
Technical,
Legal,
Compatibility,
Operator,
}
impl ConstraintSource {
pub(crate) const fn as_str(self) -> &'static str {
match self {
ConstraintSource::Canon => "canon",
ConstraintSource::Adr => "adr",
ConstraintSource::External => "external",
ConstraintSource::Technical => "technical",
ConstraintSource::Legal => "legal",
ConstraintSource::Compatibility => "compatibility",
ConstraintSource::Operator => "operator",
}
}
#[cfg(test)]
pub(crate) const KNOWN: &'static [&'static str] = &[
"canon",
"adr",
"external",
"technical",
"legal",
"compatibility",
"operator",
];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct KnowledgeRecord {
id: u32,
slug: String,
title: String,
record_kind: RecordKind,
status: String,
created: String,
updated: String,
tags: Vec<String>,
facet: RecordFacet,
evidence: Evidence,
tier1: Vec<crate::relation::RelationEdge>,
pub(crate) body: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RecordFacet {
Assumption(AssumptionFacet),
Decision(DecisionFacet),
Question(QuestionFacet),
Constraint(ConstraintFacet),
Evidence(EvidenceFacet),
Hypothesis(HypothesisFacet),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct AssumptionFacet {
claim: Option<String>,
confidence: Option<Confidence>,
basis: Option<Basis>,
validation_plan: Option<String>,
validated_by: Option<String>,
validated_on: Option<String>,
invalidated_by: Option<String>,
invalidated_on: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct DecisionFacet {
context: Option<String>,
choice: Option<String>,
alternatives: Vec<String>,
rationale: Option<String>,
consequences: Vec<String>,
decided_by: Option<String>,
decided_on: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct QuestionFacet {
question: Option<String>,
why_matters: Option<String>,
answer: Option<String>,
answered_by: Option<String>,
answered_on: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ConstraintFacet {
statement: Option<String>,
source: Option<ConstraintSource>,
applies_to: Vec<String>,
waiver_reason: Option<String>,
waived_by: Option<String>,
waived_on: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct EvidenceFacet {
pub(crate) datum: Option<String>,
pub(crate) provenance: Option<Provenance>,
pub(crate) confidence: Option<Confidence>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct HypothesisFacet {
pub(crate) proposition: Option<String>,
pub(crate) predicts: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Evidence {
supports: Vec<String>,
contradicts: Vec<String>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RawRecordToml {
id: u32,
slug: String,
title: String,
record_kind: RecordKind,
status: String,
created: String,
updated: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
facet: RawFacet,
#[serde(default)]
evidence: RawEvidence,
}
#[derive(Debug, Default, Deserialize)]
struct RawFacet {
#[serde(default)]
claim: String,
#[serde(default)]
confidence: String,
#[serde(default)]
basis: String,
#[serde(default)]
validation_plan: String,
#[serde(default)]
validated_by: String,
#[serde(default)]
validated_on: String,
#[serde(default)]
invalidated_by: String,
#[serde(default)]
invalidated_on: String,
#[serde(default)]
context: String,
#[serde(default)]
choice: String,
#[serde(default)]
alternatives: Vec<String>,
#[serde(default)]
rationale: String,
#[serde(default)]
consequences: Vec<String>,
#[serde(default)]
decided_by: String,
#[serde(default)]
decided_on: String,
#[serde(default)]
question: String,
#[serde(default)]
why_matters: String,
#[serde(default)]
answer: String,
#[serde(default)]
answered_by: String,
#[serde(default)]
answered_on: String,
#[serde(default)]
statement: String,
#[serde(default)]
source: String,
#[serde(default)]
applies_to: Vec<String>,
#[serde(default)]
waiver_reason: String,
#[serde(default)]
waived_by: String,
#[serde(default)]
waived_on: String,
#[serde(default)]
datum: String,
#[serde(default)]
provenance: String,
#[serde(default)]
proposition: String,
#[serde(default)]
predicts: String,
}
#[derive(Debug, Default, Deserialize)]
struct RawEvidence {
#[serde(default)]
supports: Vec<String>,
#[serde(default)]
contradicts: Vec<String>,
#[serde(default)]
notes: Vec<String>,
}
fn parse_enum<T: serde::de::DeserializeOwned>(token: &str, what: &str) -> anyhow::Result<T> {
use serde::de::IntoDeserializer;
let de: serde::de::value::StrDeserializer<'_, serde::de::value::Error> =
token.into_deserializer();
T::deserialize(de).map_err(|e| anyhow::anyhow!("invalid {what} `{token}`: {e}"))
}
fn optional_enum<T: serde::de::DeserializeOwned>(
token: &str,
what: &str,
) -> anyhow::Result<Option<T>> {
if token.is_empty() {
Ok(None)
} else {
parse_enum(token, what).map(Some)
}
}
fn optional_text(text: String) -> Option<String> {
if text.is_empty() { None } else { Some(text) }
}
fn validate(raw: RawRecordToml) -> anyhow::Result<KnowledgeRecord> {
let facet = validate_facet(raw.record_kind, raw.facet)?;
let evidence = Evidence {
supports: raw.evidence.supports,
contradicts: raw.evidence.contradicts,
notes: raw.evidence.notes,
};
Ok(KnowledgeRecord {
id: raw.id,
slug: raw.slug,
title: raw.title,
record_kind: raw.record_kind,
status: raw.status,
created: raw.created,
updated: raw.updated,
tags: raw.tags,
facet,
evidence,
tier1: Vec::new(),
body: String::new(),
})
}
fn validate_facet(kind: RecordKind, raw: RawFacet) -> anyhow::Result<RecordFacet> {
Ok(match kind {
RecordKind::Assumption => RecordFacet::Assumption(AssumptionFacet {
claim: optional_text(raw.claim),
confidence: optional_enum(&raw.confidence, "confidence")?,
basis: optional_enum(&raw.basis, "basis")?,
validation_plan: optional_text(raw.validation_plan),
validated_by: optional_text(raw.validated_by),
validated_on: optional_text(raw.validated_on),
invalidated_by: optional_text(raw.invalidated_by),
invalidated_on: optional_text(raw.invalidated_on),
}),
RecordKind::Decision => RecordFacet::Decision(DecisionFacet {
context: optional_text(raw.context),
choice: optional_text(raw.choice),
alternatives: raw.alternatives,
rationale: optional_text(raw.rationale),
consequences: raw.consequences,
decided_by: optional_text(raw.decided_by),
decided_on: optional_text(raw.decided_on),
}),
RecordKind::Question => RecordFacet::Question(QuestionFacet {
question: optional_text(raw.question),
why_matters: optional_text(raw.why_matters),
answer: optional_text(raw.answer),
answered_by: optional_text(raw.answered_by),
answered_on: optional_text(raw.answered_on),
}),
RecordKind::Constraint => RecordFacet::Constraint(ConstraintFacet {
statement: optional_text(raw.statement),
source: optional_enum(&raw.source, "source")?,
applies_to: raw.applies_to,
waiver_reason: optional_text(raw.waiver_reason),
waived_by: optional_text(raw.waived_by),
waived_on: optional_text(raw.waived_on),
}),
RecordKind::Evidence => RecordFacet::Evidence(EvidenceFacet {
datum: optional_text(raw.datum),
provenance: optional_enum(&raw.provenance, "provenance")?,
confidence: optional_enum(&raw.confidence, "confidence")?,
}),
RecordKind::Hypothesis => RecordFacet::Hypothesis(HypothesisFacet {
proposition: optional_text(raw.proposition),
predicts: optional_text(raw.predicts),
}),
})
}
#[cfg(test)]
fn render_record_toml(record: &KnowledgeRecord) -> String {
[
format!("schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\n\n"),
format!("id = {}\n", record.id),
format!("slug = {}\n", toml_string(&record.slug)),
format!("title = {}\n", toml_string(&record.title)),
format!("record_kind = \"{}\"\n", record.record_kind.as_str()),
format!("status = {}\n", toml_string(&record.status)),
format!("created = {}\n", toml_string(&record.created)),
format!("updated = {}\n", toml_string(&record.updated)),
format!("tags = [{}]\n", toml_array_inner(&record.tags)),
render_facet(&record.facet),
render_evidence(&record.evidence),
]
.concat()
}
#[cfg(test)]
fn opt_text_line(key: &str, value: Option<&str>) -> String {
format!("{key} = {}\n", toml_string(value.unwrap_or("")))
}
#[cfg(test)]
fn list_line(key: &str, xs: &[String]) -> String {
format!("{key} = [{}]\n", toml_array_inner(xs))
}
#[cfg(test)]
fn render_facet(facet: &RecordFacet) -> String {
let mut out = String::from("\n[facet]\n");
match facet {
RecordFacet::Assumption(f) => {
out.push_str(&opt_text_line("claim", f.claim.as_deref()));
out.push_str(&opt_text_line(
"confidence",
f.confidence.map(Confidence::as_str),
));
out.push_str(&opt_text_line("basis", f.basis.map(Basis::as_str)));
out.push_str(&opt_text_line(
"validation_plan",
f.validation_plan.as_deref(),
));
out.push_str(&opt_text_line("validated_by", f.validated_by.as_deref()));
out.push_str(&opt_text_line("validated_on", f.validated_on.as_deref()));
out.push_str(&opt_text_line(
"invalidated_by",
f.invalidated_by.as_deref(),
));
out.push_str(&opt_text_line(
"invalidated_on",
f.invalidated_on.as_deref(),
));
}
RecordFacet::Decision(f) => {
out.push_str(&opt_text_line("context", f.context.as_deref()));
out.push_str(&opt_text_line("choice", f.choice.as_deref()));
out.push_str(&list_line("alternatives", &f.alternatives));
out.push_str(&opt_text_line("rationale", f.rationale.as_deref()));
out.push_str(&list_line("consequences", &f.consequences));
out.push_str(&opt_text_line("decided_by", f.decided_by.as_deref()));
out.push_str(&opt_text_line("decided_on", f.decided_on.as_deref()));
}
RecordFacet::Question(f) => {
out.push_str(&opt_text_line("question", f.question.as_deref()));
out.push_str(&opt_text_line("why_matters", f.why_matters.as_deref()));
out.push_str(&opt_text_line("answer", f.answer.as_deref()));
out.push_str(&opt_text_line("answered_by", f.answered_by.as_deref()));
out.push_str(&opt_text_line("answered_on", f.answered_on.as_deref()));
}
RecordFacet::Constraint(f) => {
out.push_str(&opt_text_line("statement", f.statement.as_deref()));
out.push_str(&opt_text_line(
"source",
f.source.map(ConstraintSource::as_str),
));
out.push_str(&list_line("applies_to", &f.applies_to));
out.push_str(&opt_text_line("waiver_reason", f.waiver_reason.as_deref()));
out.push_str(&opt_text_line("waived_by", f.waived_by.as_deref()));
out.push_str(&opt_text_line("waived_on", f.waived_on.as_deref()));
}
RecordFacet::Evidence(f) => {
out.push_str(&opt_text_line("datum", f.datum.as_deref()));
out.push_str(&opt_text_line(
"provenance",
f.provenance.map(Provenance::as_str),
));
out.push_str(&opt_text_line(
"confidence",
f.confidence.map(Confidence::as_str),
));
}
RecordFacet::Hypothesis(f) => {
out.push_str(&opt_text_line("proposition", f.proposition.as_deref()));
out.push_str(&opt_text_line("predicts", f.predicts.as_deref()));
}
}
out
}
#[cfg(test)]
fn render_evidence(e: &Evidence) -> String {
[
String::from("\n[evidence]\n"),
list_line("supports", &e.supports),
list_line("contradicts", &e.contradicts),
list_line("notes", &e.notes),
]
.concat()
}
fn render_record_toml_seed(
kind: RecordKind,
id: u32,
slug: &str,
title: &str,
date: &str,
) -> anyhow::Result<String> {
let template = match kind {
RecordKind::Assumption => "templates/knowledge-assumption.toml",
RecordKind::Decision => "templates/knowledge-decision.toml",
RecordKind::Question => "templates/knowledge-question.toml",
RecordKind::Constraint => "templates/knowledge-constraint.toml",
RecordKind::Evidence => "templates/knowledge-evidence.toml",
RecordKind::Hypothesis => "templates/knowledge-hypothesis.toml",
};
Ok(crate::install::asset_text(template)?
.replace("{{id}}", &id.to_string())
.replace("{{slug}}", &toml_string(slug))
.replace("{{title}}", &toml_string(title))
.replace("{{date}}", date))
}
fn render_record_md(canonical_id: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/knowledge.md")?
.replace("{{ref}}", canonical_id)
.replace("{{title}}", title))
}
fn record_scaffold(kind: RecordKind, ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
let id = ctx.id;
let name = format!("{id:03}");
Ok(vec![
Artifact::File {
rel_path: PathBuf::from(format!("{name}/{RECORD_STEM}-{name}.toml")),
body: render_record_toml_seed(kind, id, ctx.slug, ctx.title, ctx.date)?,
},
Artifact::File {
rel_path: PathBuf::from(format!("{name}/{RECORD_STEM}-{name}.md")),
body: render_record_md(ctx.canonical, ctx.title)?,
},
Artifact::Symlink {
rel_path: PathBuf::from(format!("{name}-{}", ctx.slug)),
target: name,
},
])
}
fn resolve_ref(reference: &str) -> anyhow::Result<(RecordKind, u32)> {
let (prefix, tail) = reference.rsplit_once('-').with_context(|| {
format!("`{reference}` is not a canonical record ref (expected e.g. ASM-007)")
})?;
let kind = RecordKind::from_prefix(&prefix.to_uppercase()).with_context(|| {
format!(
"unknown record prefix `{prefix}` in `{reference}` (expected ASM/DEC/QUE/CON/EVD/HYP)"
)
})?;
let id: u32 = tail
.parse()
.with_context(|| format!("`{tail}` is not a numeric id in `{reference}`"))?;
Ok((kind, id))
}
fn union_statuses() -> Vec<&'static str> {
let mut union: Vec<&'static str> = Vec::new();
for kind in RecordKind::ALL {
for &status in statuses(kind) {
if !union.contains(&status) {
union.push(status);
}
}
}
union
}
fn read_record(root: &Path, kind: RecordKind, id: u32) -> anyhow::Result<KnowledgeRecord> {
let name = format!("{id:03}");
let path = root
.join(kind.kind().dir)
.join(&name)
.join(format!("{RECORD_STEM}-{name}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("record not found at {}", path.display()))?;
let raw: RawRecordToml = dtoml::parse_entity_toml(&text, kind.prefix(), id)
.with_context(|| format!("Failed to parse {}", path.display()))?;
let mut record = validate(raw)?;
record.tier1 = crate::relation::tier1_edges(kind.kind(), &text)?;
let md_path = root
.join(kind.kind().dir)
.join(&name)
.join(format!("{RECORD_STEM}-{name}.md"));
record.body = std::fs::read_to_string(&md_path)
.with_context(|| format!("Failed to read {}", md_path.display()))?;
Ok(record)
}
pub(crate) fn relation_edges(
root: &Path,
kind: RecordKind,
id: u32,
) -> anyhow::Result<Vec<crate::relation::RelationEdge>> {
let record = read_record(root, kind, id)?;
Ok(record.tier1)
}
fn read_kind(root: &Path, kind: RecordKind) -> anyhow::Result<Vec<KnowledgeRecord>> {
let tree = root.join(kind.kind().dir);
let mut records = Vec::new();
for id in entity::scan_ids(&tree)? {
records.push(read_record(root, kind, id)?);
}
Ok(records)
}
fn read_all(root: &Path) -> anyhow::Result<Vec<KnowledgeRecord>> {
let mut records = Vec::new();
for kind in RecordKind::ALL {
records.extend(read_kind(root, kind)?);
}
Ok(records)
}
pub(crate) fn run_new(
path: Option<PathBuf>,
record_kind: RecordKind,
title: Option<String>,
slug: Option<String>,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let trunk_ids = crate::git::trunk_entity_ids(&root, record_kind.kind().dir)?;
let (backend, mut reserved) = crate::reserve::backend(
&root,
record_kind.kind().prefix,
crate::install::prompt_confirm,
)?;
let title = crate::input::resolve_title(title)?;
let slug = crate::input::resolve_slug(&title, slug)?;
let date = crate::clock::today();
let out = entity::materialise(
record_kind.kind(),
&*backend,
&root,
&MaterialiseRequest::Fresh,
&Inputs {
slug: &slug,
title: &title,
date: &date,
},
&trunk_ids,
&mut reserved,
)?;
let id = out
.eid
.numeric_id()
.context("knowledge kind must yield a numeric id")?;
writeln!(
io::stdout(),
"Created {}: {}",
record_kind.canonical_id(id),
out.dir.display()
)?;
Ok(())
}
fn format_metadata(record: &KnowledgeRecord) -> Vec<String> {
let mut parts: Vec<String> = Vec::new();
parts.push(format!(
"{} — {}\n",
record.record_kind.canonical_id(record.id),
record.title
));
parts.push(format!(
"{} · {} · {}\n",
record.slug,
record.record_kind.as_str(),
record.status,
));
parts.push(format!(
"created {} · updated {}\n",
record.created, record.updated
));
if !record.tags.is_empty() {
parts.push(format!("tags: {}\n", record.tags.join(", ")));
}
parts.push(format_facet(&record.facet));
parts.push(format_evidence(&record.evidence));
for label in [
crate::relation::RelationLabel::Shapes,
crate::relation::RelationLabel::Spawns,
crate::relation::RelationLabel::GovernedBy,
crate::relation::RelationLabel::Supports,
crate::relation::RelationLabel::Disputes,
] {
let targets = crate::relation::targets_for(&record.tier1, label);
if !targets.is_empty() {
let targets_str = targets.join(", ");
parts.push(format!("{}: [{}]\n", label.name(), targets_str));
}
}
parts
}
fn format_show(record: &KnowledgeRecord) -> String {
let mut parts = format_metadata(record);
parts.push(format!("\n{}", record.body));
parts.concat()
}
fn format_inspect(record: &KnowledgeRecord) -> String {
format_metadata(record).concat()
}
fn show_opt_line(key: &str, value: Option<&str>) -> String {
match value {
Some(v) => format!(" {key}: {v}\n"),
None => String::new(),
}
}
fn show_list_line(key: &str, xs: &[String]) -> String {
if xs.is_empty() {
String::new()
} else {
format!(" {key}: {}\n", xs.join(", "))
}
}
fn format_facet(facet: &RecordFacet) -> String {
let body = match facet {
RecordFacet::Assumption(f) => [
show_opt_line("claim", f.claim.as_deref()),
show_opt_line("confidence", f.confidence.map(Confidence::as_str)),
show_opt_line("basis", f.basis.map(Basis::as_str)),
show_opt_line("validation_plan", f.validation_plan.as_deref()),
show_opt_line("validated_by", f.validated_by.as_deref()),
show_opt_line("validated_on", f.validated_on.as_deref()),
show_opt_line("invalidated_by", f.invalidated_by.as_deref()),
show_opt_line("invalidated_on", f.invalidated_on.as_deref()),
]
.concat(),
RecordFacet::Decision(f) => [
show_opt_line("context", f.context.as_deref()),
show_opt_line("choice", f.choice.as_deref()),
show_list_line("alternatives", &f.alternatives),
show_opt_line("rationale", f.rationale.as_deref()),
show_list_line("consequences", &f.consequences),
show_opt_line("decided_by", f.decided_by.as_deref()),
show_opt_line("decided_on", f.decided_on.as_deref()),
]
.concat(),
RecordFacet::Question(f) => [
show_opt_line("question", f.question.as_deref()),
show_opt_line("why_matters", f.why_matters.as_deref()),
show_opt_line("answer", f.answer.as_deref()),
show_opt_line("answered_by", f.answered_by.as_deref()),
show_opt_line("answered_on", f.answered_on.as_deref()),
]
.concat(),
RecordFacet::Constraint(f) => [
show_opt_line("statement", f.statement.as_deref()),
show_opt_line("source", f.source.map(ConstraintSource::as_str)),
show_list_line("applies_to", &f.applies_to),
show_opt_line("waiver_reason", f.waiver_reason.as_deref()),
show_opt_line("waived_by", f.waived_by.as_deref()),
show_opt_line("waived_on", f.waived_on.as_deref()),
]
.concat(),
RecordFacet::Evidence(f) => [
show_opt_line("datum", f.datum.as_deref()),
show_opt_line("provenance", f.provenance.map(Provenance::as_str)),
show_opt_line("confidence", f.confidence.map(Confidence::as_str)),
]
.concat(),
RecordFacet::Hypothesis(f) => [
show_opt_line("proposition", f.proposition.as_deref()),
show_opt_line("predicts", f.predicts.as_deref()),
]
.concat(),
};
if body.is_empty() {
String::new()
} else {
format!("\n[facet]\n{body}")
}
}
fn format_evidence(e: &Evidence) -> String {
let body = [
show_list_line("supports", &e.supports),
show_list_line("contradicts", &e.contradicts),
show_list_line("notes", &e.notes),
]
.concat();
if body.is_empty() {
String::new()
} else {
format!("\n[evidence]\n{body}")
}
}
fn show_json(record: &KnowledgeRecord, with_body: bool) -> anyhow::Result<String> {
let mut inner = serde_json::Map::new();
inner.insert(
"id".into(),
serde_json::json!(record.record_kind.canonical_id(record.id)),
);
inner.insert(
"record_kind".into(),
serde_json::json!(record.record_kind.as_str()),
);
inner.insert("slug".into(), serde_json::json!(record.slug));
inner.insert("title".into(), serde_json::json!(record.title));
inner.insert("status".into(), serde_json::json!(record.status));
inner.insert("created".into(), serde_json::json!(record.created));
inner.insert("updated".into(), serde_json::json!(record.updated));
inner.insert("tags".into(), serde_json::json!(record.tags));
if with_body {
inner.insert("body".into(), serde_json::json!(record.body));
}
inner.insert("facet".into(), serde_json::json!(facet_json(&record.facet)));
inner.insert(
"evidence".into(),
serde_json::json!({
"supports": record.evidence.supports,
"contradicts": record.evidence.contradicts,
"notes": record.evidence.notes,
}),
);
inner.insert("relationships".into(), serde_json::json!({
"shapes": crate::relation::targets_for(&record.tier1, crate::relation::RelationLabel::Shapes),
"spawns": crate::relation::targets_for(&record.tier1, crate::relation::RelationLabel::Spawns),
"governed_by": crate::relation::targets_for(&record.tier1, crate::relation::RelationLabel::GovernedBy),
"supports": crate::relation::targets_for(&record.tier1, crate::relation::RelationLabel::Supports),
"disputes": crate::relation::targets_for(&record.tier1, crate::relation::RelationLabel::Disputes),
}));
let value = serde_json::json!({
"kind": "knowledge",
"knowledge": inner,
});
serde_json::to_string_pretty(&value).context("failed to serialize knowledge show JSON")
}
fn facet_json(facet: &RecordFacet) -> serde_json::Value {
match facet {
RecordFacet::Assumption(f) => serde_json::json!({
"claim": f.claim,
"confidence": f.confidence.map(Confidence::as_str),
"basis": f.basis.map(Basis::as_str),
"validation_plan": f.validation_plan,
"validated_by": f.validated_by,
"validated_on": f.validated_on,
"invalidated_by": f.invalidated_by,
"invalidated_on": f.invalidated_on,
}),
RecordFacet::Decision(f) => serde_json::json!({
"context": f.context,
"choice": f.choice,
"alternatives": f.alternatives,
"rationale": f.rationale,
"consequences": f.consequences,
"decided_by": f.decided_by,
"decided_on": f.decided_on,
}),
RecordFacet::Question(f) => serde_json::json!({
"question": f.question,
"why_matters": f.why_matters,
"answer": f.answer,
"answered_by": f.answered_by,
"answered_on": f.answered_on,
}),
RecordFacet::Constraint(f) => serde_json::json!({
"statement": f.statement,
"source": f.source.map(ConstraintSource::as_str),
"applies_to": f.applies_to,
"waiver_reason": f.waiver_reason,
"waived_by": f.waived_by,
"waived_on": f.waived_on,
}),
RecordFacet::Evidence(f) => serde_json::json!({
"datum": f.datum,
"provenance": f.provenance.map(Provenance::as_str),
"confidence": f.confidence.map(Confidence::as_str),
}),
RecordFacet::Hypothesis(f) => serde_json::json!({
"proposition": f.proposition,
"predicts": f.predicts,
}),
}
}
fn run_show_inspect(
path: Option<PathBuf>,
reference: &str,
format: Format,
format_table: fn(&KnowledgeRecord) -> String,
with_body: bool,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let (kind, id) = resolve_ref(reference)?;
let record = read_record(&root, kind, id)?;
let out = match format {
Format::Table => format_table(&record),
Format::Json => show_json(&record, with_body)?,
};
write!(io::stdout(), "{out}")?;
Ok(())
}
pub(crate) fn run_show(
path: Option<PathBuf>,
reference: &str,
format: Format,
) -> anyhow::Result<()> {
run_show_inspect(path, reference, format, format_show, true)
}
pub(crate) fn run_inspect(
path: Option<PathBuf>,
reference: &str,
format: Format,
) -> anyhow::Result<()> {
run_show_inspect(path, reference, format, format_inspect, false)
}
#[derive(Debug, Serialize)]
struct RecordRow {
id: String,
record_kind: &'static str,
status: String,
slug: String,
title: String,
}
const KN_COLUMNS: [listing::Column<KnowledgeRecord>; 6] = [
listing::Column {
name: "id",
header: "id",
cell: |r| r.record_kind.canonical_id(r.id),
paint: listing::ColumnPaint::Fixed(owo_colors::DynColors::Ansi(
owo_colors::AnsiColors::Cyan,
)),
},
listing::Column {
name: "kind",
header: "kind",
cell: |r| r.record_kind.as_str().to_string(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "status",
header: "status",
cell: |r| r.status.clone(),
paint: listing::ColumnPaint::ByValue(|r| listing::status_hue(&r.status)),
},
listing::Column {
name: "tags",
header: "tags",
cell: |r| r.tags.join(", "),
paint: listing::ColumnPaint::PerToken {
split: |r| r.tags.clone(),
render: listing::paint_tag,
},
},
listing::Column {
name: "slug",
header: "slug",
cell: |r| r.slug.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "title",
header: "title",
cell: |r| r.title.clone(),
paint: listing::ColumnPaint::Alternate([listing::TITLE_EVEN, listing::TITLE_ODD]),
},
];
const KN_DEFAULT: &[&str] = &["id", "kind", "status", "title"];
fn validate_statuses(given: &[String]) -> anyhow::Result<()> {
listing::validate_statuses(given, &union_statuses())
}
fn key(r: &KnowledgeRecord) -> listing::FilterFields {
listing::FilterFields {
canonical: r.record_kind.canonical_id(r.id),
slug: r.slug.clone(),
title: r.title.clone(),
status: r.status.clone(),
tags: r.tags.clone(),
}
}
fn json_rows(records: &[KnowledgeRecord]) -> Vec<RecordRow> {
records
.iter()
.map(|r| RecordRow {
id: r.record_kind.canonical_id(r.id),
record_kind: r.record_kind.as_str(),
status: r.status.clone(),
slug: r.slug.clone(),
title: r.title.clone(),
})
.collect()
}
fn list_rows(root: &Path, mut args: ListArgs) -> anyhow::Result<String> {
validate_statuses(&args.status)?;
let render = args.render;
let columns = args.columns.take();
let reveal_hidden = args.all || !args.status.is_empty();
let (filter, format) = listing::build(args)?;
let corpus = read_all(root)?;
let visible: Vec<KnowledgeRecord> = corpus
.into_iter()
.filter(|r| reveal_hidden || !is_hidden(r.record_kind, &r.status))
.collect();
let mut records = listing::retain(visible, &filter, |_| false, key);
records.sort_by_key(|r| (kind_ordinal(r.record_kind), r.id));
match format {
Format::Table => {
let any_tagged = records.iter().any(|r| !r.tags.is_empty());
let effective_default = listing::default_with_tags(KN_DEFAULT, any_tagged);
let sel = listing::select_columns(&KN_COLUMNS, &effective_default, columns.as_deref())?;
Ok(listing::render_columns(&records, &sel, render))
}
Format::Json => listing::json_envelope("knowledge", &json_rows(&records)),
}
}
fn kind_ordinal(kind: RecordKind) -> usize {
RecordKind::ALL
.iter()
.position(|&k| k == kind)
.unwrap_or(usize::MAX)
}
pub(crate) fn run_list(path: Option<PathBuf>, args: ListArgs) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let out = list_rows(&root, args)?;
write!(io::stdout(), "{out}")?;
Ok(())
}
pub(crate) fn run_status(
path: Option<PathBuf>,
reference: &str,
state: &str,
color: bool,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let (kind, id) = resolve_ref(reference)?;
let vocab = statuses(kind);
if !vocab.contains(&state) {
anyhow::bail!(
"`{state}` is not a {} status (known: {})",
kind.as_str(),
vocab.join(", ")
);
}
let today = crate::clock::today();
let name = format!("{id:03}");
let record_path = root
.join(kind.kind().dir)
.join(&name)
.join(format!("{RECORD_STEM}-{name}.toml"));
let hint = format!(
"malformed record {name}: missing seeded `status`/`updated` \
— restore the missing keys and retry; the file is left untouched"
);
crate::dep_seq::set_authored_status(
&record_path,
&[("status", state), ("updated", &today)],
&hint,
)?;
writeln!(
io::stdout(),
"{}: {}",
kind.canonical_id(id),
crate::listing::status_colored(state, color)
)?;
Ok(())
}
fn run_paths(
path: Option<PathBuf>,
refs: &[String],
sel: &crate::paths::PathSelection,
) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let mut all_lines: Vec<String> = Vec::new();
for r in refs {
let (kind, id) = resolve_ref(r)?;
let name = format!("{id:03}");
let entity_dir = root.join(kind.kind().dir).join(&name);
let toml_name = format!("{RECORD_STEM}-{name}.toml");
let md_name = format!("{RECORD_STEM}-{name}.md");
let set = crate::paths::scan_entity_dir(
&entity_dir,
&entity_dir.join(&toml_name),
Some(&entity_dir.join(&md_name)),
&root,
)?;
let lines = crate::paths::select_paths(&set, sel)?;
all_lines.extend(lines);
}
write!(io::stdout(), "{}", all_lines.join("\n"))?;
Ok(())
}
use crate::CommonListArgs;
use clap::Subcommand;
#[derive(Subcommand)]
pub(crate) enum KnowledgeCommand {
New {
kind: RecordKind,
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
#[command(flatten)]
common: crate::CommonShowArgs,
},
Inspect {
#[command(flatten)]
common: crate::CommonShowArgs,
},
Status {
id: String,
state: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Paths {
refs: Vec<String>,
#[arg(short = 't', long)]
toml: bool,
#[arg(short = 'm', long)]
md: bool,
#[arg(short = 'e', long)]
entity: bool,
#[arg(short = 's', long)]
single: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
pub(crate) fn dispatch(cmd: KnowledgeCommand, color: bool) -> anyhow::Result<()> {
match cmd {
KnowledgeCommand::New {
kind,
title,
slug,
path,
} => run_new(path, kind, title, slug),
KnowledgeCommand::List { list, path } => run_list(path, list.into_list_args(color)),
KnowledgeCommand::Show { common } => {
let format = if common.json {
Format::Json
} else {
common.format
};
run_show(common.path, &common.id, format)
}
KnowledgeCommand::Inspect { common } => {
let format = if common.json {
Format::Json
} else {
common.format
};
run_inspect(common.path, &common.id, format)
}
KnowledgeCommand::Status { id, state, path } => run_status(path, &id, &state, color),
KnowledgeCommand::Paths {
refs,
toml,
md,
entity,
single,
path,
} => run_paths(
path,
&refs,
&crate::paths::PathSelection {
toml,
md,
entity,
single,
},
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meta::Meta;
use std::collections::BTreeSet;
use std::path::Path;
fn ctx_for(kind: RecordKind) -> ScaffoldCtx<'static> {
let canonical: &'static str = match kind {
RecordKind::Assumption => "ASM-003",
RecordKind::Decision => "DEC-003",
RecordKind::Question => "QUE-003",
RecordKind::Constraint => "CON-003",
RecordKind::Evidence => "EVD-003",
RecordKind::Hypothesis => "HYP-003",
};
ScaffoldCtx {
id: 3,
canonical,
slug: "token-expiry",
title: "Token expiry",
date: "2026-06-08",
}
}
#[test]
fn record_kind_from_prefix_round_trips_each_kind() {
for kind in RecordKind::ALL {
assert_eq!(RecordKind::from_prefix(kind.prefix()), Some(kind));
}
assert_eq!(RecordKind::from_prefix("REQ"), None);
let prefixes: BTreeSet<&str> = RecordKind::ALL.iter().map(|k| k.prefix()).collect();
assert_eq!(prefixes.len(), 6, "the six prefixes are distinct");
}
#[test]
fn canonical_id_uses_the_kind_prefix() {
assert_eq!(RecordKind::Assumption.canonical_id(7), "ASM-007");
assert_eq!(RecordKind::Decision.canonical_id(12), "DEC-012");
}
#[test]
fn default_status_is_the_first_vocab_element_per_kind() {
assert_eq!(RecordKind::Assumption.default_status(), "held");
assert_eq!(RecordKind::Decision.default_status(), "proposed");
assert_eq!(RecordKind::Question.default_status(), "open");
assert_eq!(RecordKind::Constraint.default_status(), "active");
assert_eq!(RecordKind::Evidence.default_status(), "captured");
assert_eq!(RecordKind::Hypothesis.default_status(), "proposed");
for kind in RecordKind::ALL {
assert_eq!(Some(kind.default_status()), statuses(kind).first().copied());
}
}
#[test]
fn status_vocabularies_are_the_expected_known_sets() {
assert_eq!(
statuses(RecordKind::Assumption),
["held", "testing", "validated", "invalidated", "obsolete"]
);
assert_eq!(
statuses(RecordKind::Decision),
["proposed", "accepted", "rejected", "superseded"]
);
assert_eq!(
statuses(RecordKind::Question),
["open", "answered", "obsolete"]
);
assert_eq!(
statuses(RecordKind::Constraint),
["active", "waived", "superseded", "retired"]
);
assert_eq!(
statuses(RecordKind::Evidence),
[
"captured",
"disputed",
"confirmed",
"retracted",
"superseded"
]
);
assert_eq!(
statuses(RecordKind::Hypothesis),
["proposed", "confirmed", "refuted"]
);
}
#[test]
fn hide_set_is_a_subset_of_the_vocab_and_excludes_the_seed() {
for kind in RecordKind::ALL {
let vocab: BTreeSet<&str> = statuses(kind).iter().copied().collect();
for h in hidden(kind) {
assert!(vocab.contains(h), "{kind:?}: hidden `{h}` is in-vocab");
assert!(
!is_hidden(kind, kind.default_status()),
"{kind:?}: the seed is never hidden"
);
}
}
assert!(!is_hidden(RecordKind::Decision, "accepted"));
assert!(is_hidden(RecordKind::Decision, "superseded"));
}
#[test]
fn is_terminal_returns_correct_per_kind() {
assert!(!RecordKind::Assumption.is_terminal("held"));
assert!(!RecordKind::Assumption.is_terminal("testing"));
assert!(RecordKind::Assumption.is_terminal("validated"));
assert!(RecordKind::Assumption.is_terminal("invalidated"));
assert!(RecordKind::Assumption.is_terminal("obsolete"));
assert!(!RecordKind::Decision.is_terminal("proposed"));
assert!(RecordKind::Decision.is_terminal("accepted"));
assert!(RecordKind::Decision.is_terminal("rejected"));
assert!(RecordKind::Decision.is_terminal("superseded"));
assert!(!RecordKind::Question.is_terminal("open"));
assert!(RecordKind::Question.is_terminal("answered"));
assert!(RecordKind::Question.is_terminal("obsolete"));
assert!(!RecordKind::Constraint.is_terminal("active"));
assert!(RecordKind::Constraint.is_terminal("waived"));
assert!(RecordKind::Constraint.is_terminal("superseded"));
assert!(RecordKind::Constraint.is_terminal("retired"));
assert!(!RecordKind::Evidence.is_terminal("captured"));
assert!(!RecordKind::Evidence.is_terminal("disputed"));
assert!(!RecordKind::Evidence.is_terminal("confirmed"));
assert!(RecordKind::Evidence.is_terminal("retracted"));
assert!(RecordKind::Evidence.is_terminal("superseded"));
assert!(!RecordKind::Hypothesis.is_terminal("proposed"));
assert!(RecordKind::Hypothesis.is_terminal("confirmed"));
assert!(RecordKind::Hypothesis.is_terminal("refuted"));
}
#[test]
fn terminal_set_is_subset_of_the_vocab_and_excludes_the_seed() {
for kind in RecordKind::ALL {
let vocab: BTreeSet<&str> = statuses(kind).iter().copied().collect();
for t in terminal(kind) {
assert!(vocab.contains(t), "{kind:?}: terminal `{t}` is in-vocab");
}
assert!(
!kind.is_terminal(kind.default_status()),
"{kind:?}: the seed is never terminal"
);
}
assert!(RecordKind::Decision.is_terminal("accepted"));
assert!(!is_hidden(RecordKind::Decision, "accepted"));
}
#[test]
fn confidence_known_set_matches_variants() {
use clap::ValueEnum;
let variants: BTreeSet<&str> = Confidence::value_variants()
.iter()
.map(|v| v.as_str())
.collect();
let known: BTreeSet<&str> = Confidence::KNOWN.iter().copied().collect();
assert_eq!(variants, known);
}
#[test]
fn basis_known_set_matches_variants() {
use clap::ValueEnum;
let variants: BTreeSet<&str> = Basis::value_variants().iter().map(|v| v.as_str()).collect();
let known: BTreeSet<&str> = Basis::KNOWN.iter().copied().collect();
assert_eq!(variants, known);
}
#[test]
fn constraint_source_known_set_matches_variants() {
use clap::ValueEnum;
let variants: BTreeSet<&str> = ConstraintSource::value_variants()
.iter()
.map(|v| v.as_str())
.collect();
let known: BTreeSet<&str> = ConstraintSource::KNOWN.iter().copied().collect();
assert_eq!(variants, known);
}
#[test]
fn provenance_known_set_matches_variants() {
use clap::ValueEnum;
let variants: BTreeSet<&str> = Provenance::value_variants()
.iter()
.map(|v| v.as_str())
.collect();
let known: BTreeSet<&str> = Provenance::KNOWN.iter().copied().collect();
assert_eq!(variants, known);
}
#[test]
fn seeded_facet_maps_empty_to_absent_per_kind() {
for kind in RecordKind::ALL {
let seed = render_record_toml_seed(kind, 1, "s", "T", "2026-06-08").unwrap();
let record = validate(toml::from_str::<RawRecordToml>(&seed).unwrap()).unwrap();
assert_eq!(
record.status,
kind.default_status(),
"{kind:?}: seeded status"
);
assert!(record.evidence.supports.is_empty());
assert!(record.evidence.contradicts.is_empty());
assert!(record.evidence.notes.is_empty());
match &record.facet {
RecordFacet::Assumption(f) => {
assert_eq!(
f,
&AssumptionFacet::default(),
"{kind:?}: empty facet absent"
);
}
RecordFacet::Decision(f) => {
assert_eq!(f, &DecisionFacet::default());
}
RecordFacet::Question(f) => {
assert_eq!(f, &QuestionFacet::default());
}
RecordFacet::Constraint(f) => {
assert_eq!(f, &ConstraintFacet::default());
}
RecordFacet::Evidence(f) => {
assert_eq!(f, &EvidenceFacet::default());
}
RecordFacet::Hypothesis(f) => {
assert_eq!(f, &HypothesisFacet::default());
}
}
}
}
#[test]
fn non_empty_facet_enums_parse_to_their_variants() {
let assessed = "\
id = 1
slug = \"a\"
title = \"A\"
record_kind = \"assumption\"
status = \"testing\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
claim = \"tokens expire in 1h\"
confidence = \"high\"
basis = \"observation\"
validation_plan = \"probe the IdP\"
validated_by = \"\"
validated_on = \"\"
invalidated_by = \"\"
invalidated_on = \"\"
[evidence]
supports = [\"DEC-005-C\"]
contradicts = []
notes = [\"see the audit\"]
";
let record = validate(toml::from_str::<RawRecordToml>(assessed).unwrap()).unwrap();
match record.facet {
RecordFacet::Assumption(f) => {
assert_eq!(f.claim.as_deref(), Some("tokens expire in 1h"));
assert_eq!(f.confidence, Some(Confidence::High));
assert_eq!(f.basis, Some(Basis::Observation));
assert_eq!(f.validation_plan.as_deref(), Some("probe the IdP"));
assert_eq!(f.validated_by, None);
}
_ => panic!("expected an assumption facet"),
}
assert_eq!(record.evidence.supports, vec!["DEC-005-C"]);
assert_eq!(record.evidence.notes, vec!["see the audit"]);
}
#[test]
fn validate_errors_on_an_unknown_facet_enum_token() {
let body = "\
id = 1
slug = \"a\"
title = \"A\"
record_kind = \"assumption\"
status = \"held\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
confidence = \"bogus\"
";
let raw: RawRecordToml = toml::from_str(body).unwrap();
assert!(
validate(raw).is_err(),
"an unknown confidence token is rejected"
);
}
fn populated_fixture(kind: RecordKind) -> String {
let head = format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\n\nid = 7\nslug = \"token-expiry\"\ntitle = \"Token expiry\"\nrecord_kind = \"{}\"\nstatus = {}\ncreated = \"2026-06-08\"\nupdated = \"2026-06-09\"\ntags = [\"auth\", \"security\"]\n",
kind.as_str(),
toml_string(kind.default_status()),
);
let facet = match kind {
RecordKind::Assumption => {
"\n[facet]\nclaim = \"tokens expire in 1h\"\nconfidence = \"high\"\nbasis = \"observation\"\nvalidation_plan = \"probe the IdP\"\nvalidated_by = \"david\"\nvalidated_on = \"2026-06-09\"\ninvalidated_by = \"\"\ninvalidated_on = \"\"\n"
}
RecordKind::Decision => {
"\n[facet]\ncontext = \"the import seam\"\nchoice = \"git cherry\"\nalternatives = [\"--merged\", \"delta-emptiness\"]\nrationale = \"patch-id is sound\"\nconsequences = [\"slower scan\", \"correct\"]\ndecided_by = \"david\"\ndecided_on = \"2026-06-09\"\n"
}
RecordKind::Question => {
"\n[facet]\nquestion = \"do we re-anchor B?\"\nwhy_matters = \"the delta corrupts otherwise\"\nanswer = \"yes, on a disjointness proof\"\nanswered_by = \"david\"\nanswered_on = \"2026-06-09\"\n"
}
RecordKind::Constraint => {
"\n[facet]\nstatement = \"no disk in the pure layer\"\nsource = \"canon\"\napplies_to = [\"src/knowledge.rs\", \"src/backlog.rs\"]\nwaiver_reason = \"\"\nwaived_by = \"\"\nwaived_on = \"\"\n"
}
RecordKind::Evidence => {
"\n[facet]\ndatum = \"the IdP returns 401 after 1h\"\nprovenance = \"experiment\"\nconfidence = \"high\"\n"
}
RecordKind::Hypothesis => {
"\n[facet]\nproposition = \"token TTL is 3600s\"\npredicts = \"requests after 3601s get 401\"\n"
}
};
let evidence = "\n[evidence]\nsupports = [\"ADR-001\"]\ncontradicts = []\nnotes = [\"see §5\", \"and §9\"]\n";
format!("{head}{facet}{evidence}")
}
#[test]
fn populated_record_round_trips_byte_stable_per_kind() {
for kind in RecordKind::ALL {
let original = populated_fixture(kind);
let record = validate(toml::from_str::<RawRecordToml>(&original).unwrap()).unwrap();
let rendered = render_record_toml(&record);
assert_eq!(
rendered, original,
"{kind:?}: toml -> struct -> toml must be byte-stable"
);
let reparsed = validate(toml::from_str::<RawRecordToml>(&rendered).unwrap()).unwrap();
assert_eq!(
reparsed, record,
"{kind:?}: struct stable across the round-trip"
);
}
}
#[test]
fn populated_record_round_trips_into_shared_meta() {
let original = populated_fixture(RecordKind::Decision);
let meta: Meta = toml::from_str(&original).unwrap();
assert_eq!(
meta,
Meta {
id: 7,
slug: "token-expiry".to_string(),
title: "Token expiry".to_string(),
status: "proposed".to_string(),
tags: vec!["auth".to_string(), "security".to_string()],
}
);
}
#[test]
fn record_scaffold_lays_out_toml_md_symlink_per_kind() {
for kind in RecordKind::ALL {
let ctx = ctx_for(kind);
let fileset = record_scaffold(kind, &ctx).unwrap();
assert_eq!(fileset.len(), 3, "{kind:?}: toml + md + symlink");
let toml_body = match &fileset[0] {
Artifact::File { rel_path, body } => {
assert_eq!(rel_path, Path::new("003/record-003.toml"));
body
}
Artifact::Symlink { .. } => panic!("first artifact is the toml"),
};
assert!(toml_body.contains(&format!("record_kind = \"{}\"", kind.as_str())));
assert!(
toml_body.contains(&format!("status = \"{}\"", kind.default_status())),
"{kind:?}: scaffolded status == default_status (F-A2)"
);
let facet_at = toml_body.find("[facet]").expect("a [facet] block");
let evidence_at = toml_body.find("[evidence]").expect("an [evidence] block");
let tags_at = toml_body.find("tags = []").expect("seeded tags");
let relationships_at = toml_body
.find("[relationships]")
.expect("a [relationships] block");
assert!(tags_at < facet_at, "{kind:?}: meta before [facet]");
assert!(
facet_at < evidence_at,
"{kind:?}: [facet] before [evidence]"
);
assert!(
evidence_at < relationships_at,
"{kind:?}: [evidence] before [relationships]"
);
assert!(
!toml_body.contains("[[relation]]"),
"{kind:?}: Slice A seeds no [[relation]] block"
);
assert!(
toml_body.contains("supersedes = []"),
"{kind:?}: seeded supersedes"
);
assert!(
toml_body.contains("superseded_by = []"),
"{kind:?}: seeded superseded_by"
);
assert!(
!toml_body.contains("{{"),
"{kind:?}: no token survives render"
);
assert!(matches!(
&fileset[1],
Artifact::File { rel_path, body }
if rel_path == Path::new("003/record-003.md")
&& body.contains(&format!("{}: Token expiry", ctx.canonical))
));
assert!(matches!(
&fileset[2],
Artifact::Symlink { rel_path, target }
if rel_path == Path::new("003-token-expiry") && target == "003"
));
}
}
#[test]
fn scaffold_escapes_hostile_title_and_slug() {
let title = crate::tomlfmt::HOSTILE_TITLE;
let slug = crate::tomlfmt::HOSTILE_SLUG;
let body =
render_record_toml_seed(RecordKind::Assumption, 7, slug, title, "2026-06-08").unwrap();
let parsed: Meta = toml::from_str(&body).unwrap();
assert_eq!(parsed.slug, slug);
assert_eq!(parsed.title, title);
}
#[test]
fn render_escapes_hostile_facet_values() {
let body = "\
id = 1
slug = \"s\"
title = \"T\"
record_kind = \"decision\"
status = \"proposed\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
context = \"a\\\"b\"
choice = \"\"
alternatives = [\"x\\\"y\"]
rationale = \"\"
consequences = []
decided_by = \"\"
decided_on = \"\"
[evidence]
supports = []
contradicts = []
notes = []
";
let record = validate(toml::from_str::<RawRecordToml>(body).unwrap()).unwrap();
let rendered = render_record_toml(&record);
let reparsed = validate(toml::from_str::<RawRecordToml>(&rendered).unwrap()).unwrap();
assert_eq!(reparsed, record);
}
fn seed_record(root: &Path, kind: RecordKind, id: u32, body: &str) {
let name = format!("{id:03}");
let dir = root.join(kind.kind().dir).join(&name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("record-{name}.toml")), body).unwrap();
std::fs::write(
dir.join(format!("record-{name}.md")),
format!("# {}: Test\n", kind.canonical_id(id)),
)
.unwrap();
}
#[test]
fn record_without_relation_block_has_empty_tier1() {
let root = std::env::temp_dir().join("doctrine-sl096-pt1-empty");
let _ = std::fs::remove_dir_all(&root);
let record = format!(
"\
schema = \"{SCHEMA_KNOWLEDGE}\"
version = 1
id = 1
slug = \"test\"
title = \"Test\"
record_kind = \"assumption\"
status = \"held\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
[evidence]
supports = []
contradicts = []
notes = []
"
);
seed_record(&root, RecordKind::Assumption, 1, &record);
let r = read_record(&root, RecordKind::Assumption, 1).unwrap();
assert!(r.tier1.is_empty(), "no [[relation]] block → empty tier1");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn record_with_authored_relation_rows_populates_tier1() {
let root = std::env::temp_dir().join("doctrine-sl096-pt1-auth");
let _ = std::fs::remove_dir_all(&root);
let record = format!(
"\
schema = \"{SCHEMA_KNOWLEDGE}\"
version = 1
id = 1
slug = \"test\"
title = \"Test\"
record_kind = \"assumption\"
status = \"held\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
[evidence]
supports = []
contradicts = []
notes = []
[[relation]]
label = \"shapes\"
target = \"SL-001\"
[[relation]]
label = \"spawns\"
target = \"ISS-001\"
[[relation]]
label = \"governed_by\"
target = \"ADR-001\"
"
);
seed_record(&root, RecordKind::Assumption, 1, &record);
let r = read_record(&root, RecordKind::Assumption, 1).unwrap();
assert_eq!(r.tier1.len(), 3);
assert_eq!(r.tier1[0].label, crate::relation::RelationLabel::Shapes);
assert_eq!(r.tier1[0].target, "SL-001");
assert_eq!(r.tier1[1].label, crate::relation::RelationLabel::Spawns);
assert_eq!(r.tier1[1].target, "ISS-001");
assert_eq!(r.tier1[2].label, crate::relation::RelationLabel::GovernedBy);
assert_eq!(r.tier1[2].target, "ADR-001");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn record_with_illegal_label_excludes_illegal_from_tier1() {
let root = std::env::temp_dir().join("doctrine-sl096-pt1-illegal");
let _ = std::fs::remove_dir_all(&root);
let record = format!(
"\
schema = \"{SCHEMA_KNOWLEDGE}\"
version = 1
id = 1
slug = \"test\"
title = \"Test\"
record_kind = \"assumption\"
status = \"held\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
[evidence]
supports = []
contradicts = []
notes = []
[[relation]]
label = \"supersedes\"
target = \"SL-001\"
[[relation]]
label = \"shapes\"
target = \"PRD-001\"
"
);
seed_record(&root, RecordKind::Assumption, 1, &record);
let r = read_record(&root, RecordKind::Assumption, 1).unwrap();
assert_eq!(
r.tier1.len(),
2,
"supersedes now has a RECORD rule (LifecycleOnly), shapes is Writable — both in tier1"
);
assert_eq!(r.tier1[0].label, crate::relation::RelationLabel::Supersedes);
assert_eq!(r.tier1[0].target, "SL-001");
assert_eq!(r.tier1[1].label, crate::relation::RelationLabel::Shapes);
assert_eq!(r.tier1[1].target, "PRD-001");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn record_with_unknown_label_excludes_unknown_from_tier1() {
let root = std::env::temp_dir().join("doctrine-sl096-pt1-unknown");
let _ = std::fs::remove_dir_all(&root);
let record = format!(
"\
schema = \"{SCHEMA_KNOWLEDGE}\"
version = 1
id = 1
slug = \"test\"
title = \"Test\"
record_kind = \"assumption\"
status = \"held\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
[evidence]
supports = []
contradicts = []
notes = []
[[relation]]
label = \"nonsense\"
target = \"X\"
[[relation]]
label = \"governed_by\"
target = \"ADR-001\"
"
);
seed_record(&root, RecordKind::Assumption, 1, &record);
let r = read_record(&root, RecordKind::Assumption, 1).unwrap();
assert_eq!(
r.tier1.len(),
1,
"unknown nonsense label excluded, governed_by survives"
);
assert_eq!(r.tier1[0].label, crate::relation::RelationLabel::GovernedBy);
assert_eq!(r.tier1[0].target, "ADR-001");
let _ = std::fs::remove_dir_all(&root);
}
fn record_fixture(root: &Path, kind: RecordKind, id: u32, extra: &[&str]) {
let name = format!("{id:03}");
let dir = root.join(kind.kind().dir).join(&name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{RECORD_STEM}-{name}.toml")), "toml").unwrap();
std::fs::write(dir.join(format!("{RECORD_STEM}-{name}.md")), "md").unwrap();
for e in extra {
std::fs::write(dir.join(e), e).unwrap();
}
}
#[test]
fn paths_full_shows_toml_md_and_extras_in_canonical_order() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
record_fixture(root, RecordKind::Assumption, 1, &["notes.md", "z.log"]);
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: false,
single: false,
};
let entity_dir = root.join(RecordKind::Assumption.kind().dir).join("001");
let identity_toml = entity_dir.join("record-001.toml");
let identity_md = entity_dir.join("record-001.md");
let set =
crate::paths::scan_entity_dir(&entity_dir, &identity_toml, Some(&identity_md), root)
.unwrap();
let lines = crate::paths::select_paths(&set, &sel).unwrap();
let output = lines.join("\n");
assert!(output.contains(".doctrine/knowledge/assumption/001/record-001.toml"));
assert!(output.contains(".doctrine/knowledge/assumption/001/record-001.md"));
assert!(output.contains(".doctrine/knowledge/assumption/001/notes.md"));
assert!(output.contains(".doctrine/knowledge/assumption/001/z.log"));
}
#[test]
fn paths_single_truncates_to_first() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
record_fixture(root, RecordKind::Decision, 1, &["notes.md"]);
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: false,
single: true,
};
let entity_dir = root.join(RecordKind::Decision.kind().dir).join("001");
let identity_toml = entity_dir.join("record-001.toml");
let identity_md = entity_dir.join("record-001.md");
let set =
crate::paths::scan_entity_dir(&entity_dir, &identity_toml, Some(&identity_md), root)
.unwrap();
let lines = crate::paths::select_paths(&set, &sel).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], ".doctrine/knowledge/decision/001/record-001.toml");
}
#[test]
fn paths_toml_only() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
record_fixture(root, RecordKind::Question, 2, &["notes.md"]);
let sel = crate::paths::PathSelection {
toml: true,
md: false,
entity: false,
single: false,
};
let entity_dir = root.join(RecordKind::Question.kind().dir).join("002");
let identity_toml = entity_dir.join("record-002.toml");
let identity_md = entity_dir.join("record-002.md");
let set =
crate::paths::scan_entity_dir(&entity_dir, &identity_toml, Some(&identity_md), root)
.unwrap();
let lines = crate::paths::select_paths(&set, &sel).unwrap();
assert_eq!(
lines,
vec![".doctrine/knowledge/question/002/record-002.toml"]
);
}
#[test]
fn paths_md_only() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
record_fixture(root, RecordKind::Constraint, 3, &[]);
let sel = crate::paths::PathSelection {
toml: false,
md: true,
entity: false,
single: false,
};
let entity_dir = root.join(RecordKind::Constraint.kind().dir).join("003");
let identity_toml = entity_dir.join("record-003.toml");
let identity_md = entity_dir.join("record-003.md");
let set =
crate::paths::scan_entity_dir(&entity_dir, &identity_toml, Some(&identity_md), root)
.unwrap();
let lines = crate::paths::select_paths(&set, &sel).unwrap();
assert_eq!(
lines,
vec![".doctrine/knowledge/constraint/003/record-003.md"]
);
}
#[test]
fn paths_entity_gives_toml_and_md() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
record_fixture(root, RecordKind::Assumption, 4, &["extra.txt"]);
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: true,
single: false,
};
let entity_dir = root.join(RecordKind::Assumption.kind().dir).join("004");
let identity_toml = entity_dir.join("record-004.toml");
let identity_md = entity_dir.join("record-004.md");
let set =
crate::paths::scan_entity_dir(&entity_dir, &identity_toml, Some(&identity_md), root)
.unwrap();
let lines = crate::paths::select_paths(&set, &sel).unwrap();
assert_eq!(
lines,
vec![
".doctrine/knowledge/assumption/004/record-004.toml",
".doctrine/knowledge/assumption/004/record-004.md"
]
);
}
#[test]
fn paths_invalid_ref_errors() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
record_fixture(root, RecordKind::Assumption, 1, &[]);
let (_, id) = resolve_ref("ASM-99999").unwrap();
let entity_dir = root
.join(RecordKind::Assumption.kind().dir)
.join(format!("{id:03}"));
let identity_toml = entity_dir.join(format!("record-{id:03}.toml"));
let identity_md = entity_dir.join(format!("record-{id:03}.md"));
let scan =
crate::paths::scan_entity_dir(&entity_dir, &identity_toml, Some(&identity_md), root);
assert!(scan.is_err());
}
#[test]
fn paths_multi_ref_splat_preserves_order() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
record_fixture(root, RecordKind::Assumption, 1, &[]);
record_fixture(root, RecordKind::Decision, 1, &[]);
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: false,
single: false,
};
let mut all_lines: Vec<String> = Vec::new();
for (kind, n) in [
(RecordKind::Assumption, "001"),
(RecordKind::Decision, "001"),
] {
let entity_dir = root.join(kind.kind().dir).join(n);
let toml_name = format!("{RECORD_STEM}-{n}.toml");
let md_name = format!("{RECORD_STEM}-{n}.md");
let set = crate::paths::scan_entity_dir(
&entity_dir,
&entity_dir.join(&toml_name),
Some(&entity_dir.join(&md_name)),
root,
)
.unwrap();
all_lines.extend(crate::paths::select_paths(&set, &sel).unwrap());
}
assert_eq!(all_lines.len(), 4);
assert!(all_lines[0].contains("assumption/001/record-001.toml"));
assert!(all_lines[2].contains("decision/001/record-001.toml"));
}
#[test]
fn estimate_roundtrip_on_record() {
let toml = format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\n\
version = 1\n\
id = 1\n\
slug = \"test\"\n\
title = \"Test\"\n\
record_kind = \"assumption\"\n\
status = \"held\"\n\
created = \"2026-01-01\"\n\
updated = \"2026-01-01\"\n\
tags = []\n\
[facet]\n\
claim = \"x\"\n\
[evidence]\n\
[estimate]\n\
lower = 3.0\n\
upper = 3.0\n"
);
let raw: RawRecordToml = crate::dtoml::parse_entity_toml(&toml, "ASM", 1).unwrap();
assert_eq!(raw.id, 1);
assert_eq!(raw.record_kind, RecordKind::Assumption);
assert_eq!(raw.title, "Test");
let full: toml::Table = toml.parse().unwrap();
let est_table = full.get("estimate").and_then(|v| v.as_table());
let facet = crate::estimate::parse_optional(est_table)
.unwrap()
.expect("estimate should be present");
assert_eq!(facet.lower, 3.0);
assert_eq!(facet.upper, 3.0);
let record = validate(raw).unwrap();
assert_eq!(record.title, "Test");
assert_eq!(record.record_kind, RecordKind::Assumption);
}
}