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