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 KgQueryArgs {
#[arg(long = "source-id", value_name = "ID")]
pub source_id: Option<String>,
#[arg(long = "by-source-uri", value_name = "URI")]
pub by_source_uri: Option<String>,
#[arg(long = "max-depth", value_name = "N")]
pub max_depth: Option<u32>,
#[arg(long, value_name = "NS")]
pub namespace: Option<String>,
#[arg(long = "valid-at", value_name = "RFC3339")]
pub valid_at: Option<String>,
#[arg(long = "allowed-agents", value_name = "CSV")]
pub allowed_agents: Option<String>,
#[arg(long, value_name = "N")]
pub limit: Option<u32>,
#[arg(long = "include-invalidated")]
pub include_invalidated: bool,
#[arg(long)]
pub json: bool,
}
pub fn cmd_kg_query(
db_path: &std::path::Path,
args: &KgQueryArgs,
out: &mut CliOutput<'_>,
) -> Result<()> {
if args.source_id.is_none() && args.by_source_uri.is_none() {
anyhow::bail!("kg-query: either --source-id or --by-source-uri is required");
}
let conn = db::open(db_path)?;
let mut params = json!({});
if let Some(sid) = &args.source_id {
params["source_id"] = json!(sid);
}
if let Some(uri) = &args.by_source_uri {
params[crate::models::field_names::BY_SOURCE_URI] = json!(uri);
}
if let Some(d) = args.max_depth {
params["max_depth"] = json!(d);
}
if let Some(ns) = &args.namespace {
params["namespace"] = json!(ns);
}
if let Some(t) = &args.valid_at {
params["valid_at"] = json!(t);
}
if let Some(csv) = &args.allowed_agents {
let agents: Vec<&str> = csv
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
params["allowed_agents"] = json!(agents);
}
if let Some(l) = args.limit {
params["limit"] = json!(l);
}
if args.include_invalidated {
params[crate::models::field_names::INCLUDE_INVALIDATED] = json!(true);
}
let envelope = crate::mcp::handle_kg_query(&conn, ¶ms)
.map_err(|e| anyhow::anyhow!("kg-query: {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-query: {count} row(s)")?;
if let Some(arr) = envelope.get("memories").and_then(Value::as_array) {
for m in arr {
let target = m.get("target_id").and_then(Value::as_str).unwrap_or("?");
let title = m.get("title").and_then(Value::as_str).unwrap_or("");
let depth = m.get("depth").and_then(Value::as_u64).unwrap_or(0);
let relation = m.get("relation").and_then(Value::as_str).unwrap_or("");
writeln!(out.stdout, " [d={depth}] {target} {relation} {title}",)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_utils::{TestEnv, seed_memory};
#[test]
fn kg_query_cli_requires_source_or_uri() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = KgQueryArgs {
source_id: None,
by_source_uri: None,
max_depth: None,
namespace: None,
valid_at: None,
allowed_agents: None,
limit: None,
include_invalidated: false,
json: true,
};
let mut out = env.output();
let err = cmd_kg_query(&db, &args, &mut out).expect_err("must fail");
assert!(err.to_string().contains("source-id"), "got: {err}");
}
#[test]
fn kg_query_cli_empty_db_returns_zero_rows() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let src_id = seed_memory(&db, "ns", "kg-source", "content");
let args = KgQueryArgs {
source_id: Some(src_id),
by_source_uri: None,
max_depth: None,
namespace: None,
valid_at: None,
allowed_agents: None,
limit: None,
include_invalidated: false,
json: true,
};
{
let mut out = env.output();
cmd_kg_query(&db, &args, &mut out).expect("kg-query 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_query_cli_text_output_with_rows_and_all_params() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let src = seed_memory(&db, "ns", "kg-src", "content");
let tgt = seed_memory(&db, "ns", "kg-tgt", "target content");
{
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![src, tgt, now],
)
.expect("insert link");
}
let args = KgQueryArgs {
source_id: Some(src),
by_source_uri: None,
max_depth: Some(2),
namespace: Some("ns".into()),
valid_at: None,
allowed_agents: None,
limit: Some(100),
include_invalidated: true,
json: false,
};
{
let mut out = env.output();
cmd_kg_query(&db, &args, &mut out).expect("kg-query ok");
}
let stdout = env.stdout_str();
assert!(stdout.contains("row(s)"), "got: {stdout}");
assert!(stdout.contains("related_to"), "got: {stdout}");
assert!(stdout.contains("kg-tgt"), "got: {stdout}");
}
#[test]
fn kg_query_cli_by_source_uri_path() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
seed_memory(&db, "ns", "kg-uri", "content");
let args = KgQueryArgs {
source_id: None,
by_source_uri: Some("doc://nonexistent".into()),
max_depth: None,
namespace: None,
valid_at: Some(chrono::Utc::now().to_rfc3339()),
allowed_agents: Some("test-agent, , ai:other".into()),
limit: None,
include_invalidated: false,
json: true,
};
{
let mut out = env.output();
cmd_kg_query(&db, &args, &mut out).expect("kg-query ok");
}
let envelope: Value = serde_json::from_str(env.stdout_str().trim()).expect("json");
assert_eq!(envelope["count"].as_u64(), Some(0));
}
}