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 = [
208                "core",
209                "temporal_track",
210                "clip",
211                "whisper",
212                "temporal_enrich",
213            ];
214            if free_features.contains(&feature) {
215                return Ok(());
216            }
217            bail!(
218                "The '{}' feature requires a paid plan.\n\n\
219                 1. Sign up or log in at https://memvid.com/dashboard\n\
220                 2. Subscribe to a paid plan\n\
221                 3. Get your API key from the dashboard\n\
222                 4. Set it: export MEMVID_API_KEY=your_api_key\n\n\
223                 Learn more: https://memvid.com/pricing",
224                feature
225            );
226        }
227    };
228
229    // Check if feature is in the plan's feature list
230    // Enterprise gets all features
231    if ticket.plan_id == "enterprise" || ticket.ticket.features.contains(&"*".to_string()) {
232        return Ok(());
233    }
234
235    if ticket.ticket.features.contains(&feature.to_string()) {
236        return Ok(());
237    }
238
239    bail!(
240        "The '{}' feature is not available on your {} plan.\n\n\
241         Upgrade to access this feature.\n\
242         Visit: https://memvid.com/dashboard/plan",
243        feature,
244        ticket.plan_name
245    );
246}
247
248/// Open a memory file in read-only mode
249pub fn open_read_only_mem(path: &Path) -> Result<Memvid> {
250    Ok(Memvid::open_read_only(path)?)
251}
252
253/// Format bytes in a human-readable format (B, KB, MB, GB, TB)
254pub fn format_bytes(bytes: u64) -> String {
255    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
256    let mut value = bytes as f64;
257    let mut unit = 0;
258    while value >= 1024.0 && unit < UNITS.len() - 1 {
259        value /= 1024.0;
260        unit += 1;
261    }
262    if unit == 0 {
263        format!("{bytes} B")
264    } else {
265        format!("{value:.1} {}", UNITS[unit])
266    }
267}
268
269/// Round a percentage value to one decimal place
270pub fn round_percent(value: f64) -> f64 {
271    if !value.is_finite() {
272        return 0.0;
273    }
274    (value * 10.0).round() / 10.0
275}
276
277/// Format a percentage value for display
278pub fn format_percent(value: f64) -> String {
279    if !value.is_finite() {
280        return "n/a".to_string();
281    }
282    let rounded = round_percent(value);
283    let normalized = if rounded.abs() < 0.05 { 0.0 } else { rounded };
284    if normalized.fract().abs() < 0.05 {
285        format!("{:.0}%", normalized.round())
286    } else {
287        format!("{normalized:.1}%")
288    }
289}
290
291/// Convert boolean to "yes" or "no" string
292pub fn yes_no(value: bool) -> &'static str {
293    if value {
294        "yes"
295    } else {
296        "no"
297    }
298}
299
300/// Convert lock owner hint to JSON format
301pub fn owner_hint_to_json(owner: &LockOwnerHint) -> serde_json::Value {
302    json!({
303        "pid": owner.pid,
304        "cmd": owner.cmd,
305        "started_at": owner.started_at,
306        "file_path": owner
307            .file_path
308            .as_ref()
309            .map(|path| path.display().to_string()),
310        "file_id": owner.file_id,
311        "last_heartbeat": owner.last_heartbeat,
312        "heartbeat_ms": owner.heartbeat_ms,
313    })
314}
315
316/// Parse a size string (e.g., "6MB", "1.5 GB") into bytes
317pub fn parse_size(input: &str) -> Result<u64> {
318    use anyhow::bail;
319
320    let trimmed = input.trim();
321    if trimmed.is_empty() {
322        bail!("size must not be empty");
323    }
324
325    let mut number = String::new();
326    let mut suffix = String::new();
327    let mut seen_unit = false;
328    for ch in trimmed.chars() {
329        if ch.is_ascii_digit() || ch == '.' {
330            if seen_unit {
331                bail!("invalid size '{input}': unexpected digit after unit");
332            }
333            number.push(ch);
334        } else if ch.is_ascii_whitespace() {
335            if !number.is_empty() {
336                seen_unit = true;
337            }
338        } else {
339            seen_unit = true;
340            suffix.push(ch);
341        }
342    }
343
344    if number.is_empty() {
345        bail!("invalid size '{input}': missing numeric value");
346    }
347
348    let value: f64 = number
349        .parse()
350        .map_err(|err| anyhow::anyhow!("invalid size '{input}': {err}"))?;
351    let unit = suffix.trim().to_ascii_lowercase();
352
353    let multiplier = match unit.as_str() {
354        "" | "b" | "bytes" => 1.0,
355        "k" | "kb" | "kib" => 1024.0,
356        "m" | "mb" | "mib" => 1024.0 * 1024.0,
357        "g" | "gb" | "gib" => 1024.0 * 1024.0 * 1024.0,
358        "t" | "tb" | "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
359        other => bail!("unsupported size unit '{other}'"),
360    };
361
362    let bytes = value * multiplier;
363    if bytes <= 0.0 {
364        bail!("size must be greater than zero");
365    }
366    if bytes > u64::MAX as f64 {
367        bail!("size '{input}' exceeds supported maximum");
368    }
369
370    Ok(bytes.round() as u64)
371}
372
373/// Ensure that CLI mutations are allowed for the given memory
374pub fn ensure_cli_mutation_allowed(mem: &Memvid) -> Result<()> {
375    let ticket = mem.current_ticket();
376    if ticket.issuer == "free-tier" {
377        return Ok(());
378    }
379    let stats = mem.stats()?;
380    if stats.tier == Tier::Free {
381        return Ok(());
382    }
383    if ticket.issuer.trim().is_empty() {
384        bail!(
385            "Apply a ticket before mutating this memory (tier {:?})",
386            stats.tier
387        );
388    }
389    Ok(())
390}
391
392/// Apply lock CLI settings to a memory instance
393pub fn apply_lock_cli(mem: &mut Memvid, opts: &crate::commands::LockCliArgs) {
394    let settings = mem.lock_settings_mut();
395    settings.timeout_ms = opts.lock_timeout;
396    settings.force_stale = opts.force;
397}
398
399/// Select a frame by ID or URI
400pub fn select_frame(
401    mem: &mut Memvid,
402    frame_id: Option<u64>,
403    uri: Option<&str>,
404) -> Result<memvid_core::Frame> {
405    match (frame_id, uri) {
406        (Some(id), None) => Ok(mem.frame_by_id(id)?),
407        (None, Some(target_uri)) => Ok(mem.frame_by_uri(target_uri)?),
408        (Some(_), Some(_)) => bail!("specify only one of --frame-id or --uri"),
409        (None, None) => bail!("specify --frame-id or --uri to select a frame"),
410    }
411}
412
413/// Convert frame status to string representation
414pub fn frame_status_str(status: memvid_core::FrameStatus) -> &'static str {
415    match status {
416        memvid_core::FrameStatus::Active => "active",
417        memvid_core::FrameStatus::Superseded => "superseded",
418        memvid_core::FrameStatus::Deleted => "deleted",
419    }
420}
421
422/// Check if a string looks like a memory file path
423pub fn looks_like_memory(candidate: &str) -> bool {
424    let path = std::path::Path::new(candidate);
425    looks_like_memory_path(path) || candidate.trim().to_ascii_lowercase().ends_with(".mv2")
426}
427
428/// Check if a path looks like a memory file
429pub fn looks_like_memory_path(path: &std::path::Path) -> bool {
430    path.extension()
431        .map(|ext| ext.eq_ignore_ascii_case("mv2"))
432        .unwrap_or(false)
433}
434
435/// Auto-detect a memory file in the current directory
436pub fn autodetect_memory_file() -> Result<std::path::PathBuf> {
437    let mut matches = Vec::new();
438    for entry in std::fs::read_dir(".")? {
439        let path = entry?.path();
440        if path.is_file() && looks_like_memory_path(&path) {
441            matches.push(path);
442        }
443    }
444
445    match matches.len() {
446        0 => bail!(
447            "no .mv2 file detected in the current directory; specify the memory file explicitly"
448        ),
449        1 => Ok(matches.remove(0)),
450        _ => bail!("multiple .mv2 files detected; specify the memory file explicitly"),
451    }
452}
453
454/// Parse a timecode string (HH:MM:SS or MM:SS or SS) into milliseconds
455pub fn parse_timecode(value: &str) -> Result<u64> {
456    use anyhow::Context;
457
458    let parts: Vec<&str> = value.split(':').collect();
459    if parts.is_empty() || parts.len() > 3 {
460        bail!("invalid time value `{value}`; expected SS, MM:SS, or HH:MM:SS");
461    }
462    let mut multiplier = 1_f64;
463    let mut total_seconds = 0_f64;
464    for part in parts.iter().rev() {
465        let trimmed = part.trim();
466        if trimmed.is_empty() {
467            bail!("invalid time value `{value}`");
468        }
469        let component: f64 = trimmed
470            .parse()
471            .with_context(|| format!("invalid time component `{trimmed}`"))?;
472        total_seconds += component * multiplier;
473        multiplier *= 60.0;
474    }
475    if total_seconds < 0.0 {
476        bail!("time values must be positive");
477    }
478    Ok((total_seconds * 1000.0).round() as u64)
479}
480
481/// Format a Unix timestamp to ISO 8601 string
482#[cfg(feature = "temporal_track")]
483pub fn format_timestamp(ts: i64) -> Option<String> {
484    use time::format_description::well_known::Rfc3339;
485    use time::OffsetDateTime;
486
487    OffsetDateTime::from_unix_timestamp(ts)
488        .ok()
489        .and_then(|dt| dt.format(&Rfc3339).ok())
490}
491
492/// Format milliseconds as HH:MM:SS.mmm
493pub fn format_timestamp_ms(ms: u64) -> String {
494    let total_seconds = ms / 1000;
495    let millis = ms % 1000;
496    let hours = total_seconds / 3600;
497    let minutes = (total_seconds % 3600) / 60;
498    let seconds = total_seconds % 60;
499    format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
500}
501
502/// Read payload bytes from a file or stdin
503pub fn read_payload(path: Option<&Path>) -> Result<Vec<u8>> {
504    use std::fs::File;
505    use std::io::{BufReader, Read};
506
507    match path {
508        Some(p) => {
509            let mut reader = BufReader::new(File::open(p)?);
510            let mut buffer = Vec::new();
511            if let Ok(meta) = std::fs::metadata(p) {
512                if let Ok(len) = usize::try_from(meta.len()) {
513                    buffer.reserve(len.saturating_add(1));
514                }
515            }
516            reader.read_to_end(&mut buffer)?;
517            Ok(buffer)
518        }
519        None => {
520            let stdin = std::io::stdin();
521            let mut reader = BufReader::new(stdin.lock());
522            let mut buffer = Vec::new();
523            reader.read_to_end(&mut buffer)?;
524            Ok(buffer)
525        }
526    }
527}
528
529/// Read an embedding vector from a file.
530///
531/// Supports:
532/// - Whitespace-separated floats: `0.1 0.2 0.3`
533/// - JSON array: `[0.1, 0.2, 0.3]`
534pub fn read_embedding(path: &Path) -> Result<Vec<f32>> {
535    use anyhow::anyhow;
536
537    let text = std::fs::read_to_string(path)?;
538    let trimmed = text.trim();
539    if trimmed.starts_with('[') {
540        let values: Vec<f32> = serde_json::from_str(trimmed).map_err(|err| {
541            anyhow!(
542                "failed to parse embedding JSON array from `{}`: {err}",
543                path.display()
544            )
545        })?;
546        if values.is_empty() {
547            bail!("embedding file `{}` contained no values", path.display());
548        }
549        return Ok(values);
550    }
551    let mut values = Vec::new();
552    for token in trimmed.split_whitespace() {
553        let value: f32 = token
554            .parse()
555            .map_err(|err| anyhow!("invalid embedding value {token}: {err}"))?;
556        values.push(value);
557    }
558    if values.is_empty() {
559        bail!("embedding file `{}` contained no values", path.display());
560    }
561    Ok(values)
562}
563
564/// Parse a comma-separated vector string into f32 values
565pub fn parse_vector(input: &str) -> Result<Vec<f32>> {
566    use anyhow::anyhow;
567
568    let mut values = Vec::new();
569    for token in input.split(|c: char| c == ',' || c.is_whitespace()) {
570        if token.is_empty() {
571            continue;
572        }
573        let value: f32 = token
574            .parse()
575            .map_err(|err| anyhow!("invalid vector value {token}: {err}"))?;
576        values.push(value);
577    }
578    if values.is_empty() {
579        bail!("vector must contain at least one value");
580    }
581    Ok(values)
582}
583
584/// Parse a date boundary string (YYYY-MM-DD)
585pub fn parse_date_boundary(raw: Option<&String>, end_of_day: bool) -> Result<Option<i64>> {
586    use anyhow::anyhow;
587    use time::macros::format_description;
588    use time::{Date, PrimitiveDateTime, Time};
589
590    let Some(value) = raw else {
591        return Ok(None);
592    };
593    let trimmed = value.trim();
594    if trimmed.is_empty() {
595        return Ok(None);
596    }
597    let format = format_description!("[year]-[month]-[day]");
598    let date =
599        Date::parse(trimmed, &format).map_err(|err| anyhow!("invalid date '{trimmed}': {err}"))?;
600    let time = if end_of_day {
601        Time::from_hms_milli(23, 59, 59, 999)
602            .map_err(|err| anyhow!("unable to interpret end-of-day boundary: {err}"))?
603    } else {
604        Time::MIDNIGHT
605    };
606    let timestamp = PrimitiveDateTime::new(date, time)
607        .assume_utc()
608        .unix_timestamp();
609    Ok(Some(timestamp))
610}