1use anyhow::{Context, Result};
10use clap::Args;
11use reqwest::blocking::Client;
12use reqwest::header::{HeaderMap, HeaderValue};
13use serde_json::json;
14use std::fs;
15use std::path::PathBuf;
16use std::time::Duration;
17
18use super::config::PersistentConfig;
19
20#[derive(Args)]
22pub struct StatusArgs {
23 #[arg(long)]
25 pub json: bool,
26
27 #[arg(short, long)]
29 pub dir: Option<PathBuf>,
30
31 #[arg(long)]
33 pub offline: bool,
34}
35
36#[derive(Debug, Default)]
38struct SubscriptionInfo {
39 status: String,
40 plan_name: String,
41 capacity_gb: f64,
42 renews_at: Option<String>,
43}
44
45#[derive(Debug)]
47struct LocalMemory {
48 path: PathBuf,
49 size_bytes: u64,
50 name: String,
51}
52
53pub fn handle_status(args: StatusArgs) -> Result<()> {
55 let config = PersistentConfig::load()?;
56 let config_path = PersistentConfig::config_path()?;
57
58 let env_api_key = std::env::var("MEMVID_API_KEY").ok();
60 let env_dashboard_url = std::env::var("MEMVID_DASHBOARD_URL")
61 .or_else(|_| std::env::var("MEMVID_API_URL"))
62 .ok();
63
64 let effective_api_key = env_api_key.clone().or(config.api_key.clone());
66 let effective_dashboard_url = env_dashboard_url
67 .clone()
68 .or(config.dashboard_url.clone())
69 .or(config.api_url.clone())
70 .unwrap_or_else(|| "https://memvid.com".to_string());
71
72 let has_api_key = effective_api_key.is_some();
74 let api_key_source = if env_api_key.is_some() {
75 "environment"
76 } else if config.api_key.is_some() {
77 "config file"
78 } else {
79 "not set"
80 };
81
82 let named_memories: Vec<(String, String)> = config
84 .memory
85 .iter()
86 .map(|(k, v)| (k.clone(), v.clone()))
87 .collect();
88
89 let groq_key = std::env::var("GROQ_API_KEY")
91 .ok()
92 .or_else(|| config.get("groq_api_key"));
93 let openai_key = std::env::var("OPENAI_API_KEY")
94 .ok()
95 .or_else(|| config.get("openai_api_key"));
96 let gemini_key = std::env::var("GEMINI_API_KEY")
97 .ok()
98 .or_else(|| config.get("gemini_api_key"));
99 let anthropic_key = std::env::var("ANTHROPIC_API_KEY")
100 .ok()
101 .or_else(|| config.get("anthropic_api_key"));
102
103 let subscription = if has_api_key && !args.offline {
105 fetch_subscription_info(
106 &effective_api_key.as_ref().unwrap(),
107 &effective_dashboard_url,
108 )
109 .ok()
110 } else {
111 None
112 };
113
114 let scan_dir = args.dir.clone().unwrap_or_else(|| PathBuf::from("."));
116 let local_memories = scan_local_memories(&scan_dir);
117 let total_size: u64 = local_memories.iter().map(|m| m.size_bytes).sum();
118
119 if args.json {
120 output_json(
121 &config_path,
122 has_api_key,
123 api_key_source,
124 &effective_api_key,
125 &effective_dashboard_url,
126 &named_memories,
127 &subscription,
128 &local_memories,
129 total_size,
130 groq_key.is_some(),
131 openai_key.is_some(),
132 gemini_key.is_some(),
133 anthropic_key.is_some(),
134 )?;
135 } else {
136 output_pretty(
137 &config_path,
138 has_api_key,
139 api_key_source,
140 &effective_api_key,
141 &effective_dashboard_url,
142 &named_memories,
143 &subscription,
144 &local_memories,
145 total_size,
146 groq_key.is_some(),
147 openai_key.is_some(),
148 gemini_key.is_some(),
149 anthropic_key.is_some(),
150 );
151 }
152
153 Ok(())
154}
155
156fn fetch_subscription_info(api_key: &str, dashboard_url: &str) -> Result<SubscriptionInfo> {
157 let url = format!("{}/api/ticket", dashboard_url.trim_end_matches('/'));
158
159 let mut headers = HeaderMap::new();
160 headers.insert(
161 "x-api-key",
162 HeaderValue::from_str(api_key).context("Invalid API key format")?,
163 );
164
165 let client = Client::builder()
166 .timeout(Duration::from_secs(5))
167 .build()
168 .context("Failed to create HTTP client")?;
169
170 let response = client
171 .get(&url)
172 .headers(headers)
173 .send()
174 .context("Failed to fetch subscription info")?;
175
176 let body: serde_json::Value = response.json().context("Failed to parse response")?;
177
178 let data = body.get("data").unwrap_or(&body);
179 let ticket = data.get("ticket").unwrap_or(data);
180 let subscription = data.get("subscription");
181
182 let capacity_bytes = ticket
183 .get("capacity_bytes")
184 .and_then(|v| v.as_u64())
185 .unwrap_or(1_073_741_824); let capacity_gb = capacity_bytes as f64 / 1_073_741_824.0;
188
189 let status = subscription
190 .and_then(|s| s.get("status"))
191 .and_then(|v| v.as_str())
192 .unwrap_or("active")
193 .to_string();
194
195 let plan_name = ticket
196 .get("issuer")
197 .and_then(|v| v.as_str())
198 .unwrap_or("Free")
199 .to_string();
200
201 let renews_at = subscription
202 .and_then(|s| s.get("planEndDate").or_else(|| s.get("ends_at")))
203 .and_then(|v| v.as_str())
204 .map(|s| s.to_string());
205
206 Ok(SubscriptionInfo {
207 status,
208 plan_name,
209 capacity_gb,
210 renews_at,
211 })
212}
213
214fn scan_local_memories(dir: &PathBuf) -> Vec<LocalMemory> {
215 let mut memories = Vec::new();
216
217 if let Ok(entries) = fs::read_dir(dir) {
218 for entry in entries.filter_map(|e| e.ok()) {
219 let path = entry.path();
220 if path.extension().map_or(false, |ext| ext == "mv2") {
221 if let Ok(metadata) = fs::metadata(&path) {
222 let name = path
223 .file_name()
224 .map(|n| n.to_string_lossy().to_string())
225 .unwrap_or_default();
226 memories.push(LocalMemory {
227 path,
228 size_bytes: metadata.len(),
229 name,
230 });
231 }
232 }
233 }
234 }
235
236 memories.sort_by(|a, b| a.name.cmp(&b.name));
238 memories
239}
240
241fn format_bytes(bytes: u64) -> String {
242 if bytes >= 1_073_741_824 {
243 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
244 } else if bytes >= 1_048_576 {
245 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
246 } else if bytes >= 1024 {
247 format!("{:.1} KB", bytes as f64 / 1024.0)
248 } else {
249 format!("{} bytes", bytes)
250 }
251}
252
253#[allow(clippy::too_many_arguments)]
254fn output_pretty(
255 config_path: &PathBuf,
256 has_api_key: bool,
257 api_key_source: &str,
258 effective_api_key: &Option<String>,
259 dashboard_url: &str,
260 named_memories: &[(String, String)],
261 subscription: &Option<SubscriptionInfo>,
262 local_memories: &[LocalMemory],
263 total_size: u64,
264 has_groq: bool,
265 has_openai: bool,
266 has_gemini: bool,
267 has_anthropic: bool,
268) {
269 println!();
270 println!("Memvid Status");
271 println!("{}", "━".repeat(50));
272 println!();
273
274 if has_api_key {
276 let key = effective_api_key.as_ref().unwrap();
277 let masked = PersistentConfig::mask_value(key);
278 println!("✓ API Key: configured ({})", masked);
279 println!(" Source: {}", api_key_source);
280 } else {
281 println!("✗ API Key: not configured");
282 println!(" Fix: memvid config set api_key <your-key>");
283 }
284
285 if let Some(sub) = subscription {
287 println!();
288 println!("✓ Plan: {} ({:.1} GB)", sub.plan_name, sub.capacity_gb);
289 println!("✓ Subscription: {}", sub.status);
290 if let Some(renews) = &sub.renews_at {
291 println!(" Renews: {}", renews);
292 }
293 } else if has_api_key {
294 println!();
295 println!("⚠ Plan: Could not fetch (use --offline to skip)");
296 }
297
298 println!();
300 println!("Dashboard: {}", dashboard_url);
301
302 if !named_memories.is_empty() {
304 println!();
305 println!("Named Memories:");
306 for (name, id) in named_memories {
307 let short_id = if id.len() > 12 {
308 format!("{}...", &id[..12])
309 } else {
310 id.clone()
311 };
312 println!(" {} → {}", name, short_id);
313 }
314 }
315
316 println!();
318 println!("LLM Providers:");
319 print_key_status(" Groq", has_groq);
320 print_key_status(" OpenAI", has_openai);
321 print_key_status(" Gemini", has_gemini);
322 print_key_status(" Anthropic", has_anthropic);
323
324 println!();
326 if local_memories.is_empty() {
327 println!("Local Memories: None found in current directory");
328 } else {
329 println!(
330 "Local Memories: {} files ({})",
331 local_memories.len(),
332 format_bytes(total_size)
333 );
334 for mem in local_memories.iter().take(5) {
335 println!(" {} ({})", mem.name, format_bytes(mem.size_bytes));
336 }
337 if local_memories.len() > 5 {
338 println!(" ... and {} more", local_memories.len() - 5);
339 }
340 }
341
342 println!();
344 println!("Config: {}", config_path.display());
345 println!();
346}
347
348fn print_key_status(name: &str, configured: bool) {
349 if configured {
350 println!("{}: ✓ configured", name);
351 } else {
352 println!("{}: ✗ not set", name);
353 }
354}
355
356#[allow(clippy::too_many_arguments)]
357fn output_json(
358 config_path: &PathBuf,
359 has_api_key: bool,
360 api_key_source: &str,
361 effective_api_key: &Option<String>,
362 dashboard_url: &str,
363 named_memories: &[(String, String)],
364 subscription: &Option<SubscriptionInfo>,
365 local_memories: &[LocalMemory],
366 total_size: u64,
367 has_groq: bool,
368 has_openai: bool,
369 has_gemini: bool,
370 has_anthropic: bool,
371) -> Result<()> {
372 let memories_json: Vec<serde_json::Value> = local_memories
373 .iter()
374 .map(|m| {
375 json!({
376 "name": m.name,
377 "path": m.path.display().to_string(),
378 "size_bytes": m.size_bytes,
379 })
380 })
381 .collect();
382
383 let named_memories_json: serde_json::Map<String, serde_json::Value> = named_memories
384 .iter()
385 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
386 .collect();
387
388 let output = json!({
389 "config_path": config_path.display().to_string(),
390 "api_key": {
391 "configured": has_api_key,
392 "source": api_key_source,
393 "value": effective_api_key.as_ref().map(|k| PersistentConfig::mask_value(k)),
394 },
395 "dashboard_url": dashboard_url,
396 "subscription": subscription.as_ref().map(|s| json!({
397 "status": s.status,
398 "plan": s.plan_name,
399 "capacity_gb": s.capacity_gb,
400 "renews_at": s.renews_at,
401 })),
402 "named_memories": named_memories_json,
403 "llm_providers": {
404 "groq": has_groq,
405 "openai": has_openai,
406 "gemini": has_gemini,
407 "anthropic": has_anthropic,
408 },
409 "local_memories": {
410 "count": local_memories.len(),
411 "total_bytes": total_size,
412 "files": memories_json,
413 },
414 });
415
416 println!("{}", serde_json::to_string_pretty(&output)?);
417 Ok(())
418}