use std::path::Path;
use chrono::{DateTime, FixedOffset, NaiveDate, TimeZone};
use crate::cli::{LogArgs, LogCommand, LogSinceArgs, LogTailArgs};
use crate::context::Context;
use crate::error::{CliError, CliResult, ExitCode};
use dbmd_core::{Log, LogEntry, LogKind, Store};
pub fn run(ctx: &Context, args: &LogArgs) -> CliResult {
match &args.command {
LogCommand::Tail(a) => run_tail(ctx, a),
LogCommand::Since(a) => run_since(ctx, a),
LogCommand::Append(tokens) => run_append(ctx, tokens),
}
}
pub fn run_tail(ctx: &Context, args: &LogTailArgs) -> CliResult {
let store = open_store(&args.dir)?;
let entries = Log::tail(&store, args.n)?;
emit_entries(ctx, &entries);
Ok(())
}
pub fn run_since(ctx: &Context, args: &LogSinceArgs) -> CliResult {
let store = open_store(&args.dir)?;
let time = parse_flexible_timestamp(&args.timestamp)?;
let entries = Log::since(&store, time)?;
emit_entries(ctx, &entries);
Ok(())
}
pub fn run_append(ctx: &Context, tokens: &[String]) -> CliResult {
let parsed = ParsedAppend::from_tokens(tokens)?;
let store = open_store(".")?;
let object = if parsed.object == "-" {
None
} else {
Some(parsed.object.clone())
};
let entry = LogEntry {
timestamp: now_fixed(),
kind: LogKind::parse(&parsed.kind),
object,
note: parsed.note.unwrap_or_default(),
};
Log::append(&store, &entry)?;
if ctx.json {
let obj = serde_json::json!({
"appended": true,
"kind": entry.kind.as_str(),
"object": entry.object,
"timestamp": fmt_ts(&entry.timestamp),
});
println!("{obj}");
} else {
match &entry.object {
Some(o) => {
println!(
"[{}] {} | {}",
fmt_ts(&entry.timestamp),
entry.kind.as_str(),
o
)
}
None => println!("[{}] {}", fmt_ts(&entry.timestamp), entry.kind.as_str()),
}
}
Ok(())
}
struct ParsedAppend {
kind: String,
object: String,
note: Option<String>,
}
impl ParsedAppend {
fn from_tokens(tokens: &[String]) -> Result<ParsedAppend, CliError> {
let mut positionals: Vec<String> = Vec::new();
let mut note: Option<String> = None;
let mut i = 0;
while i < tokens.len() {
let tok = tokens[i].as_str();
if tok == "-m" || tok == "--message" {
let val = tokens.get(i + 1).ok_or_else(|| {
usage_error("`-m` requires a note argument: dbmd log <kind> <object> -m <note>")
})?;
note = Some(val.clone());
i += 2;
continue;
}
if let Some(rest) = tok.strip_prefix("--message=") {
note = Some(rest.to_string());
i += 1;
continue;
}
if let Some(rest) = tok.strip_prefix("-m") {
if !rest.is_empty() {
note = Some(rest.to_string());
i += 1;
continue;
}
}
positionals.push(tok.to_string());
i += 1;
}
if positionals.len() < 2 {
return Err(usage_error(
"usage: dbmd log <kind> <object> [-m <note>] (<object> is a store-relative path, or `-` for store-wide)",
));
}
if positionals.len() > 2 {
return Err(usage_error(
"too many arguments: dbmd log <kind> <object> [-m <note>] — quote a multi-word note after -m",
));
}
Ok(ParsedAppend {
kind: positionals[0].clone(),
object: positionals[1].clone(),
note,
})
}
}
fn emit_entries(ctx: &Context, entries: &[LogEntry]) {
if ctx.json {
let arr: Vec<serde_json::Value> = entries.iter().map(entry_to_json).collect();
println!("{}", serde_json::Value::Array(arr));
return;
}
for (idx, e) in entries.iter().enumerate() {
if idx > 0 {
println!();
}
match &e.object {
Some(o) => println!("[{}] {} | {}", fmt_ts(&e.timestamp), e.kind.as_str(), o),
None => println!("[{}] {}", fmt_ts(&e.timestamp), e.kind.as_str()),
}
if !e.note.is_empty() {
println!("{}", e.note);
}
}
}
fn entry_to_json(e: &LogEntry) -> serde_json::Value {
serde_json::json!({
"timestamp": fmt_ts(&e.timestamp),
"kind": e.kind.as_str(),
"object": e.object,
"note": e.note,
})
}
fn fmt_ts(ts: &DateTime<FixedOffset>) -> String {
ts.format("%Y-%m-%d %H:%M").to_string()
}
fn now_fixed() -> DateTime<FixedOffset> {
dbmd_core::now()
}
pub(crate) fn open_store(dir: &str) -> Result<Store, CliError> {
Store::open(Path::new(dir)).map_err(|e| dbmd_core::Error::from(e).into())
}
pub(crate) fn into_cli<T, E: Into<dbmd_core::Error>>(r: Result<T, E>) -> Result<T, CliError> {
r.map_err(|e| e.into().into())
}
pub(crate) fn parse_flexible_timestamp(raw: &str) -> Result<DateTime<FixedOffset>, CliError> {
let s = raw.trim();
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Ok(dt);
}
if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
if let Some(naive) = date.and_hms_opt(0, 0, 0) {
if let Some(dt) =
FixedOffset::east_opt(0).and_then(|tz| tz.from_local_datetime(&naive).single())
{
return Ok(dt);
}
}
}
Err(CliError::new(
ExitCode::Runtime,
"BAD_TIMESTAMP",
format!("not a valid RFC3339 timestamp or YYYY-MM-DD date: {raw:?}"),
)
.with_hint("use `2026-05-27T10:00:00Z`, `2026-05-27T10:00:00-07:00`, or `2026-05-27`"))
}
fn usage_error(message: &str) -> CliError {
CliError::new(ExitCode::Runtime, "LOG_USAGE", message)
.with_hint("dbmd log <kind> <object> [-m <note>]")
}