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