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 RecallObservationsArgs {
#[arg(long = "recall-id", value_name = "ID")]
pub recall_id: Option<String>,
#[arg(long, conflicts_with = "unconsumed")]
pub consumed: bool,
#[arg(long)]
pub unconsumed: bool,
#[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_recall_observations(
db_path: &std::path::Path,
args: &RecallObservationsArgs,
out: &mut CliOutput<'_>,
) -> Result<()> {
let conn = db::open(db_path)?;
let mut params = json!({});
if let Some(r) = &args.recall_id {
params["recall_id"] = json!(r);
}
if args.consumed {
params["consumed"] = json!(true);
} else if args.unconsumed {
params["consumed"] = json!(false);
}
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_recall_observations(&conn, ¶ms)
.map_err(|e| anyhow::anyhow!("recall-observations: {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, "recall-observations: {count} row(s)")?;
if let Some(arr) = envelope
.get(crate::models::field_names::OBSERVATIONS)
.and_then(Value::as_array)
{
for r in arr {
let recall = r.get("recall_id").and_then(Value::as_str).unwrap_or("?");
let mid = r.get("memory_id").and_then(Value::as_str).unwrap_or("?");
let consumed = r.get("consumed_at").and_then(Value::as_str).is_some();
let rank = r.get("rank").and_then(Value::as_u64).unwrap_or(0);
writeln!(
out.stdout,
" recall={recall} memory={mid} rank={rank} consumed={consumed}",
)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_utils::TestEnv;
#[test]
fn recall_observations_cli_empty_db_returns_zero() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = RecallObservationsArgs {
recall_id: None,
consumed: false,
unconsumed: false,
since: None,
until: None,
limit: None,
json: true,
};
{
let mut out = env.output();
cmd_recall_observations(&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));
assert!(envelope["observations"].is_array());
}
#[test]
fn recall_observations_cli_text_mode_emits_count_line() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = RecallObservationsArgs {
recall_id: None,
consumed: false,
unconsumed: false,
since: None,
until: None,
limit: None,
json: false,
};
{
let mut out = env.output();
cmd_recall_observations(&db, &args, &mut out).expect("ok");
}
let stdout = env.stdout_str();
assert!(
stdout.starts_with("recall-observations: 0 row(s)"),
"got: {stdout}"
);
}
#[test]
fn recall_observations_cli_text_output_with_rows_and_filters() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let mem_id = crate::cli::test_utils::seed_memory(&db, "ns", "obs-mem", "body");
{
let conn = db::open(&db).unwrap();
crate::observations::record_recall(
&conn,
"recall-xyz",
&[crate::observations::Candidate {
memory_id: &mem_id,
retriever: "hybrid",
rank: 1,
score: 0.9,
}],
)
.expect("record_recall");
}
let args = RecallObservationsArgs {
recall_id: Some("recall-xyz".into()),
consumed: false,
unconsumed: true,
since: Some("2026-01-01T00:00:00+00:00".into()),
until: Some("2030-01-01T00:00:00+00:00".into()),
limit: Some(50),
json: false,
};
{
let mut out = env.output();
cmd_recall_observations(&db, &args, &mut out).expect("ok");
}
let stdout = env.stdout_str();
assert!(stdout.contains("1 row(s)"), "got: {stdout}");
assert!(stdout.contains("recall=recall-xyz"), "got: {stdout}");
assert!(
stdout.contains(&format!("memory={mem_id}")),
"got: {stdout}"
);
assert!(stdout.contains("rank=1"), "got: {stdout}");
assert!(stdout.contains("consumed=false"), "got: {stdout}");
}
#[test]
fn recall_observations_cli_consumed_filter_param() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = RecallObservationsArgs {
recall_id: None,
consumed: true,
unconsumed: false,
since: None,
until: None,
limit: None,
json: true,
};
{
let mut out = env.output();
cmd_recall_observations(&db, &args, &mut out).expect("ok");
}
let envelope: Value = serde_json::from_str(env.stdout_str().trim()).expect("json");
assert_eq!(envelope["count"].as_u64(), Some(0));
}
}