Skip to main content

sqry_cli/output/
csv.rs

1//! CSV/TSV output formatter for tabular export
2//!
3//! Provides RFC 4180 compliant CSV output with optional TSV (tab-separated) mode.
4//! Includes formula injection protection for Excel/LibreOffice safety.
5
6use super::preview::{PreviewConfig, PreviewExtractor};
7use super::{DisplaySymbol, Formatter, FormatterMetadata, OutputStreams};
8use anyhow::Result;
9use std::path::PathBuf;
10
11/// Characters that trigger formula execution in spreadsheets
12const FORMULA_CHARS: &[char] = &['=', '+', '-', '@', '\t', '\r'];
13
14/// Default columns to include in CSV output
15const DEFAULT_COLUMNS: &[CsvColumn] = &[
16    CsvColumn::Name,
17    CsvColumn::QualifiedName,
18    CsvColumn::Kind,
19    CsvColumn::File,
20    CsvColumn::Line,
21    CsvColumn::Column,
22    CsvColumn::EndLine,
23    CsvColumn::EndColumn,
24    CsvColumn::Language,
25];
26
27/// Available columns for CSV/TSV output
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum CsvColumn {
30    Name,
31    QualifiedName,
32    Kind,
33    File,
34    Line,
35    Column,
36    EndLine,
37    EndColumn,
38    Language,
39    Preview,
40    // Diff-specific columns
41    ChangeType,
42    SignatureBefore,
43    SignatureAfter,
44}
45
46impl CsvColumn {
47    /// Parse column name from string
48    #[must_use]
49    pub fn parse_column(s: &str) -> Option<Self> {
50        match s.to_lowercase().as_str() {
51            "name" => Some(Self::Name),
52            "qualified_name" | "qualifiedname" => Some(Self::QualifiedName),
53            "kind" => Some(Self::Kind),
54            "file" => Some(Self::File),
55            "line" => Some(Self::Line),
56            "column" | "col" => Some(Self::Column),
57            "end_line" | "endline" => Some(Self::EndLine),
58            "end_column" | "endcolumn" => Some(Self::EndColumn),
59            "language" | "lang" => Some(Self::Language),
60            "preview" | "context" => Some(Self::Preview),
61            "change_type" | "changetype" => Some(Self::ChangeType),
62            "signature_before" | "signaturebefore" => Some(Self::SignatureBefore),
63            "signature_after" | "signatureafter" => Some(Self::SignatureAfter),
64            _ => None,
65        }
66    }
67
68    /// Get header name for this column
69    #[must_use]
70    pub fn header(self) -> &'static str {
71        match self {
72            Self::Name => "name",
73            Self::QualifiedName => "qualified_name",
74            Self::Kind => "kind",
75            Self::File => "file",
76            Self::Line => "line",
77            Self::Column => "column",
78            Self::EndLine => "end_line",
79            Self::EndColumn => "end_column",
80            Self::Language => "language",
81            Self::Preview => "preview",
82            Self::ChangeType => "change_type",
83            Self::SignatureBefore => "signature_before",
84            Self::SignatureAfter => "signature_after",
85        }
86    }
87}
88
89/// Configuration for CSV/TSV output
90#[derive(Debug, Clone)]
91pub struct CsvConfig {
92    /// Include header row
93    pub headers: bool,
94    /// Columns to include (None = all default columns)
95    pub columns: Option<Vec<CsvColumn>>,
96    /// Field delimiter (comma for CSV, tab for TSV)
97    pub delimiter: char,
98    /// Disable formula injection protection (raw mode)
99    pub raw_mode: bool,
100    /// Preview configuration (None = no preview)
101    pub preview_config: Option<PreviewConfig>,
102}
103
104impl Default for CsvConfig {
105    fn default() -> Self {
106        Self {
107            headers: false,
108            columns: None,
109            delimiter: ',',
110            raw_mode: false,
111            preview_config: None,
112        }
113    }
114}
115
116/// CSV/TSV formatter for tabular output
117pub struct CsvFormatter {
118    config: CsvConfig,
119    /// Workspace root for preview path validation
120    workspace_root: Option<PathBuf>,
121}
122
123impl CsvFormatter {
124    /// Create CSV formatter (comma-delimited)
125    #[must_use]
126    pub fn csv(headers: bool, columns: Option<Vec<CsvColumn>>) -> Self {
127        Self {
128            config: CsvConfig {
129                headers,
130                columns,
131                delimiter: ',',
132                ..Default::default()
133            },
134            workspace_root: None,
135        }
136    }
137
138    /// Create TSV formatter (tab-delimited)
139    #[must_use]
140    pub fn tsv(headers: bool, columns: Option<Vec<CsvColumn>>) -> Self {
141        Self {
142            config: CsvConfig {
143                headers,
144                columns,
145                delimiter: '\t',
146                ..Default::default()
147            },
148            workspace_root: None,
149        }
150    }
151
152    /// Enable preview column
153    #[must_use]
154    pub fn with_preview(mut self, config: PreviewConfig) -> Self {
155        self.config.preview_config = Some(config);
156        self
157    }
158
159    /// Set raw mode (disable formula injection protection)
160    #[must_use]
161    pub fn raw_mode(mut self, raw: bool) -> Self {
162        self.config.raw_mode = raw;
163        self
164    }
165
166    /// Set workspace root for preview path validation
167    #[must_use]
168    pub fn with_workspace_root(mut self, root: PathBuf) -> Self {
169        self.workspace_root = Some(root);
170        self
171    }
172
173    /// Get the columns to output
174    fn get_columns(&self) -> Vec<CsvColumn> {
175        let mut cols = self
176            .config
177            .columns
178            .clone()
179            .unwrap_or_else(|| DEFAULT_COLUMNS.to_vec());
180
181        // Add preview column if preview is enabled and not already present
182        if self.config.preview_config.is_some() && !cols.contains(&CsvColumn::Preview) {
183            cols.push(CsvColumn::Preview);
184        }
185
186        cols
187    }
188
189    /// Escape a field value for CSV output (RFC 4180)
190    fn escape_csv_field(&self, value: &str) -> String {
191        let needs_quoting = value.contains(self.config.delimiter)
192            || value.contains('"')
193            || value.contains('\n')
194            || value.contains('\r');
195
196        let escaped = if needs_quoting {
197            format!("\"{}\"", value.replace('"', "\"\""))
198        } else {
199            value.to_string()
200        };
201
202        // Apply formula injection protection unless raw mode
203        if self.config.raw_mode {
204            escaped
205        } else {
206            Self::apply_formula_protection(&escaped)
207        }
208    }
209
210    /// Escape a field value for TSV output
211    fn escape_tsv_field(&self, value: &str) -> String {
212        // Replace tabs and newlines with spaces using char-by-char approach
213        let escaped: String = value
214            .chars()
215            .filter_map(|c| match c {
216                '\t' | '\n' => Some(' '),
217                '\r' => None,
218                _ => Some(c),
219            })
220            .collect();
221
222        // Apply formula injection protection unless raw mode
223        if self.config.raw_mode {
224            escaped
225        } else {
226            Self::apply_formula_protection(&escaped)
227        }
228    }
229
230    /// Apply formula injection protection by prefixing dangerous values
231    fn apply_formula_protection(value: &str) -> String {
232        if let Some(first_char) = value.chars().next()
233            && FORMULA_CHARS.contains(&first_char)
234        {
235            return format!("'{value}");
236        }
237        value.to_string()
238    }
239
240    /// Escape a field based on delimiter type
241    fn escape_field(&self, value: &str) -> String {
242        if self.config.delimiter == '\t' {
243            self.escape_tsv_field(value)
244        } else {
245            self.escape_csv_field(value)
246        }
247    }
248
249    /// Get field value for a symbol and column
250    fn get_field_value(symbol: &DisplaySymbol, column: CsvColumn, preview: Option<&str>) -> String {
251        let language = symbol.metadata.get("__raw_language").map(String::as_str);
252        let is_static = symbol
253            .metadata
254            .get("static")
255            .is_some_and(|value| value == "true");
256        match column {
257            CsvColumn::Name => symbol.name.clone(),
258            CsvColumn::QualifiedName => super::display_qualified_name(
259                &symbol.qualified_name,
260                &symbol.kind,
261                language,
262                is_static,
263            ),
264            CsvColumn::Kind => symbol.kind.clone(),
265            CsvColumn::File => symbol.file_path.display().to_string(),
266            CsvColumn::Line => symbol.start_line.to_string(),
267            CsvColumn::Column => symbol.start_column.to_string(),
268            CsvColumn::EndLine => symbol.end_line.to_string(),
269            CsvColumn::EndColumn => symbol.end_column.to_string(),
270            CsvColumn::Language => symbol
271                .metadata
272                .get("__raw_language")
273                .cloned()
274                .unwrap_or_else(|| "unknown".to_string()),
275            CsvColumn::Preview => preview.unwrap_or("").to_string(),
276            // Diff-specific columns - not applicable to regular symbol output
277            // These columns are only used through the diff command's custom CSV formatter
278            CsvColumn::ChangeType | CsvColumn::SignatureBefore | CsvColumn::SignatureAfter => {
279                String::new()
280            }
281        }
282    }
283}
284
285impl Formatter for CsvFormatter {
286    fn format(
287        &self,
288        symbols: &[DisplaySymbol],
289        _metadata: Option<&FormatterMetadata>,
290        streams: &mut OutputStreams,
291    ) -> Result<()> {
292        let columns = self.get_columns();
293        let delimiter = self.config.delimiter.to_string();
294
295        // Write header row if enabled
296        if self.config.headers {
297            let header_row: Vec<&str> = columns.iter().copied().map(CsvColumn::header).collect();
298            streams.write_result(&header_row.join(&delimiter))?;
299        }
300
301        // Create preview extractor if needed
302        let mut preview_extractor = if self.config.preview_config.is_some() {
303            let workspace = self
304                .workspace_root
305                .clone()
306                .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
307            Some(PreviewExtractor::new(
308                self.config.preview_config.clone().unwrap(),
309                workspace,
310            ))
311        } else {
312            None
313        };
314
315        // Write data rows
316        for symbol in symbols {
317            // Get preview if configured
318            let preview_str = if let Some(ref mut extractor) = preview_extractor {
319                let ctx = extractor.extract(&symbol.file_path, symbol.start_line)?;
320                Some(ctx.to_preview_string(500)) // Limit preview length in CSV
321            } else {
322                None
323            };
324
325            let fields: Vec<String> = columns
326                .iter()
327                .copied()
328                .map(|col| {
329                    let value = Self::get_field_value(symbol, col, preview_str.as_deref());
330                    self.escape_field(&value)
331                })
332                .collect();
333
334            streams.write_result(&fields.join(&delimiter))?;
335        }
336
337        Ok(())
338    }
339}
340
341/// Parse column specification string into column list
342///
343/// # Errors
344/// Returns an error message if any column name is invalid or if no valid columns are provided.
345pub fn parse_columns(spec: Option<&String>) -> Result<Option<Vec<CsvColumn>>, String> {
346    let Some(raw) = spec else {
347        return Ok(None);
348    };
349
350    let mut columns = Vec::new();
351    for name in raw.split(',').map(str::trim).filter(|n| !n.is_empty()) {
352        if let Some(col) = CsvColumn::parse_column(name) {
353            if !columns.contains(&col) {
354                columns.push(col);
355            }
356        } else {
357            return Err(format!(
358                "Unknown column '{name}'. Valid columns: name, qualified_name, kind, file, line, column, end_line, end_column, language, preview, change_type, signature_before, signature_after"
359            ));
360        }
361    }
362
363    if columns.is_empty() {
364        return Err("No valid columns specified for --columns".to_string());
365    }
366
367    Ok(Some(columns))
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use std::collections::HashMap;
374    use std::path::PathBuf;
375
376    fn create_test_display_symbol(name: &str, file: &str, line: usize) -> DisplaySymbol {
377        let mut metadata = HashMap::new();
378        metadata.insert("__raw_language".to_string(), "rust".to_string());
379        metadata.insert("__raw_file_path".to_string(), file.to_string());
380        DisplaySymbol {
381            name: name.to_string(),
382            qualified_name: format!("test::{name}"),
383            kind: "function".to_string(),
384            file_path: PathBuf::from(file),
385            start_line: line,
386            start_column: 1,
387            end_line: line,
388            end_column: 10,
389            metadata,
390            caller_identity: None,
391            callee_identity: None,
392        }
393    }
394
395    #[test]
396    fn test_csv_escape_simple() {
397        let formatter = CsvFormatter::csv(false, None).raw_mode(true);
398        assert_eq!(formatter.escape_csv_field("hello"), "hello");
399    }
400
401    #[test]
402    fn test_csv_escape_quotes() {
403        let formatter = CsvFormatter::csv(false, None).raw_mode(true);
404        assert_eq!(
405            formatter.escape_csv_field("say \"hi\""),
406            "\"say \"\"hi\"\"\""
407        );
408    }
409
410    #[test]
411    fn test_csv_escape_commas() {
412        let formatter = CsvFormatter::csv(false, None).raw_mode(true);
413        assert_eq!(formatter.escape_csv_field("a,b"), "\"a,b\"");
414    }
415
416    #[test]
417    fn test_csv_escape_newlines() {
418        let formatter = CsvFormatter::csv(false, None).raw_mode(true);
419        assert_eq!(formatter.escape_csv_field("a\nb"), "\"a\nb\"");
420    }
421
422    #[test]
423    fn test_csv_escape_combined() {
424        // Input contains a comma, a double-quote, and a newline — all three
425        // require RFC 4180 quoting.  The field must be wrapped in double-quotes
426        // and any internal double-quotes must be doubled.
427        let formatter = CsvFormatter::csv(false, None).raw_mode(true);
428        let result = formatter.escape_csv_field("a,\"b\"\nc");
429        assert_eq!(
430            result, "\"a,\"\"b\"\"\nc\"",
431            "combined escape (comma + quote + newline) produced unexpected output"
432        );
433    }
434
435    #[test]
436    fn test_csv_escape_empty() {
437        let formatter = CsvFormatter::csv(false, None).raw_mode(true);
438        assert_eq!(formatter.escape_csv_field(""), "");
439    }
440
441    #[test]
442    fn test_csv_escape_unicode() {
443        let formatter = CsvFormatter::csv(false, None).raw_mode(true);
444        assert_eq!(formatter.escape_csv_field("日本語"), "日本語");
445    }
446
447    #[test]
448    fn test_tsv_escape_tabs() {
449        let formatter = CsvFormatter::tsv(false, None).raw_mode(true);
450        assert_eq!(formatter.escape_tsv_field("a\tb"), "a b");
451    }
452
453    #[test]
454    fn test_tsv_escape_newlines() {
455        let formatter = CsvFormatter::tsv(false, None).raw_mode(true);
456        assert_eq!(formatter.escape_tsv_field("a\nb"), "a b");
457    }
458
459    #[test]
460    fn test_formula_injection_equals() {
461        let formatter = CsvFormatter::csv(false, None);
462        assert_eq!(formatter.escape_field("=SUM(1)"), "'=SUM(1)");
463    }
464
465    #[test]
466    fn test_formula_injection_plus() {
467        let formatter = CsvFormatter::csv(false, None);
468        assert_eq!(formatter.escape_field("+1"), "'+1");
469    }
470
471    #[test]
472    fn test_formula_injection_minus() {
473        let formatter = CsvFormatter::csv(false, None);
474        assert_eq!(formatter.escape_field("-1"), "'-1");
475    }
476
477    #[test]
478    fn test_formula_injection_at() {
479        let formatter = CsvFormatter::csv(false, None);
480        assert_eq!(formatter.escape_field("@SUM"), "'@SUM");
481    }
482
483    #[test]
484    fn test_formula_injection_raw_mode() {
485        let formatter = CsvFormatter::csv(false, None).raw_mode(true);
486        assert_eq!(formatter.escape_field("=SUM(1)"), "=SUM(1)");
487    }
488
489    #[test]
490    fn test_normal_values_unmodified() {
491        let formatter = CsvFormatter::csv(false, None);
492        assert_eq!(formatter.escape_field("hello"), "hello");
493        assert_eq!(formatter.escape_field("world"), "world");
494    }
495
496    #[test]
497    fn test_parse_columns() {
498        let spec = Some("name,file,line".to_string());
499        let cols = parse_columns(spec.as_ref()).unwrap().unwrap();
500        assert_eq!(cols.len(), 3);
501        assert_eq!(cols[0], CsvColumn::Name);
502        assert_eq!(cols[1], CsvColumn::File);
503        assert_eq!(cols[2], CsvColumn::Line);
504    }
505
506    #[test]
507    fn test_parse_columns_with_aliases() {
508        let spec = Some("name,lang,col".to_string());
509        let cols = parse_columns(spec.as_ref()).unwrap().unwrap();
510        assert_eq!(cols.len(), 3);
511        assert_eq!(cols[0], CsvColumn::Name);
512        assert_eq!(cols[1], CsvColumn::Language);
513        assert_eq!(cols[2], CsvColumn::Column);
514    }
515
516    #[test]
517    fn test_parse_columns_invalid() {
518        let spec = Some("name,unknown".to_string());
519        let err = parse_columns(spec.as_ref()).unwrap_err();
520        assert!(
521            err.contains("Unknown column"),
522            "Unexpected error message: {err}"
523        );
524    }
525
526    #[test]
527    fn test_csv_header_default() {
528        let formatter = CsvFormatter::csv(true, None);
529        let cols = formatter.get_columns();
530        assert_eq!(
531            cols.len(),
532            DEFAULT_COLUMNS.len(),
533            "default column count changed; update DEFAULT_COLUMNS if intentional"
534        );
535        // Spot-check that key columns are present
536        assert!(
537            cols.contains(&CsvColumn::Name),
538            "Name column must be present"
539        );
540        assert!(
541            cols.contains(&CsvColumn::Kind),
542            "Kind column must be present"
543        );
544        assert!(
545            cols.contains(&CsvColumn::File),
546            "File column must be present"
547        );
548        assert!(
549            cols.contains(&CsvColumn::Language),
550            "Language column must be present"
551        );
552    }
553
554    #[test]
555    fn test_csv_header_subset() {
556        let columns = Some(vec![CsvColumn::Name, CsvColumn::File, CsvColumn::Line]);
557        let formatter = CsvFormatter::csv(true, columns);
558        let cols = formatter.get_columns();
559        assert_eq!(cols.len(), 3);
560    }
561
562    #[test]
563    fn test_csv_formatter_output() {
564        use crate::output::TestOutputStreams;
565
566        let display = create_test_display_symbol("test_func", "src/lib.rs", 42);
567        let formatter = CsvFormatter::csv(true, Some(vec![CsvColumn::Name, CsvColumn::Line]));
568
569        let (test, mut streams) = TestOutputStreams::new();
570        formatter.format(&[display], None, &mut streams).unwrap();
571
572        let output = test.stdout_string();
573        assert!(
574            output.contains("name,line"),
575            "Header should contain name,line: {}",
576            output
577        );
578        assert!(
579            output.contains("test_func,42"),
580            "Output should contain test_func,42: {}",
581            output
582        );
583    }
584
585    #[test]
586    fn test_tsv_escape_carriage_return_removed() {
587        let formatter = CsvFormatter::tsv(false, None).raw_mode(true);
588        // \r is filtered out (mapped to None in the filter_map)
589        assert_eq!(formatter.escape_tsv_field("a\rb"), "ab");
590    }
591
592    #[test]
593    fn test_csv_column_parse_all_variants() {
594        assert_eq!(CsvColumn::parse_column("name"), Some(CsvColumn::Name));
595        assert_eq!(
596            CsvColumn::parse_column("qualified_name"),
597            Some(CsvColumn::QualifiedName)
598        );
599        assert_eq!(
600            CsvColumn::parse_column("qualifiedname"),
601            Some(CsvColumn::QualifiedName)
602        );
603        assert_eq!(CsvColumn::parse_column("kind"), Some(CsvColumn::Kind));
604        assert_eq!(CsvColumn::parse_column("file"), Some(CsvColumn::File));
605        assert_eq!(CsvColumn::parse_column("line"), Some(CsvColumn::Line));
606        assert_eq!(CsvColumn::parse_column("column"), Some(CsvColumn::Column));
607        assert_eq!(CsvColumn::parse_column("col"), Some(CsvColumn::Column));
608        assert_eq!(
609            CsvColumn::parse_column("end_line"),
610            Some(CsvColumn::EndLine)
611        );
612        assert_eq!(CsvColumn::parse_column("endline"), Some(CsvColumn::EndLine));
613        assert_eq!(
614            CsvColumn::parse_column("end_column"),
615            Some(CsvColumn::EndColumn)
616        );
617        assert_eq!(
618            CsvColumn::parse_column("endcolumn"),
619            Some(CsvColumn::EndColumn)
620        );
621        assert_eq!(
622            CsvColumn::parse_column("language"),
623            Some(CsvColumn::Language)
624        );
625        assert_eq!(CsvColumn::parse_column("lang"), Some(CsvColumn::Language));
626        assert_eq!(CsvColumn::parse_column("preview"), Some(CsvColumn::Preview));
627        assert_eq!(CsvColumn::parse_column("context"), Some(CsvColumn::Preview));
628        assert_eq!(
629            CsvColumn::parse_column("change_type"),
630            Some(CsvColumn::ChangeType)
631        );
632        assert_eq!(
633            CsvColumn::parse_column("changetype"),
634            Some(CsvColumn::ChangeType)
635        );
636        assert_eq!(
637            CsvColumn::parse_column("signature_before"),
638            Some(CsvColumn::SignatureBefore)
639        );
640        assert_eq!(
641            CsvColumn::parse_column("signaturebefore"),
642            Some(CsvColumn::SignatureBefore)
643        );
644        assert_eq!(
645            CsvColumn::parse_column("signature_after"),
646            Some(CsvColumn::SignatureAfter)
647        );
648        assert_eq!(
649            CsvColumn::parse_column("signatureafter"),
650            Some(CsvColumn::SignatureAfter)
651        );
652        assert_eq!(CsvColumn::parse_column("unknown"), None);
653    }
654
655    #[test]
656    fn test_csv_column_headers_all_variants() {
657        assert_eq!(CsvColumn::Name.header(), "name");
658        assert_eq!(CsvColumn::QualifiedName.header(), "qualified_name");
659        assert_eq!(CsvColumn::Kind.header(), "kind");
660        assert_eq!(CsvColumn::File.header(), "file");
661        assert_eq!(CsvColumn::Line.header(), "line");
662        assert_eq!(CsvColumn::Column.header(), "column");
663        assert_eq!(CsvColumn::EndLine.header(), "end_line");
664        assert_eq!(CsvColumn::EndColumn.header(), "end_column");
665        assert_eq!(CsvColumn::Language.header(), "language");
666        assert_eq!(CsvColumn::Preview.header(), "preview");
667        assert_eq!(CsvColumn::ChangeType.header(), "change_type");
668        assert_eq!(CsvColumn::SignatureBefore.header(), "signature_before");
669        assert_eq!(CsvColumn::SignatureAfter.header(), "signature_after");
670    }
671
672    #[test]
673    fn test_parse_columns_deduplication() {
674        // Duplicate column names should be deduplicated
675        let spec = Some("name,name,file".to_string());
676        let cols = parse_columns(spec.as_ref()).unwrap().unwrap();
677        assert_eq!(cols.len(), 2, "Should deduplicate identical columns");
678        assert_eq!(cols[0], CsvColumn::Name);
679        assert_eq!(cols[1], CsvColumn::File);
680    }
681
682    #[test]
683    fn test_parse_columns_none() {
684        let result = parse_columns(None).unwrap();
685        assert!(result.is_none());
686    }
687
688    #[test]
689    fn test_parse_columns_empty_string_errors() {
690        let spec = Some("".to_string());
691        let err = parse_columns(spec.as_ref()).unwrap_err();
692        assert!(err.contains("No valid columns"), "Unexpected error: {err}");
693    }
694
695    #[test]
696    fn test_get_field_value_diff_columns_return_empty() {
697        let symbol = create_test_display_symbol("fn_name", "src/main.rs", 1);
698        // Diff columns produce empty strings for non-diff output
699        assert_eq!(
700            CsvFormatter::get_field_value(&symbol, CsvColumn::ChangeType, None),
701            ""
702        );
703        assert_eq!(
704            CsvFormatter::get_field_value(&symbol, CsvColumn::SignatureBefore, None),
705            ""
706        );
707        assert_eq!(
708            CsvFormatter::get_field_value(&symbol, CsvColumn::SignatureAfter, None),
709            ""
710        );
711    }
712
713    #[test]
714    fn test_get_field_value_preview_column() {
715        let symbol = create_test_display_symbol("fn_name", "src/main.rs", 1);
716        // With preview string
717        assert_eq!(
718            CsvFormatter::get_field_value(&symbol, CsvColumn::Preview, Some("fn fn_name() {}")),
719            "fn fn_name() {}"
720        );
721        // Without preview string
722        assert_eq!(
723            CsvFormatter::get_field_value(&symbol, CsvColumn::Preview, None),
724            ""
725        );
726    }
727
728    #[test]
729    fn test_get_field_value_language_unknown_fallback() {
730        let metadata = HashMap::new();
731        // No __raw_language key → should return "unknown"
732        let symbol = DisplaySymbol {
733            name: "test".to_string(),
734            qualified_name: "test".to_string(),
735            kind: "function".to_string(),
736            file_path: PathBuf::from("test.rs"),
737            start_line: 1,
738            start_column: 0,
739            end_line: 1,
740            end_column: 0,
741            metadata,
742            caller_identity: None,
743            callee_identity: None,
744        };
745        assert_eq!(
746            CsvFormatter::get_field_value(&symbol, CsvColumn::Language, None),
747            "unknown"
748        );
749    }
750
751    #[test]
752    fn test_csv_config_default() {
753        let config = CsvConfig::default();
754        // Default has headers disabled
755        assert!(!config.headers);
756        // default delimiter is comma
757        assert_eq!(config.delimiter, ',');
758        assert!(!config.raw_mode);
759        assert!(config.columns.is_none());
760        assert!(config.preview_config.is_none());
761
762        // When no columns are configured, the formatter falls back to
763        // DEFAULT_COLUMNS.  Assert the count and spot-check key columns instead
764        // of hard-coding a magic number.
765        let formatter = CsvFormatter::csv(false, None);
766        let cols = formatter.get_columns();
767        assert_eq!(
768            cols.len(),
769            DEFAULT_COLUMNS.len(),
770            "default column count changed; update DEFAULT_COLUMNS if intentional"
771        );
772        assert!(
773            cols.contains(&CsvColumn::Name),
774            "Name column must be present"
775        );
776        assert!(
777            cols.contains(&CsvColumn::Kind),
778            "Kind column must be present"
779        );
780        assert!(
781            cols.contains(&CsvColumn::File),
782            "File column must be present"
783        );
784        assert!(
785            cols.contains(&CsvColumn::Language),
786            "Language column must be present"
787        );
788    }
789
790    #[test]
791    fn test_csv_formatter_get_columns_with_preview_appended() {
792        use crate::output::preview::PreviewConfig;
793
794        // When preview_config is set and Preview is not already in the columns,
795        // get_columns() should append it.
796        let formatter = CsvFormatter::csv(false, Some(vec![CsvColumn::Name]))
797            .with_preview(PreviewConfig::new(1));
798        let cols = formatter.get_columns();
799        assert!(
800            cols.contains(&CsvColumn::Preview),
801            "Preview column should be auto-appended"
802        );
803    }
804
805    #[test]
806    fn test_csv_formatter_get_columns_preview_not_duplicated() {
807        use crate::output::preview::PreviewConfig;
808
809        // When Preview is already in the columns, it should not be added again.
810        let formatter = CsvFormatter::csv(false, Some(vec![CsvColumn::Name, CsvColumn::Preview]))
811            .with_preview(PreviewConfig::new(1));
812        let cols = formatter.get_columns();
813        let preview_count = cols.iter().filter(|c| **c == CsvColumn::Preview).count();
814        assert_eq!(preview_count, 1, "Preview should not be duplicated");
815    }
816
817    #[test]
818    fn test_formula_injection_tab_char() {
819        // Tab as first char should be prefixed (it's in FORMULA_CHARS)
820        let result = CsvFormatter::apply_formula_protection("\thello");
821        assert_eq!(result, "'\thello");
822    }
823
824    #[test]
825    fn test_formula_injection_carriage_return() {
826        let result = CsvFormatter::apply_formula_protection("\rhello");
827        assert_eq!(result, "'\rhello");
828    }
829
830    #[test]
831    fn test_formula_injection_safe_value() {
832        let result = CsvFormatter::apply_formula_protection("safe_value");
833        assert_eq!(result, "safe_value");
834    }
835
836    #[test]
837    fn test_formula_injection_empty_value() {
838        let result = CsvFormatter::apply_formula_protection("");
839        assert_eq!(result, "");
840    }
841
842    #[test]
843    fn test_csv_formatter_with_workspace_root() {
844        let tmp = tempfile::tempdir().unwrap();
845        let formatter =
846            CsvFormatter::csv(false, None).with_workspace_root(tmp.path().to_path_buf());
847        // Ensure workspace_root is set (accessor not public, but we can verify
848        // the builder returns without error and columns work normally)
849        let cols = formatter.get_columns();
850        assert_eq!(
851            cols.len(),
852            DEFAULT_COLUMNS.len(),
853            "default column count changed; update DEFAULT_COLUMNS if intentional"
854        );
855    }
856
857    #[test]
858    fn test_csv_escape_carriage_return_triggers_quoting() {
859        let formatter = CsvFormatter::csv(false, None).raw_mode(true);
860        // \r triggers RFC 4180 quoting; the field is wrapped in double-quotes
861        // with no internal quotes to double.
862        let result = formatter.escape_csv_field("a\rb");
863        assert_eq!(result, "\"a\rb\"", "\\r should trigger RFC 4180 quoting");
864    }
865
866    #[test]
867    fn test_tsv_formatter_output() {
868        use crate::output::TestOutputStreams;
869
870        let display = create_test_display_symbol("test_func", "src/lib.rs", 42);
871        let formatter = CsvFormatter::tsv(false, Some(vec![CsvColumn::Name, CsvColumn::Line]));
872
873        let (test, mut streams) = TestOutputStreams::new();
874        formatter.format(&[display], None, &mut streams).unwrap();
875
876        let output = test.stdout_string();
877        assert!(
878            output.contains("test_func\t42"),
879            "Output should contain test_func<tab>42: {}",
880            output
881        );
882    }
883}