1use crate::config::Config;
10use crate::gateway::kumiho_client::{
11 ItemResponse, KumihoClient, KumihoError, RevisionResponse, build_client_from_config, slugify,
12};
13use anyhow::{Result, bail};
14use std::collections::HashMap;
15use std::io::{self, Write};
16
17pub const CLI_SPACE_NAME: &str = "Memory";
20const MEMORY_ITEM_KIND: &str = "memory";
21const CONTENT_PREVIEW_LEN: usize = 120;
22
23pub async fn handle_command(command: crate::MemoryCommands, config: &Config) -> Result<()> {
25 let ctx = CliContext::from_config(config);
26
27 match command {
28 crate::MemoryCommands::List {
29 category,
30 session,
31 limit,
32 offset,
33 } => list_entries(&ctx, category.as_deref(), session.as_deref(), limit, offset).await,
34 crate::MemoryCommands::Get { key } => get_entry(&ctx, &key).await,
35 crate::MemoryCommands::Stats => show_stats(&ctx).await,
36 crate::MemoryCommands::Clear { key, category, yes } => {
37 clear_entries(&ctx, key.as_deref(), category.as_deref(), yes).await
38 }
39 }
40}
41
42struct CliContext {
43 client: KumihoClient,
44 project: String,
45 api_url: String,
46}
47
48impl CliContext {
49 fn from_config(config: &Config) -> Self {
50 Self {
51 client: build_client_from_config(config),
52 project: config.kumiho.memory_project.clone(),
53 api_url: config.kumiho.api_url.clone(),
54 }
55 }
56
57 fn space_path(&self) -> String {
58 format!("/{}/{CLI_SPACE_NAME}", self.project)
59 }
60
61 async fn ensure_space(&self) -> Result<()> {
62 self.client
63 .ensure_project(&self.project)
64 .await
65 .map_err(|e| kumiho_err(e, "ensure project"))?;
66 self.client
67 .ensure_child_space(&self.project, &format!("/{}", self.project), CLI_SPACE_NAME)
68 .await
69 .map_err(|e| kumiho_err(e, "ensure space"))?;
70 Ok(())
71 }
72}
73
74async fn list_entries(
75 ctx: &CliContext,
76 category_filter: Option<&str>,
77 session_filter: Option<&str>,
78 limit: usize,
79 offset: usize,
80) -> Result<()> {
81 ctx.ensure_space().await?;
82 let space = ctx.space_path();
83
84 let fetch_limit =
86 u32::try_from(limit.saturating_add(offset).saturating_mul(2).max(50)).unwrap_or(u32::MAX);
87 let items = ctx
88 .client
89 .list_items_paged(&space, false, fetch_limit, 0)
90 .await
91 .map_err(|e| kumiho_err(e, "list items"))?;
92
93 if items.is_empty() {
94 println!("No memory entries in {space}.");
95 return Ok(());
96 }
97
98 let mut rows: Vec<(ItemResponse, RevisionResponse)> = Vec::with_capacity(items.len());
99 for item in items {
100 match ctx.client.get_latest_revision(&item.kref).await {
101 Ok(rev) => rows.push((item, rev)),
102 Err(KumihoError::Api { status: 404, .. }) => continue,
103 Err(e) => return Err(kumiho_err(e, "fetch revision")),
104 }
105 }
106
107 let filtered: Vec<_> = rows
108 .into_iter()
109 .filter(|(_, rev)| {
110 category_filter
111 .map(|c| rev.metadata.get("category").map(String::as_str) == Some(c))
112 .unwrap_or(true)
113 })
114 .filter(|(_, rev)| {
115 session_filter
116 .map(|s| rev.metadata.get("session_id").map(String::as_str) == Some(s))
117 .unwrap_or(true)
118 })
119 .skip(offset)
120 .take(limit)
121 .collect();
122
123 if filtered.is_empty() {
124 println!("No memory entries matched the given filters.");
125 return Ok(());
126 }
127
128 for (item, rev) in &filtered {
129 let key = rev
130 .metadata
131 .get("key")
132 .cloned()
133 .unwrap_or_else(|| item.item_name.clone());
134 let category = rev
135 .metadata
136 .get("category")
137 .map(String::as_str)
138 .unwrap_or("core");
139 let content = rev
140 .metadata
141 .get("content")
142 .map(String::as_str)
143 .unwrap_or("");
144 println!(
145 "{key}\t[{category}]\t{}",
146 truncate(content, CONTENT_PREVIEW_LEN)
147 );
148 }
149
150 println!();
151 println!(
152 "{} entr{} shown.",
153 filtered.len(),
154 if filtered.len() == 1 { "y" } else { "ies" }
155 );
156 Ok(())
157}
158
159async fn get_entry(ctx: &CliContext, key: &str) -> Result<()> {
160 ctx.ensure_space().await?;
161 let space = ctx.space_path();
162 let slug = slugify(key);
163 if slug.is_empty() {
164 bail!("Key '{key}' could not be slugified to a valid identifier");
165 }
166
167 let items = ctx
168 .client
169 .list_items_filtered(&space, &slug, false)
170 .await
171 .map_err(|e| kumiho_err(e, "search for entry"))?;
172
173 let item = items
174 .into_iter()
175 .find(|i| i.item_name == slug)
176 .ok_or_else(|| anyhow::anyhow!("No memory entry found for key '{key}' (slug: {slug})"))?;
177
178 let rev = ctx
179 .client
180 .get_latest_revision(&item.kref)
181 .await
182 .map_err(|e| kumiho_err(e, "fetch revision"))?;
183
184 let content = rev
185 .metadata
186 .get("content")
187 .map(String::as_str)
188 .unwrap_or("");
189 let category = rev
190 .metadata
191 .get("category")
192 .map(String::as_str)
193 .unwrap_or("core");
194 let original_key = rev
195 .metadata
196 .get("key")
197 .cloned()
198 .unwrap_or_else(|| slug.clone());
199
200 println!("Key: {original_key}");
201 println!("Slug: {slug}");
202 println!("Category: {category}");
203 if let Some(session) = rev.metadata.get("session_id") {
204 println!("Session: {session}");
205 }
206 if let Some(origin) = rev.metadata.get("migrated_from") {
207 println!("Origin: {origin}");
208 }
209 if let Some(imported) = rev.metadata.get("imported_at") {
210 println!("Imported: {imported}");
211 }
212 if let Some(created) = rev.created_at.as_deref() {
213 println!("Created: {created}");
214 }
215 println!("Kref: {}", item.kref);
216 println!();
217 println!("{content}");
218
219 Ok(())
220}
221
222async fn show_stats(ctx: &CliContext) -> Result<()> {
223 println!("Kumiho endpoint: {}", ctx.api_url);
224 println!("Memory project: {}", ctx.project);
225
226 let root = format!("/{}", ctx.project);
227 let spaces = match ctx.client.list_spaces(&root, true).await {
228 Ok(spaces) => spaces,
229 Err(KumihoError::Unreachable(err)) => {
230 bail!(
231 "Kumiho service unreachable at {}: {err}. \
232 Check that Kumiho is running and that `kumiho.api_url` points to it.",
233 ctx.api_url
234 );
235 }
236 Err(e) => return Err(kumiho_err(e, "list spaces")),
237 };
238
239 let mut total_items = 0usize;
240 let mut rows: Vec<(String, usize)> = Vec::new();
241
242 let mut space_paths: Vec<String> = vec![root.clone()];
244 space_paths.extend(spaces.into_iter().map(|s| s.path));
245
246 for path in &space_paths {
247 let count = ctx
248 .client
249 .list_items_paged(path, false, 500, 0)
250 .await
251 .map(|items| items.len())
252 .unwrap_or(0);
253 total_items += count;
254 rows.push((path.clone(), count));
255 }
256
257 println!("Spaces: {}", space_paths.len());
258 println!("Total items: {}", total_items);
259 println!();
260 println!("Per-space counts:");
261 for (path, count) in &rows {
262 println!(" {path:<40} {count}");
263 }
264
265 Ok(())
266}
267
268async fn clear_entries(
269 ctx: &CliContext,
270 key_filter: Option<&str>,
271 category_filter: Option<&str>,
272 skip_confirm: bool,
273) -> Result<()> {
274 ctx.ensure_space().await?;
275 let space = ctx.space_path();
276
277 let items = ctx
279 .client
280 .list_items_paged(&space, false, 500, 0)
281 .await
282 .map_err(|e| kumiho_err(e, "list items"))?;
283
284 let mut targets: Vec<ItemResponse> = Vec::new();
285 for item in items {
286 if let Some(prefix) = key_filter {
287 let slug_prefix = slugify(prefix);
288 if !item.item_name.starts_with(&slug_prefix) {
289 continue;
290 }
291 }
292 if let Some(cat) = category_filter {
293 let rev = ctx.client.get_latest_revision(&item.kref).await.ok();
294 let matches = rev
295 .as_ref()
296 .and_then(|r| r.metadata.get("category"))
297 .map(String::as_str)
298 == Some(cat);
299 if !matches {
300 continue;
301 }
302 }
303 targets.push(item);
304 }
305
306 if targets.is_empty() {
307 println!("No entries matched the given filter — nothing to clear.");
308 return Ok(());
309 }
310
311 if !skip_confirm {
312 print!(
313 "About to delete {} entr{} from {space}. Continue? [y/N] ",
314 targets.len(),
315 if targets.len() == 1 { "y" } else { "ies" }
316 );
317 io::stdout().flush().ok();
318 let mut input = String::new();
319 io::stdin().read_line(&mut input)?;
320 if !matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
321 println!("Cancelled.");
322 return Ok(());
323 }
324 }
325
326 let mut deleted = 0usize;
327 for item in targets {
328 match ctx.client.delete_item(&item.kref).await {
329 Ok(()) => deleted += 1,
330 Err(e) => {
331 eprintln!("Failed to delete {}: {}", item.item_name, e);
332 }
333 }
334 }
335
336 println!(
337 "Deleted {deleted} entr{}.",
338 if deleted == 1 { "y" } else { "ies" }
339 );
340 Ok(())
341}
342
343fn truncate(s: &str, max_chars: usize) -> String {
344 let single_line: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
345 if single_line.chars().count() <= max_chars {
346 single_line
347 } else {
348 let truncated: String = single_line.chars().take(max_chars).collect();
349 format!("{truncated}…")
350 }
351}
352
353fn kumiho_err(e: KumihoError, action: &'static str) -> anyhow::Error {
354 match e {
355 KumihoError::Unreachable(err) => anyhow::anyhow!(
356 "Kumiho service unreachable while attempting to {action}: {err}. \
357 Check that Kumiho is running and that `kumiho.api_url` points to it."
358 ),
359 KumihoError::Api { status, body } => {
360 anyhow::anyhow!("Kumiho returned {status} while attempting to {action}: {body}")
361 }
362 KumihoError::Decode(msg) => anyhow::anyhow!(
363 "Kumiho returned an unexpected response while attempting to {action}: {msg}"
364 ),
365 }
366}
367
368#[allow(dead_code)]
374pub fn cli_revision_metadata(
375 key: &str,
376 content: &str,
377 category: &str,
378 session_id: Option<&str>,
379) -> HashMap<String, String> {
380 let mut meta = HashMap::new();
381 meta.insert("key".into(), key.to_string());
382 meta.insert("content".into(), content.to_string());
383 meta.insert("category".into(), category.to_string());
384 if let Some(session) = session_id {
385 meta.insert("session_id".into(), session.to_string());
386 }
387 meta.insert("kind".into(), MEMORY_ITEM_KIND.to_string());
388 meta
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn truncate_shortens_long_strings() {
397 let long = "a".repeat(200);
398 let t = truncate(&long, 50);
399 assert_eq!(t.chars().count(), 51); assert!(t.ends_with('…'));
401 }
402
403 #[test]
404 fn truncate_passes_through_short_strings() {
405 let t = truncate("short", 50);
406 assert_eq!(t, "short");
407 }
408
409 #[test]
410 fn truncate_collapses_whitespace() {
411 let t = truncate("a b\nc\td", 50);
412 assert_eq!(t, "a b c d");
413 }
414
415 #[test]
416 fn cli_revision_metadata_contains_expected_keys() {
417 let meta = cli_revision_metadata("my_key", "hello", "core", Some("sess-1"));
418 assert_eq!(meta.get("key").map(String::as_str), Some("my_key"));
419 assert_eq!(meta.get("content").map(String::as_str), Some("hello"));
420 assert_eq!(meta.get("category").map(String::as_str), Some("core"));
421 assert_eq!(meta.get("session_id").map(String::as_str), Some("sess-1"));
422 assert_eq!(meta.get("kind").map(String::as_str), Some(MEMORY_ITEM_KIND));
423 }
424
425 #[test]
426 fn cli_revision_metadata_omits_session_when_absent() {
427 let meta = cli_revision_metadata("k", "v", "daily", None);
428 assert!(!meta.contains_key("session_id"));
429 }
430}