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