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 = ["core", "temporal_track", "clip", "whisper", "temporal_enrich"];
208 if free_features.contains(&feature) {
209 return Ok(());
210 }
211 bail!(
212 "The '{}' feature requires a paid plan.\n\n\
213 1. Sign up or log in at https://memvid.com/dashboard\n\
214 2. Subscribe to a paid plan\n\
215 3. Get your API key from the dashboard\n\
216 4. Set it: export MEMVID_API_KEY=your_api_key\n\n\
217 Learn more: https://memvid.com/pricing",
218 feature
219 );
220 }
221 };
222
223 if ticket.plan_id == "enterprise" || ticket.ticket.features.contains(&"*".to_string()) {
226 return Ok(());
227 }
228
229 if ticket.ticket.features.contains(&feature.to_string()) {
230 return Ok(());
231 }
232
233 bail!(
234 "The '{}' feature is not available on your {} plan.\n\n\
235 Upgrade to access this feature.\n\
236 Visit: https://memvid.com/dashboard/plan",
237 feature,
238 ticket.plan_name
239 );
240}
241
242pub fn open_read_only_mem(path: &Path) -> Result<Memvid> {
244 Ok(Memvid::open_read_only(path)?)
245}
246
247pub fn format_bytes(bytes: u64) -> String {
249 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
250 let mut value = bytes as f64;
251 let mut unit = 0;
252 while value >= 1024.0 && unit < UNITS.len() - 1 {
253 value /= 1024.0;
254 unit += 1;
255 }
256 if unit == 0 {
257 format!("{bytes} B")
258 } else {
259 format!("{value:.1} {}", UNITS[unit])
260 }
261}
262
263pub fn round_percent(value: f64) -> f64 {
265 if !value.is_finite() {
266 return 0.0;
267 }
268 (value * 10.0).round() / 10.0
269}
270
271pub fn format_percent(value: f64) -> String {
273 if !value.is_finite() {
274 return "n/a".to_string();
275 }
276 let rounded = round_percent(value);
277 let normalized = if rounded.abs() < 0.05 { 0.0 } else { rounded };
278 if normalized.fract().abs() < 0.05 {
279 format!("{:.0}%", normalized.round())
280 } else {
281 format!("{normalized:.1}%")
282 }
283}
284
285pub fn yes_no(value: bool) -> &'static str {
287 if value {
288 "yes"
289 } else {
290 "no"
291 }
292}
293
294pub fn owner_hint_to_json(owner: &LockOwnerHint) -> serde_json::Value {
296 json!({
297 "pid": owner.pid,
298 "cmd": owner.cmd,
299 "started_at": owner.started_at,
300 "file_path": owner
301 .file_path
302 .as_ref()
303 .map(|path| path.display().to_string()),
304 "file_id": owner.file_id,
305 "last_heartbeat": owner.last_heartbeat,
306 "heartbeat_ms": owner.heartbeat_ms,
307 })
308}
309
310pub fn parse_size(input: &str) -> Result<u64> {
312 use anyhow::bail;
313
314 let trimmed = input.trim();
315 if trimmed.is_empty() {
316 bail!("size must not be empty");
317 }
318
319 let mut number = String::new();
320 let mut suffix = String::new();
321 let mut seen_unit = false;
322 for ch in trimmed.chars() {
323 if ch.is_ascii_digit() || ch == '.' {
324 if seen_unit {
325 bail!("invalid size '{input}': unexpected digit after unit");
326 }
327 number.push(ch);
328 } else if ch.is_ascii_whitespace() {
329 if !number.is_empty() {
330 seen_unit = true;
331 }
332 } else {
333 seen_unit = true;
334 suffix.push(ch);
335 }
336 }
337
338 if number.is_empty() {
339 bail!("invalid size '{input}': missing numeric value");
340 }
341
342 let value: f64 = number
343 .parse()
344 .map_err(|err| anyhow::anyhow!("invalid size '{input}': {err}"))?;
345 let unit = suffix.trim().to_ascii_lowercase();
346
347 let multiplier = match unit.as_str() {
348 "" | "b" | "bytes" => 1.0,
349 "k" | "kb" | "kib" => 1024.0,
350 "m" | "mb" | "mib" => 1024.0 * 1024.0,
351 "g" | "gb" | "gib" => 1024.0 * 1024.0 * 1024.0,
352 "t" | "tb" | "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
353 other => bail!("unsupported size unit '{other}'"),
354 };
355
356 let bytes = value * multiplier;
357 if bytes <= 0.0 {
358 bail!("size must be greater than zero");
359 }
360 if bytes > u64::MAX as f64 {
361 bail!("size '{input}' exceeds supported maximum");
362 }
363
364 Ok(bytes.round() as u64)
365}
366
367pub fn ensure_cli_mutation_allowed(mem: &Memvid) -> Result<()> {
369 let ticket = mem.current_ticket();
370 if ticket.issuer == "free-tier" {
371 return Ok(());
372 }
373 let stats = mem.stats()?;
374 if stats.tier == Tier::Free {
375 return Ok(());
376 }
377 if ticket.issuer.trim().is_empty() {
378 bail!(
379 "Apply a ticket before mutating this memory (tier {:?})",
380 stats.tier
381 );
382 }
383 Ok(())
384}
385
386pub fn apply_lock_cli(mem: &mut Memvid, opts: &crate::commands::LockCliArgs) {
388 let settings = mem.lock_settings_mut();
389 settings.timeout_ms = opts.lock_timeout;
390 settings.force_stale = opts.force;
391}
392
393pub fn select_frame(
395 mem: &mut Memvid,
396 frame_id: Option<u64>,
397 uri: Option<&str>,
398) -> Result<memvid_core::Frame> {
399 match (frame_id, uri) {
400 (Some(id), None) => Ok(mem.frame_by_id(id)?),
401 (None, Some(target_uri)) => Ok(mem.frame_by_uri(target_uri)?),
402 (Some(_), Some(_)) => bail!("specify only one of --frame-id or --uri"),
403 (None, None) => bail!("specify --frame-id or --uri to select a frame"),
404 }
405}
406
407pub fn frame_status_str(status: memvid_core::FrameStatus) -> &'static str {
409 match status {
410 memvid_core::FrameStatus::Active => "active",
411 memvid_core::FrameStatus::Superseded => "superseded",
412 memvid_core::FrameStatus::Deleted => "deleted",
413 }
414}
415
416pub fn looks_like_memory(candidate: &str) -> bool {
418 let path = std::path::Path::new(candidate);
419 looks_like_memory_path(path) || candidate.trim().to_ascii_lowercase().ends_with(".mv2")
420}
421
422pub fn looks_like_memory_path(path: &std::path::Path) -> bool {
424 path.extension()
425 .map(|ext| ext.eq_ignore_ascii_case("mv2"))
426 .unwrap_or(false)
427}
428
429pub fn autodetect_memory_file() -> Result<std::path::PathBuf> {
431 let mut matches = Vec::new();
432 for entry in std::fs::read_dir(".")? {
433 let path = entry?.path();
434 if path.is_file() && looks_like_memory_path(&path) {
435 matches.push(path);
436 }
437 }
438
439 match matches.len() {
440 0 => bail!(
441 "no .mv2 file detected in the current directory; specify the memory file explicitly"
442 ),
443 1 => Ok(matches.remove(0)),
444 _ => bail!("multiple .mv2 files detected; specify the memory file explicitly"),
445 }
446}
447
448pub fn parse_timecode(value: &str) -> Result<u64> {
450 use anyhow::Context;
451
452 let parts: Vec<&str> = value.split(':').collect();
453 if parts.is_empty() || parts.len() > 3 {
454 bail!("invalid time value `{value}`; expected SS, MM:SS, or HH:MM:SS");
455 }
456 let mut multiplier = 1_f64;
457 let mut total_seconds = 0_f64;
458 for part in parts.iter().rev() {
459 let trimmed = part.trim();
460 if trimmed.is_empty() {
461 bail!("invalid time value `{value}`");
462 }
463 let component: f64 = trimmed
464 .parse()
465 .with_context(|| format!("invalid time component `{trimmed}`"))?;
466 total_seconds += component * multiplier;
467 multiplier *= 60.0;
468 }
469 if total_seconds < 0.0 {
470 bail!("time values must be positive");
471 }
472 Ok((total_seconds * 1000.0).round() as u64)
473}
474
475#[cfg(feature = "temporal_track")]
477pub fn format_timestamp(ts: i64) -> Option<String> {
478 use time::format_description::well_known::Rfc3339;
479 use time::OffsetDateTime;
480
481 OffsetDateTime::from_unix_timestamp(ts)
482 .ok()
483 .and_then(|dt| dt.format(&Rfc3339).ok())
484}
485
486pub fn format_timestamp_ms(ms: u64) -> String {
488 let total_seconds = ms / 1000;
489 let millis = ms % 1000;
490 let hours = total_seconds / 3600;
491 let minutes = (total_seconds % 3600) / 60;
492 let seconds = total_seconds % 60;
493 format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
494}
495
496pub fn read_payload(path: Option<&Path>) -> Result<Vec<u8>> {
498 use std::fs::File;
499 use std::io::{BufReader, Read};
500
501 match path {
502 Some(p) => {
503 let mut reader = BufReader::new(File::open(p)?);
504 let mut buffer = Vec::new();
505 if let Ok(meta) = std::fs::metadata(p) {
506 if let Ok(len) = usize::try_from(meta.len()) {
507 buffer.reserve(len.saturating_add(1));
508 }
509 }
510 reader.read_to_end(&mut buffer)?;
511 Ok(buffer)
512 }
513 None => {
514 let stdin = std::io::stdin();
515 let mut reader = BufReader::new(stdin.lock());
516 let mut buffer = Vec::new();
517 reader.read_to_end(&mut buffer)?;
518 Ok(buffer)
519 }
520 }
521}
522
523pub fn read_embedding(path: &Path) -> Result<Vec<f32>> {
529 use anyhow::anyhow;
530
531 let text = std::fs::read_to_string(path)?;
532 let trimmed = text.trim();
533 if trimmed.starts_with('[') {
534 let values: Vec<f32> = serde_json::from_str(trimmed).map_err(|err| {
535 anyhow!(
536 "failed to parse embedding JSON array from `{}`: {err}",
537 path.display()
538 )
539 })?;
540 if values.is_empty() {
541 bail!("embedding file `{}` contained no values", path.display());
542 }
543 return Ok(values);
544 }
545 let mut values = Vec::new();
546 for token in trimmed.split_whitespace() {
547 let value: f32 = token
548 .parse()
549 .map_err(|err| anyhow!("invalid embedding value {token}: {err}"))?;
550 values.push(value);
551 }
552 if values.is_empty() {
553 bail!("embedding file `{}` contained no values", path.display());
554 }
555 Ok(values)
556}
557
558pub fn parse_vector(input: &str) -> Result<Vec<f32>> {
560 use anyhow::anyhow;
561
562 let mut values = Vec::new();
563 for token in input.split(|c: char| c == ',' || c.is_whitespace()) {
564 if token.is_empty() {
565 continue;
566 }
567 let value: f32 = token
568 .parse()
569 .map_err(|err| anyhow!("invalid vector value {token}: {err}"))?;
570 values.push(value);
571 }
572 if values.is_empty() {
573 bail!("vector must contain at least one value");
574 }
575 Ok(values)
576}
577
578pub fn parse_date_boundary(raw: Option<&String>, end_of_day: bool) -> Result<Option<i64>> {
580 use anyhow::anyhow;
581 use time::macros::format_description;
582 use time::{Date, PrimitiveDateTime, Time};
583
584 let Some(value) = raw else {
585 return Ok(None);
586 };
587 let trimmed = value.trim();
588 if trimmed.is_empty() {
589 return Ok(None);
590 }
591 let format = format_description!("[year]-[month]-[day]");
592 let date =
593 Date::parse(trimmed, &format).map_err(|err| anyhow!("invalid date '{trimmed}': {err}"))?;
594 let time = if end_of_day {
595 Time::from_hms_milli(23, 59, 59, 999)
596 .map_err(|err| anyhow!("unable to interpret end-of-day boundary: {err}"))?
597 } else {
598 Time::MIDNIGHT
599 };
600 let timestamp = PrimitiveDateTime::new(date, time)
601 .assume_utc()
602 .unix_timestamp();
603 Ok(Some(timestamp))
604}