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: {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 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 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 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 assert_eq!(
716 CsvFormatter::get_field_value(&symbol, CsvColumn::Preview, Some("fn fn_name() {}")),
717 "fn fn_name() {}"
718 );
719 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 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 assert!(!config.headers);
754 assert_eq!(config.delimiter, ',');
756 assert!(!config.raw_mode);
757 assert!(config.columns.is_none());
758 assert!(config.preview_config.is_none());
759
760 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 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 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 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 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 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}