1use crate::core::{Buffer, Chunk, Context};
6use crate::storage::traits::StorageStats;
7use serde::Serialize;
8use std::fmt::Write;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum OutputFormat {
13 Text,
15 Json,
17 Ndjson,
20}
21
22impl OutputFormat {
23 #[must_use]
25 pub fn parse(s: &str) -> Self {
26 match s.to_lowercase().as_str() {
27 "json" => Self::Json,
28 "ndjson" | "jsonl" | "stream" => Self::Ndjson,
29 _ => Self::Text,
30 }
31 }
32
33 #[must_use]
35 pub const fn is_streaming(&self) -> bool {
36 matches!(self, Self::Ndjson)
37 }
38}
39
40#[must_use]
42pub fn format_status(stats: &StorageStats, format: OutputFormat) -> String {
43 match format {
44 OutputFormat::Text => format_status_text(stats),
45 OutputFormat::Json | OutputFormat::Ndjson => format_json(stats),
46 }
47}
48
49fn format_status_text(stats: &StorageStats) -> String {
50 let mut output = String::new();
51 output.push_str("RLM-RS Status\n");
52 output.push_str("=============\n\n");
53 let _ = writeln!(output, " Buffers: {}", stats.buffer_count);
54 let _ = writeln!(output, " Chunks: {}", stats.chunk_count);
55 let _ = writeln!(
56 output,
57 " Content size: {} bytes",
58 stats.total_content_size
59 );
60 let _ = writeln!(
61 output,
62 " Context: {}",
63 if stats.has_context { "yes" } else { "no" }
64 );
65 let _ = writeln!(output, " Schema: v{}", stats.schema_version);
66 if let Some(size) = stats.db_size {
67 let _ = writeln!(output, " DB size: {size} bytes");
68 }
69 output
70}
71
72#[must_use]
74pub fn format_buffer_list(buffers: &[Buffer], format: OutputFormat) -> String {
75 match format {
76 OutputFormat::Text => format_buffer_list_text(buffers),
77 OutputFormat::Json | OutputFormat::Ndjson => format_json(&buffers),
78 }
79}
80
81fn format_buffer_list_text(buffers: &[Buffer]) -> String {
82 if buffers.is_empty() {
83 return "No buffers found.\n".to_string();
84 }
85
86 let mut output = String::new();
87 output.push_str("Buffers:\n");
88 let _ = writeln!(
89 output,
90 "{:<6} {:<20} {:<12} {:<8} Source",
91 "ID", "Name", "Size", "Chunks"
92 );
93 output.push_str(&"-".repeat(70));
94 output.push('\n');
95
96 for buffer in buffers {
97 let id = buffer.id.map_or_else(|| "-".to_string(), |i| i.to_string());
98 let name = buffer.name.as_deref().unwrap_or("-");
99 let size = format_size(buffer.metadata.size);
100 let chunks = buffer
101 .metadata
102 .chunk_count
103 .map_or_else(|| "-".to_string(), |c| c.to_string());
104 let source = buffer
105 .source
106 .as_ref()
107 .map_or_else(|| "-".to_string(), |p| p.to_string_lossy().to_string());
108
109 let _ = writeln!(
110 output,
111 "{:<6} {:<20} {:<12} {:<8} {}",
112 id,
113 truncate(name, 20),
114 size,
115 chunks,
116 truncate(&source, 30)
117 );
118 }
119
120 output
121}
122
123#[must_use]
125pub fn format_buffer(buffer: &Buffer, chunks: Option<&[Chunk]>, format: OutputFormat) -> String {
126 match format {
127 OutputFormat::Text => format_buffer_text(buffer, chunks),
128 OutputFormat::Json | OutputFormat::Ndjson => {
129 #[derive(Serialize)]
130 struct BufferWithChunks<'a> {
131 buffer: &'a Buffer,
132 chunks: Option<&'a [Chunk]>,
133 }
134 format_json(&BufferWithChunks { buffer, chunks })
135 }
136 }
137}
138
139fn format_buffer_text(buffer: &Buffer, chunks: Option<&[Chunk]>) -> String {
140 let mut output = String::new();
141
142 let _ = writeln!(
143 output,
144 "Buffer: {}",
145 buffer.name.as_deref().unwrap_or("unnamed")
146 );
147 let _ = writeln!(output, " ID: {}", buffer.id.unwrap_or(0));
148 let _ = writeln!(output, " Size: {} bytes", buffer.metadata.size);
149 if let Some(lines) = buffer.metadata.line_count {
150 let _ = writeln!(output, " Lines: {lines}");
151 }
152 if let Some(chunk_count) = buffer.metadata.chunk_count {
153 let _ = writeln!(output, " Chunks: {chunk_count}");
154 }
155 if let Some(ref ct) = buffer.metadata.content_type {
156 let _ = writeln!(output, " Content type: {ct}");
157 }
158 if let Some(ref source) = buffer.source {
159 let _ = writeln!(output, " Source: {}", source.display());
160 }
161
162 if let Some(chunks) = chunks {
163 output.push('\n');
164 output.push_str("Chunks:\n");
165 let _ = writeln!(
166 output,
167 "{:<6} {:<12} {:<12} {:<10} Preview",
168 "Index", "Start", "End", "Size"
169 );
170 output.push_str(&"-".repeat(70));
171 output.push('\n');
172
173 for chunk in chunks {
174 let preview = truncate(&chunk.content.replace('\n', "\\n"), 30);
175 let _ = writeln!(
176 output,
177 "{:<6} {:<12} {:<12} {:<10} {}",
178 chunk.index,
179 chunk.byte_range.start,
180 chunk.byte_range.end,
181 chunk.size(),
182 preview
183 );
184 }
185 }
186
187 output
188}
189
190#[must_use]
192pub fn format_peek(content: &str, start: usize, end: usize, format: OutputFormat) -> String {
193 match format {
194 OutputFormat::Text => {
195 let mut output = String::new();
196 let _ = writeln!(output, "Bytes {start}..{end} ({} bytes):", end - start);
197 output.push_str("---\n");
198 output.push_str(content);
199 if !content.ends_with('\n') {
200 output.push('\n');
201 }
202 output.push_str("---\n");
203 output
204 }
205 OutputFormat::Json | OutputFormat::Ndjson => {
206 #[derive(Serialize)]
207 struct PeekOutput<'a> {
208 start: usize,
209 end: usize,
210 size: usize,
211 content: &'a str,
212 }
213 format_json(&PeekOutput {
214 start,
215 end,
216 size: end - start,
217 content,
218 })
219 }
220 }
221}
222
223#[must_use]
225pub fn format_grep_matches(matches: &[GrepMatch], pattern: &str, format: OutputFormat) -> String {
226 match format {
227 OutputFormat::Text => format_grep_text(matches, pattern),
228 OutputFormat::Json | OutputFormat::Ndjson => format_json(&matches),
229 }
230}
231
232fn format_grep_text(matches: &[GrepMatch], pattern: &str) -> String {
233 if matches.is_empty() {
234 return format!("No matches found for pattern: {pattern}\n");
235 }
236
237 let mut output = String::new();
238 let _ = writeln!(
239 output,
240 "Found {} matches for pattern: {pattern}\n",
241 matches.len()
242 );
243
244 for (i, m) in matches.iter().enumerate() {
245 let _ = writeln!(output, "Match {} at byte {}:", i + 1, m.offset);
246 let _ = writeln!(output, " {}", m.snippet.replace('\n', "\\n"));
247 }
248
249 output
250}
251
252#[must_use]
254pub fn format_chunk_indices(indices: &[(usize, usize)], format: OutputFormat) -> String {
255 match format {
256 OutputFormat::Text => {
257 let mut output = String::new();
258 let _ = writeln!(output, "{} chunks:", indices.len());
259 for (i, (start, end)) in indices.iter().enumerate() {
260 let _ = writeln!(output, " [{i}] {start}..{end} ({} bytes)", end - start);
261 }
262 output
263 }
264 OutputFormat::Json | OutputFormat::Ndjson => format_json(&indices),
265 }
266}
267
268#[must_use]
270pub fn format_write_chunks_result(paths: &[String], format: OutputFormat) -> String {
271 match format {
272 OutputFormat::Text => {
273 let mut output = String::new();
274 let _ = writeln!(output, "Wrote {} chunks:", paths.len());
275 for path in paths {
276 let _ = writeln!(output, " {path}");
277 }
278 output
279 }
280 OutputFormat::Json | OutputFormat::Ndjson => format_json(&paths),
281 }
282}
283
284#[must_use]
286pub fn format_context(context: &Context, format: OutputFormat) -> String {
287 match format {
288 OutputFormat::Text => {
289 let mut output = String::new();
290 output.push_str("Context:\n");
291 let _ = writeln!(output, " Variables: {}", context.variable_count());
292 let _ = writeln!(output, " Globals: {}", context.global_count());
293 let _ = writeln!(output, " Buffers: {}", context.buffer_count());
294 output
295 }
296 OutputFormat::Json | OutputFormat::Ndjson => format_json(&context),
297 }
298}
299
300#[derive(Debug, Clone, Serialize)]
302pub struct GrepMatch {
303 pub offset: usize,
305 pub matched: String,
307 pub snippet: String,
309}
310
311fn format_json<T: Serialize>(value: &T) -> String {
313 serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string())
314}
315
316#[must_use]
321pub fn format_error(error: &crate::Error, format: OutputFormat) -> String {
322 match format {
323 OutputFormat::Text => error.to_string(),
324 OutputFormat::Json | OutputFormat::Ndjson => {
325 let (error_type, suggestion) = get_error_details(error);
326 let json = serde_json::json!({
327 "success": false,
328 "error": {
329 "type": error_type,
330 "message": error.to_string(),
331 "suggestion": suggestion
332 }
333 });
334 serde_json::to_string_pretty(&json).unwrap_or_else(|_| "{}".to_string())
335 }
336 }
337}
338
339const fn get_error_details(error: &crate::Error) -> (&'static str, Option<&'static str>) {
341 use crate::error::{ChunkingError, CommandError, IoError, StorageError};
342
343 match error {
344 crate::Error::Storage(e) => match e {
345 StorageError::NotInitialized => (
346 "NotInitialized",
347 Some("Run 'rlm-cli init' to initialize the database"),
348 ),
349 StorageError::BufferNotFound { .. } => (
350 "BufferNotFound",
351 Some("Run 'rlm-cli list' to see available buffers"),
352 ),
353 StorageError::ChunkNotFound { .. } => (
354 "ChunkNotFound",
355 Some("Run 'rlm-cli chunk list <buffer>' to see valid chunk IDs"),
356 ),
357 StorageError::ContextNotFound => ("ContextNotFound", Some("Context not yet created")),
358 StorageError::Database(_) => ("DatabaseError", None),
359 StorageError::Migration(_) => ("MigrationError", None),
360 StorageError::Transaction(_) => ("TransactionError", None),
361 StorageError::Serialization(_) => ("SerializationError", None),
362 #[cfg(feature = "usearch-hnsw")]
363 StorageError::VectorSearch(_) => ("VectorSearchError", None),
364 #[cfg(feature = "fastembed-embeddings")]
365 StorageError::Embedding(_) => {
366 ("EmbeddingError", Some("Check disk space and try again"))
367 }
368 },
369 crate::Error::Io(e) => match e {
370 IoError::FileNotFound { .. } => ("FileNotFound", Some("Verify the file path exists")),
371 IoError::ReadFailed { .. } => ("ReadError", None),
372 IoError::WriteFailed { .. } => ("WriteError", None),
373 IoError::MmapFailed { .. } => ("MemoryMapError", None),
374 IoError::DirectoryFailed { .. } => ("DirectoryError", None),
375 IoError::PathTraversal { .. } => (
376 "PathTraversalDenied",
377 Some("Path traversal outside allowed directory is not permitted"),
378 ),
379 IoError::Generic(_) => ("IoError", None),
380 },
381 crate::Error::Chunking(e) => match e {
382 ChunkingError::InvalidUtf8 { .. } => ("InvalidUtf8", None),
383 ChunkingError::ChunkTooLarge { .. } => {
384 ("ChunkTooLarge", Some("Use a smaller --chunk-size value"))
385 }
386 ChunkingError::InvalidConfig { .. } => ("InvalidConfig", None),
387 ChunkingError::OverlapTooLarge { .. } => (
388 "OverlapTooLarge",
389 Some("Overlap must be less than chunk size"),
390 ),
391 ChunkingError::ParallelFailed { .. } => ("ParallelError", None),
392 ChunkingError::SemanticFailed(_) => ("SemanticError", None),
393 ChunkingError::Regex(_) => ("RegexError", None),
394 ChunkingError::UnknownStrategy { .. } => (
395 "UnknownStrategy",
396 Some("Valid strategies: fixed, semantic, parallel"),
397 ),
398 },
399 crate::Error::Command(e) => match e {
400 CommandError::UnknownCommand(_) => ("UnknownCommand", None),
401 CommandError::InvalidArgument(_) => ("InvalidArgument", None),
402 CommandError::MissingArgument(_) => ("MissingArgument", None),
403 CommandError::ExecutionFailed(_) => ("ExecutionFailed", None),
404 CommandError::Cancelled => ("Cancelled", None),
405 CommandError::OutputFormat(_) => ("OutputFormatError", None),
406 },
407 crate::Error::InvalidState { .. } => ("InvalidState", None),
408 crate::Error::Config { .. } => ("ConfigError", None),
409 crate::Error::Search(_) => ("SearchError", None),
410 }
411}
412
413#[allow(clippy::cast_precision_loss)]
415fn format_size(bytes: usize) -> String {
416 if bytes < 1024 {
417 format!("{bytes} B")
418 } else if bytes < 1024 * 1024 {
419 format!("{:.1} KB", bytes as f64 / 1024.0)
420 } else if bytes < 1024 * 1024 * 1024 {
421 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
422 } else {
423 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
424 }
425}
426
427fn truncate(s: &str, max_len: usize) -> String {
429 if s.len() <= max_len {
430 s.to_string()
431 } else if max_len <= 3 {
432 s[..max_len].to_string()
433 } else {
434 format!("{}...", &s[..max_len - 3])
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use std::path::PathBuf;
442
443 #[test]
444 fn test_output_format_from_str() {
445 assert_eq!(OutputFormat::parse("json"), OutputFormat::Json);
446 assert_eq!(OutputFormat::parse("JSON"), OutputFormat::Json);
447 assert_eq!(OutputFormat::parse("text"), OutputFormat::Text);
448 assert_eq!(OutputFormat::parse("unknown"), OutputFormat::Text);
449 }
450
451 #[test]
452 fn test_output_format_ndjson() {
453 assert_eq!(OutputFormat::parse("ndjson"), OutputFormat::Ndjson);
454 assert_eq!(OutputFormat::parse("NDJSON"), OutputFormat::Ndjson);
455 assert_eq!(OutputFormat::parse("jsonl"), OutputFormat::Ndjson);
456 assert_eq!(OutputFormat::parse("stream"), OutputFormat::Ndjson);
457 assert!(OutputFormat::Ndjson.is_streaming());
458 assert!(!OutputFormat::Json.is_streaming());
459 assert!(!OutputFormat::Text.is_streaming());
460 }
461
462 #[test]
463 fn test_format_size() {
464 assert_eq!(format_size(100), "100 B");
465 assert_eq!(format_size(1024), "1.0 KB");
466 assert_eq!(format_size(1024 * 1024), "1.0 MB");
467 assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB");
468 assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2.0 GB");
469 }
470
471 #[test]
472 fn test_truncate() {
473 assert_eq!(truncate("Hello", 10), "Hello");
474 assert_eq!(truncate("Hello World", 8), "Hello...");
475 assert_eq!(truncate("Hi", 2), "Hi");
476 assert_eq!(truncate("Hello", 3), "Hel");
477 assert_eq!(truncate("Hello", 1), "H");
478 }
479
480 #[test]
481 fn test_format_status() {
482 let stats = StorageStats {
483 buffer_count: 2,
484 chunk_count: 10,
485 total_content_size: 1024,
486 has_context: true,
487 schema_version: 1,
488 db_size: Some(4096),
489 };
490
491 let text = format_status(&stats, OutputFormat::Text);
492 assert!(text.contains("Buffers: 2"));
493 assert!(text.contains("Chunks: 10"));
494 assert!(text.contains("DB size:"));
495
496 let json = format_status(&stats, OutputFormat::Json);
497 assert!(json.contains("\"buffer_count\": 2"));
498 }
499
500 #[test]
501 fn test_format_status_no_db_size() {
502 let stats = StorageStats {
503 buffer_count: 0,
504 chunk_count: 0,
505 total_content_size: 0,
506 has_context: false,
507 schema_version: 1,
508 db_size: None,
509 };
510
511 let text = format_status(&stats, OutputFormat::Text);
512 assert!(text.contains("Context: no"));
513 assert!(!text.contains("DB size:"));
514 }
515
516 #[test]
517 fn test_format_buffer_list_empty() {
518 let buffers: Vec<Buffer> = vec![];
519 let text = format_buffer_list(&buffers, OutputFormat::Text);
520 assert!(text.contains("No buffers found"));
521
522 let json = format_buffer_list(&buffers, OutputFormat::Json);
523 assert!(json.contains("[]"));
524 }
525
526 #[test]
527 fn test_format_buffer_list_with_data() {
528 let mut buffer = Buffer::from_named("test".to_string(), "content".to_string());
529 buffer.id = Some(1);
530 buffer.source = Some(PathBuf::from("/path/to/file.txt"));
531 buffer.metadata.chunk_count = Some(3);
532
533 let buffers = vec![buffer];
534 let text = format_buffer_list(&buffers, OutputFormat::Text);
535 assert!(text.contains("test"));
536 assert!(text.contains('1'));
537
538 let json = format_buffer_list(&buffers, OutputFormat::Json);
539 assert!(json.contains("\"name\": \"test\""));
540 }
541
542 #[test]
543 fn test_format_buffer_without_chunks() {
544 let mut buffer = Buffer::from_named("test-buf".to_string(), "Hello world".to_string());
545 buffer.id = Some(42);
546 buffer.metadata.line_count = Some(1);
547 buffer.metadata.chunk_count = Some(1);
548 buffer.metadata.content_type = Some("text/plain".to_string());
549 buffer.source = Some(PathBuf::from("/test/path.txt"));
550
551 let text = format_buffer(&buffer, None, OutputFormat::Text);
552 assert!(text.contains("Buffer: test-buf"));
553 assert!(text.contains("ID: 42"));
554 assert!(text.contains("Lines: 1"));
555 assert!(text.contains("Chunks: 1"));
556 assert!(text.contains("Content type: text/plain"));
557 assert!(text.contains("Source:"));
558
559 let json = format_buffer(&buffer, None, OutputFormat::Json);
560 assert!(json.contains("\"buffer\""));
561 }
562
563 #[test]
564 fn test_format_buffer_with_chunks() {
565 let mut buffer = Buffer::from_named("buf".to_string(), "Hello\nWorld".to_string());
566 buffer.id = Some(1);
567
568 let chunks = vec![
569 Chunk::new(1, "Hello".to_string(), 0..5, 0),
570 Chunk::new(1, "World".to_string(), 6..11, 1),
571 ];
572
573 let text = format_buffer(&buffer, Some(&chunks), OutputFormat::Text);
574 assert!(text.contains("Chunks:"));
575 assert!(text.contains("Index"));
576 assert!(text.contains("Hello"));
577
578 let json = format_buffer(&buffer, Some(&chunks), OutputFormat::Json);
579 assert!(json.contains("\"chunks\""));
580 }
581
582 #[test]
583 fn test_format_peek() {
584 let content = "Hello, world!";
585
586 let text = format_peek(content, 0, 13, OutputFormat::Text);
587 assert!(text.contains("Bytes 0..13"));
588 assert!(text.contains("Hello, world!"));
589
590 let json = format_peek(content, 0, 13, OutputFormat::Json);
591 assert!(json.contains("\"content\": \"Hello, world!\""));
592 assert!(json.contains("\"start\": 0"));
593 }
594
595 #[test]
596 fn test_format_peek_no_trailing_newline() {
597 let content = "no newline";
598 let text = format_peek(content, 0, 10, OutputFormat::Text);
599 assert!(text.ends_with("---\n"));
600 }
601
602 #[test]
603 fn test_format_grep_matches_empty() {
604 let matches: Vec<GrepMatch> = vec![];
605 let text = format_grep_matches(&matches, "pattern", OutputFormat::Text);
606 assert!(text.contains("No matches found"));
607
608 let json = format_grep_matches(&matches, "pattern", OutputFormat::Json);
609 assert!(json.contains("[]"));
610 }
611
612 #[test]
613 fn test_format_grep_matches_with_data() {
614 let matches = vec![
615 GrepMatch {
616 offset: 10,
617 matched: "hello".to_string(),
618 snippet: "say hello world".to_string(),
619 },
620 GrepMatch {
621 offset: 50,
622 matched: "hello".to_string(),
623 snippet: "another\nhello".to_string(),
624 },
625 ];
626
627 let text = format_grep_matches(&matches, "hello", OutputFormat::Text);
628 assert!(text.contains("Found 2 matches"));
629 assert!(text.contains("Match 1 at byte 10"));
630 assert!(text.contains("another\\nhello"));
631
632 let json = format_grep_matches(&matches, "hello", OutputFormat::Json);
633 assert!(json.contains("\"offset\": 10"));
634 }
635
636 #[test]
637 fn test_format_chunk_indices() {
638 let indices = vec![(0, 100), (100, 200), (200, 300)];
639
640 let text = format_chunk_indices(&indices, OutputFormat::Text);
641 assert!(text.contains("3 chunks"));
642 assert!(text.contains("[0] 0..100"));
643 assert!(text.contains("100 bytes"));
644
645 let json = format_chunk_indices(&indices, OutputFormat::Json);
646 assert!(json.contains('0') && json.contains("100"));
647 }
648
649 #[test]
650 fn test_format_write_chunks_result() {
651 let paths = vec!["chunk_0.txt".to_string(), "chunk_1.txt".to_string()];
652
653 let text = format_write_chunks_result(&paths, OutputFormat::Text);
654 assert!(text.contains("Wrote 2 chunks"));
655 assert!(text.contains("chunk_0.txt"));
656
657 let json = format_write_chunks_result(&paths, OutputFormat::Json);
658 assert!(json.contains("\"chunk_0.txt\""));
659 }
660
661 #[test]
662 fn test_format_context() {
663 let mut context = Context::new();
664 context.set_variable(
665 "key".to_string(),
666 crate::core::ContextValue::String("val".to_string()),
667 );
668 context.set_global("gkey".to_string(), crate::core::ContextValue::Float(42.0));
669
670 let text = format_context(&context, OutputFormat::Text);
671 assert!(text.contains("Variables: 1"));
672 assert!(text.contains("Globals: 1"));
673
674 let json = format_context(&context, OutputFormat::Json);
675 assert!(json.contains("\"variables\""));
676 }
677
678 #[test]
679 fn test_format_json_error() {
680 }
684}