memvid_cli/
utils.rs

1//! Utility functions for the Memvid CLI
2
3use std::path::Path;
4
5use anyhow::{bail, Result};
6use memvid_core::{error::LockOwnerHint, Memvid, Tier};
7use serde_json::json;
8
9use crate::config::CliConfig;
10use crate::org_ticket_cache;
11
12/// Free tier file size limit: 50 MB
13/// Files larger than this require a paid plan with API key authentication
14/// With --no-raw default, 50 MB fits ~700 small documents or ~4 large PDFs
15pub const FREE_TIER_MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; // 50 MB
16
17/// Minimum file size: 10 MB
18/// Ensures memory files have reasonable capacity for basic usage
19pub const MIN_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
20
21/// Check if the user's plan allows write/query operations
22///
23/// Returns Ok(()) if:
24/// - No API key (free tier user, not bound to dashboard)
25/// - Subscription is active, trialing, or past_due
26/// - Subscription is canceled but still in grace period (planEndDate > now)
27///
28/// Returns error if:
29/// - Subscription is canceled AND grace period has expired (planEndDate < now)
30///
31/// This enables strict access control: expired plans can only view data,
32/// not add new content or run queries.
33pub fn require_active_plan(config: &CliConfig, operation: &str) -> Result<()> {
34    // No API key = free tier user, not bound to dashboard - allow
35    if config.api_key.is_none() {
36        return Ok(());
37    }
38
39    // Get fresh org ticket for write operations
40    // This ensures we have up-to-date subscription status (max 5 min old)
41    // to prevent users from using CLI after cancellation
42    let ticket = match org_ticket_cache::get_fresh_for_writes(config) {
43        Some(t) => t,
44        None => return Ok(()), // No ticket = allow (free tier fallback)
45    };
46
47    // Active subscriptions are always allowed
48    let status = &ticket.subscription_status;
49    if status == "active" || status == "trialing" || status == "past_due" {
50        return Ok(());
51    }
52
53    // Canceled subscription - check if still in grace period
54    if status == "canceled" {
55        if ticket.is_in_grace_period() {
56            // Still in grace period - allow
57            return Ok(());
58        }
59
60        // Grace period expired - block write/query operations
61        bail!(
62            "Your subscription has expired.\n\n\
63             The '{}' operation requires an active subscription.\n\
64             Your plan ended on: {}\n\n\
65             You can still view your data with:\n\
66             • memvid stats <file>     - View file statistics\n\
67             • memvid timeline <file>  - View timeline\n\
68             • memvid view <file>      - View frames\n\n\
69             To restore full access, reactivate your subscription:\n\
70             https://memvid.com/dashboard/plan",
71            operation,
72            ticket.plan_end_date.as_deref().unwrap_or("unknown")
73        );
74    }
75
76    // Inactive or other status - allow (fallback to capacity-based)
77    Ok(())
78}
79
80/// Get the effective capacity limit for the current user
81///
82/// If an API key is configured and a valid org ticket exists, uses the
83/// ticket's capacity_bytes. Otherwise falls back to free tier limit.
84pub fn get_effective_capacity(config: &CliConfig) -> u64 {
85    if let Some(ticket) = org_ticket_cache::get_optional(config) {
86        ticket.capacity_bytes()
87    } else {
88        FREE_TIER_MAX_FILE_SIZE
89    }
90}
91
92/// Check if a file exceeds the free tier limit and require API key if so
93///
94/// Returns Ok(()) if:
95/// - File is under 1GB (no API key required)
96/// - File is over 1GB AND API key is configured with valid paid plan
97///
98/// Returns error if file is over 1GB and no API key/paid plan
99pub fn ensure_api_key_for_large_file(file_size: u64, config: &CliConfig) -> Result<()> {
100    if file_size <= FREE_TIER_MAX_FILE_SIZE {
101        return Ok(());
102    }
103
104    // File exceeds 1GB - require API key
105    if config.api_key.is_none() {
106        let size_str = format_bytes(file_size);
107        let limit_str = format_bytes(FREE_TIER_MAX_FILE_SIZE);
108        bail!(
109            "File size ({}) exceeds free tier limit ({}).\n\n\
110             To work with files larger than 1GB, you need a paid plan.\n\
111             1. Sign up or log in at https://memvid.com/dashboard\n\
112             2. Get your API key from the dashboard\n\
113             3. Set it: export MEMVID_API_KEY=your_api_key\n\n\
114             Learn more: https://memvid.com/pricing",
115            size_str,
116            limit_str
117        );
118    }
119
120    // API key is set, check plan capacity
121    if let Some(ticket) = org_ticket_cache::get_optional(config) {
122        if file_size > ticket.capacity_bytes() {
123            let size_str = format_bytes(file_size);
124            let capacity_str = format_bytes(ticket.capacity_bytes());
125            bail!(
126                "File size ({}) exceeds your {} plan capacity ({}).\n\n\
127                 Upgrade to a higher plan to work with larger files.\n\
128                 Visit: https://memvid.com/dashboard/plan",
129                size_str,
130                ticket.plan_name,
131                capacity_str
132            );
133        }
134    }
135
136    Ok(())
137}
138
139/// Check total memory size against plan capacity limit
140/// Called before operations that would increase memory size
141pub fn ensure_capacity_with_api_key(
142    current_size: u64,
143    additional_size: u64,
144    config: &CliConfig,
145) -> Result<()> {
146    let total = current_size.saturating_add(additional_size);
147
148    // Get effective capacity limit (from ticket or free tier)
149    let capacity_limit = get_effective_capacity(config);
150
151    if total <= capacity_limit {
152        return Ok(());
153    }
154
155    // Total would exceed capacity limit
156    let current_str = format_bytes(current_size);
157    let additional_str = format_bytes(additional_size);
158    let total_str = format_bytes(total);
159    let limit_str = format_bytes(capacity_limit);
160
161    if config.api_key.is_none() {
162        bail!(
163            "This operation would exceed the free tier limit.\n\n\
164             Current size:    {}\n\
165             Adding:          {}\n\
166             Total:           {}\n\
167             Free tier limit: {}\n\n\
168             To store more than 1GB, you need a paid plan.\n\
169             1. Sign up or log in at https://memvid.com/dashboard\n\
170             2. Get your API key from the dashboard\n\
171             3. Set it: export MEMVID_API_KEY=your_api_key\n\n\
172             Learn more: https://memvid.com/pricing",
173            current_str,
174            additional_str,
175            total_str,
176            limit_str
177        );
178    }
179
180    // API key is set but exceeding plan capacity
181    let plan_name = org_ticket_cache::get_optional(config)
182        .map(|t| t.plan_name.clone())
183        .unwrap_or_else(|| "current".to_string());
184
185    bail!(
186        "This operation would exceed your {} plan capacity.\n\n\
187         Current size:  {}\n\
188         Adding:        {}\n\
189         Total:         {}\n\
190         Plan capacity: {}\n\n\
191         Upgrade to a higher plan to store more data.\n\
192         Visit: https://memvid.com/dashboard/plan",
193        plan_name,
194        current_str,
195        additional_str,
196        total_str,
197        limit_str
198    );
199}
200
201/// Check if the current plan allows a feature
202pub fn ensure_feature_access(feature: &str, config: &CliConfig) -> Result<()> {
203    let ticket = match org_ticket_cache::get_optional(config) {
204        Some(t) => t,
205        None => {
206            // No API key - check if feature is in free tier
207            let free_features = ["core", "temporal_track", "clip", "whisper", "temporal_enrich"];
208            if free_features.contains(&feature) {
209                return Ok(());
210            }
211            bail!(
212                "The '{}' feature requires a paid plan.\n\n\
213                 1. Sign up or log in at https://memvid.com/dashboard\n\
214                 2. Subscribe to a paid plan\n\
215                 3. Get your API key from the dashboard\n\
216                 4. Set it: export MEMVID_API_KEY=your_api_key\n\n\
217                 Learn more: https://memvid.com/pricing",
218                feature
219            );
220        }
221    };
222
223    // Check if feature is in the plan's feature list
224    // Enterprise gets all features
225    if ticket.plan_id == "enterprise" || ticket.ticket.features.contains(&"*".to_string()) {
226        return Ok(());
227    }
228
229    if ticket.ticket.features.contains(&feature.to_string()) {
230        return Ok(());
231    }
232
233    bail!(
234        "The '{}' feature is not available on your {} plan.\n\n\
235         Upgrade to access this feature.\n\
236         Visit: https://memvid.com/dashboard/plan",
237        feature,
238        ticket.plan_name
239    );
240}
241
242/// Open a memory file in read-only mode
243pub fn open_read_only_mem(path: &Path) -> Result<Memvid> {
244    Ok(Memvid::open_read_only(path)?)
245}
246
247/// Format bytes in a human-readable format (B, KB, MB, GB, TB)
248pub fn format_bytes(bytes: u64) -> String {
249    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
250    let mut value = bytes as f64;
251    let mut unit = 0;
252    while value >= 1024.0 && unit < UNITS.len() - 1 {
253        value /= 1024.0;
254        unit += 1;
255    }
256    if unit == 0 {
257        format!("{bytes} B")
258    } else {
259        format!("{value:.1} {}", UNITS[unit])
260    }
261}
262
263/// Round a percentage value to one decimal place
264pub fn round_percent(value: f64) -> f64 {
265    if !value.is_finite() {
266        return 0.0;
267    }
268    (value * 10.0).round() / 10.0
269}
270
271/// Format a percentage value for display
272pub fn format_percent(value: f64) -> String {
273    if !value.is_finite() {
274        return "n/a".to_string();
275    }
276    let rounded = round_percent(value);
277    let normalized = if rounded.abs() < 0.05 { 0.0 } else { rounded };
278    if normalized.fract().abs() < 0.05 {
279        format!("{:.0}%", normalized.round())
280    } else {
281        format!("{normalized:.1}%")
282    }
283}
284
285/// Convert boolean to "yes" or "no" string
286pub fn yes_no(value: bool) -> &'static str {
287    if value {
288        "yes"
289    } else {
290        "no"
291    }
292}
293
294/// Convert lock owner hint to JSON format
295pub fn owner_hint_to_json(owner: &LockOwnerHint) -> serde_json::Value {
296    json!({
297        "pid": owner.pid,
298        "cmd": owner.cmd,
299        "started_at": owner.started_at,
300        "file_path": owner
301            .file_path
302            .as_ref()
303            .map(|path| path.display().to_string()),
304        "file_id": owner.file_id,
305        "last_heartbeat": owner.last_heartbeat,
306        "heartbeat_ms": owner.heartbeat_ms,
307    })
308}
309
310/// Parse a size string (e.g., "6MB", "1.5 GB") into bytes
311pub fn parse_size(input: &str) -> Result<u64> {
312    use anyhow::bail;
313
314    let trimmed = input.trim();
315    if trimmed.is_empty() {
316        bail!("size must not be empty");
317    }
318
319    let mut number = String::new();
320    let mut suffix = String::new();
321    let mut seen_unit = false;
322    for ch in trimmed.chars() {
323        if ch.is_ascii_digit() || ch == '.' {
324            if seen_unit {
325                bail!("invalid size '{input}': unexpected digit after unit");
326            }
327            number.push(ch);
328        } else if ch.is_ascii_whitespace() {
329            if !number.is_empty() {
330                seen_unit = true;
331            }
332        } else {
333            seen_unit = true;
334            suffix.push(ch);
335        }
336    }
337
338    if number.is_empty() {
339        bail!("invalid size '{input}': missing numeric value");
340    }
341
342    let value: f64 = number
343        .parse()
344        .map_err(|err| anyhow::anyhow!("invalid size '{input}': {err}"))?;
345    let unit = suffix.trim().to_ascii_lowercase();
346
347    let multiplier = match unit.as_str() {
348        "" | "b" | "bytes" => 1.0,
349        "k" | "kb" | "kib" => 1024.0,
350        "m" | "mb" | "mib" => 1024.0 * 1024.0,
351        "g" | "gb" | "gib" => 1024.0 * 1024.0 * 1024.0,
352        "t" | "tb" | "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
353        other => bail!("unsupported size unit '{other}'"),
354    };
355
356    let bytes = value * multiplier;
357    if bytes <= 0.0 {
358        bail!("size must be greater than zero");
359    }
360    if bytes > u64::MAX as f64 {
361        bail!("size '{input}' exceeds supported maximum");
362    }
363
364    Ok(bytes.round() as u64)
365}
366
367/// Ensure that CLI mutations are allowed for the given memory
368pub fn ensure_cli_mutation_allowed(mem: &Memvid) -> Result<()> {
369    let ticket = mem.current_ticket();
370    if ticket.issuer == "free-tier" {
371        return Ok(());
372    }
373    let stats = mem.stats()?;
374    if stats.tier == Tier::Free {
375        return Ok(());
376    }
377    if ticket.issuer.trim().is_empty() {
378        bail!(
379            "Apply a ticket before mutating this memory (tier {:?})",
380            stats.tier
381        );
382    }
383    Ok(())
384}
385
386/// Apply lock CLI settings to a memory instance
387pub fn apply_lock_cli(mem: &mut Memvid, opts: &crate::commands::LockCliArgs) {
388    let settings = mem.lock_settings_mut();
389    settings.timeout_ms = opts.lock_timeout;
390    settings.force_stale = opts.force;
391}
392
393/// Select a frame by ID or URI
394pub fn select_frame(
395    mem: &mut Memvid,
396    frame_id: Option<u64>,
397    uri: Option<&str>,
398) -> Result<memvid_core::Frame> {
399    match (frame_id, uri) {
400        (Some(id), None) => Ok(mem.frame_by_id(id)?),
401        (None, Some(target_uri)) => Ok(mem.frame_by_uri(target_uri)?),
402        (Some(_), Some(_)) => bail!("specify only one of --frame-id or --uri"),
403        (None, None) => bail!("specify --frame-id or --uri to select a frame"),
404    }
405}
406
407/// Convert frame status to string representation
408pub fn frame_status_str(status: memvid_core::FrameStatus) -> &'static str {
409    match status {
410        memvid_core::FrameStatus::Active => "active",
411        memvid_core::FrameStatus::Superseded => "superseded",
412        memvid_core::FrameStatus::Deleted => "deleted",
413    }
414}
415
416/// Check if a string looks like a memory file path
417pub fn looks_like_memory(candidate: &str) -> bool {
418    let path = std::path::Path::new(candidate);
419    looks_like_memory_path(path) || candidate.trim().to_ascii_lowercase().ends_with(".mv2")
420}
421
422/// Check if a path looks like a memory file
423pub fn looks_like_memory_path(path: &std::path::Path) -> bool {
424    path.extension()
425        .map(|ext| ext.eq_ignore_ascii_case("mv2"))
426        .unwrap_or(false)
427}
428
429/// Auto-detect a memory file in the current directory
430pub fn autodetect_memory_file() -> Result<std::path::PathBuf> {
431    let mut matches = Vec::new();
432    for entry in std::fs::read_dir(".")? {
433        let path = entry?.path();
434        if path.is_file() && looks_like_memory_path(&path) {
435            matches.push(path);
436        }
437    }
438
439    match matches.len() {
440        0 => bail!(
441            "no .mv2 file detected in the current directory; specify the memory file explicitly"
442        ),
443        1 => Ok(matches.remove(0)),
444        _ => bail!("multiple .mv2 files detected; specify the memory file explicitly"),
445    }
446}
447
448/// Parse a timecode string (HH:MM:SS or MM:SS or SS) into milliseconds
449pub fn parse_timecode(value: &str) -> Result<u64> {
450    use anyhow::Context;
451
452    let parts: Vec<&str> = value.split(':').collect();
453    if parts.is_empty() || parts.len() > 3 {
454        bail!("invalid time value `{value}`; expected SS, MM:SS, or HH:MM:SS");
455    }
456    let mut multiplier = 1_f64;
457    let mut total_seconds = 0_f64;
458    for part in parts.iter().rev() {
459        let trimmed = part.trim();
460        if trimmed.is_empty() {
461            bail!("invalid time value `{value}`");
462        }
463        let component: f64 = trimmed
464            .parse()
465            .with_context(|| format!("invalid time component `{trimmed}`"))?;
466        total_seconds += component * multiplier;
467        multiplier *= 60.0;
468    }
469    if total_seconds < 0.0 {
470        bail!("time values must be positive");
471    }
472    Ok((total_seconds * 1000.0).round() as u64)
473}
474
475/// Format a Unix timestamp to ISO 8601 string
476#[cfg(feature = "temporal_track")]
477pub fn format_timestamp(ts: i64) -> Option<String> {
478    use time::format_description::well_known::Rfc3339;
479    use time::OffsetDateTime;
480
481    OffsetDateTime::from_unix_timestamp(ts)
482        .ok()
483        .and_then(|dt| dt.format(&Rfc3339).ok())
484}
485
486/// Format milliseconds as HH:MM:SS.mmm
487pub fn format_timestamp_ms(ms: u64) -> String {
488    let total_seconds = ms / 1000;
489    let millis = ms % 1000;
490    let hours = total_seconds / 3600;
491    let minutes = (total_seconds % 3600) / 60;
492    let seconds = total_seconds % 60;
493    format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
494}
495
496/// Read payload bytes from a file or stdin
497pub fn read_payload(path: Option<&Path>) -> Result<Vec<u8>> {
498    use std::fs::File;
499    use std::io::{BufReader, Read};
500
501    match path {
502        Some(p) => {
503            let mut reader = BufReader::new(File::open(p)?);
504            let mut buffer = Vec::new();
505            if let Ok(meta) = std::fs::metadata(p) {
506                if let Ok(len) = usize::try_from(meta.len()) {
507                    buffer.reserve(len.saturating_add(1));
508                }
509            }
510            reader.read_to_end(&mut buffer)?;
511            Ok(buffer)
512        }
513        None => {
514            let stdin = std::io::stdin();
515            let mut reader = BufReader::new(stdin.lock());
516            let mut buffer = Vec::new();
517            reader.read_to_end(&mut buffer)?;
518            Ok(buffer)
519        }
520    }
521}
522
523/// Read an embedding vector from a file.
524///
525/// Supports:
526/// - Whitespace-separated floats: `0.1 0.2 0.3`
527/// - JSON array: `[0.1, 0.2, 0.3]`
528pub fn read_embedding(path: &Path) -> Result<Vec<f32>> {
529    use anyhow::anyhow;
530
531    let text = std::fs::read_to_string(path)?;
532    let trimmed = text.trim();
533    if trimmed.starts_with('[') {
534        let values: Vec<f32> = serde_json::from_str(trimmed).map_err(|err| {
535            anyhow!(
536                "failed to parse embedding JSON array from `{}`: {err}",
537                path.display()
538            )
539        })?;
540        if values.is_empty() {
541            bail!("embedding file `{}` contained no values", path.display());
542        }
543        return Ok(values);
544    }
545    let mut values = Vec::new();
546    for token in trimmed.split_whitespace() {
547        let value: f32 = token
548            .parse()
549            .map_err(|err| anyhow!("invalid embedding value {token}: {err}"))?;
550        values.push(value);
551    }
552    if values.is_empty() {
553        bail!("embedding file `{}` contained no values", path.display());
554    }
555    Ok(values)
556}
557
558/// Parse a comma-separated vector string into f32 values
559pub fn parse_vector(input: &str) -> Result<Vec<f32>> {
560    use anyhow::anyhow;
561
562    let mut values = Vec::new();
563    for token in input.split(|c: char| c == ',' || c.is_whitespace()) {
564        if token.is_empty() {
565            continue;
566        }
567        let value: f32 = token
568            .parse()
569            .map_err(|err| anyhow!("invalid vector value {token}: {err}"))?;
570        values.push(value);
571    }
572    if values.is_empty() {
573        bail!("vector must contain at least one value");
574    }
575    Ok(values)
576}
577
578/// Parse a date boundary string (YYYY-MM-DD)
579pub fn parse_date_boundary(raw: Option<&String>, end_of_day: bool) -> Result<Option<i64>> {
580    use anyhow::anyhow;
581    use time::macros::format_description;
582    use time::{Date, PrimitiveDateTime, Time};
583
584    let Some(value) = raw else {
585        return Ok(None);
586    };
587    let trimmed = value.trim();
588    if trimmed.is_empty() {
589        return Ok(None);
590    }
591    let format = format_description!("[year]-[month]-[day]");
592    let date =
593        Date::parse(trimmed, &format).map_err(|err| anyhow!("invalid date '{trimmed}': {err}"))?;
594    let time = if end_of_day {
595        Time::from_hms_milli(23, 59, 59, 999)
596            .map_err(|err| anyhow!("unable to interpret end-of-day boundary: {err}"))?
597    } else {
598        Time::MIDNIGHT
599    };
600    let timestamp = PrimitiveDateTime::new(date, time)
601        .assume_utc()
602        .unix_timestamp();
603    Ok(Some(timestamp))
604}