1use std::path::Path;
4
5use anyhow::{bail, Result};
6use memvid_core::{error::LockOwnerHint, Memvid, Tier};
7use serde_json::json;
8
9pub fn open_read_only_mem(path: &Path) -> Result<Memvid> {
11 Ok(Memvid::open_read_only(path)?)
12}
13
14pub 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
30pub 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
38pub 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
52pub fn yes_no(value: bool) -> &'static str {
54 if value {
55 "yes"
56 } else {
57 "no"
58 }
59}
60
61pub 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
77pub 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
134pub 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
153pub 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
160pub 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
174pub 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
183pub 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
189pub 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
196pub 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
215pub 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#[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
253pub 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
263pub 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
290pub 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
308pub 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
328pub 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}