use std::path::{Path, PathBuf};
use dbmd_core::{summary, Frontmatter, Store};
use crate::cli::WriteArgs;
use crate::context::Context;
use crate::error::{CliError, CliResult, ExitCode};
pub fn run(ctx: &Context, args: &WriteArgs) -> CliResult {
let store = open_store(&args.dir)?;
let mut fm = Frontmatter::default();
let now = dbmd_core::now();
fm.created = Some(now);
fm.updated = Some(now);
apply_fm_assignments(&mut fm, &args.fm)?;
set_fm(&mut fm, "type", &args.r#type)?;
let body = match &args.body_file {
Some(p) => read_body_file(p)?,
None => String::new(),
};
let summary_text = match &args.summary {
Some(s) => summary::normalize(s),
None => summary::compose_default(&store, &args.r#type, &fm, &body)?,
};
if is_content_type(&args.r#type) && summary_text.trim().is_empty() {
return Err(no_summary_error(&args.r#type));
}
fm.summary = Some(summary_text);
let requested_rel = to_store_relative(&store, &args.path);
enforce_frozen(&store, &requested_rel)?;
let resolved = resolve_write_path(&store, &args.r#type, &fm, &args.path)?;
let resolved_disp = path_to_unix(&resolved);
let abs = store.abs_path(&resolved);
enforce_frozen(&store, &resolved)?;
if abs.exists() {
return Err(collision_error(&store, &resolved));
}
dbmd_core::parser::write_file(&abs, &fm, &body).map_err(core_err)?;
let index_warning = index_on_write(&store, &resolved);
let policy_warning = ignored_type_derivation_warning(&store, &args.r#type, &fm);
emit_result(
ctx,
&resolved_disp,
&args.r#type,
&index_warning,
&policy_warning,
);
Ok(())
}
pub(crate) fn open_store(dir: &str) -> Result<Store, CliError> {
Store::open(Path::new(dir)).map_err(|e| CliError::from(dbmd_core::Error::from(e)))
}
pub(crate) fn to_store_relative(store: &Store, raw: &str) -> PathBuf {
if let Some(rel) = canonical_store_relative(store, Path::new(raw)) {
return rel;
}
let p = Path::new(raw);
let rel = if p.is_absolute() {
store.rel_path(p).unwrap_or_else(|| p.to_path_buf())
} else {
p.strip_prefix("./").unwrap_or(p).to_path_buf()
};
PathBuf::from(path_to_unix(&rel))
}
pub(crate) fn canonical_store_relative(store: &Store, target: &Path) -> Option<PathBuf> {
let canonical_target = std::fs::canonicalize(target).ok()?;
let canonical_root = std::fs::canonicalize(&store.root).unwrap_or_else(|_| store.root.clone());
let rel = canonical_target.strip_prefix(&canonical_root).ok()?;
Some(PathBuf::from(path_to_unix(rel)))
}
pub(crate) fn enforce_frozen(store: &Store, target: &Path) -> Result<(), CliError> {
if let Some(frozen) = store.config.frozen_match(target) {
return Err(policy_frozen_error(&frozen));
}
Ok(())
}
pub(crate) fn index_on_write(store: &Store, file: &Path) -> Option<String> {
match dbmd_core::index::Index::on_write(store, file) {
Ok(()) => None,
Err(e) => Some(index_warning_text(&e)),
}
}
pub(crate) fn index_on_rename(store: &Store, old: &Path, new: &Path) -> Option<String> {
match dbmd_core::index::Index::on_rename(store, old, new) {
Ok(()) => None,
Err(e) => Some(index_warning_text(&e)),
}
}
pub(crate) fn policy_frozen_error(frozen: &Path) -> CliError {
let path = path_to_unix(frozen);
CliError::new(
ExitCode::Policy,
dbmd_core::validate::codes::POLICY_FROZEN_PAGE,
format!("write refused: `{path}` is a frozen page (DB.md ## Policies → ### Frozen pages)"),
)
.with_hint(
"write to a different path, or ask the operator to remove it from DB.md ### Frozen pages",
)
}
fn resolve_write_path(
store: &Store,
type_: &str,
fm: &Frontmatter,
raw_path: &str,
) -> Result<PathBuf, CliError> {
let rel = to_store_relative(store, raw_path);
let name = rel.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
CliError::runtime(format!("write path `{raw_path}` has no filename component"))
})?;
if let Some(folder) = explicit_type_folder(&rel, type_) {
return store
.shard_path_in(&folder, type_, fm, name)
.map_err(core_err);
}
store.shard_path_for(type_, fm, name).map_err(core_err)
}
fn explicit_type_folder(rel: &Path, type_: &str) -> Option<PathBuf> {
let comps: Vec<&str> = rel
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
if comps.len() < 3 {
return None;
}
let layer = dbmd_core::Layer::from_dir_name(comps[0])?;
if layer != dbmd_core::layer_for_type(type_) {
return None;
}
Some(PathBuf::from(comps[0]).join(comps[1]))
}
fn collision_error(store: &Store, resolved: &Path) -> CliError {
let path = path_to_unix(resolved);
let (existing_type, existing_summary) = read_type_and_summary(&store.abs_path(resolved));
let mut message = format!("`{path}` already exists");
match (&existing_type, &existing_summary) {
(Some(t), Some(s)) => message.push_str(&format!(" — existing type: {t}, summary: {s}")),
(Some(t), None) => message.push_str(&format!(" — existing type: {t}")),
(None, Some(s)) => message.push_str(&format!(" — existing summary: {s}")),
(None, None) => {}
}
CliError::new(ExitCode::Collision, "PATH_COLLISION", message)
.with_hint("update the existing file (dbmd fm set), or write to a disambiguated path")
}
fn no_summary_error(type_: &str) -> CliError {
CliError::new(
ExitCode::Runtime,
"SUMMARY_REQUIRED",
format!("a `{type_}` content file requires a summary, and none could be composed"),
)
.with_hint("pass --summary '<one line>' (the deterministic default was empty for this file)")
}
fn emit_result(
ctx: &Context,
resolved: &str,
type_: &str,
index_warning: &Option<String>,
policy_warning: &Option<String>,
) {
for w in [index_warning, policy_warning].into_iter().flatten() {
eprintln!("dbmd: warning: {w}");
}
if ctx.json {
let out = serde_json::json!({
"written": resolved,
"type": type_,
});
println!("{out}");
} else {
println!("{resolved}");
}
}
fn apply_fm_assignments(fm: &mut Frontmatter, assignments: &[String]) -> Result<(), CliError> {
for raw in assignments {
let (key, value) = raw.split_once('=').ok_or_else(|| {
CliError::runtime(format!("--fm expects key=value, got `{raw}`"))
.with_hint("use --fm date=2026-05-22 (repeat the flag for multiple fields)")
})?;
let key = key.trim();
if key.is_empty() {
return Err(CliError::runtime(format!(
"--fm has an empty key in `{raw}`"
)));
}
set_fm(fm, key, value)?;
}
Ok(())
}
fn read_body_file(path: &str) -> Result<String, CliError> {
std::fs::read_to_string(path)
.map_err(|e| CliError::runtime(format!("cannot read --body-file {path}: {e}")))
}
fn ignored_type_derivation_warning(store: &Store, type_: &str, fm: &Frontmatter) -> Option<String> {
let targets = fm
.link_fields()
.into_iter()
.filter(|(key, _)| key == "derived_from")
.map(|(_, link)| link.target);
let hit = dbmd_core::validate::derived_from_ignored_type(store, type_, targets)?;
Some(format!(
"wiki-page derives from ignored-type record `{}` (type `{}`, per DB.md ## Policies → ### Ignored types)",
hit.target, hit.target_type
))
}
fn is_content_type(type_: &str) -> bool {
!matches!(type_, "db-md" | "index" | "log")
}
fn read_type_and_summary(abs: &Path) -> (Option<String>, Option<String>) {
match dbmd_core::parser::read_file(abs) {
Ok((fm, _body)) => (fm.type_, fm.summary),
Err(_) => (None, None),
}
}
fn index_warning_text(e: &dbmd_core::Error) -> String {
format!("index not updated ({e}); run `dbmd index rebuild` to resync")
}
pub(crate) fn core_err<E: Into<dbmd_core::Error>>(e: E) -> CliError {
CliError::from(e.into())
}
fn set_fm(fm: &mut Frontmatter, key: &str, value: &str) -> Result<(), CliError> {
fm.set(key, value).map_err(core_err)
}
fn path_to_unix(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 seeding_now_populates_typed_timestamps_and_round_trips_to_rfc3339() {
let now = dbmd_core::now();
let fm = Frontmatter {
created: Some(now),
updated: Some(now),
..Default::default()
};
let yaml = fm.to_yaml();
assert!(yaml.contains("created:"), "{yaml}");
assert!(yaml.contains("updated:"), "{yaml}");
let rendered = now.to_rfc3339();
let reparsed = chrono::DateTime::parse_from_rfc3339(&rendered)
.expect("the seeded timestamp must render as valid RFC3339");
assert_eq!(
reparsed, now,
"seeded `now` must round-trip through RFC3339"
);
}
#[test]
fn enforce_frozen_refuses_extensionless_policy_entry_against_md_target() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("DB.md"), "---\ntype: db-md\n---\n# s\n").unwrap();
let mut store = Store::open(dir.path()).unwrap();
store.config.frozen_pages = vec![PathBuf::from("records/decisions/q1")];
let err = enforce_frozen(&store, Path::new("records/decisions/q1.md")).unwrap_err();
assert_eq!(err.code, dbmd_core::validate::codes::POLICY_FROZEN_PAGE);
assert_eq!(err.exit, ExitCode::Policy);
assert!(enforce_frozen(&store, Path::new("./records/decisions/q1.md")).is_err());
enforce_frozen(&store, Path::new("records/decisions/q2.md"))
.expect("a non-frozen path must not be refused");
}
#[test]
fn is_content_type_excludes_meta() {
assert!(is_content_type("contact"));
assert!(is_content_type("proposal")); assert!(!is_content_type("index"));
assert!(!is_content_type("db-md"));
assert!(!is_content_type("log"));
}
#[test]
fn canonical_store_relative_rebases_an_absolute_in_store_target() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("DB.md"), "---\ntype: db-md\n---\n# s\n").unwrap();
let store = Store::open(dir.path()).unwrap();
let abs = store.root.join("records/decisions/q1.md");
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
std::fs::write(&abs, "---\ntype: decision\nsummary: x\n---\n# Q1\n").unwrap();
assert_eq!(
canonical_store_relative(&store, &abs),
Some(PathBuf::from("records/decisions/q1.md"))
);
assert_eq!(
canonical_store_relative(&store, &store.root.join("records/decisions/ghost.md")),
None
);
let outside = tempfile::TempDir::new().unwrap();
let outside_file = outside.path().join("elsewhere.md");
std::fs::write(&outside_file, "x").unwrap();
assert_eq!(canonical_store_relative(&store, &outside_file), None);
}
#[test]
fn to_store_relative_collapses_absolute_and_relative_spellings() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("DB.md"), "---\ntype: db-md\n---\n# s\n").unwrap();
let store = Store::open(dir.path()).unwrap();
let abs = store.root.join("records/decisions/q1.md");
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
std::fs::write(&abs, "---\ntype: decision\nsummary: x\n---\n# Q1\n").unwrap();
let from_abs = to_store_relative(&store, abs.to_str().unwrap());
assert_eq!(from_abs, PathBuf::from("records/decisions/q1.md"));
assert_eq!(
to_store_relative(&store, "./records/decisions/new.md"),
PathBuf::from("records/decisions/new.md")
);
}
}