use crate::models::field_names;
use anyhow::Result;
use clap::Args;
use serde_json::{Value, json};
use crate::cli::CliOutput;
use crate::storage as db;
#[derive(Args, Debug, Clone)]
pub struct KgTimelineArgs {
#[arg(long = "source-id", value_name = "ID")]
pub source_id: String,
#[arg(long, value_name = "RFC3339")]
pub since: Option<String>,
#[arg(long, value_name = "RFC3339")]
pub until: Option<String>,
#[arg(long, value_name = "N")]
pub limit: Option<u32>,
#[arg(long)]
pub json: bool,
}
pub fn cmd_kg_timeline(
db_path: &std::path::Path,
args: &KgTimelineArgs,
out: &mut CliOutput<'_>,
) -> Result<()> {
let conn = db::open(db_path)?;
let mut params = json!({"source_id": args.source_id});
if let Some(s) = &args.since {
params["since"] = json!(s);
}
if let Some(u) = &args.until {
params["until"] = json!(u);
}
if let Some(l) = args.limit {
params["limit"] = json!(l);
}
let envelope = crate::mcp::handle_kg_timeline(&conn, ¶ms)
.map_err(|e| anyhow::anyhow!("kg-timeline: {e}"))?;
if args.json {
writeln!(out.stdout, "{}", serde_json::to_string(&envelope)?)?;
return Ok(());
}
let count = envelope.get("count").and_then(Value::as_u64).unwrap_or(0);
writeln!(out.stdout, "kg-timeline: {count} event(s)")?;
if let Some(arr) = envelope.get("events").and_then(Value::as_array) {
for e in arr {
let tid = e.get("target_id").and_then(Value::as_str).unwrap_or("?");
let rel = e.get("relation").and_then(Value::as_str).unwrap_or("?");
let vf = e
.get(field_names::VALID_FROM)
.and_then(Value::as_str)
.unwrap_or("");
writeln!(out.stdout, " {vf} {rel} {tid}")?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_utils::{TestEnv, seed_memory};
#[test]
fn kg_timeline_cli_empty_returns_zero() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let s = seed_memory(&db, "ns", "src", "content");
let args = KgTimelineArgs {
source_id: s,
since: None,
until: None,
limit: None,
json: true,
};
{
let mut out = env.output();
cmd_kg_timeline(&db, &args, &mut out).expect("ok");
}
let stdout = env.stdout_str();
let envelope: Value = serde_json::from_str(stdout.trim()).expect("parse envelope");
assert_eq!(envelope["count"].as_u64(), Some(0));
}
#[test]
fn kg_timeline_cli_invalid_id_returns_err() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = KgTimelineArgs {
source_id: "bad id with spaces".into(),
since: None,
until: None,
limit: None,
json: true,
};
let mut out = env.output();
let err = cmd_kg_timeline(&db, &args, &mut out).expect_err("must fail");
assert!(err.to_string().contains("kg-timeline"), "got: {err}");
}
#[test]
fn kg_timeline_cli_text_output_with_events_and_all_params() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let src = seed_memory(&db, "ns", "tl-src", "content");
let tgt = seed_memory(&db, "ns", "tl-tgt", "target content");
{
let conn = db::open(&db).unwrap();
let vf = "2026-01-02T00:00:00+00:00";
conn.execute(
"INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from)
VALUES (?1, ?2, 'related_to', ?3, ?3)",
rusqlite::params![src, tgt, vf],
)
.expect("insert link");
}
let args = KgTimelineArgs {
source_id: src,
since: Some("2026-01-01T00:00:00+00:00".into()),
until: Some("2026-12-31T00:00:00+00:00".into()),
limit: Some(100),
json: false,
};
{
let mut out = env.output();
cmd_kg_timeline(&db, &args, &mut out).expect("ok");
}
let stdout = env.stdout_str();
assert!(stdout.contains("event(s)"), "got: {stdout}");
assert!(stdout.contains("related_to"), "got: {stdout}");
}
}