use std::path::Path;
use dbmd_core::Store;
use crate::cli::LinkArgs;
use crate::cmd::write::{core_err, enforce_frozen, index_on_write, open_store, to_store_relative};
use crate::context::Context;
use crate::error::{CliError, CliResult, ExitCode};
const LAYER_DIRS: [&str; 3] = ["sources", "records", "wiki"];
pub fn run(ctx: &Context, args: &LinkArgs) -> CliResult {
let store = open_store(&args.dir)?;
let from_rel = to_store_relative(&store, &args.from);
let from_abs = store.abs_path(&from_rel);
if !from_abs.exists() {
return Err(missing_from_error(&from_rel));
}
enforce_frozen(&store, &from_rel)?;
let target = canonical_link_target(&store, &args.to)?;
append_wiki_link(&from_abs, &target)?;
let index_warning = index_on_write(&store, &from_rel);
emit_result(ctx, &path_to_unix(&from_rel), &target, &index_warning);
Ok(())
}
fn append_wiki_link(abs: &Path, target: &str) -> Result<(), CliError> {
let (fm, mut body) = dbmd_core::parser::read_file(abs).map_err(core_err)?;
let link_line = format!("[[{target}]]\n");
if body.is_empty() {
body = link_line;
} else {
if !body.ends_with('\n') {
body.push('\n');
}
if !body.ends_with("\n\n") {
body.push('\n');
}
body.push_str(&link_line);
}
dbmd_core::parser::write_file(abs, &fm, &body).map_err(core_err)?;
Ok(())
}
fn canonical_link_target(store: &Store, raw: &str) -> Result<String, CliError> {
let rel = to_store_relative(store, raw);
let unix = path_to_unix(&rel);
let bare = unix.strip_suffix(".md").unwrap_or(&unix).to_string();
let head = bare.split('/').next().unwrap_or("");
let is_full_path = bare.contains('/') && LAYER_DIRS.contains(&head);
if !is_full_path {
return Err(short_form_error(raw));
}
Ok(bare)
}
fn missing_from_error(from: &Path) -> CliError {
CliError::runtime(format!(
"cannot link from `{}`: file does not exist",
path_to_unix(from)
))
.with_hint("create it first with `dbmd write`")
}
fn short_form_error(raw: &str) -> CliError {
CliError::new(
ExitCode::Runtime,
dbmd_core::validate::codes::WIKI_LINK_SHORT_FORM,
format!("link target `{raw}` is not a full store-relative path"),
)
.with_hint("use the full path, e.g. `records/contacts/sarah-chen` (no short-form, no `.md`)")
}
fn emit_result(ctx: &Context, from: &str, to: &str, index_warning: &Option<String>) {
if let Some(w) = index_warning {
eprintln!("dbmd: warning: {w}");
}
if ctx.json {
let out = serde_json::json!({
"linked": from,
"to": to,
});
println!("{out}");
} else {
println!("{from} -> [[{to}]]");
}
}
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::*;
use std::fs;
use tempfile::TempDir;
fn store_with_db_md() -> (TempDir, Store) {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("DB.md"), "---\ntype: db-md\n---\n# s\n").unwrap();
let store = Store::open(dir.path()).unwrap();
(dir, store)
}
#[test]
fn canonical_link_target_accepts_full_path_and_strips_md() {
let (_d, store) = store_with_db_md();
assert_eq!(
canonical_link_target(&store, "records/contacts/sarah.md").unwrap(),
"records/contacts/sarah"
);
assert_eq!(
canonical_link_target(&store, "wiki/topics/scale").unwrap(),
"wiki/topics/scale"
);
}
#[test]
fn canonical_link_target_rejects_short_form() {
let (_d, store) = store_with_db_md();
let err = canonical_link_target(&store, "sarah-chen").unwrap_err();
assert_eq!(err.code, dbmd_core::validate::codes::WIKI_LINK_SHORT_FORM);
assert!(canonical_link_target(&store, "people/sarah").is_err());
}
#[test]
fn append_wiki_link_preserves_frontmatter_and_appends_line() {
let (_d, store) = store_with_db_md();
let abs = store.root.join("records/contacts/sarah.md");
fs::create_dir_all(abs.parent().unwrap()).unwrap();
fs::write(
&abs,
"---\ntype: contact\nsummary: x\n---\n# Sarah\n\nNotes.\n",
)
.unwrap();
append_wiki_link(&abs, "records/companies/acme").unwrap();
let text = fs::read_to_string(&abs).unwrap();
assert!(text.contains("[[records/companies/acme]]"));
assert!(text.starts_with("---\ntype: contact\n"));
assert!(text.contains("# Sarah"));
assert!(text.contains("Notes."));
}
#[test]
fn append_wiki_link_into_empty_body() {
let (_d, store) = store_with_db_md();
let abs = store.root.join("records/contacts/empty.md");
fs::create_dir_all(abs.parent().unwrap()).unwrap();
fs::write(&abs, "---\ntype: contact\nsummary: x\n---\n").unwrap();
append_wiki_link(&abs, "records/companies/acme").unwrap();
let text = fs::read_to_string(&abs).unwrap();
assert!(text.ends_with("[[records/companies/acme]]\n"));
}
}