cellstate/commands/
inspect.rs

1//! `cellstate inspect` — Live memory state viewer.
2//!
3//! Polls the CELLSTATE API and prints a live-updating terminal view of
4//! active memories, recent events, and scoring weights.
5//!
6//! For full streaming TUI: see `cellstate watch` (P1 roadmap item).
7
8use crate::http;
9use crate::ui;
10use console::style;
11use reqwest::blocking::Client;
12use serde::Deserialize;
13use std::env;
14use std::time::Duration;
15
16// ============================================================================
17// API response types (minimal — we only need what inspect shows)
18// ============================================================================
19
20#[derive(Debug, Deserialize)]
21struct NoteListResponse {
22    notes: Vec<NoteItem>,
23    #[serde(default)]
24    total: i32,
25}
26
27#[derive(Debug, Deserialize)]
28struct NoteItem {
29    note_id: String,
30    title: String,
31    content: String,
32    #[serde(default)]
33    abstraction_level: Option<String>,
34    created_at: String,
35    #[serde(default)]
36    receipt_hash: Option<String>,
37}
38
39#[derive(Debug, Deserialize)]
40struct HealthResponse {
41    status: String,
42    #[serde(default)]
43    version: Option<String>,
44}
45
46// ============================================================================
47// Command
48// ============================================================================
49
50#[derive(Debug, clap::Args)]
51pub struct InspectArgs {
52    /// Base URL of the CELLSTATE API
53    #[arg(
54        long,
55        env = "CELLSTATE_BASE_URL",
56        default_value = http::DEFAULT_BASE_URL,
57    )]
58    pub base_url: String,
59
60    /// API key for authentication
61    #[arg(long, env = "CELLSTATE_API_KEY")]
62    pub api_key: Option<String>,
63
64    /// Refresh interval in seconds (0 = single snapshot)
65    #[arg(long, short = 'i', default_value = "0")]
66    pub interval: u64,
67
68    /// Max memories to display
69    #[arg(long, default_value = "20")]
70    pub limit: u32,
71}
72
73pub fn run(args: &InspectArgs) -> anyhow::Result<()> {
74    let api_key = args
75        .api_key
76        .clone()
77        .or_else(|| env::var("CELLSTATE_API_KEY").ok());
78
79    let client = http::default_http_client(10)?;
80
81    if args.interval == 0 {
82        // Single snapshot
83        print_snapshot(&client, args, api_key.as_deref())?;
84    } else {
85        // Repeated refresh
86        loop {
87            print_snapshot(&client, args, api_key.as_deref())?;
88            println!();
89            println!(
90                "{}",
91                style(format!(
92                    "Refreshing every {}s — Ctrl+C to stop",
93                    args.interval
94                ))
95                .dim()
96            );
97            std::thread::sleep(Duration::from_secs(args.interval));
98            // Move cursor up to overwrite on next iteration (best-effort)
99            print!("\x1B[2J\x1B[H");
100        }
101    }
102
103    Ok(())
104}
105
106fn print_snapshot(
107    client: &Client,
108    args: &InspectArgs,
109    api_key: Option<&str>,
110) -> anyhow::Result<()> {
111    ui::print_banner();
112
113    // Health check
114    let health_url = format!("{}/health/live", args.base_url);
115    match client.get(&health_url).send() {
116        Ok(r) if r.status().is_success() => {
117            let h: HealthResponse = r.json().unwrap_or_else(|e| {
118                eprintln!("Failed to parse health response: {e}");
119                HealthResponse {
120                    status: "unknown".into(),
121                    version: None,
122                }
123            });
124            let version_str = h.version.as_deref().unwrap_or("unknown");
125            let status_badge = match h.status.as_str() {
126                "ok" | "live" => style(&h.status).green(),
127                "degraded" => style(&h.status).yellow(),
128                _ => style(&h.status).red(),
129            };
130            ui::kv_ok(
131                "Server",
132                &format!("{} (v{}) — {}", args.base_url, version_str, status_badge),
133            );
134        }
135        _ => {
136            ui::fail(&format!("Cannot reach CELLSTATE at {}", args.base_url));
137            ui::info("Is CELLSTATE running? Try: cellstate start");
138            return Ok(());
139        }
140    }
141
142    // Fetch recent memories
143    ui::section("Recent Memories");
144
145    let notes_url = format!("{}/api/v1/notes?limit={}", args.base_url, args.limit);
146    let mut req = client.get(&notes_url);
147    if let Some(key) = api_key {
148        req = req.header("X-Api-Key", key);
149    }
150
151    match req.send() {
152        Ok(r) if r.status().is_success() => {
153            let resp: NoteListResponse = r.json()?;
154            let total = if resp.total > 0 {
155                resp.total as usize
156            } else {
157                resp.notes.len()
158            };
159
160            println!(
161                "  Showing {} of {} memories",
162                style(resp.notes.len()).cyan(),
163                style(total).cyan()
164            );
165            println!();
166
167            for note in &resp.notes {
168                let level = note.abstraction_level.as_deref().unwrap_or("Raw");
169                let level_badge = match level {
170                    "Summary" | "L1" => style("L1").yellow(),
171                    "Principle" | "L2" => style("L2").magenta(),
172                    _ => style("L0").cyan(),
173                };
174
175                // Title is the primary identifier; content is the detail
176                print!("  {} ", level_badge);
177                println!("{}", style(&note.title).bold());
178
179                let content_preview = if note.content.len() > 100 {
180                    format!("{}…", &note.content[..100])
181                } else {
182                    note.content.clone()
183                };
184                println!("     {}", style(&content_preview).dim());
185
186                let short_id = &note.note_id[..8.min(note.note_id.len())];
187                print!("     {} {}", style("id:").dim(), style(short_id).dim());
188
189                if let Some(receipt) = &note.receipt_hash {
190                    let short_receipt = &receipt[..12.min(receipt.len())];
191                    print!(
192                        "  {} {}",
193                        style("receipt:").dim(),
194                        style(short_receipt).green()
195                    );
196                }
197
198                let ts = note
199                    .created_at
200                    .split('T')
201                    .next()
202                    .unwrap_or(&note.created_at);
203                println!("  {} {}", style("created:").dim(), style(ts).dim());
204                println!();
205            }
206
207            if resp.notes.is_empty() {
208                println!("  {}", style("No memories yet.").dim().italic());
209                println!(
210                    "  {}",
211                    style("Try: memory.add('User prefers dark mode')").dim()
212                );
213                println!();
214            }
215        }
216        Ok(r) if r.status() == 401 => {
217            ui::warn("Unauthorized. Set CELLSTATE_API_KEY or pass --api-key.");
218        }
219        Ok(r) => {
220            ui::warn(&format!("API returned {}", r.status()));
221        }
222        Err(e) => {
223            ui::fail(&format!("Request failed: {e}"));
224        }
225    }
226
227    // Quick tips
228    println!(
229        "{} {} {}",
230        style("Tip:").dim(),
231        style("cellstate inspect --interval 5").cyan(),
232        style("for live updates").dim()
233    );
234    println!(
235        "{} {} {}",
236        style("Tip:").dim(),
237        style("cellstate watch").cyan(),
238        style("for full streaming TUI (coming soon)").dim()
239    );
240    println!();
241
242    Ok(())
243}