Skip to main content

codanna/io/
output.rs

1//! Output management for CLI commands.
2//!
3//! Handles formatting and display for different output formats,
4//! providing a unified interface for text and JSON output.
5
6use crate::error::IndexError;
7use crate::io::exit_code::ExitCode;
8use crate::io::format::{JsonResponse, OutputFormat};
9use crate::io::schema::{OutputData, OutputStatus, UnifiedOutput};
10use serde::Serialize;
11use std::fmt::Display;
12use std::io::{self, Write};
13
14/// Manages output formatting and display.
15///
16/// Provides methods for outputting success results, collections,
17/// and errors in either text or JSON format based on configuration.
18pub struct OutputManager {
19    format: OutputFormat,
20    stdout: Box<dyn Write>,
21    stderr: Box<dyn Write>,
22}
23
24impl OutputManager {
25    /// Create a new output manager with the specified format.
26    pub fn new(format: OutputFormat) -> Self {
27        Self {
28            format,
29            stdout: Box::new(io::stdout()),
30            stderr: Box::new(io::stderr()),
31        }
32    }
33
34    /// Helper to write to a stream, ignoring broken pipe errors.
35    ///
36    /// Broken pipes occur when the reader closes before we finish writing
37    /// (e.g., when piping to `head`). This is normal behavior and should not
38    /// be treated as an error. The exit code should reflect the operation's
39    /// success, not the pipe status.
40    fn write_ignoring_broken_pipe(stream: &mut dyn Write, content: &str) -> io::Result<()> {
41        if let Err(e) = writeln!(stream, "{content}") {
42            // Only propagate non-broken-pipe errors
43            if e.kind() != io::ErrorKind::BrokenPipe {
44                return Err(e);
45            }
46            // Silently ignore broken pipe - this is expected when piping to head, grep, etc.
47        }
48        Ok(())
49    }
50
51    /// Create an output manager for testing with custom writers.
52    #[doc(hidden)]
53    pub fn new_with_writers(
54        format: OutputFormat,
55        stdout: Box<dyn Write>,
56        stderr: Box<dyn Write>,
57    ) -> Self {
58        Self {
59            format,
60            stdout,
61            stderr,
62        }
63    }
64
65    /// Output a successful result.
66    ///
67    /// In JSON mode, wraps the data in a success response.
68    /// In text mode, displays the data using its Display implementation.
69    /// Broken pipe errors are silently ignored to support piping to commands like `head`.
70    pub fn success<T>(&mut self, data: T) -> io::Result<ExitCode>
71    where
72        T: Serialize + Display,
73    {
74        match self.format {
75            OutputFormat::Json => {
76                let response = JsonResponse::success(&data);
77                let json_str = serde_json::to_string_pretty(&response)?;
78                Self::write_ignoring_broken_pipe(&mut *self.stdout, &json_str)?;
79            }
80            OutputFormat::Text => {
81                let text = format!("{data}");
82                Self::write_ignoring_broken_pipe(&mut *self.stdout, &text)?;
83            }
84        }
85        Ok(ExitCode::Success)
86    }
87
88    /// Output a single item or indicate not found.
89    ///
90    /// If the item is Some, outputs it as success.
91    /// If None, outputs a not found message.
92    pub fn item<T>(&mut self, item: Option<T>, entity: &str, name: &str) -> io::Result<ExitCode>
93    where
94        T: Serialize + Display,
95    {
96        match item {
97            Some(data) => self.success(data),
98            None => self.not_found(entity, name),
99        }
100    }
101
102    /// Output a not found result.
103    /// Returns ExitCode::NotFound (3) to indicate the entity was not found.
104    /// Broken pipe errors are silently ignored.
105    pub fn not_found(&mut self, entity: &str, name: &str) -> io::Result<ExitCode> {
106        match self.format {
107            OutputFormat::Json => {
108                let response = JsonResponse::not_found(entity, name);
109                let json_str = serde_json::to_string_pretty(&response)?;
110                Self::write_ignoring_broken_pipe(&mut *self.stdout, &json_str)?;
111            }
112            OutputFormat::Text => {
113                let text = format!("{entity} '{name}' not found");
114                Self::write_ignoring_broken_pipe(&mut *self.stderr, &text)?;
115            }
116        }
117        Ok(ExitCode::NotFound)
118    }
119
120    /// Output a collection with proper formatting.
121    ///
122    /// Empty collections are treated as not found (returns ExitCode::NotFound).
123    /// Non-empty collections are displayed as a list (returns ExitCode::Success).
124    /// Broken pipe errors are silently ignored.
125    pub fn collection<T, I>(&mut self, items: I, entity_name: &str) -> io::Result<ExitCode>
126    where
127        T: Serialize + Display,
128        I: IntoIterator<Item = T>,
129    {
130        let items: Vec<T> = items.into_iter().collect();
131
132        if items.is_empty() {
133            return self.not_found(entity_name, "any");
134        }
135
136        match self.format {
137            OutputFormat::Json => {
138                let response = JsonResponse::success(&items);
139                let json_str = serde_json::to_string_pretty(&response)?;
140                Self::write_ignoring_broken_pipe(&mut *self.stdout, &json_str)?;
141            }
142            OutputFormat::Text => {
143                let header = format!("Found {} {entity_name}:", items.len());
144                Self::write_ignoring_broken_pipe(&mut *self.stdout, &header)?;
145                Self::write_ignoring_broken_pipe(&mut *self.stdout, &"=".repeat(40))?;
146                for item in items {
147                    let item_str = format!("{item}");
148                    Self::write_ignoring_broken_pipe(&mut *self.stdout, &item_str)?;
149                }
150            }
151        }
152        Ok(ExitCode::Success)
153    }
154
155    /// Output an error with suggestions.
156    /// Returns the appropriate ExitCode based on the error type.
157    /// Broken pipe errors are silently ignored.
158    pub fn error(&mut self, error: &IndexError) -> io::Result<ExitCode> {
159        match self.format {
160            OutputFormat::Json => {
161                let response = JsonResponse::from_error(error);
162                let json_str = serde_json::to_string_pretty(&response)?;
163                Self::write_ignoring_broken_pipe(&mut *self.stderr, &json_str)?;
164            }
165            OutputFormat::Text => {
166                let error_msg = format!("Error: {error}");
167                Self::write_ignoring_broken_pipe(&mut *self.stderr, &error_msg)?;
168                for suggestion in error.recovery_suggestions() {
169                    let suggestion_msg = format!("  Suggestion: {suggestion}");
170                    Self::write_ignoring_broken_pipe(&mut *self.stderr, &suggestion_msg)?;
171                }
172            }
173        }
174        Ok(ExitCode::from_error(error))
175    }
176
177    /// Output progress information (text mode only).
178    ///
179    /// In JSON mode, progress messages are suppressed to avoid
180    /// polluting the JSON output.
181    /// Broken pipe errors are silently ignored.
182    pub fn progress(&mut self, message: &str) -> io::Result<()> {
183        if matches!(self.format, OutputFormat::Text) {
184            Self::write_ignoring_broken_pipe(&mut *self.stderr, message)?;
185        }
186        Ok(())
187    }
188
189    /// Output informational message (text mode only).
190    /// Broken pipe errors are silently ignored.
191    pub fn info(&mut self, message: &str) -> io::Result<()> {
192        if matches!(self.format, OutputFormat::Text) {
193            Self::write_ignoring_broken_pipe(&mut *self.stdout, message)?;
194        }
195        Ok(())
196    }
197
198    /// Output a collection of SymbolContext items.
199    ///
200    /// This method is specifically designed for SymbolContext to ensure
201    /// consistent formatting across all retrieve commands.
202    ///
203    /// # Returns
204    /// - `ExitCode::Success` - When contexts are found and output successfully
205    /// - `ExitCode::NotFound` - When the collection is empty
206    ///
207    /// # Performance
208    /// Collects the iterator once to check for empty and get count.
209    /// This is necessary for proper error handling and text formatting.
210    pub fn symbol_contexts(
211        &mut self,
212        contexts: impl IntoIterator<Item = crate::symbol::context::SymbolContext>,
213        entity_name: &str,
214    ) -> io::Result<ExitCode> {
215        let contexts: Vec<_> = contexts.into_iter().collect();
216
217        if contexts.is_empty() {
218            return self.not_found(entity_name, "any");
219        }
220
221        match self.format {
222            OutputFormat::Json => {
223                let response = JsonResponse::success(&contexts);
224                let json_str = serde_json::to_string_pretty(&response)?;
225                Self::write_ignoring_broken_pipe(&mut *self.stdout, &json_str)?;
226            }
227            OutputFormat::Text => {
228                let header = format!("Found {} {}:", contexts.len(), entity_name);
229                Self::write_ignoring_broken_pipe(&mut *self.stdout, &header)?;
230                Self::write_ignoring_broken_pipe(&mut *self.stdout, &"=".repeat(40))?;
231
232                for context in contexts {
233                    let formatted = format!("{context}");
234                    Self::write_ignoring_broken_pipe(&mut *self.stdout, &formatted)?;
235                }
236            }
237        }
238        Ok(ExitCode::Success)
239    }
240
241    /// Output a UnifiedOutput structure with dynamic data handling.
242    ///
243    /// This method handles all OutputData variants appropriately:
244    /// - Items: Simple list display
245    /// - Grouped: Hierarchical display by category
246    /// - Contextual: Rich nested structure with relationships
247    /// - Ranked: Sorted display with scores
248    /// - Single: Single item display
249    /// - Empty: Not found handling
250    ///
251    /// # Performance
252    /// Uses zero-cost abstractions from UnifiedOutput.
253    /// Display formatting relies on the UnifiedOutput's Display implementation.
254    ///
255    /// # Returns
256    /// The exit code from the UnifiedOutput structure
257    pub fn unified<T>(&mut self, output: UnifiedOutput<'_, T>) -> io::Result<ExitCode>
258    where
259        T: Serialize + Display,
260    {
261        let exit_code = output.exit_code;
262
263        match self.format {
264            OutputFormat::Json => {
265                // For JSON, serialize the UnifiedOutput directly
266                // This preserves the structured data with metadata and guidance
267                let json_str = serde_json::to_string_pretty(&output)?;
268                Self::write_ignoring_broken_pipe(&mut *self.stdout, &json_str)?;
269            }
270            OutputFormat::Text => {
271                // For text, check if we have special handling needs
272                match (&output.data, &output.status) {
273                    // Only show "not found" when status is NotFound (symbol doesn't exist)
274                    (OutputData::Empty, OutputStatus::NotFound) => {
275                        let entity = format!("{:?}", output.entity_type);
276                        let msg = format!("{} not found", entity.to_lowercase());
277                        Self::write_ignoring_broken_pipe(&mut *self.stderr, &msg)?;
278                    }
279                    (OutputData::Items { items }, OutputStatus::NotFound) if items.is_empty() => {
280                        let entity = format!("{:?}", output.entity_type);
281                        let msg = format!("{} not found", entity.to_lowercase());
282                        Self::write_ignoring_broken_pipe(&mut *self.stderr, &msg)?;
283                    }
284                    // Empty with Success status: symbol exists but no results (handled by guidance)
285                    (OutputData::Empty, OutputStatus::Success) => {
286                        // Don't output "not found" - guidance will explain
287                    }
288                    (OutputData::Items { items }, OutputStatus::Success) if items.is_empty() => {
289                        // Don't output "not found" - guidance will explain
290                    }
291                    _ => {
292                        // For all other cases, use the Display implementation
293                        let formatted = format!("{output}");
294                        Self::write_ignoring_broken_pipe(&mut *self.stdout, &formatted)?;
295                    }
296                }
297
298                // Add guidance message if present (text mode only, to stderr)
299                if let Some(guidance) = &output.guidance {
300                    Self::write_ignoring_broken_pipe(&mut *self.stderr, "")?;
301                    Self::write_ignoring_broken_pipe(&mut *self.stderr, guidance)?;
302                }
303            }
304        }
305
306        Ok(exit_code)
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    /// A writer that always returns broken pipe error
315    struct BrokenPipeWriter;
316
317    impl Write for BrokenPipeWriter {
318        fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
319            Err(io::Error::new(io::ErrorKind::BrokenPipe, "pipe broken"))
320        }
321
322        fn flush(&mut self) -> io::Result<()> {
323            Err(io::Error::new(io::ErrorKind::BrokenPipe, "pipe broken"))
324        }
325    }
326
327    #[test]
328    fn test_output_manager_text_success() {
329        let stdout = Vec::new();
330        let stderr = Vec::new();
331
332        let mut manager =
333            OutputManager::new_with_writers(OutputFormat::Text, Box::new(stdout), Box::new(stderr));
334
335        let code = manager.success("Test output").unwrap();
336        assert_eq!(code, ExitCode::Success);
337    }
338
339    #[test]
340    fn test_broken_pipe_returns_success_exit_code() {
341        let mut manager = OutputManager::new_with_writers(
342            OutputFormat::Json,
343            Box::new(BrokenPipeWriter),
344            Box::new(Vec::new()),
345        );
346
347        // Should return Success exit code even with broken pipe
348        let code = manager.success("test data").unwrap();
349        assert_eq!(code, ExitCode::Success);
350    }
351
352    #[test]
353    fn test_broken_pipe_returns_not_found_exit_code() {
354        let mut manager = OutputManager::new_with_writers(
355            OutputFormat::Json,
356            Box::new(BrokenPipeWriter),
357            Box::new(Vec::new()),
358        );
359
360        // Should return NotFound exit code even with broken pipe
361        let code = manager.not_found("Symbol", "missing").unwrap();
362        assert_eq!(code, ExitCode::NotFound);
363    }
364
365    #[test]
366    fn test_broken_pipe_in_text_mode() {
367        let mut manager = OutputManager::new_with_writers(
368            OutputFormat::Text,
369            Box::new(BrokenPipeWriter),
370            Box::new(Vec::new()),
371        );
372
373        // Should handle broken pipe gracefully in text mode
374        let code = manager.success("test output").unwrap();
375        assert_eq!(code, ExitCode::Success);
376    }
377
378    #[test]
379    fn test_broken_pipe_stderr() {
380        let mut manager = OutputManager::new_with_writers(
381            OutputFormat::Text,
382            Box::new(Vec::new()),
383            Box::new(BrokenPipeWriter),
384        );
385
386        // progress writes to stderr
387        let result = manager.progress("Processing...");
388        assert!(result.is_ok());
389
390        // not_found text mode writes to stderr
391        let code = manager.not_found("Entity", "name").unwrap();
392        assert_eq!(code, ExitCode::NotFound);
393    }
394
395    #[test]
396    fn test_output_manager_json_success() {
397        let stdout = Vec::new();
398        let stderr = Vec::new();
399
400        let mut manager =
401            OutputManager::new_with_writers(OutputFormat::Json, Box::new(stdout), Box::new(stderr));
402
403        #[derive(Serialize)]
404        struct TestData {
405            value: i32,
406        }
407
408        impl Display for TestData {
409            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410                write!(f, "TestData({})", self.value)
411            }
412        }
413
414        let data = TestData { value: 42 };
415        let code = manager.success(data).unwrap();
416        assert_eq!(code, ExitCode::Success);
417    }
418
419    #[test]
420    fn test_symbol_contexts_collection() {
421        use crate::symbol::Symbol;
422        use crate::symbol::context::{SymbolContext, SymbolRelationships};
423        use crate::types::{FileId, Range, SymbolId, SymbolKind};
424
425        // Helper to create test context
426        fn create_context(id: u32, name: &str) -> SymbolContext {
427            let symbol = Symbol::new(
428                SymbolId::new(id).unwrap(),
429                name,
430                SymbolKind::Function,
431                FileId::new(1).unwrap(),
432                Range::new(10, 0, 20, 0),
433            );
434
435            SymbolContext {
436                symbol,
437                file_path: format!("src/{name}.rs:11"),
438                relationships: SymbolRelationships::default(),
439            }
440        }
441
442        // Test with multiple items
443        let stdout = Vec::new();
444        let stderr = Vec::new();
445        let mut manager =
446            OutputManager::new_with_writers(OutputFormat::Json, Box::new(stdout), Box::new(stderr));
447
448        let contexts = vec![create_context(1, "main"), create_context(2, "process")];
449
450        let code = manager.symbol_contexts(contexts, "functions").unwrap();
451        assert_eq!(code, ExitCode::Success);
452    }
453
454    #[test]
455    fn test_symbol_contexts_empty() {
456        use crate::symbol::context::SymbolContext;
457
458        let stdout = Vec::new();
459        let stderr = Vec::new();
460        let mut manager =
461            OutputManager::new_with_writers(OutputFormat::Json, Box::new(stdout), Box::new(stderr));
462
463        let contexts: Vec<SymbolContext> = vec![];
464        let code = manager.symbol_contexts(contexts, "symbols").unwrap();
465        assert_eq!(code, ExitCode::NotFound);
466    }
467
468    #[test]
469    fn test_symbol_contexts_text_format() {
470        use crate::symbol::Symbol;
471        use crate::symbol::context::{SymbolContext, SymbolRelationships};
472        use crate::types::{FileId, Range, SymbolId, SymbolKind};
473
474        let symbol = Symbol::new(
475            SymbolId::new(1).unwrap(),
476            "test_function",
477            SymbolKind::Function,
478            FileId::new(1).unwrap(),
479            Range::new(42, 0, 50, 0),
480        );
481
482        let context = SymbolContext {
483            symbol,
484            file_path: "src/test.rs:43".to_string(),
485            relationships: SymbolRelationships::default(),
486        };
487
488        let stdout = Vec::new();
489        let stderr = Vec::new();
490        let mut manager =
491            OutputManager::new_with_writers(OutputFormat::Text, Box::new(stdout), Box::new(stderr));
492
493        let code = manager.symbol_contexts(vec![context], "function").unwrap();
494        assert_eq!(code, ExitCode::Success);
495
496        // Since we can't extract the output easily from a Box<dyn Write>,
497        // we'll trust that if the method runs without error and returns Success,
498        // it's working correctly. More detailed verification would require
499        // a different test approach.
500    }
501
502    #[test]
503    fn test_symbol_contexts_broken_pipe() {
504        use crate::symbol::Symbol;
505        use crate::symbol::context::{SymbolContext, SymbolRelationships};
506        use crate::types::{FileId, Range, SymbolId, SymbolKind};
507
508        let symbol = Symbol::new(
509            SymbolId::new(1).unwrap(),
510            "test",
511            SymbolKind::Function,
512            FileId::new(1).unwrap(),
513            Range::new(1, 0, 2, 0),
514        );
515
516        let context = SymbolContext {
517            symbol,
518            file_path: "test.rs:1".to_string(),
519            relationships: SymbolRelationships::default(),
520        };
521
522        // Test with broken pipe on stdout
523        let mut manager = OutputManager::new_with_writers(
524            OutputFormat::Json,
525            Box::new(BrokenPipeWriter),
526            Box::new(Vec::new()),
527        );
528
529        // Should succeed despite broken pipe
530        let code = manager
531            .symbol_contexts(vec![context.clone()], "symbols")
532            .unwrap();
533        assert_eq!(code, ExitCode::Success);
534
535        // Test empty collection with broken pipe
536        let code = manager
537            .symbol_contexts(Vec::<SymbolContext>::new(), "symbols")
538            .unwrap();
539        assert_eq!(code, ExitCode::NotFound);
540    }
541}