1use 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
12pub const FREE_TIER_MAX_FILE_SIZE: u64 = 1024 * 1024 * 1024; pub 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
28pub 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 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 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
75pub 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 let capacity_limit = get_effective_capacity(config);
86
87 if total <= capacity_limit {
88 return Ok(());
89 }
90
91 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 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
137pub 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 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 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
178pub fn open_read_only_mem(path: &Path) -> Result<Memvid> {
180 Ok(Memvid::open_read_only(path)?)
181}
182
183pub 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
199pub 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
207pub 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
221pub fn yes_no(value: bool) -> &'static str {
223 if value {
224 "yes"
225 } else {
226 "no"
227 }
228}
229
230pub 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
246pub 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
303pub 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
322pub 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
329pub 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
343pub 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
352pub 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
358pub 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
365pub 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
384pub 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#[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
422pub 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
432pub 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
459pub 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
494pub 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
514pub 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}