Skip to main content

rlm_rs/cli/
output.rs

1//! Output formatting for CLI commands.
2//!
3//! Supports text and JSON output formats.
4
5use crate::core::{Buffer, Chunk, Context};
6use crate::storage::traits::StorageStats;
7use serde::Serialize;
8use std::fmt::Write;
9
10/// Output format options.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum OutputFormat {
13    /// Human-readable text output.
14    Text,
15    /// JSON output.
16    Json,
17    /// Newline-delimited JSON (NDJSON) for streaming.
18    /// Each record is a single JSON object on its own line.
19    Ndjson,
20}
21
22impl OutputFormat {
23    /// Parses format from string.
24    #[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    /// Returns true if this format is a streaming format.
34    #[must_use]
35    pub const fn is_streaming(&self) -> bool {
36        matches!(self, Self::Ndjson)
37    }
38}
39
40/// Formats a status response.
41#[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/// Formats a buffer list.
73#[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/// Formats a single buffer.
124#[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/// Formats peek output.
191#[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/// Formats grep matches.
224#[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/// Formats chunk indices.
253#[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/// Formats write chunks result.
269#[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/// Formats context.
285#[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/// A grep match result.
301#[derive(Debug, Clone, Serialize)]
302pub struct GrepMatch {
303    /// Byte offset in the buffer.
304    pub offset: usize,
305    /// The matched text.
306    pub matched: String,
307    /// Context snippet around the match.
308    pub snippet: String,
309}
310
311/// Formats a value as JSON.
312fn format_json<T: Serialize>(value: &T) -> String {
313    serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string())
314}
315
316/// Formats an error for output.
317///
318/// When format is JSON, returns a structured error object.
319/// When format is Text, returns the error message string.
320#[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
339/// Extracts error type and recovery suggestion from an error.
340const 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/// Formats a byte size as human-readable.
414#[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
427/// Truncates a string to max length with ellipsis.
428fn 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        // Test that format_json handles errors gracefully
681        // This is hard to trigger with normal Serialize types
682        // but the fallback to "{}" is tested implicitly
683    }
684}