use std::path::Path;
use serde_yml::Value as YamlValue;
use crate::cli::{FmArgs, FmCommand, FmGetArgs, FmInitArgs, FmQueryArgs, FmSetArgs};
use crate::cmd::log::{into_cli, open_store};
use crate::cmd::write::canonical_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 file = Path::new(&args.file);
let store = open_store(".")?;
let rel = store_relative(&store, file);
enforce_not_frozen(&store, &rel)?;
let (mut fm, body) = into_cli(parser::read_file(file))?;
into_cli(fm.set(key, value))?;
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 file = Path::new(&args.file);
let store = open_store(".")?;
let rel = store_relative(&store, file);
enforce_not_frozen(&store, &rel)?;
let (mut fm, body) = into_cli(parser::read_file(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);
}
if let Some(s) = &args.summary {
fm.summary = Some(summary::normalize(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 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 store_relative(store: &Store, file: &Path) -> std::path::PathBuf {
if let Some(rel) = canonical_store_relative(store, file) {
return rel;
}
if let Ok(rel) = file.strip_prefix(&store.root) {
return rel.to_path_buf();
}
let s = path_str(file);
Path::new(s.strip_prefix("./").unwrap_or(&s)).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, wiki")
})
}
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_yml::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("/")
}