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: {output}"
576        );
577        assert!(
578            output.contains("test_func,42"),
579            "Output should contain test_func,42: {output}"
580        );
581    }
582
583    #[test]
584    fn test_tsv_escape_carriage_return_removed() {
585        let formatter = CsvFormatter::tsv(false, None).raw_mode(true);
586        // \r is filtered out (mapped to None in the filter_map)
587        assert_eq!(formatter.escape_tsv_field("a\rb"), "ab");
588    }
589
590    #[test]
591    fn test_csv_column_parse_all_variants() {
592        assert_eq!(CsvColumn::parse_column("name"), Some(CsvColumn::Name));
593        assert_eq!(
594            CsvColumn::parse_column("qualified_name"),
595            Some(CsvColumn::QualifiedName)
596        );
597        assert_eq!(
598            CsvColumn::parse_column("qualifiedname"),
599            Some(CsvColumn::QualifiedName)
600        );
601        assert_eq!(CsvColumn::parse_column("kind"), Some(CsvColumn::Kind));
602        assert_eq!(CsvColumn::parse_column("file"), Some(CsvColumn::File));
603        assert_eq!(CsvColumn::parse_column("line"), Some(CsvColumn::Line));
604        assert_eq!(CsvColumn::parse_column("column"), Some(CsvColumn::Column));
605        assert_eq!(CsvColumn::parse_column("col"), Some(CsvColumn::Column));
606        assert_eq!(
607            CsvColumn::parse_column("end_line"),
608            Some(CsvColumn::EndLine)
609        );
610        assert_eq!(CsvColumn::parse_column("endline"), Some(CsvColumn::EndLine));
611        assert_eq!(
612            CsvColumn::parse_column("end_column"),
613            Some(CsvColumn::EndColumn)
614        );
615        assert_eq!(
616            CsvColumn::parse_column("endcolumn"),
617            Some(CsvColumn::EndColumn)
618        );
619        assert_eq!(
620            CsvColumn::parse_column("language"),
621            Some(CsvColumn::Language)
622        );
623        assert_eq!(CsvColumn::parse_column("lang"), Some(CsvColumn::Language));
624        assert_eq!(CsvColumn::parse_column("preview"), Some(CsvColumn::Preview));
625        assert_eq!(CsvColumn::parse_column("context"), Some(CsvColumn::Preview));
626        assert_eq!(
627            CsvColumn::parse_column("change_type"),
628            Some(CsvColumn::ChangeType)
629        );
630        assert_eq!(
631            CsvColumn::parse_column("changetype"),
632            Some(CsvColumn::ChangeType)
633        );
634        assert_eq!(
635            CsvColumn::parse_column("signature_before"),
636            Some(CsvColumn::SignatureBefore)
637        );
638        assert_eq!(
639            CsvColumn::parse_column("signaturebefore"),
640            Some(CsvColumn::SignatureBefore)
641        );
642        assert_eq!(
643            CsvColumn::parse_column("signature_after"),
644            Some(CsvColumn::SignatureAfter)
645        );
646        assert_eq!(
647            CsvColumn::parse_column("signatureafter"),
648            Some(CsvColumn::SignatureAfter)
649        );
650        assert_eq!(CsvColumn::parse_column("unknown"), None);
651    }
652
653    #[test]
654    fn test_csv_column_headers_all_variants() {
655        assert_eq!(CsvColumn::Name.header(), "name");
656        assert_eq!(CsvColumn::QualifiedName.header(), "qualified_name");
657        assert_eq!(CsvColumn::Kind.header(), "kind");
658        assert_eq!(CsvColumn::File.header(), "file");
659        assert_eq!(CsvColumn::Line.header(), "line");
660        assert_eq!(CsvColumn::Column.header(), "column");
661        assert_eq!(CsvColumn::EndLine.header(), "end_line");
662        assert_eq!(CsvColumn::EndColumn.header(), "end_column");
663        assert_eq!(CsvColumn::Language.header(), "language");
664        assert_eq!(CsvColumn::Preview.header(), "preview");
665        assert_eq!(CsvColumn::ChangeType.header(), "change_type");
666        assert_eq!(CsvColumn::SignatureBefore.header(), "signature_before");
667        assert_eq!(CsvColumn::SignatureAfter.header(), "signature_after");
668    }
669
670    #[test]
671    fn test_parse_columns_deduplication() {
672        // Duplicate column names should be deduplicated
673        let spec = Some("name,name,file".to_string());
674        let cols = parse_columns(spec.as_ref()).unwrap().unwrap();
675        assert_eq!(cols.len(), 2, "Should deduplicate identical columns");
676        assert_eq!(cols[0], CsvColumn::Name);
677        assert_eq!(cols[1], CsvColumn::File);
678    }
679
680    #[test]
681    fn test_parse_columns_none() {
682        let result = parse_columns(None).unwrap();
683        assert!(result.is_none());
684    }
685
686    #[test]
687    fn test_parse_columns_empty_string_errors() {
688        let spec = Some(String::new());
689        let err = parse_columns(spec.as_ref()).unwrap_err();
690        assert!(err.contains("No valid columns"), "Unexpected error: {err}");
691    }
692
693    #[test]
694    fn test_get_field_value_diff_columns_return_empty() {
695        let symbol = create_test_display_symbol("fn_name", "src/main.rs", 1);
696        // Diff columns produce empty strings for non-diff output
697        assert_eq!(
698            CsvFormatter::get_field_value(&symbol, CsvColumn::ChangeType, None),
699            ""
700        );
701        assert_eq!(
702            CsvFormatter::get_field_value(&symbol, CsvColumn::SignatureBefore, None),
703            ""
704        );
705        assert_eq!(
706            CsvFormatter::get_field_value(&symbol, CsvColumn::SignatureAfter, None),
707            ""
708        );
709    }
710
711    #[test]
712    fn test_get_field_value_preview_column() {
713        let symbol = create_test_display_symbol("fn_name", "src/main.rs", 1);
714        // With preview string
715        assert_eq!(
716            CsvFormatter::get_field_value(&symbol, CsvColumn::Preview, Some("fn fn_name() {}")),
717            "fn fn_name() {}"
718        );
719        // Without preview string
720        assert_eq!(
721            CsvFormatter::get_field_value(&symbol, CsvColumn::Preview, None),
722            ""
723        );
724    }
725
726    #[test]
727    fn test_get_field_value_language_unknown_fallback() {
728        let metadata = HashMap::new();
729        // No __raw_language key → should return "unknown"
730        let symbol = DisplaySymbol {
731            name: "test".to_string(),
732            qualified_name: "test".to_string(),
733            kind: "function".to_string(),
734            file_path: PathBuf::from("test.rs"),
735            start_line: 1,
736            start_column: 0,
737            end_line: 1,
738            end_column: 0,
739            metadata,
740            caller_identity: None,
741            callee_identity: None,
742        };
743        assert_eq!(
744            CsvFormatter::get_field_value(&symbol, CsvColumn::Language, None),
745            "unknown"
746        );
747    }
748
749    #[test]
750    fn test_csv_config_default() {
751        let config = CsvConfig::default();
752        // Default has headers disabled
753        assert!(!config.headers);
754        // default delimiter is comma
755        assert_eq!(config.delimiter, ',');
756        assert!(!config.raw_mode);
757        assert!(config.columns.is_none());
758        assert!(config.preview_config.is_none());
759
760        // When no columns are configured, the formatter falls back to
761        // DEFAULT_COLUMNS.  Assert the count and spot-check key columns instead
762        // of hard-coding a magic number.
763        let formatter = CsvFormatter::csv(false, None);
764        let cols = formatter.get_columns();
765        assert_eq!(
766            cols.len(),
767            DEFAULT_COLUMNS.len(),
768            "default column count changed; update DEFAULT_COLUMNS if intentional"
769        );
770        assert!(
771            cols.contains(&CsvColumn::Name),
772            "Name column must be present"
773        );
774        assert!(
775            cols.contains(&CsvColumn::Kind),
776            "Kind column must be present"
777        );
778        assert!(
779            cols.contains(&CsvColumn::File),
780            "File column must be present"
781        );
782        assert!(
783            cols.contains(&CsvColumn::Language),
784            "Language column must be present"
785        );
786    }
787
788    #[test]
789    fn test_csv_formatter_get_columns_with_preview_appended() {
790        use crate::output::preview::PreviewConfig;
791
792        // When preview_config is set and Preview is not already in the columns,
793        // get_columns() should append it.
794        let formatter = CsvFormatter::csv(false, Some(vec![CsvColumn::Name]))
795            .with_preview(PreviewConfig::new(1));
796        let cols = formatter.get_columns();
797        assert!(
798            cols.contains(&CsvColumn::Preview),
799            "Preview column should be auto-appended"
800        );
801    }
802
803    #[test]
804    fn test_csv_formatter_get_columns_preview_not_duplicated() {
805        use crate::output::preview::PreviewConfig;
806
807        // When Preview is already in the columns, it should not be added again.
808        let formatter = CsvFormatter::csv(false, Some(vec![CsvColumn::Name, CsvColumn::Preview]))
809            .with_preview(PreviewConfig::new(1));
810        let cols = formatter.get_columns();
811        let preview_count = cols.iter().filter(|c| **c == CsvColumn::Preview).count();
812        assert_eq!(preview_count, 1, "Preview should not be duplicated");
813    }
814
815    #[test]
816    fn test_formula_injection_tab_char() {
817        // Tab as first char should be prefixed (it's in FORMULA_CHARS)
818        let result = CsvFormatter::apply_formula_protection("\thello");
819        assert_eq!(result, "'\thello");
820    }
821
822    #[test]
823    fn test_formula_injection_carriage_return() {
824        let result = CsvFormatter::apply_formula_protection("\rhello");
825        assert_eq!(result, "'\rhello");
826    }
827
828    #[test]
829    fn test_formula_injection_safe_value() {
830        let result = CsvFormatter::apply_formula_protection("safe_value");
831        assert_eq!(result, "safe_value");
832    }
833
834    #[test]
835    fn test_formula_injection_empty_value() {
836        let result = CsvFormatter::apply_formula_protection("");
837        assert_eq!(result, "");
838    }
839
840    #[test]
841    fn test_csv_formatter_with_workspace_root() {
842        let tmp = tempfile::tempdir().unwrap();
843        let formatter =
844            CsvFormatter::csv(false, None).with_workspace_root(tmp.path().to_path_buf());
845        // Ensure workspace_root is set (accessor not public, but we can verify
846        // the builder returns without error and columns work normally)
847        let cols = formatter.get_columns();
848        assert_eq!(
849            cols.len(),
850            DEFAULT_COLUMNS.len(),
851            "default column count changed; update DEFAULT_COLUMNS if intentional"
852        );
853    }
854
855    #[test]
856    fn test_csv_escape_carriage_return_triggers_quoting() {
857        let formatter = CsvFormatter::csv(false, None).raw_mode(true);
858        // \r triggers RFC 4180 quoting; the field is wrapped in double-quotes
859        // with no internal quotes to double.
860        let result = formatter.escape_csv_field("a\rb");
861        assert_eq!(result, "\"a\rb\"", "\\r should trigger RFC 4180 quoting");
862    }
863
864    #[test]
865    fn test_tsv_formatter_output() {
866        use crate::output::TestOutputStreams;
867
868        let display = create_test_display_symbol("test_func", "src/lib.rs", 42);
869        let formatter = CsvFormatter::tsv(false, Some(vec![CsvColumn::Name, CsvColumn::Line]));
870
871        let (test, mut streams) = TestOutputStreams::new();
872        formatter.format(&[display], None, &mut streams).unwrap();
873
874        let output = test.stdout_string();
875        assert!(
876            output.contains("test_func\t42"),
877            "Output should contain test_func<tab>42: {output}"
878        );
879    }
880}