Skip to main content

rlm_rs/cli/
commands.rs

1//! CLI command implementations.
2//!
3//! Contains the business logic for each CLI command.
4
5// Allow style choices for clarity
6#![allow(clippy::format_push_string)]
7#![allow(clippy::uninlined_format_args)]
8#![allow(clippy::too_many_lines)]
9#![allow(clippy::option_if_let_else)]
10#![allow(clippy::manual_div_ceil)]
11#![allow(clippy::redundant_closure_for_method_calls)]
12#![allow(clippy::if_not_else)]
13
14use crate::chunking::{ChunkerMetadata, create_chunker};
15use crate::cli::output::{
16    GrepMatch, OutputFormat, format_buffer, format_buffer_list, format_chunk_indices,
17    format_grep_matches, format_peek, format_status, format_write_chunks_result,
18};
19use crate::cli::parser::{ChunkCommands, Cli, Commands};
20use crate::core::{Buffer, Context, ContextValue};
21use crate::embedding::create_embedder;
22use crate::error::{CommandError, Result, StorageError};
23use crate::io::{read_file, write_file};
24use crate::search::{
25    SearchConfig, SearchResult, embed_buffer_chunks, hybrid_search, hybrid_search_in_buffer,
26};
27use crate::storage::{SqliteStorage, Storage};
28use regex::RegexBuilder;
29use std::fmt::Write as FmtWrite;
30use std::io::{self, Read, Write as IoWrite};
31
32/// Executes the CLI command.
33///
34/// # Arguments
35///
36/// * `cli` - Parsed CLI arguments.
37///
38/// # Returns
39///
40/// Result with output string on success.
41///
42/// # Errors
43///
44/// Returns an error if the command fails to execute.
45#[allow(clippy::too_many_lines)]
46pub fn execute(cli: &Cli) -> Result<String> {
47    let format = OutputFormat::parse(&cli.format);
48    let db_path = cli.get_db_path();
49
50    match &cli.command {
51        Commands::Init { force } => cmd_init(&db_path, *force, format),
52        Commands::Status => cmd_status(&db_path, format),
53        Commands::Reset { yes } => cmd_reset(&db_path, *yes, format),
54        Commands::Load {
55            file,
56            name,
57            chunker,
58            chunk_size,
59            overlap,
60        } => cmd_load(
61            &db_path,
62            file,
63            name.as_deref(),
64            chunker,
65            *chunk_size,
66            *overlap,
67            format,
68        ),
69        Commands::ListBuffers => cmd_list_buffers(&db_path, format),
70        Commands::ShowBuffer { buffer, chunks } => {
71            cmd_show_buffer(&db_path, buffer, *chunks, format)
72        }
73        Commands::DeleteBuffer { buffer, yes } => cmd_delete_buffer(&db_path, buffer, *yes, format),
74        Commands::Peek { buffer, start, end } => cmd_peek(&db_path, buffer, *start, *end, format),
75        Commands::Grep {
76            buffer,
77            pattern,
78            max_matches,
79            window,
80            ignore_case,
81        } => cmd_grep(
82            &db_path,
83            buffer,
84            pattern,
85            *max_matches,
86            *window,
87            *ignore_case,
88            format,
89        ),
90        Commands::ChunkIndices {
91            buffer,
92            chunk_size,
93            overlap,
94        } => cmd_chunk_indices(&db_path, buffer, *chunk_size, *overlap, format),
95        Commands::WriteChunks {
96            buffer,
97            out_dir,
98            chunk_size,
99            overlap,
100            prefix,
101        } => cmd_write_chunks(
102            &db_path,
103            buffer,
104            out_dir,
105            *chunk_size,
106            *overlap,
107            prefix,
108            format,
109        ),
110        Commands::AddBuffer { name, content } => {
111            cmd_add_buffer(&db_path, name, content.as_deref(), format)
112        }
113        Commands::UpdateBuffer {
114            buffer,
115            content,
116            embed,
117            strategy,
118            chunk_size,
119            overlap,
120        } => cmd_update_buffer(
121            &db_path,
122            buffer,
123            content.as_deref(),
124            *embed,
125            strategy,
126            *chunk_size,
127            *overlap,
128            format,
129        ),
130        Commands::ExportBuffers { output, pretty } => {
131            cmd_export_buffers(&db_path, output.as_deref(), *pretty, format)
132        }
133        Commands::Variable {
134            name,
135            value,
136            delete,
137        } => cmd_variable(&db_path, name, value.as_deref(), *delete, format),
138        Commands::Global {
139            name,
140            value,
141            delete,
142        } => cmd_global(&db_path, name, value.as_deref(), *delete, format),
143        Commands::Search {
144            query,
145            top_k,
146            threshold,
147            mode,
148            rrf_k,
149            buffer,
150            preview,
151            preview_len,
152        } => cmd_search(
153            &db_path,
154            query,
155            *top_k,
156            *threshold,
157            mode,
158            *rrf_k,
159            buffer.as_deref(),
160            *preview,
161            *preview_len,
162            format,
163        ),
164        Commands::Aggregate {
165            buffer,
166            min_relevance,
167            group_by,
168            sort_by,
169            output_buffer,
170        } => cmd_aggregate(
171            &db_path,
172            buffer.as_deref(),
173            min_relevance,
174            group_by,
175            sort_by,
176            output_buffer.as_deref(),
177            format,
178        ),
179        Commands::Dispatch {
180            buffer,
181            batch_size,
182            workers,
183            query,
184            mode,
185            threshold,
186        } => cmd_dispatch(
187            &db_path,
188            buffer,
189            *batch_size,
190            *workers,
191            query.as_deref(),
192            mode,
193            *threshold,
194            format,
195        ),
196        Commands::Chunk(chunk_cmd) => match chunk_cmd {
197            ChunkCommands::Get { id, metadata } => cmd_chunk_get(&db_path, *id, *metadata, format),
198            ChunkCommands::List {
199                buffer,
200                preview,
201                preview_len,
202            } => cmd_chunk_list(&db_path, buffer, *preview, *preview_len, format),
203            ChunkCommands::Embed { buffer, force } => {
204                cmd_chunk_embed(&db_path, buffer, *force, format)
205            }
206            ChunkCommands::Status => cmd_chunk_status(&db_path, format),
207        },
208    }
209}
210
211/// Opens storage and ensures it's initialized.
212fn open_storage(db_path: &std::path::Path) -> Result<SqliteStorage> {
213    let storage = SqliteStorage::open(db_path)?;
214
215    if !storage.is_initialized()? {
216        return Err(StorageError::NotInitialized.into());
217    }
218
219    Ok(storage)
220}
221
222/// Resolves a buffer identifier (ID or name) to a buffer.
223fn resolve_buffer(storage: &SqliteStorage, identifier: &str) -> Result<Buffer> {
224    // Try as ID first
225    if let Ok(id) = identifier.parse::<i64>()
226        && let Some(buffer) = storage.get_buffer(id)?
227    {
228        return Ok(buffer);
229    }
230
231    // Try as name
232    if let Some(buffer) = storage.get_buffer_by_name(identifier)? {
233        return Ok(buffer);
234    }
235
236    Err(StorageError::BufferNotFound {
237        identifier: identifier.to_string(),
238    }
239    .into())
240}
241
242// ==================== Command Implementations ====================
243
244fn cmd_init(db_path: &std::path::Path, force: bool, _format: OutputFormat) -> Result<String> {
245    // Check if already exists
246    if db_path.exists() && !force {
247        return Err(CommandError::ExecutionFailed(
248            "Database already exists. Use --force to reinitialize.".to_string(),
249        )
250        .into());
251    }
252
253    // Create parent directory if needed
254    if let Some(parent) = db_path.parent()
255        && !parent.exists()
256    {
257        std::fs::create_dir_all(parent).map_err(|e| {
258            CommandError::ExecutionFailed(format!("Failed to create directory: {e}"))
259        })?;
260    }
261
262    // If force, delete existing
263    if force && db_path.exists() {
264        std::fs::remove_file(db_path).map_err(|e| {
265            CommandError::ExecutionFailed(format!("Failed to remove existing database: {e}"))
266        })?;
267    }
268
269    let mut storage = SqliteStorage::open(db_path)?;
270    storage.init()?;
271
272    // Initialize empty context
273    let context = Context::new();
274    storage.save_context(&context)?;
275
276    Ok(format!(
277        "Initialized RLM database at: {}\n",
278        db_path.display()
279    ))
280}
281
282fn cmd_status(db_path: &std::path::Path, format: OutputFormat) -> Result<String> {
283    let storage = open_storage(db_path)?;
284    let stats = storage.stats()?;
285    Ok(format_status(&stats, format))
286}
287
288fn cmd_reset(db_path: &std::path::Path, yes: bool, _format: OutputFormat) -> Result<String> {
289    if !yes {
290        // In a real implementation, we'd prompt the user
291        // For now, require --yes flag
292        return Err(CommandError::ExecutionFailed(
293            "Use --yes to confirm reset. This will delete all data.".to_string(),
294        )
295        .into());
296    }
297
298    let mut storage = open_storage(db_path)?;
299    storage.reset()?;
300
301    // Reinitialize with empty context
302    let context = Context::new();
303    storage.save_context(&context)?;
304
305    Ok("RLM state reset successfully.\n".to_string())
306}
307
308fn chunker_metadata(buffer: &Buffer, chunk_size: usize, overlap: usize) -> ChunkerMetadata {
309    let mut metadata = ChunkerMetadata::with_size_and_overlap(chunk_size, overlap);
310    if let Some(source) = &buffer.source {
311        metadata = metadata.source(source.to_string_lossy().as_ref());
312    }
313    if let Some(content_type) = buffer.metadata.content_type.as_deref() {
314        metadata = metadata.content_type(content_type);
315    }
316    metadata
317}
318
319fn cmd_load(
320    db_path: &std::path::Path,
321    file: &std::path::Path,
322    name: Option<&str>,
323    chunker_name: &str,
324    chunk_size: usize,
325    overlap: usize,
326    format: OutputFormat,
327) -> Result<String> {
328    let mut storage = open_storage(db_path)?;
329
330    // Read file content
331    let content = read_file(file)?;
332
333    // Create buffer
334    let buffer_name = name
335        .map(String::from)
336        .or_else(|| file.file_name().and_then(|n| n.to_str()).map(String::from));
337
338    let mut buffer = Buffer::from_file(file.to_path_buf(), content.clone());
339    buffer.name = buffer_name;
340    buffer.compute_hash();
341
342    // Add buffer to storage
343    let buffer_id = storage.add_buffer(&buffer)?;
344
345    // Chunk the content
346    let chunker = create_chunker(chunker_name)?;
347    let meta = chunker_metadata(&buffer, chunk_size, overlap);
348    let chunks = chunker.chunk(buffer_id, &content, Some(&meta))?;
349
350    // Store chunks
351    storage.add_chunks(buffer_id, &chunks)?;
352
353    // Generate embeddings for semantic search (automatic during load)
354    let embedder = create_embedder()?;
355    let embedded_count = embed_buffer_chunks(&mut storage, embedder.as_ref(), buffer_id)?;
356
357    // Update buffer with chunk count
358    let mut updated_buffer =
359        storage
360            .get_buffer(buffer_id)?
361            .ok_or_else(|| StorageError::BufferNotFound {
362                identifier: buffer_id.to_string(),
363            })?;
364    updated_buffer.set_chunk_count(chunks.len());
365    storage.update_buffer(&updated_buffer)?;
366
367    // Update context
368    if let Some(mut context) = storage.load_context()? {
369        context.add_buffer(buffer_id);
370        storage.save_context(&context)?;
371    }
372
373    match format {
374        OutputFormat::Text => Ok(format!(
375            "Loaded buffer {} (ID: {}) with {} chunks ({} embedded) from {}\n",
376            updated_buffer.name.as_deref().unwrap_or("unnamed"),
377            buffer_id,
378            chunks.len(),
379            embedded_count,
380            file.display()
381        )),
382        OutputFormat::Json | OutputFormat::Ndjson => {
383            let result = serde_json::json!({
384                "buffer_id": buffer_id,
385                "name": updated_buffer.name,
386                "chunk_count": chunks.len(),
387                "embedded_count": embedded_count,
388                "size": content.len(),
389                "source": file.to_string_lossy()
390            });
391            Ok(serde_json::to_string_pretty(&result).unwrap_or_default())
392        }
393    }
394}
395
396fn cmd_list_buffers(db_path: &std::path::Path, format: OutputFormat) -> Result<String> {
397    let storage = open_storage(db_path)?;
398    let buffers = storage.list_buffers()?;
399    Ok(format_buffer_list(&buffers, format))
400}
401
402fn cmd_show_buffer(
403    db_path: &std::path::Path,
404    identifier: &str,
405    show_chunks: bool,
406    format: OutputFormat,
407) -> Result<String> {
408    let storage = open_storage(db_path)?;
409    let buffer = resolve_buffer(&storage, identifier)?;
410
411    let chunks = if show_chunks {
412        Some(storage.get_chunks(buffer.id.unwrap_or(0))?)
413    } else {
414        None
415    };
416
417    Ok(format_buffer(&buffer, chunks.as_deref(), format))
418}
419
420fn cmd_delete_buffer(
421    db_path: &std::path::Path,
422    identifier: &str,
423    yes: bool,
424    _format: OutputFormat,
425) -> Result<String> {
426    if !yes {
427        return Err(
428            CommandError::ExecutionFailed("Use --yes to confirm deletion.".to_string()).into(),
429        );
430    }
431
432    let mut storage = open_storage(db_path)?;
433    let buffer = resolve_buffer(&storage, identifier)?;
434    let buffer_id = buffer.id.unwrap_or(0);
435    let buffer_name = buffer.name.unwrap_or_else(|| format!("{buffer_id}"));
436
437    storage.delete_buffer(buffer_id)?;
438
439    // Update context
440    if let Some(mut context) = storage.load_context()? {
441        context.remove_buffer(buffer_id);
442        storage.save_context(&context)?;
443    }
444
445    Ok(format!("Deleted buffer: {buffer_name}\n"))
446}
447
448fn cmd_peek(
449    db_path: &std::path::Path,
450    identifier: &str,
451    start: usize,
452    end: Option<usize>,
453    format: OutputFormat,
454) -> Result<String> {
455    let storage = open_storage(db_path)?;
456    let buffer = resolve_buffer(&storage, identifier)?;
457
458    let end = end.unwrap_or(start + 3000).min(buffer.content.len());
459    let start = start.min(buffer.content.len());
460
461    let content = buffer.slice(start, end).unwrap_or("");
462    Ok(format_peek(content, start, end, format))
463}
464
465fn cmd_grep(
466    db_path: &std::path::Path,
467    identifier: &str,
468    pattern: &str,
469    max_matches: usize,
470    window: usize,
471    ignore_case: bool,
472    format: OutputFormat,
473) -> Result<String> {
474    let storage = open_storage(db_path)?;
475    let buffer = resolve_buffer(&storage, identifier)?;
476
477    let regex = RegexBuilder::new(pattern)
478        .case_insensitive(ignore_case)
479        .build()
480        .map_err(|e| CommandError::InvalidArgument(format!("Invalid regex: {e}")))?;
481
482    let mut matches = Vec::new();
483    for m in regex.find_iter(&buffer.content) {
484        if matches.len() >= max_matches {
485            break;
486        }
487
488        let start = m.start().saturating_sub(window);
489        let end = (m.end() + window).min(buffer.content.len());
490
491        // Find valid UTF-8 boundaries
492        let start = crate::io::find_char_boundary(&buffer.content, start);
493        let end = crate::io::find_char_boundary(&buffer.content, end);
494
495        matches.push(GrepMatch {
496            offset: m.start(),
497            matched: m.as_str().to_string(),
498            snippet: buffer.content[start..end].to_string(),
499        });
500    }
501
502    Ok(format_grep_matches(&matches, pattern, format))
503}
504
505fn cmd_chunk_indices(
506    db_path: &std::path::Path,
507    identifier: &str,
508    chunk_size: usize,
509    overlap: usize,
510    format: OutputFormat,
511) -> Result<String> {
512    let storage = open_storage(db_path)?;
513    let buffer = resolve_buffer(&storage, identifier)?;
514
515    let content_len = buffer.content.len();
516    let mut indices = Vec::new();
517
518    if chunk_size == 0 || overlap >= chunk_size {
519        return Err(
520            CommandError::InvalidArgument("Invalid chunk_size or overlap".to_string()).into(),
521        );
522    }
523
524    let step = chunk_size - overlap;
525    let mut start = 0;
526
527    while start < content_len {
528        let end = (start + chunk_size).min(content_len);
529        indices.push((start, end));
530        if end >= content_len {
531            break;
532        }
533        start += step;
534    }
535
536    Ok(format_chunk_indices(&indices, format))
537}
538
539fn cmd_write_chunks(
540    db_path: &std::path::Path,
541    identifier: &str,
542    out_dir: &std::path::Path,
543    chunk_size: usize,
544    overlap: usize,
545    prefix: &str,
546    format: OutputFormat,
547) -> Result<String> {
548    let mut storage = open_storage(db_path)?;
549    let buffer = resolve_buffer(&storage, identifier)?;
550    let buffer_id = buffer.id.unwrap_or(0);
551
552    // Create chunker and chunk the content
553    let chunker = create_chunker("semantic")?;
554    let meta = ChunkerMetadata::with_size_and_overlap(chunk_size, overlap);
555    let chunks = chunker.chunk(buffer_id, &buffer.content, Some(&meta))?;
556
557    // Store chunks in SQLite
558    storage.add_chunks(buffer_id, &chunks)?;
559
560    // Update buffer with chunk count
561    let mut updated_buffer =
562        storage
563            .get_buffer(buffer_id)?
564            .ok_or_else(|| StorageError::BufferNotFound {
565                identifier: buffer_id.to_string(),
566            })?;
567    updated_buffer.set_chunk_count(chunks.len());
568    storage.update_buffer(&updated_buffer)?;
569
570    // Write chunks to files
571    let chunks_iter = chunks
572        .iter()
573        .enumerate()
574        .map(|(i, c)| (i, c.content.as_str()));
575    let paths = crate::io::reader::write_chunks(out_dir, chunks_iter, prefix)?;
576
577    Ok(format_write_chunks_result(&paths, format))
578}
579
580fn cmd_add_buffer(
581    db_path: &std::path::Path,
582    name: &str,
583    content: Option<&str>,
584    format: OutputFormat,
585) -> Result<String> {
586    let mut storage = open_storage(db_path)?;
587
588    // Read content from stdin if not provided
589    let content = if let Some(c) = content {
590        c.to_string()
591    } else {
592        let mut buffer = String::new();
593        io::stdin().read_to_string(&mut buffer).map_err(|e| {
594            CommandError::ExecutionFailed(format!("Failed to read from stdin: {e}"))
595        })?;
596        buffer
597    };
598
599    let buffer = Buffer::from_named(name.to_string(), content.clone());
600    let buffer_id = storage.add_buffer(&buffer)?;
601
602    // Update context
603    if let Some(mut context) = storage.load_context()? {
604        context.add_buffer(buffer_id);
605        storage.save_context(&context)?;
606    }
607
608    match format {
609        OutputFormat::Text => Ok(format!(
610            "Added buffer '{}' (ID: {}, {} bytes)\n",
611            name,
612            buffer_id,
613            content.len()
614        )),
615        OutputFormat::Json | OutputFormat::Ndjson => {
616            let result = serde_json::json!({
617                "buffer_id": buffer_id,
618                "name": name,
619                "size": content.len()
620            });
621            Ok(serde_json::to_string_pretty(&result).unwrap_or_default())
622        }
623    }
624}
625
626#[allow(clippy::too_many_arguments, clippy::redundant_clone)]
627fn cmd_update_buffer(
628    db_path: &std::path::Path,
629    identifier: &str,
630    content: Option<&str>,
631    embed: bool,
632    strategy: &str,
633    chunk_size: usize,
634    overlap: usize,
635    format: OutputFormat,
636) -> Result<String> {
637    let mut storage = open_storage(db_path)?;
638    let buffer = resolve_buffer(&storage, identifier)?;
639    let buffer_id = buffer
640        .id
641        .ok_or_else(|| CommandError::ExecutionFailed("Buffer has no ID".to_string()))?;
642    let buffer_name = buffer.name.clone().unwrap_or_else(|| buffer_id.to_string());
643
644    // Read content from stdin if not provided
645    let new_content = if let Some(c) = content {
646        c.to_string()
647    } else {
648        let mut buf = String::new();
649        io::stdin().read_to_string(&mut buf).map_err(|e| {
650            CommandError::ExecutionFailed(format!("Failed to read from stdin: {e}"))
651        })?;
652        buf
653    };
654
655    let content_size = new_content.len();
656
657    // Get old chunk count for comparison
658    let old_chunk_count = storage.chunk_count(buffer_id)?;
659
660    // Delete existing chunks (this cascades to embeddings)
661    storage.delete_chunks(buffer_id)?;
662
663    // Update buffer content
664    let updated_buffer = Buffer {
665        id: Some(buffer_id),
666        name: buffer.name.clone(),
667        content: new_content.clone(),
668        source: buffer.source.clone(),
669        metadata: buffer.metadata.clone(),
670    };
671    storage.update_buffer(&updated_buffer)?;
672
673    // Re-chunk the content
674    let chunker = create_chunker(strategy)?;
675    let meta = ChunkerMetadata::with_size_and_overlap(chunk_size, overlap);
676    let chunks = chunker.chunk(buffer_id, &new_content, Some(&meta))?;
677    let new_chunk_count = chunks.len();
678    storage.add_chunks(buffer_id, &chunks)?;
679
680    // Optionally embed the new chunks
681    let embed_result = if embed {
682        let embedder = create_embedder()?;
683        let result = crate::search::embed_buffer_chunks_incremental(
684            &mut storage,
685            embedder.as_ref(),
686            buffer_id,
687            false,
688        )?;
689        Some(result)
690    } else {
691        None
692    };
693
694    match format {
695        OutputFormat::Text => {
696            let mut output = String::new();
697            output.push_str(&format!(
698                "Updated buffer '{}' ({} bytes)\n",
699                buffer_name, content_size
700            ));
701            output.push_str(&format!(
702                "Chunks: {} -> {} (using {} strategy)\n",
703                old_chunk_count, new_chunk_count, strategy
704            ));
705            if let Some(ref result) = embed_result {
706                output.push_str(&format!(
707                    "Embedded {} chunks using model '{}'\n",
708                    result.embedded_count, result.model_name
709                ));
710            }
711            Ok(output)
712        }
713        OutputFormat::Json | OutputFormat::Ndjson => {
714            let json = serde_json::json!({
715                "buffer_id": buffer_id,
716                "buffer_name": buffer_name,
717                "content_size": content_size,
718                "old_chunk_count": old_chunk_count,
719                "new_chunk_count": new_chunk_count,
720                "strategy": strategy,
721                "embedded": embed_result.as_ref().map(|r| serde_json::json!({
722                    "count": r.embedded_count,
723                    "model": r.model_name
724                }))
725            });
726            Ok(serde_json::to_string_pretty(&json).unwrap_or_default())
727        }
728    }
729}
730
731/// Analyst finding from a subagent.
732#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
733struct AnalystFinding {
734    chunk_id: i64,
735    relevance: String,
736    #[serde(default)]
737    findings: Vec<String>,
738    #[serde(default)]
739    summary: Option<String>,
740    #[serde(default)]
741    follow_up: Vec<String>,
742}
743
744/// Relevance level for sorting.
745fn relevance_order(relevance: &str) -> u8 {
746    match relevance.to_lowercase().as_str() {
747        "high" => 0,
748        "medium" => 1,
749        "low" => 2,
750        "none" => 3,
751        _ => 4,
752    }
753}
754
755/// Check if relevance meets minimum threshold.
756fn meets_relevance_threshold(relevance: &str, min_relevance: &str) -> bool {
757    relevance_order(relevance) <= relevance_order(min_relevance)
758}
759
760fn cmd_aggregate(
761    db_path: &std::path::Path,
762    buffer: Option<&str>,
763    min_relevance: &str,
764    group_by: &str,
765    sort_by: &str,
766    output_buffer: Option<&str>,
767    format: OutputFormat,
768) -> Result<String> {
769    let mut storage = open_storage(db_path)?;
770
771    // Read findings from buffer or stdin
772    let input = if let Some(buffer_name) = buffer {
773        let buf = resolve_buffer(&storage, buffer_name)?;
774        buf.content
775    } else {
776        let mut buf = String::new();
777        io::stdin().read_to_string(&mut buf).map_err(|e| {
778            CommandError::ExecutionFailed(format!("Failed to read from stdin: {e}"))
779        })?;
780        buf
781    };
782
783    // Parse findings
784    let findings: Vec<AnalystFinding> = serde_json::from_str(&input)
785        .map_err(|e| CommandError::ExecutionFailed(format!("Invalid JSON input: {e}")))?;
786
787    // Filter by relevance
788    let filtered: Vec<_> = findings
789        .into_iter()
790        .filter(|f| meets_relevance_threshold(&f.relevance, min_relevance))
791        .collect();
792
793    // Sort findings
794    let mut sorted = filtered;
795    match sort_by {
796        "relevance" => sorted.sort_by_key(|f| relevance_order(&f.relevance)),
797        "chunk_id" => sorted.sort_by_key(|f| f.chunk_id),
798        "findings_count" => sorted.sort_by_key(|f| std::cmp::Reverse(f.findings.len())),
799        _ => {}
800    }
801
802    // Group findings
803    let grouped: std::collections::BTreeMap<String, Vec<&AnalystFinding>> = match group_by {
804        "relevance" => {
805            let mut map = std::collections::BTreeMap::new();
806            for f in &sorted {
807                map.entry(f.relevance.clone())
808                    .or_insert_with(Vec::new)
809                    .push(f);
810            }
811            map
812        }
813        "chunk_id" => {
814            let mut map = std::collections::BTreeMap::new();
815            for f in &sorted {
816                map.entry(f.chunk_id.to_string())
817                    .or_insert_with(Vec::new)
818                    .push(f);
819            }
820            map
821        }
822        _ => {
823            let mut map = std::collections::BTreeMap::new();
824            map.insert("all".to_string(), sorted.iter().collect());
825            map
826        }
827    };
828
829    // Collect all unique findings (deduplicated)
830    let mut all_findings: Vec<&str> = Vec::new();
831    for f in &sorted {
832        for finding in &f.findings {
833            if !all_findings.contains(&finding.as_str()) {
834                all_findings.push(finding);
835            }
836        }
837    }
838
839    // Build summary stats
840    let total_findings = sorted.len();
841    let high_count = sorted.iter().filter(|f| f.relevance == "high").count();
842    let medium_count = sorted.iter().filter(|f| f.relevance == "medium").count();
843    let low_count = sorted.iter().filter(|f| f.relevance == "low").count();
844    let unique_findings_count = all_findings.len();
845
846    // Store in output buffer if requested
847    if let Some(out_name) = output_buffer {
848        let output_content = serde_json::to_string_pretty(&sorted).unwrap_or_default();
849        let out_buffer = Buffer::from_named(out_name.to_string(), output_content);
850        storage.add_buffer(&out_buffer)?;
851    }
852
853    match format {
854        OutputFormat::Text => {
855            let mut output = String::new();
856            output.push_str(&format!("Aggregated {} analyst findings\n", total_findings));
857            output.push_str(&format!(
858                "Relevance: {} high, {} medium, {} low\n",
859                high_count, medium_count, low_count
860            ));
861            output.push_str(&format!("Unique findings: {}\n\n", unique_findings_count));
862
863            for (group, items) in &grouped {
864                output.push_str(&format!("## {} ({} chunks)\n", group, items.len()));
865                for f in items {
866                    output.push_str(&format!("  Chunk {}: ", f.chunk_id));
867                    if let Some(ref summary) = f.summary {
868                        output.push_str(&truncate_str(summary, 80));
869                    } else if !f.findings.is_empty() {
870                        output.push_str(&truncate_str(&f.findings[0], 80));
871                    }
872                    output.push('\n');
873                }
874                output.push('\n');
875            }
876
877            if output_buffer.is_some() {
878                output.push_str(&format!(
879                    "Results stored in buffer '{}'\n",
880                    output_buffer.unwrap_or("")
881                ));
882            }
883
884            Ok(output)
885        }
886        OutputFormat::Json | OutputFormat::Ndjson => {
887            let json = serde_json::json!({
888                "summary": {
889                    "total_findings": total_findings,
890                    "high_relevance": high_count,
891                    "medium_relevance": medium_count,
892                    "low_relevance": low_count,
893                    "unique_findings": unique_findings_count
894                },
895                "grouped": grouped,
896                "findings": sorted,
897                "all_findings_deduplicated": all_findings,
898                "output_buffer": output_buffer
899            });
900            Ok(serde_json::to_string_pretty(&json).unwrap_or_default())
901        }
902    }
903}
904
905fn cmd_export_buffers(
906    db_path: &std::path::Path,
907    output: Option<&std::path::Path>,
908    _pretty: bool,
909    _format: OutputFormat,
910) -> Result<String> {
911    let storage = open_storage(db_path)?;
912    let content = storage.export_buffers()?;
913
914    if let Some(path) = output {
915        write_file(path, &content)?;
916        Ok(format!("Exported buffers to: {}\n", path.display()))
917    } else {
918        // Write to stdout
919        let stdout = io::stdout();
920        let mut handle = stdout.lock();
921        handle.write_all(content.as_bytes()).map_err(|e| {
922            CommandError::ExecutionFailed(format!("Failed to write to stdout: {e}"))
923        })?;
924        Ok(String::new()) // Content already written
925    }
926}
927
928fn cmd_variable(
929    db_path: &std::path::Path,
930    name: &str,
931    value: Option<&str>,
932    delete: bool,
933    format: OutputFormat,
934) -> Result<String> {
935    let mut storage = open_storage(db_path)?;
936    let mut context = storage.load_context()?.unwrap_or_else(Context::new);
937
938    if delete {
939        context.remove_variable(name);
940        storage.save_context(&context)?;
941        return Ok(format!("Deleted variable: {name}\n"));
942    }
943
944    if let Some(v) = value {
945        context.set_variable(name.to_string(), ContextValue::String(v.to_string()));
946        storage.save_context(&context)?;
947        Ok(format!("Set variable: {name} = {v}\n"))
948    } else {
949        context.get_variable(name).map_or_else(
950            || Ok(format!("Variable '{name}' not found\n")),
951            |v| match format {
952                OutputFormat::Text => Ok(format!("{name} = {v:?}\n")),
953                OutputFormat::Json | OutputFormat::Ndjson => {
954                    Ok(serde_json::to_string_pretty(v).unwrap_or_default())
955                }
956            },
957        )
958    }
959}
960
961fn cmd_global(
962    db_path: &std::path::Path,
963    name: &str,
964    value: Option<&str>,
965    delete: bool,
966    format: OutputFormat,
967) -> Result<String> {
968    let mut storage = open_storage(db_path)?;
969    let mut context = storage.load_context()?.unwrap_or_else(Context::new);
970
971    if delete {
972        context.remove_global(name);
973        storage.save_context(&context)?;
974        return Ok(format!("Deleted global: {name}\n"));
975    }
976
977    if let Some(v) = value {
978        context.set_global(name.to_string(), ContextValue::String(v.to_string()));
979        storage.save_context(&context)?;
980        Ok(format!("Set global: {name} = {v}\n"))
981    } else {
982        context.get_global(name).map_or_else(
983            || Ok(format!("Global '{name}' not found\n")),
984            |v| match format {
985                OutputFormat::Text => Ok(format!("{name} = {v:?}\n")),
986                OutputFormat::Json | OutputFormat::Ndjson => {
987                    Ok(serde_json::to_string_pretty(v).unwrap_or_default())
988                }
989            },
990        )
991    }
992}
993
994// ==================== Dispatch Command ====================
995
996#[allow(clippy::too_many_arguments)]
997fn cmd_dispatch(
998    db_path: &std::path::Path,
999    identifier: &str,
1000    batch_size: usize,
1001    workers: Option<usize>,
1002    query: Option<&str>,
1003    mode: &str,
1004    threshold: f32,
1005    format: OutputFormat,
1006) -> Result<String> {
1007    let storage = open_storage(db_path)?;
1008    let buffer = resolve_buffer(&storage, identifier)?;
1009    let buffer_id = buffer.id.unwrap_or(0);
1010    let buffer_name = buffer.name.unwrap_or_else(|| buffer_id.to_string());
1011
1012    // Get all chunks for this buffer
1013    let chunks = storage.get_chunks(buffer_id)?;
1014
1015    if chunks.is_empty() {
1016        return Ok(format!("No chunks found in buffer '{}'\n", buffer_name));
1017    }
1018
1019    // Get chunk IDs, optionally filtered by search query
1020    let chunk_ids: Vec<i64> = if let Some(query_str) = query {
1021        // Filter chunks by search relevance
1022        let embedder = create_embedder()?;
1023
1024        let (use_semantic, use_bm25) = match mode.to_lowercase().as_str() {
1025            "semantic" => (true, false),
1026            "bm25" => (false, true),
1027            _ => (true, true),
1028        };
1029
1030        let config = SearchConfig::new()
1031            .with_top_k(chunks.len()) // Get all matches
1032            .with_threshold(threshold)
1033            .with_semantic(use_semantic)
1034            .with_bm25(use_bm25);
1035
1036        let results =
1037            hybrid_search_in_buffer(&storage, embedder.as_ref(), query_str, &config, buffer_id)?;
1038
1039        results.into_iter().map(|r| r.chunk_id).collect()
1040    } else {
1041        chunks.iter().filter_map(|c| c.id).collect()
1042    };
1043
1044    if chunk_ids.is_empty() {
1045        return Ok(format!(
1046            "No matching chunks found in buffer '{}' for query\n",
1047            buffer_name
1048        ));
1049    }
1050
1051    // Calculate batch assignments
1052    let effective_batch_size = if let Some(num_workers) = workers {
1053        // Divide chunks evenly among workers
1054        (chunk_ids.len() + num_workers - 1) / num_workers
1055    } else {
1056        batch_size
1057    };
1058
1059    // Create batches
1060    let batches: Vec<Vec<i64>> = chunk_ids
1061        .chunks(effective_batch_size)
1062        .map(|chunk| chunk.to_vec())
1063        .collect();
1064
1065    match format {
1066        OutputFormat::Text => {
1067            let mut output = String::new();
1068            let _ = writeln!(
1069                output,
1070                "Dispatch plan for buffer '{}' ({} chunks -> {} batches):\n",
1071                buffer_name,
1072                chunk_ids.len(),
1073                batches.len()
1074            );
1075
1076            for (i, batch) in batches.iter().enumerate() {
1077                let _ = writeln!(
1078                    output,
1079                    "Batch {}: {} chunks (IDs: {})",
1080                    i,
1081                    batch.len(),
1082                    batch
1083                        .iter()
1084                        .take(5)
1085                        .map(|id| id.to_string())
1086                        .collect::<Vec<_>>()
1087                        .join(", ")
1088                        + if batch.len() > 5 { ", ..." } else { "" }
1089                );
1090            }
1091
1092            output
1093                .push_str("\nUsage: Feed each batch to a subagent with 'rlm-cli chunk get <id>'\n");
1094            Ok(output)
1095        }
1096        OutputFormat::Json | OutputFormat::Ndjson => {
1097            let json = serde_json::json!({
1098                "buffer_id": buffer_id,
1099                "buffer_name": buffer_name,
1100                "total_chunks": chunk_ids.len(),
1101                "batch_count": batches.len(),
1102                "batch_size": effective_batch_size,
1103                "query_filter": query,
1104                "batches": batches.iter().enumerate().map(|(i, batch)| {
1105                    serde_json::json!({
1106                        "batch_index": i,
1107                        "chunk_count": batch.len(),
1108                        "chunk_ids": batch
1109                    })
1110                }).collect::<Vec<_>>()
1111            });
1112            Ok(serde_json::to_string_pretty(&json).unwrap_or_default())
1113        }
1114    }
1115}
1116
1117// ==================== Search Commands ====================
1118
1119#[allow(clippy::too_many_arguments)]
1120fn cmd_search(
1121    db_path: &std::path::Path,
1122    query: &str,
1123    top_k: usize,
1124    threshold: f32,
1125    mode: &str,
1126    rrf_k: u32,
1127    buffer_filter: Option<&str>,
1128    preview: bool,
1129    preview_len: usize,
1130    format: OutputFormat,
1131) -> Result<String> {
1132    let storage = open_storage(db_path)?;
1133    let embedder = create_embedder()?;
1134
1135    // Determine search mode
1136    let (use_semantic, use_bm25) = match mode.to_lowercase().as_str() {
1137        "semantic" => (true, false),
1138        "bm25" => (false, true),
1139        _ => (true, true), // hybrid is default
1140    };
1141
1142    // If buffer filter is specified, validate it exists
1143    let buffer_id = if let Some(identifier) = buffer_filter {
1144        let buffer = resolve_buffer(&storage, identifier)?;
1145        buffer.id
1146    } else {
1147        None
1148    };
1149
1150    let config = SearchConfig::new()
1151        .with_top_k(top_k)
1152        .with_threshold(threshold)
1153        .with_rrf_k(rrf_k)
1154        .with_semantic(use_semantic)
1155        .with_bm25(use_bm25);
1156
1157    let mut results = if let Some(buffer_id) = buffer_id {
1158        hybrid_search_in_buffer(&storage, embedder.as_ref(), query, &config, buffer_id)?
1159    } else {
1160        hybrid_search(&storage, embedder.as_ref(), query, &config)?
1161    };
1162
1163    // Populate content previews if requested
1164    if preview {
1165        crate::search::populate_previews(&storage, &mut results, preview_len)?;
1166    }
1167
1168    Ok(format_search_results(&results, query, mode, format))
1169}
1170
1171/// Formats a score for display, using scientific notation for very small values.
1172fn format_score(score: f64) -> String {
1173    if score == 0.0 {
1174        "0".to_string()
1175    } else if score.abs() < 0.0001 {
1176        format!("{score:.2e}")
1177    } else {
1178        format!("{score:.4}")
1179    }
1180}
1181
1182fn format_search_results(
1183    results: &[SearchResult],
1184    query: &str,
1185    mode: &str,
1186    format: OutputFormat,
1187) -> String {
1188    match format {
1189        OutputFormat::Text => {
1190            if results.is_empty() {
1191                return format!("No results found for query: \"{query}\"\n");
1192            }
1193
1194            let mut output = String::new();
1195            let _ = writeln!(
1196                output,
1197                "Search results for \"{query}\" ({mode} mode, {} results):\n",
1198                results.len()
1199            );
1200            let _ = writeln!(
1201                output,
1202                "{:<10} {:<12} {:<12} {:<12}",
1203                "Chunk ID", "Score", "Semantic", "BM25"
1204            );
1205            output.push_str(&"-".repeat(50));
1206            output.push('\n');
1207
1208            for result in results {
1209                let semantic = result
1210                    .semantic_score
1211                    .map_or_else(|| "-".to_string(), |s| format_score(f64::from(s)));
1212                let bm25 = result
1213                    .bm25_score
1214                    .map_or_else(|| "-".to_string(), format_score);
1215
1216                let _ = writeln!(
1217                    output,
1218                    "{:<10} {:<12.4} {:<12} {:<12}",
1219                    result.chunk_id, result.score, semantic, bm25
1220                );
1221
1222                // Show content preview if available
1223                if let Some(ref preview) = result.content_preview {
1224                    let _ = writeln!(output, "  Preview: {preview}");
1225                }
1226            }
1227
1228            output.push_str("\nUse 'rlm-cli chunk get <id>' to retrieve chunk content.\n");
1229            output
1230        }
1231        OutputFormat::Json | OutputFormat::Ndjson => {
1232            let json = serde_json::json!({
1233                "query": query,
1234                "mode": mode,
1235                "count": results.len(),
1236                "results": results.iter().map(|r| {
1237                    let mut obj = serde_json::json!({
1238                        "chunk_id": r.chunk_id,
1239                        "buffer_id": r.buffer_id,
1240                        "index": r.index,
1241                        "score": r.score,
1242                        "semantic_score": r.semantic_score,
1243                        "bm25_score": r.bm25_score
1244                    });
1245                    if let Some(ref preview) = r.content_preview {
1246                        obj["content_preview"] = serde_json::json!(preview);
1247                    }
1248                    obj
1249                }).collect::<Vec<_>>()
1250            });
1251            serde_json::to_string_pretty(&json).unwrap_or_default()
1252        }
1253    }
1254}
1255
1256// ==================== Chunk Commands ====================
1257
1258fn cmd_chunk_get(
1259    db_path: &std::path::Path,
1260    chunk_id: i64,
1261    include_metadata: bool,
1262    format: OutputFormat,
1263) -> Result<String> {
1264    let storage = open_storage(db_path)?;
1265
1266    let chunk = storage
1267        .get_chunk(chunk_id)?
1268        .ok_or(StorageError::ChunkNotFound { id: chunk_id })?;
1269
1270    match format {
1271        OutputFormat::Text => {
1272            if include_metadata {
1273                let mut output = String::new();
1274                let _ = writeln!(output, "Chunk ID: {}", chunk.id.unwrap_or(0));
1275                let _ = writeln!(output, "Buffer ID: {}", chunk.buffer_id);
1276                let _ = writeln!(output, "Index: {}", chunk.index);
1277                let _ = writeln!(
1278                    output,
1279                    "Byte range: {}..{}",
1280                    chunk.byte_range.start, chunk.byte_range.end
1281                );
1282                let _ = writeln!(output, "Size: {} bytes", chunk.size());
1283                output.push_str("---\n");
1284                output.push_str(&chunk.content);
1285                if !chunk.content.ends_with('\n') {
1286                    output.push('\n');
1287                }
1288                Ok(output)
1289            } else {
1290                // Plain content output for pass-by-reference use case
1291                Ok(chunk.content)
1292            }
1293        }
1294        OutputFormat::Json | OutputFormat::Ndjson => {
1295            let json = serde_json::json!({
1296                "chunk_id": chunk.id,
1297                "buffer_id": chunk.buffer_id,
1298                "index": chunk.index,
1299                "byte_range": {
1300                    "start": chunk.byte_range.start,
1301                    "end": chunk.byte_range.end
1302                },
1303                "size": chunk.size(),
1304                "content": chunk.content
1305            });
1306            Ok(serde_json::to_string_pretty(&json).unwrap_or_default())
1307        }
1308    }
1309}
1310
1311fn cmd_chunk_list(
1312    db_path: &std::path::Path,
1313    identifier: &str,
1314    show_preview: bool,
1315    preview_len: usize,
1316    format: OutputFormat,
1317) -> Result<String> {
1318    let storage = open_storage(db_path)?;
1319    let buffer = resolve_buffer(&storage, identifier)?;
1320    let buffer_id = buffer.id.unwrap_or(0);
1321
1322    let chunks = storage.get_chunks(buffer_id)?;
1323
1324    match format {
1325        OutputFormat::Text => {
1326            if chunks.is_empty() {
1327                return Ok(format!(
1328                    "No chunks found for buffer: {}\n",
1329                    buffer.name.as_deref().unwrap_or(&buffer_id.to_string())
1330                ));
1331            }
1332
1333            let mut output = String::new();
1334            let _ = writeln!(
1335                output,
1336                "Chunks for buffer '{}' ({} chunks):\n",
1337                buffer.name.as_deref().unwrap_or(&buffer_id.to_string()),
1338                chunks.len()
1339            );
1340
1341            if show_preview {
1342                let _ = writeln!(
1343                    output,
1344                    "{:<8} {:<6} {:<12} {:<12} Preview",
1345                    "ID", "Index", "Start", "Size"
1346                );
1347                output.push_str(&"-".repeat(70));
1348                output.push('\n');
1349
1350                for chunk in &chunks {
1351                    let preview: String = chunk
1352                        .content
1353                        .chars()
1354                        .take(preview_len)
1355                        .map(|c| if c == '\n' { ' ' } else { c })
1356                        .collect();
1357                    let preview = if chunk.content.len() > preview_len {
1358                        format!("{preview}...")
1359                    } else {
1360                        preview
1361                    };
1362
1363                    let _ = writeln!(
1364                        output,
1365                        "{:<8} {:<6} {:<12} {:<12} {}",
1366                        chunk.id.unwrap_or(0),
1367                        chunk.index,
1368                        chunk.byte_range.start,
1369                        chunk.size(),
1370                        preview
1371                    );
1372                }
1373            } else {
1374                let _ = writeln!(
1375                    output,
1376                    "{:<8} {:<6} {:<12} {:<12}",
1377                    "ID", "Index", "Start", "Size"
1378                );
1379                output.push_str(&"-".repeat(40));
1380                output.push('\n');
1381
1382                for chunk in &chunks {
1383                    let _ = writeln!(
1384                        output,
1385                        "{:<8} {:<6} {:<12} {:<12}",
1386                        chunk.id.unwrap_or(0),
1387                        chunk.index,
1388                        chunk.byte_range.start,
1389                        chunk.size()
1390                    );
1391                }
1392            }
1393
1394            Ok(output)
1395        }
1396        OutputFormat::Json | OutputFormat::Ndjson => {
1397            let json = serde_json::json!({
1398                "buffer_id": buffer_id,
1399                "buffer_name": buffer.name,
1400                "chunk_count": chunks.len(),
1401                "chunks": chunks.iter().map(|c| {
1402                    let mut obj = serde_json::json!({
1403                        "id": c.id,
1404                        "index": c.index,
1405                        "byte_range": {
1406                            "start": c.byte_range.start,
1407                            "end": c.byte_range.end
1408                        },
1409                        "size": c.size()
1410                    });
1411                    if show_preview {
1412                        let preview: String = c.content.chars().take(preview_len).collect();
1413                        obj["preview"] = serde_json::Value::String(preview);
1414                    }
1415                    obj
1416                }).collect::<Vec<_>>()
1417            });
1418            Ok(serde_json::to_string_pretty(&json).unwrap_or_default())
1419        }
1420    }
1421}
1422
1423fn cmd_chunk_embed(
1424    db_path: &std::path::Path,
1425    identifier: &str,
1426    force: bool,
1427    format: OutputFormat,
1428) -> Result<String> {
1429    let mut storage = open_storage(db_path)?;
1430    let buffer = resolve_buffer(&storage, identifier)?;
1431    let buffer_id = buffer.id.unwrap_or(0);
1432    let buffer_name = buffer.name.unwrap_or_else(|| buffer_id.to_string());
1433
1434    let embedder = create_embedder()?;
1435
1436    // Use incremental embedding (force_reembed = force flag)
1437    let result = crate::search::embed_buffer_chunks_incremental(
1438        &mut storage,
1439        embedder.as_ref(),
1440        buffer_id,
1441        force,
1442    )?;
1443
1444    // Check for model version mismatch warning
1445    let model_warning = if !force {
1446        if let Some(existing_model) =
1447            crate::search::check_model_mismatch(&storage, buffer_id, &result.model_name)?
1448        {
1449            Some(format!(
1450                "Warning: Some embeddings use model '{existing_model}', current model is '{}'. \
1451                 Use --force to regenerate with the new model.",
1452                result.model_name
1453            ))
1454        } else {
1455            None
1456        }
1457    } else {
1458        None
1459    };
1460
1461    match format {
1462        OutputFormat::Text => {
1463            let mut output = String::new();
1464            if let Some(warning) = &model_warning {
1465                output.push_str(warning);
1466                output.push('\n');
1467            }
1468
1469            if !result.had_changes() {
1470                output.push_str(&format!(
1471                    "Buffer '{buffer_name}' already fully embedded ({} chunks). Use --force to re-embed.\n",
1472                    result.total_chunks
1473                ));
1474            } else {
1475                if result.embedded_count > 0 {
1476                    output.push_str(&format!(
1477                        "Embedded {} new chunks in buffer '{buffer_name}' using model '{}'.\n",
1478                        result.embedded_count, result.model_name
1479                    ));
1480                }
1481                if result.replaced_count > 0 {
1482                    output.push_str(&format!(
1483                        "Re-embedded {} chunks with updated model.\n",
1484                        result.replaced_count
1485                    ));
1486                }
1487                if result.skipped_count > 0 {
1488                    output.push_str(&format!(
1489                        "Skipped {} chunks (already embedded with current model).\n",
1490                        result.skipped_count
1491                    ));
1492                }
1493            }
1494            Ok(output)
1495        }
1496        OutputFormat::Json | OutputFormat::Ndjson => {
1497            let json = serde_json::json!({
1498                "buffer_id": buffer_id,
1499                "buffer_name": buffer_name,
1500                "embedded_count": result.embedded_count,
1501                "replaced_count": result.replaced_count,
1502                "skipped_count": result.skipped_count,
1503                "total_chunks": result.total_chunks,
1504                "model": result.model_name,
1505                "had_changes": result.had_changes(),
1506                "completion_percentage": result.completion_percentage(),
1507                "model_warning": model_warning
1508            });
1509            Ok(serde_json::to_string_pretty(&json).unwrap_or_default())
1510        }
1511    }
1512}
1513
1514fn cmd_chunk_status(db_path: &std::path::Path, format: OutputFormat) -> Result<String> {
1515    let storage = open_storage(db_path)?;
1516    let buffers = storage.list_buffers()?;
1517
1518    let mut buffer_stats: Vec<(String, i64, usize, usize)> = Vec::new();
1519
1520    for buffer in &buffers {
1521        let buffer_id = buffer.id.unwrap_or(0);
1522        let buffer_name = buffer.name.clone().unwrap_or_else(|| buffer_id.to_string());
1523        let chunks = storage.get_chunks(buffer_id)?;
1524        let chunk_count = chunks.len();
1525
1526        let mut embedded_count = 0;
1527        for chunk in &chunks {
1528            if let Some(cid) = chunk.id
1529                && storage.has_embedding(cid)?
1530            {
1531                embedded_count += 1;
1532            }
1533        }
1534
1535        buffer_stats.push((buffer_name, buffer_id, chunk_count, embedded_count));
1536    }
1537
1538    let total_chunks: usize = buffer_stats.iter().map(|(_, _, c, _)| c).sum();
1539    let total_embedded: usize = buffer_stats.iter().map(|(_, _, _, e)| e).sum();
1540
1541    match format {
1542        OutputFormat::Text => {
1543            let mut output = String::new();
1544            output.push_str("Embedding Status\n");
1545            output.push_str("================\n\n");
1546            let _ = writeln!(
1547                output,
1548                "Total: {total_embedded}/{total_chunks} chunks embedded\n"
1549            );
1550
1551            if !buffer_stats.is_empty() {
1552                let _ = writeln!(
1553                    output,
1554                    "{:<6} {:<20} {:<10} {:<10} Status",
1555                    "ID", "Name", "Chunks", "Embedded"
1556                );
1557                output.push_str(&"-".repeat(60));
1558                output.push('\n');
1559
1560                for (name, id, chunks, embedded) in &buffer_stats {
1561                    let status = if *embedded == *chunks {
1562                        "✓ complete"
1563                    } else if *embedded > 0 {
1564                        "◐ partial"
1565                    } else {
1566                        "○ none"
1567                    };
1568
1569                    let _ = writeln!(
1570                        output,
1571                        "{:<6} {:<20} {:<10} {:<10} {}",
1572                        id,
1573                        truncate_str(name, 20),
1574                        chunks,
1575                        embedded,
1576                        status
1577                    );
1578                }
1579            }
1580
1581            Ok(output)
1582        }
1583        OutputFormat::Json | OutputFormat::Ndjson => {
1584            let json = serde_json::json!({
1585                "total_chunks": total_chunks,
1586                "total_embedded": total_embedded,
1587                "buffers": buffer_stats.iter().map(|(name, id, chunks, embedded)| {
1588                    serde_json::json!({
1589                        "buffer_id": id,
1590                        "name": name,
1591                        "chunk_count": chunks,
1592                        "embedded_count": embedded,
1593                        "fully_embedded": chunks == embedded
1594                    })
1595                }).collect::<Vec<_>>()
1596            });
1597            Ok(serde_json::to_string_pretty(&json).unwrap_or_default())
1598        }
1599    }
1600}
1601
1602/// Truncates a string to max length with ellipsis.
1603fn truncate_str(s: &str, max_len: usize) -> String {
1604    if s.len() <= max_len {
1605        s.to_string()
1606    } else if max_len <= 3 {
1607        s[..max_len].to_string()
1608    } else {
1609        format!("{}...", &s[..max_len - 3])
1610    }
1611}
1612
1613#[cfg(test)]
1614mod tests {
1615    use super::*;
1616    use tempfile::TempDir;
1617
1618    fn setup() -> (TempDir, std::path::PathBuf) {
1619        let temp_dir = TempDir::new().unwrap();
1620        let db_path = temp_dir.path().join("test.db");
1621        (temp_dir, db_path)
1622    }
1623
1624    #[test]
1625    fn test_cmd_init() {
1626        let (_temp_dir, db_path) = setup();
1627        let result = cmd_init(&db_path, false, OutputFormat::Text);
1628        assert!(result.is_ok());
1629        assert!(db_path.exists());
1630    }
1631
1632    #[test]
1633    fn test_cmd_init_already_exists() {
1634        let (_temp_dir, db_path) = setup();
1635
1636        // First init
1637        cmd_init(&db_path, false, OutputFormat::Text).unwrap();
1638
1639        // Second init should fail without force
1640        let result = cmd_init(&db_path, false, OutputFormat::Text);
1641        assert!(result.is_err());
1642
1643        // With force should succeed
1644        let result = cmd_init(&db_path, true, OutputFormat::Text);
1645        assert!(result.is_ok());
1646    }
1647
1648    #[test]
1649    fn test_cmd_status() {
1650        let (_temp_dir, db_path) = setup();
1651        cmd_init(&db_path, false, OutputFormat::Text).unwrap();
1652
1653        let result = cmd_status(&db_path, OutputFormat::Text);
1654        assert!(result.is_ok());
1655        assert!(result.unwrap().contains("Buffers"));
1656    }
1657
1658    #[test]
1659    fn test_cmd_reset() {
1660        let (_temp_dir, db_path) = setup();
1661        cmd_init(&db_path, false, OutputFormat::Text).unwrap();
1662
1663        // Without --yes should fail
1664        let result = cmd_reset(&db_path, false, OutputFormat::Text);
1665        assert!(result.is_err());
1666
1667        // With --yes should succeed
1668        let result = cmd_reset(&db_path, true, OutputFormat::Text);
1669        assert!(result.is_ok());
1670    }
1671
1672    #[test]
1673    fn test_cmd_add_buffer() {
1674        let (_temp_dir, db_path) = setup();
1675        cmd_init(&db_path, false, OutputFormat::Text).unwrap();
1676
1677        let result = cmd_add_buffer(
1678            &db_path,
1679            "test-buffer",
1680            Some("Hello, world!"),
1681            OutputFormat::Text,
1682        );
1683        assert!(result.is_ok());
1684        assert!(result.unwrap().contains("test-buffer"));
1685    }
1686
1687    #[test]
1688    fn test_cmd_list_buffers() {
1689        let (_temp_dir, db_path) = setup();
1690        cmd_init(&db_path, false, OutputFormat::Text).unwrap();
1691
1692        // Empty list
1693        let result = cmd_list_buffers(&db_path, OutputFormat::Text);
1694        assert!(result.is_ok());
1695        assert!(result.unwrap().contains("No buffers"));
1696
1697        // Add a buffer
1698        cmd_add_buffer(&db_path, "test", Some("content"), OutputFormat::Text).unwrap();
1699
1700        let result = cmd_list_buffers(&db_path, OutputFormat::Text);
1701        assert!(result.is_ok());
1702        assert!(result.unwrap().contains("test"));
1703    }
1704
1705    #[test]
1706    fn test_cmd_variable() {
1707        let (_temp_dir, db_path) = setup();
1708        cmd_init(&db_path, false, OutputFormat::Text).unwrap();
1709
1710        // Set variable
1711        let result = cmd_variable(&db_path, "key", Some("value"), false, OutputFormat::Text);
1712        assert!(result.is_ok());
1713
1714        // Get variable
1715        let result = cmd_variable(&db_path, "key", None, false, OutputFormat::Text);
1716        assert!(result.is_ok());
1717        assert!(result.unwrap().contains("value"));
1718
1719        // Delete variable
1720        let result = cmd_variable(&db_path, "key", None, true, OutputFormat::Text);
1721        assert!(result.is_ok());
1722    }
1723
1724    #[test]
1725    fn test_truncate_str_short() {
1726        // String shorter than max_len should be returned as-is
1727        let result = truncate_str("hello", 10);
1728        assert_eq!(result, "hello");
1729    }
1730
1731    #[test]
1732    fn test_truncate_str_exact() {
1733        // String exactly at max_len should be returned as-is
1734        let result = truncate_str("hello", 5);
1735        assert_eq!(result, "hello");
1736    }
1737
1738    #[test]
1739    fn test_truncate_str_long() {
1740        // String longer than max_len should be truncated with ...
1741        let result = truncate_str("hello world", 8);
1742        assert_eq!(result, "hello...");
1743    }
1744
1745    #[test]
1746    fn test_truncate_str_very_short_max() {
1747        // max_len <= 3 should just truncate without ellipsis
1748        let result = truncate_str("hello", 3);
1749        assert_eq!(result, "hel");
1750    }
1751
1752    #[test]
1753    fn test_truncate_str_edge_case() {
1754        // max_len of 4 should show 1 char + ...
1755        let result = truncate_str("hello", 4);
1756        assert_eq!(result, "h...");
1757    }
1758}