memvid_cli/commands/
inspection.rs

1//! Inspection command handlers (view, stats, who)
2
3#[cfg(feature = "audio-playback")]
4use std::io::Cursor;
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8#[cfg(feature = "audio-playback")]
9use std::time::Duration;
10
11use anyhow::{anyhow, bail, Context, Result};
12use clap::Args;
13use memvid_core::table::list_tables;
14use memvid_core::{
15    lockfile, normalize_text, Frame, FrameRole, MediaManifest, Memvid, TextChunkManifest,
16    TextChunkRange,
17};
18use serde_json::{json, Value};
19use tempfile::Builder;
20use tracing::warn;
21use uuid::Uuid;
22
23use crate::config::CliConfig;
24use crate::utils::{
25    format_bytes, format_percent, format_timestamp_ms, frame_status_str, open_read_only_mem,
26    owner_hint_to_json, parse_timecode, round_percent, select_frame, yes_no,
27};
28
29const DEFAULT_VIEW_PAGE_CHARS: usize = 1_200;
30const CHUNK_MANIFEST_KEY: &str = "memvid_chunks_v1";
31
32/// Arguments for the `view` subcommand
33#[derive(Args)]
34pub struct ViewArgs {
35    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
36    pub file: PathBuf,
37    #[arg(long = "frame-id", value_name = "ID", conflicts_with = "uri")]
38    pub frame_id: Option<u64>,
39    #[arg(long, value_name = "URI", conflicts_with = "frame_id")]
40    pub uri: Option<String>,
41    #[arg(long)]
42    pub json: bool,
43    #[arg(long, conflicts_with = "json")]
44    pub binary: bool,
45    #[arg(long, conflicts_with_all = ["json", "binary"])]
46    pub preview: bool,
47    /// Optional start time for video previews (HH:MM:SS[.mmm])
48    #[arg(
49        long = "start",
50        value_name = "HH:MM:SS",
51        requires = "preview",
52        conflicts_with_all = ["json", "binary", "play"]
53    )]
54    pub preview_start: Option<String>,
55    /// Optional end time for video previews (HH:MM:SS[.mmm])
56    #[arg(
57        long = "end",
58        value_name = "HH:MM:SS",
59        requires = "preview",
60        conflicts_with_all = ["json", "binary", "play"]
61    )]
62    pub preview_end: Option<String>,
63    #[arg(long = "play", conflicts_with_all = ["json", "binary", "preview"])]
64    pub play: bool,
65    #[arg(long = "start-seconds", requires = "play")]
66    pub start_seconds: Option<f32>,
67    #[arg(long = "end-seconds", requires = "play")]
68    pub end_seconds: Option<f32>,
69    #[arg(long, value_name = "N", default_value_t = 1)]
70    pub page: usize,
71    #[arg(long = "page-size", value_name = "CHARS")]
72    pub page_size: Option<usize>,
73}
74
75/// Arguments for the `stats` subcommand
76#[derive(Args)]
77pub struct StatsArgs {
78    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
79    pub file: PathBuf,
80    #[arg(long)]
81    pub json: bool,
82    /// Replay: Show stats for frames with ID <= AS_OF_FRAME (time-travel view)
83    #[arg(long = "as-of-frame", value_name = "FRAME_ID")]
84    pub as_of_frame: Option<u64>,
85    /// Replay: Show stats for frames with timestamp <= AS_OF_TS (time-travel view)
86    #[arg(long = "as-of-ts", value_name = "UNIX_TIMESTAMP")]
87    pub as_of_ts: Option<i64>,
88}
89
90/// Arguments for the `who` subcommand
91#[derive(Args)]
92pub struct WhoArgs {
93    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
94    pub file: PathBuf,
95    #[arg(long)]
96    pub json: bool,
97}
98
99/// Handler for `memvid stats`
100pub fn handle_stats(_config: &CliConfig, args: StatsArgs) -> Result<()> {
101    let mut mem = Memvid::open_read_only(&args.file)?;
102    let stats = mem.stats()?;
103    let tables = list_tables(&mut mem).unwrap_or_default();
104
105    // Note: Replay filtering for stats is currently not implemented
106    // The stats show the full memory state
107    if args.as_of_frame.is_some() || args.as_of_ts.is_some() {
108        eprintln!("Note: Replay filtering (--as-of-frame/--as-of-ts) shows current stats.");
109        eprintln!("      Use 'find' or 'timeline' commands for filtered results.");
110    }
111    let overhead_bytes = stats.size_bytes.saturating_sub(stats.payload_bytes);
112    let payload_share_percent: f64 = if stats.size_bytes > 0 {
113        round_percent((stats.payload_bytes as f64 / stats.size_bytes as f64) * 100.0)
114    } else {
115        0.0
116    };
117    let overhead_share_percent: f64 = if stats.size_bytes > 0 {
118        round_percent((100.0 - payload_share_percent).max(0.0))
119    } else {
120        0.0
121    };
122    let maintenance_command = format!(
123        "memvid doctor {} --vacuum --rebuild-time-index --rebuild-lex-index",
124        args.file.display()
125    );
126
127    if args.json {
128        let mut raw_json = serde_json::to_value(&stats)?;
129        if let Value::Object(ref mut obj) = raw_json {
130            obj.remove("tier");
131        }
132
133        // Build tables list for JSON output
134        let tables_json: Vec<serde_json::Value> = tables
135            .iter()
136            .map(|t| {
137                json!({
138                    "table_id": t.table_id,
139                    "source_file": t.source_file,
140                    "n_rows": t.n_rows,
141                    "n_cols": t.n_cols,
142                    "pages": format!("{}-{}", t.page_start, t.page_end),
143                    "quality": format!("{:?}", t.quality),
144                    "headers": t.headers,
145                })
146            })
147            .collect();
148
149        let report = json!({
150            "summary": {
151                "sequence": stats.seq_no,
152                "frames": format!("{} total ({} active)", stats.frame_count, stats.active_frame_count),
153                "usage": format!(
154                    "{} used / {} total ({})",
155                    format_bytes(stats.size_bytes),
156                    format_bytes(stats.capacity_bytes),
157                    format_percent(stats.storage_utilisation_percent)
158                ),
159                "remaining": format!("{} free", format_bytes(stats.remaining_capacity_bytes)),
160            },
161            "storage": {
162                "payload": format!("{} ({})", format_bytes(stats.payload_bytes), format_percent(payload_share_percent)),
163                "overhead": format!("{} ({}) - WAL + indexes", format_bytes(overhead_bytes), format_percent(overhead_share_percent)),
164                "logical_payload": format!("{} before compression", format_bytes(stats.logical_bytes)),
165                "compression_savings": format!("{} saved ({})", format_bytes(stats.saved_bytes), format_percent(stats.savings_percent)),
166                "compression_ratio": format_percent(stats.compression_ratio_percent),
167            },
168            "frames": {
169                "average_stored": format_bytes(stats.average_frame_payload_bytes),
170                "average_logical": format_bytes(stats.average_frame_logical_bytes),
171            },
172            "indexes": {
173                "lexical": yes_no(stats.has_lex_index),
174                "vector": yes_no(stats.has_vec_index),
175                "time": yes_no(stats.has_time_index),
176            },
177            "tables": {
178                "count": tables.len(),
179                "tables": tables_json,
180            },
181            "maintenance": maintenance_command,
182            "raw": raw_json,
183        });
184
185        println!("{}", serde_json::to_string_pretty(&report)?);
186    } else {
187        let seq_display = stats
188            .seq_no
189            .map(|seq| seq.to_string())
190            .unwrap_or_else(|| "n/a".to_string());
191
192        println!("Memory: {}", args.file.display());
193        println!("Sequence: {}", seq_display);
194        println!(
195            "Frames: {} total ({} active)",
196            stats.frame_count, stats.active_frame_count
197        );
198
199        println!("\nCapacity:");
200        println!(
201            "  Usage: {} used / {} total ({})",
202            format_bytes(stats.size_bytes),
203            format_bytes(stats.capacity_bytes),
204            format_percent(stats.storage_utilisation_percent)
205        );
206        println!(
207            "  Remaining: {}",
208            format_bytes(stats.remaining_capacity_bytes)
209        );
210
211        println!("\nStorage breakdown:");
212        println!(
213            "  Payload: {} ({})",
214            format_bytes(stats.payload_bytes),
215            format_percent(payload_share_percent)
216        );
217        println!(
218            "  Overhead: {} ({})",
219            format_bytes(overhead_bytes),
220            format_percent(overhead_share_percent)
221        );
222        // PHASE 2: Detailed overhead breakdown for observability
223        println!("    ├─ WAL: {}", format_bytes(stats.wal_bytes));
224        println!(
225            "    ├─ Lexical index: {}",
226            format_bytes(stats.lex_index_bytes)
227        );
228        println!(
229            "    ├─ Vector index: {}",
230            format_bytes(stats.vec_index_bytes)
231        );
232        println!(
233            "    └─ Time index: {}",
234            format_bytes(stats.time_index_bytes)
235        );
236        println!(
237            "  Logical payload: {} before compression",
238            format_bytes(stats.logical_bytes)
239        );
240        println!(
241            "  Compression savings: {} ({})",
242            format_bytes(stats.saved_bytes),
243            format_percent(stats.savings_percent)
244        );
245
246        println!("\nAverage frame:");
247        println!(
248            "  Stored: {}   Logical: {}",
249            format_bytes(stats.average_frame_payload_bytes),
250            format_bytes(stats.average_frame_logical_bytes)
251        );
252
253        // PHASE 2: Per-document cost analysis
254        if stats.active_frame_count > 0 {
255            let overhead_per_doc = overhead_bytes / stats.active_frame_count;
256            let lex_per_doc = stats.lex_index_bytes / stats.active_frame_count;
257            let vec_per_doc = stats.vec_index_bytes / stats.active_frame_count;
258
259            println!("\nPer-document overhead:");
260            println!("  Total: {}", format_bytes(overhead_per_doc));
261            if stats.has_lex_index {
262                println!("  Lexical: {}", format_bytes(lex_per_doc));
263            }
264            if stats.has_vec_index {
265                let vec_ratio = if stats.average_frame_payload_bytes > 0 {
266                    vec_per_doc as f64 / stats.average_frame_payload_bytes as f64
267                } else {
268                    0.0
269                };
270                println!(
271                    "  Vector: {} ({:.0}x text size)",
272                    format_bytes(vec_per_doc),
273                    vec_ratio
274                );
275            }
276        }
277
278        println!("\nIndexes:");
279        println!(
280            "  Lexical: {}   Vector: {}   Time: {}",
281            yes_no(stats.has_lex_index),
282            yes_no(stats.has_vec_index),
283            yes_no(stats.has_time_index)
284        );
285
286        if !tables.is_empty() {
287            println!("\nTables: {} extracted", tables.len());
288            for t in &tables {
289                println!(
290                    "  {} — {} rows × {} cols ({})",
291                    t.table_id, t.n_rows, t.n_cols, t.source_file
292                );
293            }
294        }
295
296        println!("\nMaintenance:");
297        println!(
298            "  Run `{}` to rebuild indexes and reclaim space.",
299            maintenance_command
300        );
301    }
302    Ok(())
303}
304
305/// Handler for `memvid who`
306pub fn handle_who(args: WhoArgs) -> Result<()> {
307    match lockfile::current_owner(&args.file)? {
308        Some(owner) => {
309            if args.json {
310                let output = json!({
311                    "locked": true,
312                    "owner": owner_hint_to_json(&owner),
313                });
314                println!("{}", serde_json::to_string_pretty(&output)?);
315            } else {
316                println!("{} is locked by:", args.file.display());
317                if let Some(pid) = owner.pid {
318                    println!("  pid: {pid}");
319                }
320                if let Some(cmd) = owner.cmd.as_deref() {
321                    println!("  cmd: {cmd}");
322                }
323                if let Some(started) = owner.started_at.as_deref() {
324                    println!("  started_at: {started}");
325                }
326                if let Some(last) = owner.last_heartbeat.as_deref() {
327                    println!("  last_heartbeat: {last}");
328                }
329                if let Some(interval) = owner.heartbeat_ms {
330                    println!("  heartbeat_interval_ms: {interval}");
331                }
332                if let Some(file_id) = owner.file_id.as_deref() {
333                    println!("  file_id: {file_id}");
334                }
335                if let Some(path) = owner.file_path.as_ref() {
336                    println!("  file_path: {}", path.display());
337                }
338            }
339        }
340        None => {
341            if args.json {
342                let output = json!({"locked": false});
343                println!("{}", serde_json::to_string_pretty(&output)?);
344            } else {
345                println!("No active writer for {}", args.file.display());
346            }
347        }
348    }
349    Ok(())
350}
351
352// ============================================================================
353// View command handler and helpers
354// ============================================================================
355
356/// Handler for `memvid view`
357pub fn handle_view(args: ViewArgs) -> Result<()> {
358    if args.page == 0 {
359        bail!("page must be greater than zero");
360    }
361    if let Some(size) = args.page_size {
362        if size == 0 {
363            bail!("page-size must be greater than zero");
364        }
365    }
366
367    let mut mem = open_read_only_mem(&args.file)?;
368    let frame = select_frame(&mut mem, args.frame_id, args.uri.as_deref())?;
369
370    if args.play {
371        #[cfg(feature = "audio-playback")]
372        {
373            play_frame_audio(&mut mem, &frame, args.start_seconds, args.end_seconds)?;
374            return Ok(());
375        }
376        #[cfg(not(feature = "audio-playback"))]
377        {
378            bail!("Audio playback requires the 'audio-playback' feature (only available on macOS)");
379        }
380    }
381
382    if args.preview {
383        let bounds = parse_preview_bounds(args.preview_start.as_ref(), args.preview_end.as_ref())?;
384        preview_frame_media(&mut mem, &frame, args.uri.as_deref(), bounds)?;
385        return Ok(());
386    }
387
388    if args.binary {
389        let bytes = mem.frame_canonical_payload(frame.id)?;
390        let mut stdout = io::stdout();
391        stdout.write_all(&bytes)?;
392        stdout.flush()?;
393        return Ok(());
394    }
395
396    let canonical_text = canonical_text_for_view(&mut mem, &frame)?;
397    let manifest_from_meta = canonical_manifest_from_frame(&canonical_text, &frame);
398
399    let page_size = args
400        .page_size
401        .or_else(|| manifest_from_meta.as_ref().map(|m| m.chunk_chars))
402        .unwrap_or(DEFAULT_VIEW_PAGE_CHARS);
403
404    let mut manifest = if args.page_size.is_none() {
405        manifest_from_meta.unwrap_or_else(|| compute_chunk_manifest(&canonical_text, page_size))
406    } else {
407        compute_chunk_manifest(&canonical_text, page_size)
408    };
409    if manifest.chunks.is_empty() {
410        manifest = TextChunkManifest {
411            chunk_chars: page_size,
412            chunks: vec![TextChunkRange {
413                start: 0,
414                end: canonical_text.chars().count(),
415            }],
416        };
417    }
418
419    if frame.role == FrameRole::DocumentChunk && args.page_size.is_none() {
420        let total_chars = canonical_text.chars().count();
421        manifest = TextChunkManifest {
422            chunk_chars: total_chars.max(1),
423            chunks: vec![TextChunkRange {
424                start: 0,
425                end: total_chars,
426            }],
427        };
428    }
429
430    let total_pages = manifest.chunks.len().max(1);
431    if args.page > total_pages {
432        bail!(
433            "page {} is out of range (total pages: {})",
434            args.page,
435            total_pages
436        );
437    }
438
439    let chunk = &manifest.chunks[args.page - 1];
440    let content = extract_chunk_slice(&canonical_text, chunk);
441
442    if args.json {
443        let mut frame_json = frame_to_json(&frame);
444        if let Some(obj) = frame_json.as_object_mut() {
445            // Note: Do NOT overwrite search_text - it contains the extracted text from the document.
446            // The "content" field shows the paginated payload view.
447            if let Some(manifest_json) = obj.get_mut("chunk_manifest") {
448                if let Some(manifest_obj) = manifest_json.as_object_mut() {
449                    let total = manifest.chunks.len();
450                    if total > 0 {
451                        let mut window = serde_json::Map::new();
452                        let idx = args.page.saturating_sub(1).min(total - 1);
453                        if idx > 0 {
454                            let prev = &manifest.chunks[idx - 1];
455                            window.insert("prev".into(), json!([prev.start, prev.end]));
456                        }
457                        let current = &manifest.chunks[idx];
458                        window.insert("current".into(), json!([current.start, current.end]));
459                        if idx + 1 < total {
460                            let next = &manifest.chunks[idx + 1];
461                            window.insert("next".into(), json!([next.start, next.end]));
462                        }
463                        manifest_obj.insert("chunks".into(), Value::Object(window));
464                    }
465                }
466            }
467        }
468        let json = json!({
469            "frame": frame_json,
470            "page": args.page,
471            "page_size": manifest.chunk_chars,
472            "page_count": total_pages,
473            "has_prev": args.page > 1,
474            "has_next": args.page < total_pages,
475            "content": content,
476        });
477        println!("{}", serde_json::to_string_pretty(&json)?);
478    } else {
479        print_frame_summary(&mut mem, &frame)?;
480        println!(
481            "Page {}/{} ({} chars per page)",
482            args.page, total_pages, manifest.chunk_chars
483        );
484        println!();
485        println!("{}", content);
486    }
487    Ok(())
488}
489
490#[derive(Debug)]
491pub struct PreviewBounds {
492    pub start_ms: Option<u64>,
493    pub end_ms: Option<u64>,
494}
495
496pub fn parse_preview_bounds(
497    start: Option<&String>,
498    end: Option<&String>,
499) -> Result<Option<PreviewBounds>> {
500    let start_ms = match start {
501        Some(value) => Some(parse_timecode(value)?),
502        None => None,
503    };
504    let end_ms = match end {
505        Some(value) => Some(parse_timecode(value)?),
506        None => None,
507    };
508
509    if let (Some(s), Some(e)) = (start_ms, end_ms) {
510        if e <= s {
511            anyhow::bail!("--end must be greater than --start");
512        }
513    }
514
515    if start_ms.is_none() && end_ms.is_none() {
516        Ok(None)
517    } else {
518        Ok(Some(PreviewBounds { start_ms, end_ms }))
519    }
520}
521
522fn preview_frame_media(
523    mem: &mut Memvid,
524    frame: &Frame,
525    cli_uri: Option<&str>,
526    bounds: Option<PreviewBounds>,
527) -> Result<()> {
528    let manifest = mem.media_manifest(frame.id)?;
529    let mime = manifest
530        .as_ref()
531        .map(|m| m.mime.clone())
532        .or_else(|| frame.metadata.as_ref().and_then(|meta| meta.mime.clone()))
533        .unwrap_or_else(|| "application/octet-stream".to_string());
534    let is_video = manifest
535        .as_ref()
536        .map(|media| media.kind.eq_ignore_ascii_case("video"))
537        .unwrap_or_else(|| mime.starts_with("video/"));
538
539    if is_video {
540        preview_frame_video(mem, frame, cli_uri, bounds, manifest, &mime)?;
541    } else {
542        if bounds.is_some() {
543            anyhow::bail!("--start/--end are only supported for video previews");
544        }
545        if is_image_mime(&mime) {
546            preview_frame_image(mem, frame, cli_uri)?;
547        } else if is_audio_mime(&mime) {
548            preview_frame_audio_file(mem, frame, cli_uri, manifest.as_ref(), &mime)?;
549        } else {
550            preview_frame_document(mem, frame, cli_uri, manifest.as_ref(), &mime)?;
551        }
552    }
553    Ok(())
554}
555
556fn preview_frame_video(
557    mem: &mut Memvid,
558    frame: &Frame,
559    cli_uri: Option<&str>,
560    bounds: Option<PreviewBounds>,
561    manifest: Option<MediaManifest>,
562    mime: &str,
563) -> Result<()> {
564    let extension = manifest
565        .as_ref()
566        .and_then(|m| m.filename.as_deref())
567        .and_then(|name| Path::new(name).extension().and_then(|ext| ext.to_str()))
568        .map(|ext| ext.trim_start_matches('.').to_ascii_lowercase())
569        .or_else(|| extension_from_mime(mime).map(|ext| ext.to_string()))
570        .unwrap_or_else(|| "mp4".to_string());
571
572    let mut temp_file = Builder::new()
573        .prefix("memvid-preview-")
574        .suffix(&format!(".{extension}"))
575        .tempfile_in(std::env::temp_dir())
576        .context("failed to create temporary preview file")?;
577
578    let mut reader = mem
579        .blob_reader(frame.id)
580        .context("failed to stream payload for preview")?;
581    io::copy(&mut reader, &mut temp_file).context("failed to write video data to preview file")?;
582    temp_file
583        .flush()
584        .context("failed to flush video preview to disk")?;
585
586    let (file, preview_path) = temp_file.keep().context("failed to persist preview file")?;
587    drop(file);
588
589    let mut display_path = preview_path.clone();
590    if let Some(ref span) = bounds {
591        let needs_trim = span.start_ms.is_some() || span.end_ms.is_some();
592        if needs_trim {
593            if let Some(trimmed) = maybe_trim_with_ffmpeg(&preview_path, &extension, span)? {
594                display_path = trimmed;
595            }
596        }
597    }
598
599    println!("Opening preview...");
600    open::that(&display_path).with_context(|| {
601        format!(
602            "failed to launch default video player for {}",
603            display_path.display()
604        )
605    })?;
606
607    let display_uri = cli_uri
608        .or_else(|| frame.uri.as_deref())
609        .unwrap_or("<unknown>");
610    println!(
611        "Opened preview for {} (frame {}) -> {} ({})",
612        display_uri,
613        frame.id,
614        display_path.display(),
615        mime
616    );
617    Ok(())
618}
619
620fn maybe_trim_with_ffmpeg(
621    source: &Path,
622    extension: &str,
623    bounds: &PreviewBounds,
624) -> Result<Option<PathBuf>> {
625    if bounds.start_ms.is_none() && bounds.end_ms.is_none() {
626        return Ok(None);
627    }
628
629    let ffmpeg = match which::which("ffmpeg") {
630        Ok(path) => path,
631        Err(_) => {
632            warn!("ffmpeg binary not found on PATH; opening full video");
633            return Ok(None);
634        }
635    };
636
637    let target = std::env::temp_dir().join(format!(
638        "memvid-preview-clip-{}.{}",
639        Uuid::new_v4(),
640        extension
641    ));
642
643    let mut command = Command::new(ffmpeg);
644    command.arg("-y");
645    if let Some(start) = bounds.start_ms {
646        command.arg("-ss").arg(format_timestamp_ms(start));
647    }
648    command.arg("-i").arg(source);
649    if let Some(end) = bounds.end_ms {
650        command.arg("-to").arg(format_timestamp_ms(end));
651    }
652    command.arg("-c").arg("copy");
653    command.arg(&target);
654
655    let status = command
656        .status()
657        .context("failed to run ffmpeg for preview trimming")?;
658    if status.success() {
659        return Ok(Some(target));
660    }
661
662    let details = status
663        .code()
664        .map(|code| code.to_string())
665        .unwrap_or_else(|| "terminated".to_string());
666    warn!("ffmpeg exited with status {details}; opening full video");
667    Ok(None)
668}
669
670fn preview_frame_image(mem: &mut Memvid, frame: &Frame, cli_uri: Option<&str>) -> Result<()> {
671    let bytes = mem
672        .frame_canonical_payload(frame.id)
673        .context("failed to load canonical payload for frame")?;
674    if bytes.is_empty() {
675        bail!("frame payload is empty; nothing to preview");
676    }
677
678    let detected_kind = infer::get(&bytes);
679    let mut mime = frame
680        .metadata
681        .as_ref()
682        .and_then(|meta| meta.mime.clone())
683        .filter(|value| is_image_mime(value));
684
685    if mime.is_none() {
686        if let Some(kind) = &detected_kind {
687            let candidate = kind.mime_type();
688            if is_image_mime(candidate) {
689                mime = Some(candidate.to_string());
690            }
691        }
692    }
693
694    let mime = mime.ok_or_else(|| anyhow!("frame does not contain an image payload"))?;
695    if !is_image_mime(&mime) {
696        bail!("frame mime type {mime} is not an image");
697    }
698
699    let extension = detected_kind
700        .as_ref()
701        .map(|kind| kind.extension().to_string())
702        .or_else(|| extension_from_mime(&mime).map(|ext| ext.to_string()))
703        .unwrap_or_else(|| "img".to_string());
704
705    let suffix = format!(".{extension}");
706    let mut temp_file = Builder::new()
707        .prefix("memvid-preview-")
708        .suffix(&suffix)
709        .tempfile_in(std::env::temp_dir())
710        .context("failed to create temporary preview file")?;
711    temp_file
712        .write_all(&bytes)
713        .context("failed to write image data to preview file")?;
714    temp_file
715        .flush()
716        .context("failed to flush preview file to disk")?;
717
718    let (file, preview_path) = temp_file.keep().context("failed to persist preview file")?;
719    drop(file);
720
721    println!("Opening preview...");
722    open::that(&preview_path).with_context(|| {
723        format!(
724            "failed to launch default image viewer for {}",
725            preview_path.display()
726        )
727    })?;
728
729    let display_uri = cli_uri
730        .or_else(|| frame.uri.as_deref())
731        .unwrap_or("<unknown>");
732    println!(
733        "Opened preview for {} (frame {}) -> {} ({})",
734        display_uri,
735        frame.id,
736        preview_path.display(),
737        mime
738    );
739    Ok(())
740}
741
742fn preview_frame_document(
743    mem: &mut Memvid,
744    frame: &Frame,
745    cli_uri: Option<&str>,
746    manifest: Option<&MediaManifest>,
747    mime: &str,
748) -> Result<()> {
749    let bytes = mem
750        .frame_canonical_payload(frame.id)
751        .context("failed to load canonical payload for frame")?;
752    if bytes.is_empty() {
753        bail!("frame payload is empty; nothing to preview");
754    }
755
756    let mut extension = manifest
757        .and_then(|m| m.filename.as_deref())
758        .and_then(|name| Path::new(name).extension().and_then(|ext| ext.to_str()))
759        .map(|ext| ext.trim_start_matches('.').to_string())
760        .or_else(|| extension_from_mime(mime).map(|ext| ext.to_string()))
761        .unwrap_or_else(|| "bin".to_string());
762
763    if extension == "bin" && std::str::from_utf8(&bytes).is_ok() {
764        extension = "txt".to_string();
765    }
766
767    let suffix = format!(".{extension}");
768    let mut temp_file = Builder::new()
769        .prefix("memvid-preview-")
770        .suffix(&suffix)
771        .tempfile_in(std::env::temp_dir())
772        .context("failed to create temporary preview file")?;
773    temp_file
774        .write_all(&bytes)
775        .context("failed to write document data to preview file")?;
776    temp_file
777        .flush()
778        .context("failed to flush preview file to disk")?;
779
780    let (file, preview_path) = temp_file.keep().context("failed to persist preview file")?;
781    drop(file);
782
783    println!("Opening preview...");
784    open::that(&preview_path).with_context(|| {
785        format!(
786            "failed to launch default viewer for {}",
787            preview_path.display()
788        )
789    })?;
790
791    let display_uri = cli_uri
792        .or_else(|| frame.uri.as_deref())
793        .unwrap_or("<unknown>");
794    println!(
795        "Opened preview for {} (frame {}) -> {} ({})",
796        display_uri,
797        frame.id,
798        preview_path.display(),
799        mime
800    );
801    Ok(())
802}
803
804fn preview_frame_audio_file(
805    mem: &mut Memvid,
806    frame: &Frame,
807    cli_uri: Option<&str>,
808    manifest: Option<&MediaManifest>,
809    mime: &str,
810) -> Result<()> {
811    let bytes = mem
812        .frame_canonical_payload(frame.id)
813        .context("failed to load canonical payload for frame")?;
814    if bytes.is_empty() {
815        bail!("frame payload is empty; nothing to preview");
816    }
817
818    let mut extension = manifest
819        .and_then(|m| m.filename.as_deref())
820        .and_then(|name| Path::new(name).extension().and_then(|ext| ext.to_str()))
821        .map(|ext| ext.trim_start_matches('.').to_string())
822        .or_else(|| extension_from_mime(mime).map(|ext| ext.to_string()))
823        .unwrap_or_else(|| "audio".to_string());
824
825    if extension == "bin" {
826        extension = "audio".to_string();
827    }
828
829    let suffix = format!(".{extension}");
830    let mut temp_file = Builder::new()
831        .prefix("memvid-preview-")
832        .suffix(&suffix)
833        .tempfile_in(std::env::temp_dir())
834        .context("failed to create temporary preview file")?;
835    temp_file
836        .write_all(&bytes)
837        .context("failed to write audio data to preview file")?;
838    temp_file
839        .flush()
840        .context("failed to flush preview file to disk")?;
841
842    let (file, preview_path) = temp_file.keep().context("failed to persist preview file")?;
843    drop(file);
844
845    println!("Opening preview...");
846    open::that(&preview_path).with_context(|| {
847        format!(
848            "failed to launch default audio player for {}",
849            preview_path.display()
850        )
851    })?;
852
853    let display_uri = cli_uri
854        .or_else(|| frame.uri.as_deref())
855        .unwrap_or("<unknown>");
856    println!(
857        "Opened preview for {} (frame {}) -> {} ({})",
858        display_uri,
859        frame.id,
860        preview_path.display(),
861        mime
862    );
863    Ok(())
864}
865
866#[cfg(feature = "audio-playback")]
867fn play_frame_audio(
868    mem: &mut Memvid,
869    frame: &Frame,
870    start_seconds: Option<f32>,
871    end_seconds: Option<f32>,
872) -> Result<()> {
873    use rodio::Source;
874
875    if let (Some(start), Some(end)) = (start_seconds, end_seconds) {
876        if end <= start {
877            bail!("--end-seconds must be greater than --start-seconds");
878        }
879    }
880
881    let bytes = mem
882        .frame_canonical_payload(frame.id)
883        .context("failed to load canonical payload for frame")?;
884    if bytes.is_empty() {
885        bail!("frame payload is empty; nothing to play");
886    }
887
888    let start = start_seconds.unwrap_or(0.0).max(0.0);
889    let duration_meta = frame
890        .metadata
891        .as_ref()
892        .and_then(|meta| meta.audio.as_ref())
893        .and_then(|audio| audio.duration_secs)
894        .unwrap_or(0.0);
895
896    if duration_meta > 0.0 && start >= duration_meta {
897        bail!("start-seconds ({start:.2}) exceeds audio duration ({duration_meta:.2})");
898    }
899
900    if let Some(end) = end_seconds {
901        if duration_meta > 0.0 && end > duration_meta + f32::EPSILON {
902            warn!(
903                "requested end-seconds {:.2} exceeds known duration {:.2}; clamping",
904                end, duration_meta
905            );
906        }
907    }
908
909    let cursor = Cursor::new(bytes);
910    let decoder = rodio::Decoder::new(cursor).context("failed to decode audio stream")?;
911    let (_stream, stream_handle) =
912        rodio::OutputStream::try_default().context("failed to open default audio output")?;
913    let sink = rodio::Sink::try_new(&stream_handle).context("failed to create audio sink")?;
914    let display_uri = frame.uri.as_deref().unwrap_or("<unknown>");
915
916    if let Some(end) = end_seconds {
917        let effective_end = if duration_meta > 0.0 {
918            end.min(duration_meta)
919        } else {
920            end
921        };
922        let duration = (effective_end - start).max(0.0);
923        if duration <= 0.0 {
924            bail!("playback duration is zero; adjust start/end seconds");
925        }
926        let source = decoder
927            .skip_duration(Duration::from_secs_f32(start))
928            .take_duration(Duration::from_secs_f32(duration));
929        sink.append(source);
930        let segment_desc = format!("{start:.2}s → {effective_end:.2}s");
931        announce_playback(display_uri, &segment_desc);
932    } else {
933        let source = decoder.skip_duration(Duration::from_secs_f32(start));
934        sink.append(source);
935        let segment_desc = format!("{start:.2}s → end");
936        announce_playback(display_uri, &segment_desc);
937    }
938    sink.sleep_until_end();
939    Ok(())
940}
941
942#[cfg(feature = "audio-playback")]
943fn announce_playback(uri: &str, segment_desc: &str) {
944    println!("Playing {uri} ({segment_desc})");
945}
946
947fn is_image_mime(value: &str) -> bool {
948    let normalized = value.split(';').next().unwrap_or(value).trim();
949    normalized.to_ascii_lowercase().starts_with("image/")
950}
951
952fn is_audio_mime(value: &str) -> bool {
953    let normalized = value.split(';').next().unwrap_or(value).trim();
954    normalized.to_ascii_lowercase().starts_with("audio/")
955}
956
957pub fn extension_from_mime(mime: &str) -> Option<&'static str> {
958    let normalized = mime
959        .split(';')
960        .next()
961        .unwrap_or(mime)
962        .trim()
963        .to_ascii_lowercase();
964    match normalized.as_str() {
965        "image/jpeg" | "image/jpg" => Some("jpg"),
966        "image/png" => Some("png"),
967        "image/gif" => Some("gif"),
968        "image/webp" => Some("webp"),
969        "image/bmp" => Some("bmp"),
970        "image/tiff" => Some("tiff"),
971        "image/x-icon" | "image/vnd.microsoft.icon" => Some("ico"),
972        "image/svg+xml" => Some("svg"),
973        "video/mp4" | "video/iso.segment" => Some("mp4"),
974        "video/quicktime" => Some("mov"),
975        "video/webm" => Some("webm"),
976        "video/x-matroska" | "video/matroska" => Some("mkv"),
977        "video/x-msvideo" => Some("avi"),
978        "video/mpeg" => Some("mpg"),
979        "application/pdf" => Some("pdf"),
980        "audio/mpeg" | "audio/mp3" => Some("mp3"),
981        "audio/wav" | "audio/x-wav" => Some("wav"),
982        "audio/x-flac" | "audio/flac" => Some("flac"),
983        "audio/ogg" | "audio/vorbis" => Some("ogg"),
984        "audio/x-m4a" | "audio/mp4" => Some("m4a"),
985        "audio/aac" => Some("aac"),
986        "audio/x-aiff" | "audio/aiff" => Some("aiff"),
987        "text/plain" => Some("txt"),
988        "text/markdown" | "text/x-markdown" => Some("md"),
989        "text/html" => Some("html"),
990        "application/xhtml+xml" => Some("xhtml"),
991        "application/json" | "text/json" | "application/vnd.api+json" => Some("json"),
992        "application/xml" | "text/xml" => Some("xml"),
993        "text/csv" | "application/csv" => Some("csv"),
994        "application/javascript" | "text/javascript" => Some("js"),
995        "text/css" => Some("css"),
996        "application/yaml" | "application/x-yaml" | "text/yaml" => Some("yaml"),
997        "application/rtf" => Some("rtf"),
998        "application/msword" => Some("doc"),
999        "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => Some("docx"),
1000        "application/vnd.ms-powerpoint" => Some("ppt"),
1001        "application/vnd.openxmlformats-officedocument.presentationml.presentation" => Some("pptx"),
1002        "application/vnd.ms-excel" => Some("xls"),
1003        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => Some("xlsx"),
1004        "application/zip" => Some("zip"),
1005        "application/x-tar" => Some("tar"),
1006        "application/x-7z-compressed" => Some("7z"),
1007        _ => None,
1008    }
1009}
1010pub fn search_snippet(text: Option<&String>) -> Option<String> {
1011    text.and_then(|value| {
1012        let trimmed = value.trim();
1013        if trimmed.is_empty() {
1014            None
1015        } else {
1016            Some(trimmed.chars().take(160).collect())
1017        }
1018    })
1019}
1020pub fn frame_to_json(frame: &Frame) -> serde_json::Value {
1021    json!({
1022        "id": frame.id,
1023        "status": frame_status_str(frame.status),
1024        "timestamp": frame.timestamp,
1025        "kind": frame.kind,
1026        "track": frame.track,
1027        "uri": frame.uri,
1028        "title": frame.title,
1029        "payload_length": frame.payload_length,
1030        "canonical_encoding": format!("{:?}", frame.canonical_encoding),
1031        "canonical_length": frame.canonical_length,
1032        "role": format!("{:?}", frame.role),
1033        "parent_id": frame.parent_id,
1034        "chunk_index": frame.chunk_index,
1035        "chunk_count": frame.chunk_count,
1036        "tags": frame.tags,
1037        "labels": frame.labels,
1038        "search_text": frame.search_text,
1039        "metadata": frame.metadata,
1040        "extra_metadata": frame.extra_metadata,
1041        "content_dates": frame.content_dates,
1042        "chunk_manifest": frame.chunk_manifest,
1043        "supersedes": frame.supersedes,
1044        "superseded_by": frame.superseded_by,
1045    })
1046}
1047pub fn print_frame_summary(mem: &mut Memvid, frame: &Frame) -> Result<()> {
1048    println!("Frame {} [{}]", frame.id, frame_status_str(frame.status));
1049    println!("Timestamp: {}", frame.timestamp);
1050    if let Some(uri) = &frame.uri {
1051        println!("URI: {uri}");
1052    }
1053    if let Some(title) = &frame.title {
1054        println!("Title: {title}");
1055    }
1056    if let Some(kind) = &frame.kind {
1057        println!("Kind: {kind}");
1058    }
1059    if let Some(track) = &frame.track {
1060        println!("Track: {track}");
1061    }
1062    if let Some(supersedes) = frame.supersedes {
1063        println!("Supersedes frame: {supersedes}");
1064    }
1065    if let Some(successor) = frame.superseded_by {
1066        println!("Superseded by frame: {successor}");
1067    }
1068    println!(
1069        "Payload: {} bytes (canonical {:?}, logical {:?})",
1070        frame.payload_length, frame.canonical_encoding, frame.canonical_length
1071    );
1072    if !frame.tags.is_empty() {
1073        println!("Tags: {}", frame.tags.join(", "));
1074    }
1075    if !frame.labels.is_empty() {
1076        println!("Labels: {}", frame.labels.join(", "));
1077    }
1078    if let Some(snippet) = search_snippet(frame.search_text.as_ref()) {
1079        println!("Search text: {snippet}");
1080    }
1081    if let Some(meta) = &frame.metadata {
1082        let rendered = serde_json::to_string_pretty(meta)?;
1083        println!("Metadata: {rendered}");
1084    }
1085    if !frame.extra_metadata.is_empty() {
1086        let mut entries: Vec<_> = frame.extra_metadata.iter().collect();
1087        entries.sort_by(|a, b| a.0.cmp(b.0));
1088        println!("Extra metadata:");
1089        for (key, value) in entries {
1090            println!("  {key}: {value}");
1091        }
1092    }
1093    if !frame.content_dates.is_empty() {
1094        println!("Content dates: {}", frame.content_dates.join(", "));
1095    }
1096    match mem.frame_embedding(frame.id) {
1097        Ok(Some(embedding)) => println!("Embedding: {} dimensions", embedding.len()),
1098        Ok(None) => println!("Embedding: none"),
1099        Err(err) => println!("Embedding: unavailable ({err})"),
1100    }
1101    Ok(())
1102}
1103fn canonical_text_for_view(mem: &mut Memvid, frame: &Frame) -> Result<String> {
1104    let bytes = mem.frame_canonical_payload(frame.id)?;
1105    let raw = match String::from_utf8(bytes) {
1106        Ok(text) => text,
1107        Err(err) => {
1108            let bytes = err.into_bytes();
1109            String::from_utf8_lossy(&bytes).into_owned()
1110        }
1111    };
1112
1113    Ok(normalize_text(&raw, usize::MAX)
1114        .map(|n| n.text)
1115        .unwrap_or_default())
1116}
1117
1118fn manifests_match_text(text: &str, manifest: &TextChunkManifest) -> bool {
1119    if manifest.chunk_chars == 0 || manifest.chunks.is_empty() {
1120        return false;
1121    }
1122    let total_chars = text.chars().count();
1123    manifest
1124        .chunks
1125        .iter()
1126        .all(|chunk| chunk.start <= chunk.end && chunk.end <= total_chars)
1127}
1128
1129fn canonical_manifest_from_frame(text: &str, frame: &Frame) -> Option<TextChunkManifest> {
1130    let primary = frame
1131        .chunk_manifest
1132        .clone()
1133        .filter(|manifest| manifests_match_text(text, manifest));
1134    if primary.is_some() {
1135        return primary;
1136    }
1137
1138    frame
1139        .extra_metadata
1140        .get(CHUNK_MANIFEST_KEY)
1141        .and_then(|raw| serde_json::from_str::<TextChunkManifest>(raw).ok())
1142        .filter(|manifest| manifests_match_text(text, manifest))
1143}
1144
1145fn compute_chunk_manifest(text: &str, chunk_chars: usize) -> TextChunkManifest {
1146    let normalized = normalize_text(text, usize::MAX)
1147        .map(|n| n.text)
1148        .unwrap_or_default();
1149
1150    let effective_chunk = chunk_chars.max(1);
1151    let total_chars = normalized.chars().count();
1152    if total_chars == 0 {
1153        return TextChunkManifest {
1154            chunk_chars: effective_chunk,
1155            chunks: vec![TextChunkRange { start: 0, end: 0 }],
1156        };
1157    }
1158    if total_chars <= effective_chunk {
1159        return TextChunkManifest {
1160            chunk_chars: effective_chunk,
1161            chunks: vec![TextChunkRange {
1162                start: 0,
1163                end: total_chars,
1164            }],
1165        };
1166    }
1167    let mut chunks = Vec::new();
1168    let mut start = 0usize;
1169    while start < total_chars {
1170        let end = (start + effective_chunk).min(total_chars);
1171        chunks.push(TextChunkRange { start, end });
1172        start = end;
1173    }
1174    TextChunkManifest {
1175        chunk_chars: effective_chunk,
1176        chunks,
1177    }
1178}
1179
1180fn extract_chunk_slice(text: &str, range: &TextChunkRange) -> String {
1181    if range.start >= range.end || text.is_empty() {
1182        return String::new();
1183    }
1184    let mut start_byte = text.len();
1185    let mut end_byte = text.len();
1186    let mut idx = 0usize;
1187    for (byte_offset, _) in text.char_indices() {
1188        if idx == range.start {
1189            start_byte = byte_offset;
1190        }
1191        if idx == range.end {
1192            end_byte = byte_offset;
1193            break;
1194        }
1195        idx += 1;
1196    }
1197    if start_byte == text.len() {
1198        return String::new();
1199    }
1200    if end_byte == text.len() {
1201        end_byte = text.len();
1202    }
1203    text[start_byte..end_byte].to_string()
1204}