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
9/// Open a memory file in read-only mode
10pub fn open_read_only_mem(path: &Path) -> Result<Memvid> {
11    Ok(Memvid::open_read_only(path)?)
12}
13
14/// Format bytes in a human-readable format (B, KB, MB, GB, TB)
15pub fn format_bytes(bytes: u64) -> String {
16    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
17    let mut value = bytes as f64;
18    let mut unit = 0;
19    while value >= 1024.0 && unit < UNITS.len() - 1 {
20        value /= 1024.0;
21        unit += 1;
22    }
23    if unit == 0 {
24        format!("{bytes} B")
25    } else {
26        format!("{value:.1} {}", UNITS[unit])
27    }
28}
29
30/// Round a percentage value to one decimal place
31pub fn round_percent(value: f64) -> f64 {
32    if !value.is_finite() {
33        return 0.0;
34    }
35    (value * 10.0).round() / 10.0
36}
37
38/// Format a percentage value for display
39pub fn format_percent(value: f64) -> String {
40    if !value.is_finite() {
41        return "n/a".to_string();
42    }
43    let rounded = round_percent(value);
44    let normalized = if rounded.abs() < 0.05 { 0.0 } else { rounded };
45    if normalized.fract().abs() < 0.05 {
46        format!("{:.0}%", normalized.round())
47    } else {
48        format!("{normalized:.1}%")
49    }
50}
51
52/// Convert boolean to "yes" or "no" string
53pub fn yes_no(value: bool) -> &'static str {
54    if value {
55        "yes"
56    } else {
57        "no"
58    }
59}
60
61/// Convert lock owner hint to JSON format
62pub fn owner_hint_to_json(owner: &LockOwnerHint) -> serde_json::Value {
63    json!({
64        "pid": owner.pid,
65        "cmd": owner.cmd,
66        "started_at": owner.started_at,
67        "file_path": owner
68            .file_path
69            .as_ref()
70            .map(|path| path.display().to_string()),
71        "file_id": owner.file_id,
72        "last_heartbeat": owner.last_heartbeat,
73        "heartbeat_ms": owner.heartbeat_ms,
74    })
75}
76
77/// Parse a size string (e.g., "6MB", "1.5 GB") into bytes
78pub fn parse_size(input: &str) -> Result<u64> {
79    use anyhow::bail;
80
81    let trimmed = input.trim();
82    if trimmed.is_empty() {
83        bail!("size must not be empty");
84    }
85
86    let mut number = String::new();
87    let mut suffix = String::new();
88    let mut seen_unit = false;
89    for ch in trimmed.chars() {
90        if ch.is_ascii_digit() || ch == '.' {
91            if seen_unit {
92                bail!("invalid size '{input}': unexpected digit after unit");
93            }
94            number.push(ch);
95        } else if ch.is_ascii_whitespace() {
96            if !number.is_empty() {
97                seen_unit = true;
98            }
99        } else {
100            seen_unit = true;
101            suffix.push(ch);
102        }
103    }
104
105    if number.is_empty() {
106        bail!("invalid size '{input}': missing numeric value");
107    }
108
109    let value: f64 = number
110        .parse()
111        .map_err(|err| anyhow::anyhow!("invalid size '{input}': {err}"))?;
112    let unit = suffix.trim().to_ascii_lowercase();
113
114    let multiplier = match unit.as_str() {
115        "" | "b" | "bytes" => 1.0,
116        "k" | "kb" | "kib" => 1024.0,
117        "m" | "mb" | "mib" => 1024.0 * 1024.0,
118        "g" | "gb" | "gib" => 1024.0 * 1024.0 * 1024.0,
119        "t" | "tb" | "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
120        other => bail!("unsupported size unit '{other}'"),
121    };
122
123    let bytes = value * multiplier;
124    if bytes <= 0.0 {
125        bail!("size must be greater than zero");
126    }
127    if bytes > u64::MAX as f64 {
128        bail!("size '{input}' exceeds supported maximum");
129    }
130
131    Ok(bytes.round() as u64)
132}
133
134/// Ensure that CLI mutations are allowed for the given memory
135pub fn ensure_cli_mutation_allowed(mem: &Memvid) -> Result<()> {
136    let ticket = mem.current_ticket();
137    if ticket.issuer == "free-tier" {
138        return Ok(());
139    }
140    let stats = mem.stats()?;
141    if stats.tier == Tier::Free {
142        return Ok(());
143    }
144    if ticket.issuer.trim().is_empty() {
145        bail!(
146            "Apply a ticket before mutating this memory (tier {:?})",
147            stats.tier
148        );
149    }
150    Ok(())
151}
152
153/// Apply lock CLI settings to a memory instance
154pub fn apply_lock_cli(mem: &mut Memvid, opts: &crate::commands::LockCliArgs) {
155    let settings = mem.lock_settings_mut();
156    settings.timeout_ms = opts.lock_timeout;
157    settings.force_stale = opts.force;
158}
159
160/// Select a frame by ID or URI
161pub fn select_frame(
162    mem: &mut Memvid,
163    frame_id: Option<u64>,
164    uri: Option<&str>,
165) -> Result<memvid_core::Frame> {
166    match (frame_id, uri) {
167        (Some(id), None) => Ok(mem.frame_by_id(id)?),
168        (None, Some(target_uri)) => Ok(mem.frame_by_uri(target_uri)?),
169        (Some(_), Some(_)) => bail!("specify only one of --frame-id or --uri"),
170        (None, None) => bail!("specify --frame-id or --uri to select a frame"),
171    }
172}
173
174/// Convert frame status to string representation
175pub fn frame_status_str(status: memvid_core::FrameStatus) -> &'static str {
176    match status {
177        memvid_core::FrameStatus::Active => "active",
178        memvid_core::FrameStatus::Superseded => "superseded",
179        memvid_core::FrameStatus::Deleted => "deleted",
180    }
181}
182
183/// Check if a string looks like a memory file path
184pub fn looks_like_memory(candidate: &str) -> bool {
185    let path = std::path::Path::new(candidate);
186    looks_like_memory_path(path) || candidate.trim().to_ascii_lowercase().ends_with(".mv2")
187}
188
189/// Check if a path looks like a memory file
190pub fn looks_like_memory_path(path: &std::path::Path) -> bool {
191    path.extension()
192        .map(|ext| ext.eq_ignore_ascii_case("mv2"))
193        .unwrap_or(false)
194}
195
196/// Auto-detect a memory file in the current directory
197pub fn autodetect_memory_file() -> Result<std::path::PathBuf> {
198    let mut matches = Vec::new();
199    for entry in std::fs::read_dir(".")? {
200        let path = entry?.path();
201        if path.is_file() && looks_like_memory_path(&path) {
202            matches.push(path);
203        }
204    }
205
206    match matches.len() {
207        0 => bail!(
208            "no .mv2 file detected in the current directory; specify the memory file explicitly"
209        ),
210        1 => Ok(matches.remove(0)),
211        _ => bail!("multiple .mv2 files detected; specify the memory file explicitly"),
212    }
213}
214
215/// Parse a timecode string (HH:MM:SS or MM:SS or SS) into milliseconds
216pub fn parse_timecode(value: &str) -> Result<u64> {
217    use anyhow::Context;
218
219    let parts: Vec<&str> = value.split(':').collect();
220    if parts.is_empty() || parts.len() > 3 {
221        bail!("invalid time value `{value}`; expected SS, MM:SS, or HH:MM:SS");
222    }
223    let mut multiplier = 1_f64;
224    let mut total_seconds = 0_f64;
225    for part in parts.iter().rev() {
226        let trimmed = part.trim();
227        if trimmed.is_empty() {
228            bail!("invalid time value `{value}`");
229        }
230        let component: f64 = trimmed
231            .parse()
232            .with_context(|| format!("invalid time component `{trimmed}`"))?;
233        total_seconds += component * multiplier;
234        multiplier *= 60.0;
235    }
236    if total_seconds < 0.0 {
237        bail!("time values must be positive");
238    }
239    Ok((total_seconds * 1000.0).round() as u64)
240}
241
242/// Format a Unix timestamp to ISO 8601 string
243#[cfg(feature = "temporal_track")]
244pub fn format_timestamp(ts: i64) -> Option<String> {
245    use time::format_description::well_known::Rfc3339;
246    use time::OffsetDateTime;
247
248    OffsetDateTime::from_unix_timestamp(ts)
249        .ok()
250        .and_then(|dt| dt.format(&Rfc3339).ok())
251}
252
253/// Format milliseconds as HH:MM:SS.mmm
254pub fn format_timestamp_ms(ms: u64) -> String {
255    let total_seconds = ms / 1000;
256    let millis = ms % 1000;
257    let hours = total_seconds / 3600;
258    let minutes = (total_seconds % 3600) / 60;
259    let seconds = total_seconds % 60;
260    format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
261}
262
263/// Read payload bytes from a file or stdin
264pub fn read_payload(path: Option<&Path>) -> Result<Vec<u8>> {
265    use std::fs::File;
266    use std::io::{BufReader, Read};
267
268    match path {
269        Some(p) => {
270            let mut reader = BufReader::new(File::open(p)?);
271            let mut buffer = Vec::new();
272            if let Ok(meta) = std::fs::metadata(p) {
273                if let Ok(len) = usize::try_from(meta.len()) {
274                    buffer.reserve(len.saturating_add(1));
275                }
276            }
277            reader.read_to_end(&mut buffer)?;
278            Ok(buffer)
279        }
280        None => {
281            let stdin = std::io::stdin();
282            let mut reader = BufReader::new(stdin.lock());
283            let mut buffer = Vec::new();
284            reader.read_to_end(&mut buffer)?;
285            Ok(buffer)
286        }
287    }
288}
289
290/// Read an embedding vector from a file (whitespace-separated float values)
291pub fn read_embedding(path: &Path) -> Result<Vec<f32>> {
292    use anyhow::anyhow;
293
294    let text = std::fs::read_to_string(path)?;
295    let mut values = Vec::new();
296    for token in text.split_whitespace() {
297        let value: f32 = token
298            .parse()
299            .map_err(|err| anyhow!("invalid embedding value {token}: {err}"))?;
300        values.push(value);
301    }
302    if values.is_empty() {
303        bail!("embedding file `{}` contained no values", path.display());
304    }
305    Ok(values)
306}
307
308/// Parse a comma-separated vector string into f32 values
309pub fn parse_vector(input: &str) -> Result<Vec<f32>> {
310    use anyhow::anyhow;
311
312    let mut values = Vec::new();
313    for token in input.split(|c: char| c == ',' || c.is_whitespace()) {
314        if token.is_empty() {
315            continue;
316        }
317        let value: f32 = token
318            .parse()
319            .map_err(|err| anyhow!("invalid vector value {token}: {err}"))?;
320        values.push(value);
321    }
322    if values.is_empty() {
323        bail!("vector must contain at least one value");
324    }
325    Ok(values)
326}
327
328/// Parse a date boundary string (YYYY-MM-DD)
329pub fn parse_date_boundary(raw: Option<&String>, end_of_day: bool) -> Result<Option<i64>> {
330    use anyhow::anyhow;
331    use time::macros::format_description;
332    use time::{Date, PrimitiveDateTime, Time};
333
334    let Some(value) = raw else {
335        return Ok(None);
336    };
337    let trimmed = value.trim();
338    if trimmed.is_empty() {
339        return Ok(None);
340    }
341    let format = format_description!("[year]-[month]-[day]");
342    let date =
343        Date::parse(trimmed, &format).map_err(|err| anyhow!("invalid date '{trimmed}': {err}"))?;
344    let time = if end_of_day {
345        Time::from_hms_milli(23, 59, 59, 999)
346            .map_err(|err| anyhow!("unable to interpret end-of-day boundary: {err}"))?
347    } else {
348        Time::MIDNIGHT
349    };
350    let timestamp = PrimitiveDateTime::new(date, time)
351        .assume_utc()
352        .unix_timestamp();
353    Ok(Some(timestamp))
354}