1#![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#[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
211fn 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
222fn resolve_buffer(storage: &SqliteStorage, identifier: &str) -> Result<Buffer> {
224 if let Ok(id) = identifier.parse::<i64>()
226 && let Some(buffer) = storage.get_buffer(id)?
227 {
228 return Ok(buffer);
229 }
230
231 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
242fn cmd_init(db_path: &std::path::Path, force: bool, _format: OutputFormat) -> Result<String> {
245 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 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 && 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 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 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 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 let content = read_file(file)?;
332
333 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 let buffer_id = storage.add_buffer(&buffer)?;
344
345 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 storage.add_chunks(buffer_id, &chunks)?;
352
353 let embedder = create_embedder()?;
355 let embedded_count = embed_buffer_chunks(&mut storage, embedder.as_ref(), buffer_id)?;
356
357 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 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 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 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 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 storage.add_chunks(buffer_id, &chunks)?;
559
560 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 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 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 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 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 let old_chunk_count = storage.chunk_count(buffer_id)?;
659
660 storage.delete_chunks(buffer_id)?;
662
663 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 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 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#[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
744fn 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
755fn 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 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 let findings: Vec<AnalystFinding> = serde_json::from_str(&input)
785 .map_err(|e| CommandError::ExecutionFailed(format!("Invalid JSON input: {e}")))?;
786
787 let filtered: Vec<_> = findings
789 .into_iter()
790 .filter(|f| meets_relevance_threshold(&f.relevance, min_relevance))
791 .collect();
792
793 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 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 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 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 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 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()) }
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#[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 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 let chunk_ids: Vec<i64> = if let Some(query_str) = query {
1021 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()) .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 let effective_batch_size = if let Some(num_workers) = workers {
1053 (chunk_ids.len() + num_workers - 1) / num_workers
1055 } else {
1056 batch_size
1057 };
1058
1059 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#[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 let (use_semantic, use_bm25) = match mode.to_lowercase().as_str() {
1137 "semantic" => (true, false),
1138 "bm25" => (false, true),
1139 _ => (true, true), };
1141
1142 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 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
1171fn 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 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
1256fn 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 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 let result = crate::search::embed_buffer_chunks_incremental(
1438 &mut storage,
1439 embedder.as_ref(),
1440 buffer_id,
1441 force,
1442 )?;
1443
1444 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
1602fn 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 cmd_init(&db_path, false, OutputFormat::Text).unwrap();
1638
1639 let result = cmd_init(&db_path, false, OutputFormat::Text);
1641 assert!(result.is_err());
1642
1643 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 let result = cmd_reset(&db_path, false, OutputFormat::Text);
1665 assert!(result.is_err());
1666
1667 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 let result = cmd_list_buffers(&db_path, OutputFormat::Text);
1694 assert!(result.is_ok());
1695 assert!(result.unwrap().contains("No buffers"));
1696
1697 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 let result = cmd_variable(&db_path, "key", Some("value"), false, OutputFormat::Text);
1712 assert!(result.is_ok());
1713
1714 let result = cmd_variable(&db_path, "key", None, false, OutputFormat::Text);
1716 assert!(result.is_ok());
1717 assert!(result.unwrap().contains("value"));
1718
1719 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 let result = truncate_str("hello", 10);
1728 assert_eq!(result, "hello");
1729 }
1730
1731 #[test]
1732 fn test_truncate_str_exact() {
1733 let result = truncate_str("hello", 5);
1735 assert_eq!(result, "hello");
1736 }
1737
1738 #[test]
1739 fn test_truncate_str_long() {
1740 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 let result = truncate_str("hello", 3);
1749 assert_eq!(result, "hel");
1750 }
1751
1752 #[test]
1753 fn test_truncate_str_edge_case() {
1754 let result = truncate_str("hello", 4);
1756 assert_eq!(result, "h...");
1757 }
1758}