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