1use super::preview::{PreviewConfig, PreviewExtractor};
7use super::{DisplaySymbol, Formatter, FormatterMetadata, OutputStreams};
8use anyhow::Result;
9use std::path::PathBuf;
10
11const FORMULA_CHARS: &[char] = &['=', '+', '-', '@', '\t', '\r'];
13
14const 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#[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 ChangeType,
42 SignatureBefore,
43 SignatureAfter,
44}
45
46impl CsvColumn {
47 #[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 #[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#[derive(Debug, Clone)]
91pub struct CsvConfig {
92 pub headers: bool,
94 pub columns: Option<Vec<CsvColumn>>,
96 pub delimiter: char,
98 pub raw_mode: bool,
100 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
116pub struct CsvFormatter {
118 config: CsvConfig,
119 workspace_root: Option<PathBuf>,
121}
122
123impl CsvFormatter {
124 #[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 #[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 #[must_use]
154 pub fn with_preview(mut self, config: PreviewConfig) -> Self {
155 self.config.preview_config = Some(config);
156 self
157 }
158
159 #[must_use]
161 pub fn raw_mode(mut self, raw: bool) -> Self {
162 self.config.raw_mode = raw;
163 self
164 }
165
166 #[must_use]
168 pub fn with_workspace_root(mut self, root: PathBuf) -> Self {
169 self.workspace_root = Some(root);
170 self
171 }
172
173 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 if self.config.preview_config.is_some() && !cols.contains(&CsvColumn::Preview) {
183 cols.push(CsvColumn::Preview);
184 }
185
186 cols
187 }
188
189 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 if self.config.raw_mode {
204 escaped
205 } else {
206 Self::apply_formula_protection(&escaped)
207 }
208 }
209
210 fn escape_tsv_field(&self, value: &str) -> String {
212 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 if self.config.raw_mode {
224 escaped
225 } else {
226 Self::apply_formula_protection(&escaped)
227 }
228 }
229
230 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 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 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 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 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 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 for symbol in symbols {
317 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)) } 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
341pub 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 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 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 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 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 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 assert_eq!(
718 CsvFormatter::get_field_value(&symbol, CsvColumn::Preview, Some("fn fn_name() {}")),
719 "fn fn_name() {}"
720 );
721 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 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 assert!(!config.headers);
756 assert_eq!(config.delimiter, ',');
758 assert!(!config.raw_mode);
759 assert!(config.columns.is_none());
760 assert!(config.preview_config.is_none());
761
762 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 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 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 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 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 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}