1use anyhow::Result;
20use clap::Args;
21use serde_json::{Value, json};
22
23use crate::cli::CliOutput;
24use crate::storage as db;
25
26#[derive(Args, Debug, Clone)]
29pub struct KgQueryArgs {
30 #[arg(long = "source-id", value_name = "ID")]
32 pub source_id: Option<String>,
33
34 #[arg(long = "by-source-uri", value_name = "URI")]
37 pub by_source_uri: Option<String>,
38
39 #[arg(long = "max-depth", value_name = "N")]
41 pub max_depth: Option<u32>,
42
43 #[arg(long, value_name = "NS")]
45 pub namespace: Option<String>,
46
47 #[arg(long = "valid-at", value_name = "RFC3339")]
49 pub valid_at: Option<String>,
50
51 #[arg(long = "allowed-agents", value_name = "CSV")]
53 pub allowed_agents: Option<String>,
54
55 #[arg(long, value_name = "N")]
57 pub limit: Option<u32>,
58
59 #[arg(long = "include-invalidated")]
61 pub include_invalidated: bool,
62
63 #[arg(long)]
66 pub json: bool,
67}
68
69pub fn cmd_kg_query(
81 db_path: &std::path::Path,
82 args: &KgQueryArgs,
83 out: &mut CliOutput<'_>,
84) -> Result<()> {
85 if args.source_id.is_none() && args.by_source_uri.is_none() {
86 anyhow::bail!("kg-query: either --source-id or --by-source-uri is required");
87 }
88 let conn = db::open(db_path)?;
89
90 let mut params = json!({});
91 if let Some(sid) = &args.source_id {
92 params["source_id"] = json!(sid);
93 }
94 if let Some(uri) = &args.by_source_uri {
95 params[crate::models::field_names::BY_SOURCE_URI] = json!(uri);
96 }
97 if let Some(d) = args.max_depth {
98 params["max_depth"] = json!(d);
99 }
100 if let Some(ns) = &args.namespace {
101 params["namespace"] = json!(ns);
102 }
103 if let Some(t) = &args.valid_at {
104 params["valid_at"] = json!(t);
105 }
106 if let Some(csv) = &args.allowed_agents {
107 let agents: Vec<&str> = csv
108 .split(',')
109 .map(str::trim)
110 .filter(|s| !s.is_empty())
111 .collect();
112 params["allowed_agents"] = json!(agents);
113 }
114 if let Some(l) = args.limit {
115 params["limit"] = json!(l);
116 }
117 if args.include_invalidated {
118 params[crate::models::field_names::INCLUDE_INVALIDATED] = json!(true);
119 }
120
121 let envelope = crate::mcp::handle_kg_query(&conn, ¶ms)
122 .map_err(|e| anyhow::anyhow!("kg-query: {e}"))?;
123
124 if args.json {
125 writeln!(out.stdout, "{}", serde_json::to_string(&envelope)?)?;
126 return Ok(());
127 }
128
129 let count = envelope.get("count").and_then(Value::as_u64).unwrap_or(0);
131 writeln!(out.stdout, "kg-query: {count} row(s)")?;
132 if let Some(arr) = envelope.get("memories").and_then(Value::as_array) {
133 for m in arr {
134 let target = m.get("target_id").and_then(Value::as_str).unwrap_or("?");
135 let title = m.get("title").and_then(Value::as_str).unwrap_or("");
136 let depth = m.get("depth").and_then(Value::as_u64).unwrap_or(0);
137 let relation = m.get("relation").and_then(Value::as_str).unwrap_or("");
138 writeln!(out.stdout, " [d={depth}] {target} {relation} {title}",)?;
139 }
140 }
141 Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::cli::test_utils::{TestEnv, seed_memory};
148
149 #[test]
150 fn kg_query_cli_requires_source_or_uri() {
151 let mut env = TestEnv::fresh();
152 let db = env.db_path.clone();
153 let args = KgQueryArgs {
154 source_id: None,
155 by_source_uri: None,
156 max_depth: None,
157 namespace: None,
158 valid_at: None,
159 allowed_agents: None,
160 limit: None,
161 include_invalidated: false,
162 json: true,
163 };
164 let mut out = env.output();
165 let err = cmd_kg_query(&db, &args, &mut out).expect_err("must fail");
166 assert!(err.to_string().contains("source-id"), "got: {err}");
167 }
168
169 #[test]
170 fn kg_query_cli_empty_db_returns_zero_rows() {
171 let mut env = TestEnv::fresh();
172 let db = env.db_path.clone();
173 let src_id = seed_memory(&db, "ns", "kg-source", "content");
174 let args = KgQueryArgs {
175 source_id: Some(src_id),
176 by_source_uri: None,
177 max_depth: None,
178 namespace: None,
179 valid_at: None,
180 allowed_agents: None,
181 limit: None,
182 include_invalidated: false,
183 json: true,
184 };
185 {
186 let mut out = env.output();
187 cmd_kg_query(&db, &args, &mut out).expect("kg-query ok");
188 }
189 let stdout = env.stdout_str();
190 let envelope: Value = serde_json::from_str(stdout.trim()).expect("parse envelope");
191 assert_eq!(envelope["count"].as_u64(), Some(0));
192 }
193
194 #[test]
198 fn kg_query_cli_text_output_with_rows_and_all_params() {
199 let mut env = TestEnv::fresh();
200 let db = env.db_path.clone();
201 let src = seed_memory(&db, "ns", "kg-src", "content");
202 let tgt = seed_memory(&db, "ns", "kg-tgt", "target content");
203 {
204 let conn = db::open(&db).unwrap();
205 let now = chrono::Utc::now().to_rfc3339();
206 conn.execute(
207 "INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from)
208 VALUES (?1, ?2, 'related_to', ?3, ?3)",
209 rusqlite::params![src, tgt, now],
210 )
211 .expect("insert link");
212 }
213 let args = KgQueryArgs {
214 source_id: Some(src),
215 by_source_uri: None,
216 max_depth: Some(2),
217 namespace: Some("ns".into()),
218 valid_at: None,
219 allowed_agents: None,
220 limit: Some(100),
221 include_invalidated: true,
222 json: false,
223 };
224 {
225 let mut out = env.output();
226 cmd_kg_query(&db, &args, &mut out).expect("kg-query ok");
227 }
228 let stdout = env.stdout_str();
229 assert!(stdout.contains("row(s)"), "got: {stdout}");
230 assert!(stdout.contains("related_to"), "got: {stdout}");
231 assert!(stdout.contains("kg-tgt"), "got: {stdout}");
232 }
233
234 #[test]
235 fn kg_query_cli_by_source_uri_path() {
236 let mut env = TestEnv::fresh();
237 let db = env.db_path.clone();
238 seed_memory(&db, "ns", "kg-uri", "content");
239 let args = KgQueryArgs {
240 source_id: None,
241 by_source_uri: Some("doc://nonexistent".into()),
242 max_depth: None,
243 namespace: None,
244 valid_at: Some(chrono::Utc::now().to_rfc3339()),
247 allowed_agents: Some("test-agent, , ai:other".into()),
248 limit: None,
249 include_invalidated: false,
250 json: true,
251 };
252 {
253 let mut out = env.output();
254 cmd_kg_query(&db, &args, &mut out).expect("kg-query ok");
255 }
256 let envelope: Value = serde_json::from_str(env.stdout_str().trim()).expect("json");
257 assert_eq!(envelope["count"].as_u64(), Some(0));
258 }
259}