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 FindPathsArgs {
#[arg(long = "source-id", value_name = "ID")]
pub source_id: String,
#[arg(long = "target-id", value_name = "ID")]
pub target_id: String,
#[arg(long = "max-depth", value_name = "N")]
pub max_depth: Option<u32>,
#[arg(long = "max-results", value_name = "N")]
pub max_results: Option<u32>,
#[arg(long = "include-invalidated")]
pub include_invalidated: bool,
#[arg(long)]
pub json: bool,
}
pub fn cmd_find_paths(
db_path: &std::path::Path,
args: &FindPathsArgs,
out: &mut CliOutput<'_>,
) -> Result<()> {
let conn = db::open(db_path)?;
let mut params = json!({
"source_id": args.source_id,
"target_id": args.target_id,
});
if let Some(d) = args.max_depth {
params["max_depth"] = json!(d);
}
if let Some(m) = args.max_results {
params["max_results"] = json!(m);
}
if args.include_invalidated {
params[crate::models::field_names::INCLUDE_INVALIDATED] = json!(true);
}
let envelope = crate::mcp::handle_find_paths(&conn, ¶ms)
.map_err(|e| anyhow::anyhow!("find-paths: {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, "find-paths: {count} path(s)")?;
if let Some(arr) = envelope.get("paths").and_then(Value::as_array) {
for (idx, path) in arr.iter().enumerate() {
if let Some(ids) = path.as_array() {
let chain: Vec<&str> = ids.iter().filter_map(Value::as_str).collect();
writeln!(out.stdout, " [{}] {}", idx + 1, chain.join(" -> "))?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_utils::{TestEnv, seed_memory};
#[test]
fn find_paths_cli_empty_db_returns_zero() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let a = seed_memory(&db, "ns", "fp-a", "alpha");
let b = seed_memory(&db, "ns", "fp-b", "beta");
let args = FindPathsArgs {
source_id: a,
target_id: b,
max_depth: None,
max_results: None,
include_invalidated: false,
json: true,
};
{
let mut out = env.output();
cmd_find_paths(&db, &args, &mut out).expect("find-paths 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 find_paths_cli_invalid_id_returns_err() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = FindPathsArgs {
source_id: "bogus id with spaces".to_string(),
target_id: "another bogus".to_string(),
max_depth: None,
max_results: None,
include_invalidated: false,
json: true,
};
let mut out = env.output();
let err = cmd_find_paths(&db, &args, &mut out).expect_err("must fail");
assert!(err.to_string().contains("find-paths"), "got: {err}");
}
#[test]
fn find_paths_cli_text_output_with_path_and_all_params() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let a = seed_memory(&db, "ns", "fp-src", "alpha");
let b = seed_memory(&db, "ns", "fp-tgt", "beta");
{
let conn = db::open(&db).unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from)
VALUES (?1, ?2, 'related_to', ?3, ?3)",
rusqlite::params![a, b, now],
)
.expect("insert link");
}
let args = FindPathsArgs {
source_id: a.clone(),
target_id: b.clone(),
max_depth: Some(4),
max_results: Some(10),
include_invalidated: true,
json: false,
};
{
let mut out = env.output();
cmd_find_paths(&db, &args, &mut out).expect("find-paths ok");
}
let stdout = env.stdout_str();
assert!(stdout.contains("path(s)"), "got: {stdout}");
assert!(stdout.contains("->"), "got: {stdout}");
}
}