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