use std::path::{Path, PathBuf};
use serde_norway::Value as YamlValue;
use crate::cli::{FmArgs, FmCommand, FmGetArgs, FmInitArgs, FmQueryArgs, FmSetArgs};
use crate::cmd::log::{into_cli, open_store};
use crate::cmd::write::{apply_schema_defaults, require_store_relative};
use crate::context::Context;
use crate::error::{CliError, CliResult, ExitCode};
use dbmd_core::{infer_type_from_path, parser, summary, Index, Layer, Query, Store};
pub fn run(ctx: &Context, args: &FmArgs) -> CliResult {
match &args.command {
FmCommand::Get(a) => run_get(ctx, a),
FmCommand::Set(a) => run_set(ctx, a),
FmCommand::Query(a) => run_query(ctx, a),
FmCommand::Init(a) => run_init(ctx, a),
}
}
pub fn run_get(ctx: &Context, args: &FmGetArgs) -> CliResult {
let (fm, _body) = into_cli(parser::read_file(Path::new(&args.file)))?;
let value = fm.get(&args.key).ok_or_else(|| {
CliError::new(
ExitCode::Runtime,
"FM_KEY_NOT_FOUND",
format!("no frontmatter key '{}' in {}", args.key, args.file),
)
})?;
if ctx.json {
let obj = serde_json::json!({
"file": args.file,
"key": args.key,
"value": yaml_to_json(&value),
});
println!("{obj}");
} else {
println!("{}", render_scalar(&value));
}
Ok(())
}
pub fn run_set(ctx: &Context, args: &FmSetArgs) -> CliResult {
let (key, value) = split_assignment(&args.assignment)?;
let store = locate_store_from_cwd()?;
let rel = require_store_relative(&store, &args.file)?;
let file = store.abs_path(&rel);
enforce_not_frozen(&store, &rel)?;
let (mut fm, body) = into_cli(parser::read_file(&file))?;
into_cli(fm.set(key, value))?;
bump_updated_unless_explicit(&mut fm, key);
into_cli(parser::write_file(&file, &fm, &body))?;
let index_ok = Index::on_write(&store, &rel).is_ok();
if ctx.json {
let obj = serde_json::json!({
"file": path_str(&rel),
"key": key,
"value": value,
"index_updated": index_ok,
});
println!("{obj}");
} else {
println!("{}", path_str(&rel));
if !index_ok {
eprintln!(
" warning: index not updated; run `dbmd index rebuild --folder <type-folder>`"
);
}
}
Ok(())
}
pub fn run_query(ctx: &Context, args: &FmQueryArgs) -> CliResult {
let (key, value) = split_assignment(&args.assignment)?;
let store = open_store(&args.dir)?;
let mut query = Query::new().with_where(key, value);
if let Some(t) = &args.r#type {
query = query.with_type(t);
}
if let Some(layer) = &args.r#in {
query = query.with_layer(parse_layer(layer)?);
}
let mut records = into_cli(query.execute(&store))?;
if let Some(limit) = args.limit {
records.truncate(limit);
}
crate::cmd::index::emit_records(ctx, &records);
Ok(())
}
pub fn run_init(ctx: &Context, args: &FmInitArgs) -> CliResult {
let store = locate_store_from_cwd()?;
let rel = require_store_relative(&store, &args.file)?;
let file = store.abs_path(&rel);
enforce_not_frozen(&store, &rel)?;
let (mut fm, body) = read_or_seed_raw_body(&file)?;
let type_ = match fm.type_.clone() {
Some(t) if !t.is_empty() => t,
_ => match infer_type_from_path(&rel) {
Some(t) => {
fm.type_ = Some(t.clone());
t
}
None => {
return Err(CliError::new(
ExitCode::Runtime,
"FM_TYPE_UNKNOWN",
format!(
"cannot infer `type` for {} — set it explicitly with `dbmd fm set {} type=<t>`",
path_str(&rel),
path_str(&rel)
),
));
}
},
};
let now = dbmd_core::now();
if fm.created.is_none() {
fm.created = Some(now);
}
if fm.updated.is_none() {
fm.updated = Some(now);
}
apply_schema_defaults(&store, &type_, &mut fm)?;
if let Some(s) = &args.summary {
fm.summary = Some(summary::collapse_whitespace(s));
} else if fm.summary.as_deref().unwrap_or("").trim().is_empty() {
let composed = summary::compose_default(&store, &type_, &fm, &body)?;
fm.summary = Some(composed);
}
into_cli(parser::write_file(&file, &fm, &body))?;
let index_ok = Index::on_write(&store, &rel).is_ok();
if ctx.json {
let obj = serde_json::json!({
"file": path_str(&rel),
"type": type_,
"summary": fm.summary,
"index_updated": index_ok,
});
println!("{obj}");
} else {
println!("{}", path_str(&rel));
if !index_ok {
eprintln!(
" warning: index not updated; run `dbmd index rebuild --folder <type-folder>`"
);
}
}
Ok(())
}
fn read_or_seed_raw_body(file: &Path) -> Result<(parser::Frontmatter, String), CliError> {
match parser::read_file(file) {
Ok(parsed) => Ok(parsed),
Err(dbmd_core::ParseError::MissingFrontmatter { .. }) => {
let body = std::fs::read_to_string(file).map_err(CliError::from)?;
if opens_frontmatter_fence(&body) {
return Err(malformed_frontmatter_error(file));
}
Ok((parser::Frontmatter::default(), body))
}
Err(e) => Err(CliError::from(dbmd_core::Error::from(e))),
}
}
fn opens_frontmatter_fence(text: &str) -> bool {
let first = text.split_inclusive('\n').next().unwrap_or("");
first.trim_end_matches(['\r', '\n']) == "---"
}
fn malformed_frontmatter_error(file: &Path) -> CliError {
CliError::new(
ExitCode::Runtime,
"FM_MALFORMED",
format!(
"{} opens a `---` frontmatter fence that is never closed",
file.display()
),
)
.with_hint(
"close the frontmatter block with a `---` line, or remove the opening `---` to import it as a raw body",
)
}
fn split_assignment(assignment: &str) -> Result<(&str, &str), CliError> {
match assignment.split_once('=') {
Some((k, v)) if !k.is_empty() => Ok((k, v)),
_ => Err(CliError::new(
ExitCode::Runtime,
"BAD_ASSIGNMENT",
format!("expected `key=value`, got {assignment:?}"),
)
.with_hint("example: status=active")),
}
}
fn enforce_not_frozen(store: &Store, rel: &Path) -> Result<(), CliError> {
if let Some(frozen) = store.config.frozen_match(rel) {
return Err(dbmd_core::Error::Policy {
code: "POLICY_FROZEN_PAGE",
message: format!(
"write refused: '{}' is a frozen page per DB.md ## Policies → ### Frozen pages",
path_str(&frozen)
),
}
.into());
}
Ok(())
}
fn bump_updated_unless_explicit(fm: &mut parser::Frontmatter, mutated_key: &str) {
if mutated_key != "updated" {
fm.updated = Some(dbmd_core::now());
}
}
fn locate_store_from_cwd() -> Result<Store, CliError> {
let start = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
match outermost_store_root(&start) {
Some(root) => Store::open_strict(&root).map_err(CliError::from),
None => Err(CliError::new(
ExitCode::NotAStore,
"NOT_A_STORE",
format!(
"not a db.md store: no DB.md found at or above {}",
start.display()
),
)
.with_hint(
"run from inside a db.md store (any directory at or under a DB.md), or author a DB.md at the store root first",
)),
}
}
fn outermost_store_root(start: &Path) -> Option<PathBuf> {
let mut outermost: Option<&Path> = None;
let mut dir: Option<&Path> = Some(start);
while let Some(d) = dir {
if Store::is_db_md_store(d) {
outermost = Some(d);
}
dir = d.parent();
}
outermost.map(|p| p.to_path_buf())
}
pub(crate) fn parse_layer(layer: &str) -> Result<Layer, CliError> {
Layer::from_dir_name(layer).ok_or_else(|| {
CliError::new(
ExitCode::Runtime,
"BAD_LAYER",
format!("unknown layer {layer:?}"),
)
.with_hint("one of: sources, records")
})
}
fn render_scalar(v: &YamlValue) -> String {
match v {
YamlValue::String(s) => s.clone(),
YamlValue::Bool(b) => b.to_string(),
YamlValue::Number(n) => n.to_string(),
YamlValue::Null => String::new(),
YamlValue::Sequence(items) => items
.iter()
.map(render_scalar)
.collect::<Vec<_>>()
.join(", "),
YamlValue::Mapping(_) | YamlValue::Tagged(_) => serde_norway::to_string(v)
.unwrap_or_default()
.trim()
.to_string(),
}
}
fn yaml_to_json(v: &YamlValue) -> serde_json::Value {
serde_json::to_value(v).unwrap_or(serde_json::Value::Null)
}
fn path_str(p: &Path) -> String {
p.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bump_updated_restamps_non_updated_edits() {
let old = chrono::DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z").unwrap();
let mut fm = parser::Frontmatter {
updated: Some(old),
..Default::default()
};
bump_updated_unless_explicit(&mut fm, "status");
let bumped = fm.updated.expect("updated must remain set");
assert!(
bumped > old,
"editing a non-`updated` field must advance `updated` past its stale value ({old} -> {bumped})"
);
}
#[test]
fn bump_updated_preserves_explicit_updated_assignment() {
let chosen = chrono::DateTime::parse_from_rfc3339("2030-06-01T12:00:00Z").unwrap();
let mut fm = parser::Frontmatter {
updated: Some(chosen),
..Default::default()
};
bump_updated_unless_explicit(&mut fm, "updated");
assert_eq!(
fm.updated,
Some(chosen),
"an explicit `fm set updated=…` must not be overwritten by the auto-bump"
);
}
#[test]
fn outermost_store_root_walks_up_past_interior_db_md() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# Store\n").unwrap();
let docs = root.join("sources").join("docs");
std::fs::create_dir_all(&docs).unwrap();
std::fs::write(
docs.join("DB.md"),
"---\ntype: pdf-source\nsummary: ingested doc named DB.md\n---\n# Doc\n",
)
.unwrap();
let found = outermost_store_root(&docs).expect("a store must be found above");
assert_eq!(
std::fs::canonicalize(&found).unwrap(),
std::fs::canonicalize(root).unwrap(),
"interior DB.md must not become the store root"
);
let records = root.join("records");
std::fs::create_dir_all(&records).unwrap();
assert_eq!(
std::fs::canonicalize(outermost_store_root(&records).unwrap()).unwrap(),
std::fs::canonicalize(root).unwrap(),
"fm set / fm init must work from a subdirectory of the store"
);
}
#[test]
fn outermost_store_root_is_none_outside_any_store_and_hint_is_actionable() {
let dir = tempfile::TempDir::new().unwrap();
assert!(
outermost_store_root(dir.path()).is_none(),
"no DB.md above this dir → no store root"
);
let err = CliError::new(ExitCode::NotAStore, "NOT_A_STORE", "x").with_hint(
"run from inside a db.md store (any directory at or under a DB.md), or author a DB.md at the store root first",
);
assert_eq!(err.code, "NOT_A_STORE");
assert_eq!(err.exit, ExitCode::NotAStore);
assert!(
!err.hint.unwrap().contains("pass the store path"),
"hint must not suggest the impossible `--dir`/store-path remedy"
);
}
}