1use crate::args::Cli;
6use crate::output::{CsvColumn, OutputStreams, parse_columns, resolve_theme};
7use crate::plugin_defaults::{self, PluginSelectionMode};
8use anyhow::{Context, Result, bail};
9use colored::Colorize;
10use serde::Serialize;
11use sqry_core::git::WorktreeManager;
12use sqry_core::graph::diff::{DiffSummary, GraphComparator, NodeChange, NodeLocation};
13use sqry_core::graph::unified::build::{BuildConfig, build_unified_graph};
14use std::collections::HashMap;
15use std::fmt::Write as _;
16use std::path::PathBuf;
17use std::sync::Arc;
18
19#[derive(Clone, Debug, Serialize)]
25pub struct DiffDisplayResult {
26 pub base_ref: String,
27 pub target_ref: String,
28 pub changes: Vec<DiffDisplayChange>,
29 pub summary: DiffDisplaySummary,
30 pub total: usize,
31 pub truncated: bool,
32}
33
34#[derive(Clone, Debug, Serialize)]
36pub struct DiffDisplayChange {
37 pub name: String,
38 pub qualified_name: String,
39 pub kind: String,
40 pub change_type: String,
41 pub base_location: Option<DiffLocation>,
42 pub target_location: Option<DiffLocation>,
43 pub signature_before: Option<String>,
44 pub signature_after: Option<String>,
45}
46
47#[derive(Clone, Debug, Serialize)]
49pub struct DiffLocation {
50 pub file_path: String,
51 pub start_line: u32,
52 pub end_line: u32,
53}
54
55#[derive(Clone, Debug, Serialize)]
57pub struct DiffDisplaySummary {
58 pub added: u64,
59 pub removed: u64,
60 pub modified: u64,
61 pub renamed: u64,
62 pub signature_changed: u64,
63}
64
65pub fn run_diff(
85 cli: &Cli,
86 base_ref: &str,
87 target_ref: &str,
88 path: Option<&str>,
89 max_results: usize,
90 kinds: &[String],
91 change_types: &[String],
92) -> Result<()> {
93 let root = resolve_repo_root(path, cli)?;
95
96 let worktree_mgr = WorktreeManager::create(&root, base_ref, target_ref)
98 .context("Failed to create git worktrees")?;
99
100 log::debug!(
101 "Created worktrees for diff: base={} target={} base_path={} target_path={}",
102 base_ref,
103 target_ref,
104 worktree_mgr.base_path().display(),
105 worktree_mgr.target_path().display()
106 );
107
108 let resolved_plugins =
110 plugin_defaults::resolve_plugin_selection(cli, &root, PluginSelectionMode::Diff)?;
111 let config = BuildConfig::default();
112
113 let base_graph = Arc::new(
114 build_unified_graph(
115 worktree_mgr.base_path(),
116 &resolved_plugins.plugin_manager,
117 &config,
118 )
119 .context(format!("Failed to build graph for base ref '{base_ref}'"))?,
120 );
121 log::debug!("Built base graph: ref={base_ref}");
122
123 let target_graph = Arc::new(
124 build_unified_graph(
125 worktree_mgr.target_path(),
126 &resolved_plugins.plugin_manager,
127 &config,
128 )
129 .context(format!(
130 "Failed to build graph for target ref '{target_ref}'"
131 ))?,
132 );
133 log::debug!("Built target graph: ref={target_ref}");
134
135 let comparator = GraphComparator::new(
137 base_graph,
138 target_graph,
139 root.clone(),
140 worktree_mgr.base_path().to_path_buf(),
141 worktree_mgr.target_path().to_path_buf(),
142 );
143 let result = comparator
144 .compute_changes()
145 .context("Failed to compute changes")?;
146
147 log::debug!(
148 "Computed changes: total={} added={} removed={} modified={} renamed={} signature_changed={}",
149 result.changes.len(),
150 result.summary.added,
151 result.summary.removed,
152 result.summary.modified,
153 result.summary.renamed,
154 result.summary.signature_changed
155 );
156
157 let filtered_changes = filter_changes(result.changes, kinds, change_types);
159
160 let filtered_summary = compute_summary(&filtered_changes);
162
163 let total = filtered_changes.len();
165 let truncated = filtered_changes.len() > max_results;
166 let limited_changes = limit_changes(filtered_changes, max_results);
167
168 format_and_output(
170 cli,
171 base_ref,
172 target_ref,
173 limited_changes,
174 &filtered_summary,
175 total,
176 truncated,
177 )?;
178
179 Ok(())
181}
182
183fn resolve_repo_root(path: Option<&str>, cli: &Cli) -> Result<PathBuf> {
192 let start_path = if let Some(p) = path {
193 PathBuf::from(p)
194 } else {
195 PathBuf::from(cli.search_path())
196 };
197
198 let start_path = start_path
200 .canonicalize()
201 .context(format!("Failed to resolve path: {}", start_path.display()))?;
202
203 let mut current = start_path.as_path();
205 loop {
206 if current.join(".git").exists() {
207 return Ok(current.to_path_buf());
208 }
209
210 match current.parent() {
211 Some(parent) => current = parent,
212 None => bail!(
213 "Not a git repository (or any parent up to mount point): {}",
214 start_path.display()
215 ),
216 }
217 }
218}
219
220fn compute_summary(changes: &[NodeChange]) -> DiffSummary {
225 use sqry_core::graph::diff::ChangeType;
226
227 let mut summary = DiffSummary {
228 added: 0,
229 removed: 0,
230 modified: 0,
231 renamed: 0,
232 signature_changed: 0,
233 unchanged: 0, };
235
236 for change in changes {
237 match change.change_type {
238 ChangeType::Added => summary.added += 1,
239 ChangeType::Removed => summary.removed += 1,
240 ChangeType::Modified => summary.modified += 1,
241 ChangeType::Renamed => summary.renamed += 1,
242 ChangeType::SignatureChanged => summary.signature_changed += 1,
243 ChangeType::Unchanged => summary.unchanged += 1,
244 }
245 }
246
247 summary
248}
249
250fn filter_changes(
252 changes: Vec<NodeChange>,
253 kinds: &[String],
254 change_types: &[String],
255) -> Vec<NodeChange> {
256 changes
257 .into_iter()
258 .filter(|change| {
259 let kind_matches =
261 kinds.is_empty() || kinds.iter().any(|k| k.eq_ignore_ascii_case(&change.kind));
262
263 let change_type_matches = change_types.is_empty()
265 || change_types
266 .iter()
267 .any(|ct| ct.eq_ignore_ascii_case(change.change_type.as_str()));
268
269 kind_matches && change_type_matches
270 })
271 .collect()
272}
273
274fn limit_changes(changes: Vec<NodeChange>, limit: usize) -> Vec<NodeChange> {
276 if changes.len() <= limit {
277 changes
278 } else {
279 changes.into_iter().take(limit).collect()
280 }
281}
282
283fn format_and_output(
285 cli: &Cli,
286 base_ref: &str,
287 target_ref: &str,
288 changes: Vec<NodeChange>,
289 summary: &DiffSummary,
290 total: usize,
291 truncated: bool,
292) -> Result<()> {
293 let mut streams = OutputStreams::new();
294
295 let result = DiffDisplayResult {
297 base_ref: base_ref.to_string(),
298 target_ref: target_ref.to_string(),
299 changes: changes.into_iter().map(convert_change).collect(),
300 summary: convert_summary(summary),
301 total,
302 truncated,
303 };
304
305 match (cli.json, cli.csv, cli.tsv) {
307 (true, _, _) => format_json_output(&mut streams, &result),
308 (_, true, _) => format_csv_output_shared(cli, &mut streams, &result, ','),
309 (_, _, true) => format_csv_output_shared(cli, &mut streams, &result, '\t'),
310 _ => {
311 let theme = resolve_theme(cli);
312 let use_color = !cli.no_color
313 && theme != crate::output::ThemeName::None
314 && std::env::var("NO_COLOR").is_err();
315 format_text_output(&mut streams, &result, use_color)
316 }
317 }
318}
319
320fn convert_change(change: NodeChange) -> DiffDisplayChange {
325 DiffDisplayChange {
326 name: change.name,
327 qualified_name: change.qualified_name,
328 kind: change.kind,
329 change_type: change.change_type.as_str().to_string(),
330 base_location: change.base_location.map(|loc| convert_location(&loc)),
331 target_location: change.target_location.map(|loc| convert_location(&loc)),
332 signature_before: change.signature_before,
333 signature_after: change.signature_after,
334 }
335}
336
337fn convert_location(loc: &NodeLocation) -> DiffLocation {
338 DiffLocation {
339 file_path: loc.file_path.display().to_string(),
340 start_line: loc.start_line,
341 end_line: loc.end_line,
342 }
343}
344
345fn convert_summary(summary: &DiffSummary) -> DiffDisplaySummary {
346 DiffDisplaySummary {
347 added: summary.added,
348 removed: summary.removed,
349 modified: summary.modified,
350 renamed: summary.renamed,
351 signature_changed: summary.signature_changed,
352 }
353}
354
355fn format_text_output(
360 streams: &mut OutputStreams,
361 result: &DiffDisplayResult,
362 use_color: bool,
363) -> Result<()> {
364 let mut output = String::new();
365
366 let _ = writeln!(
368 output,
369 "Comparing {}...{}\n",
370 result.base_ref, result.target_ref
371 );
372
373 output.push_str("Summary:\n");
375 let _ = writeln!(output, " Added: {}", result.summary.added);
376 let _ = writeln!(output, " Removed: {}", result.summary.removed);
377 let _ = writeln!(output, " Modified: {}", result.summary.modified);
378 let _ = writeln!(output, " Renamed: {}", result.summary.renamed);
379 let _ = writeln!(
380 output,
381 " Signature Changed: {}\n",
382 result.summary.signature_changed
383 );
384
385 let by_type = group_by_change_type(&result.changes);
387
388 let order = vec![
390 "added",
391 "removed",
392 "modified",
393 "renamed",
394 "signature_changed",
395 ];
396
397 for change_type in order {
398 if let Some(changes) = by_type.get(change_type) {
399 if changes.is_empty() {
400 continue;
401 }
402
403 let header = capitalize_change_type(change_type);
405 if use_color {
406 output.push_str(&colorize_header(&header, change_type));
407 } else {
408 let _ = write!(output, "{header}:");
409 }
410 output.push('\n');
411
412 for change in changes {
414 format_change_text(&mut output, change, use_color);
415 }
416 output.push('\n');
417 }
418 }
419
420 if result.truncated {
422 let _ = writeln!(
423 output,
424 "Note: Output limited to {} results (total: {})",
425 result.changes.len(),
426 result.total
427 );
428 }
429
430 streams.write_result(output.trim_end())?;
431 Ok(())
432}
433
434fn group_by_change_type(changes: &[DiffDisplayChange]) -> HashMap<String, Vec<&DiffDisplayChange>> {
435 let mut grouped: HashMap<String, Vec<&DiffDisplayChange>> = HashMap::new();
436 for change in changes {
437 grouped
438 .entry(change.change_type.clone())
439 .or_default()
440 .push(change);
441 }
442 grouped
443}
444
445fn format_change_text(output: &mut String, change: &DiffDisplayChange, use_color: bool) {
446 if use_color {
448 let _ = writeln!(
449 output,
450 " {} [{}]",
451 colorize_symbol(&change.name, &change.change_type),
452 change.kind
453 );
454 } else {
455 let _ = writeln!(output, " {} [{}]", change.name, change.kind);
456 }
457
458 if change.qualified_name != change.name {
460 let _ = writeln!(output, " {}", change.qualified_name);
461 }
462
463 if let Some(loc) = change
465 .target_location
466 .as_ref()
467 .or(change.base_location.as_ref())
468 {
469 let _ = writeln!(output, " Location: {}:{}", loc.file_path, loc.start_line);
470 }
471
472 if let (Some(before), Some(after)) = (&change.signature_before, &change.signature_after) {
474 if before != after {
475 let _ = writeln!(output, " Before: {before}");
476 let _ = writeln!(output, " After: {after}");
477 }
478 } else if let Some(sig) = &change.signature_before {
479 let _ = writeln!(output, " Signature: {sig}");
480 } else if let Some(sig) = &change.signature_after {
481 let _ = writeln!(output, " Signature: {sig}");
482 }
483}
484
485fn capitalize_change_type(s: &str) -> String {
486 match s {
487 "added" => "Added".to_string(),
488 "removed" => "Removed".to_string(),
489 "modified" => "Modified".to_string(),
490 "renamed" => "Renamed".to_string(),
491 "signature_changed" => "Signature Changed".to_string(),
492 _ => s.to_string(),
493 }
494}
495
496fn colorize_header(header: &str, change_type: &str) -> String {
497 match change_type {
498 "added" => format!("{}:", header.green().bold()),
499 "removed" => format!("{}:", header.red().bold()),
500 "modified" | "renamed" => format!("{}:", header.yellow().bold()),
501 "signature_changed" => format!("{}:", header.blue().bold()),
502 _ => format!("{header}:"),
503 }
504}
505
506fn colorize_symbol(name: &str, change_type: &str) -> String {
507 match change_type {
508 "added" => name.green().to_string(),
509 "removed" => name.red().to_string(),
510 "modified" | "renamed" => name.yellow().to_string(),
511 "signature_changed" => name.blue().to_string(),
512 _ => name.to_string(),
513 }
514}
515
516fn format_json_output(streams: &mut OutputStreams, result: &DiffDisplayResult) -> Result<()> {
521 let json =
522 serde_json::to_string_pretty(result).context("Failed to serialize diff results to JSON")?;
523 streams.write_result(&json)?;
524 Ok(())
525}
526
527fn format_csv_output_shared(
537 cli: &Cli,
538 streams: &mut OutputStreams,
539 result: &DiffDisplayResult,
540 delimiter: char,
541) -> Result<()> {
542 let mut output = String::new();
543 let is_tsv = delimiter == '\t';
544
545 let columns = if let Some(cols_spec) = &cli.columns {
547 let csv_cols = parse_columns(Some(cols_spec)).map_err(|e| anyhow::anyhow!("{e}"))?;
549
550 match csv_cols {
551 Some(cols) => {
552 let requested_count = cols.len();
553 let converted: Vec<DiffColumn> =
555 cols.into_iter().filter_map(csv_to_diff_column).collect();
556
557 if converted.is_empty() {
559 bail!(
560 "No supported columns specified for diff output.\n\
561 Supported columns: name, qualified_name, kind, file, line, change_type, signature_before, signature_after\n\
562 Unsupported for diff: column, end_line, end_column, language, preview"
563 );
564 }
565
566 let matched_count = converted.len();
568 if matched_count < requested_count {
569 eprintln!(
570 "Warning: {} of {} requested columns are not supported by diff output",
571 requested_count - matched_count,
572 requested_count
573 );
574 }
575
576 converted
577 }
578 None => get_default_diff_columns(),
579 }
580 } else {
581 get_default_diff_columns()
582 };
583
584 if cli.headers {
586 let headers: Vec<&str> = columns.iter().copied().map(column_header).collect();
587 output.push_str(&headers.join(&delimiter.to_string()));
588 output.push('\n');
589 }
590
591 for change in &result.changes {
593 let fields: Vec<String> = columns
594 .iter()
595 .map(|col| {
596 let value = get_column_value(change, *col);
597 escape_field(&value, delimiter, is_tsv, cli.raw_csv)
598 })
599 .collect();
600
601 output.push_str(&fields.join(&delimiter.to_string()));
602 output.push('\n');
603 }
604
605 streams.write_result(output.trim_end())?;
606 Ok(())
607}
608
609fn get_default_diff_columns() -> Vec<DiffColumn> {
611 vec![
612 DiffColumn::Name,
613 DiffColumn::QualifiedName,
614 DiffColumn::Kind,
615 DiffColumn::ChangeType,
616 DiffColumn::File,
617 DiffColumn::Line,
618 DiffColumn::SignatureBefore,
619 DiffColumn::SignatureAfter,
620 ]
621}
622
623#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625enum DiffColumn {
626 Name,
627 QualifiedName,
628 Kind,
629 ChangeType,
630 File,
631 Line,
632 SignatureBefore,
633 SignatureAfter,
634}
635
636fn csv_to_diff_column(col: CsvColumn) -> Option<DiffColumn> {
638 match col {
639 CsvColumn::Name => Some(DiffColumn::Name),
640 CsvColumn::QualifiedName => Some(DiffColumn::QualifiedName),
641 CsvColumn::Kind => Some(DiffColumn::Kind),
642 CsvColumn::File => Some(DiffColumn::File),
643 CsvColumn::Line => Some(DiffColumn::Line),
644 CsvColumn::ChangeType => Some(DiffColumn::ChangeType),
645 CsvColumn::SignatureBefore => Some(DiffColumn::SignatureBefore),
646 CsvColumn::SignatureAfter => Some(DiffColumn::SignatureAfter),
647 CsvColumn::Column
649 | CsvColumn::EndLine
650 | CsvColumn::EndColumn
651 | CsvColumn::Language
652 | CsvColumn::Preview => None,
653 }
654}
655
656fn column_header(col: DiffColumn) -> &'static str {
658 match col {
659 DiffColumn::Name => "name",
660 DiffColumn::QualifiedName => "qualified_name",
661 DiffColumn::Kind => "kind",
662 DiffColumn::ChangeType => "change_type",
663 DiffColumn::File => "file",
664 DiffColumn::Line => "line",
665 DiffColumn::SignatureBefore => "signature_before",
666 DiffColumn::SignatureAfter => "signature_after",
667 }
668}
669
670fn get_column_value(change: &DiffDisplayChange, col: DiffColumn) -> String {
672 match col {
673 DiffColumn::Name => change.name.clone(),
674 DiffColumn::QualifiedName => change.qualified_name.clone(),
675 DiffColumn::Kind => change.kind.clone(),
676 DiffColumn::ChangeType => change.change_type.clone(),
677 DiffColumn::File => {
678 let location = change
679 .target_location
680 .as_ref()
681 .or(change.base_location.as_ref());
682 location
683 .map(|loc| loc.file_path.clone())
684 .unwrap_or_default()
685 }
686 DiffColumn::Line => {
687 let location = change
688 .target_location
689 .as_ref()
690 .or(change.base_location.as_ref());
691 location
692 .map(|loc| loc.start_line.to_string())
693 .unwrap_or_default()
694 }
695 DiffColumn::SignatureBefore => change.signature_before.clone().unwrap_or_default(),
696 DiffColumn::SignatureAfter => change.signature_after.clone().unwrap_or_default(),
697 }
698}
699
700fn escape_field(value: &str, delimiter: char, is_tsv: bool, raw: bool) -> String {
704 if is_tsv {
705 escape_tsv_field(value, raw)
706 } else {
707 escape_csv_field(value, delimiter, raw)
708 }
709}
710
711fn escape_csv_field(value: &str, delimiter: char, raw: bool) -> String {
713 let needs_quoting = value.contains(delimiter)
714 || value.contains('"')
715 || value.contains('\n')
716 || value.contains('\r');
717
718 let escaped = if needs_quoting {
719 format!("\"{}\"", value.replace('"', "\"\""))
720 } else {
721 value.to_string()
722 };
723
724 if raw {
725 escaped
726 } else {
727 apply_formula_protection(&escaped)
728 }
729}
730
731fn escape_tsv_field(value: &str, raw: bool) -> String {
733 let escaped: String = value
734 .chars()
735 .filter_map(|c| match c {
736 '\t' | '\n' => Some(' '),
737 '\r' => None,
738 _ => Some(c),
739 })
740 .collect();
741
742 if raw {
743 escaped
744 } else {
745 apply_formula_protection(&escaped)
746 }
747}
748
749const FORMULA_CHARS: &[char] = &['=', '+', '-', '@', '\t', '\r'];
751
752fn apply_formula_protection(value: &str) -> String {
753 if let Some(first_char) = value.chars().next()
754 && FORMULA_CHARS.contains(&first_char)
755 {
756 return format!("'{value}");
757 }
758 value.to_string()
759}
760
761#[cfg(test)]
766mod tests {
767 use super::*;
768 use crate::large_stack_test;
769 use sqry_core::graph::diff::ChangeType;
770
771 #[test]
772 fn test_convert_change() {
773 let core_change = NodeChange {
774 name: "test".to_string(),
775 qualified_name: "mod::test".to_string(),
776 kind: "function".to_string(),
777 change_type: ChangeType::Added,
778 base_location: None,
779 target_location: Some(NodeLocation {
780 file_path: PathBuf::from("test.rs"),
781 start_line: 10,
782 end_line: 15,
783 start_column: 0,
784 end_column: 1,
785 }),
786 signature_before: None,
787 signature_after: Some("fn test()".to_string()),
788 };
789
790 let display_change = convert_change(core_change);
791
792 assert_eq!(display_change.name, "test");
793 assert_eq!(display_change.qualified_name, "mod::test");
794 assert_eq!(display_change.kind, "function");
795 assert_eq!(display_change.change_type, "added");
796 assert!(display_change.base_location.is_none());
797 assert!(display_change.target_location.is_some());
798
799 let loc = display_change.target_location.unwrap();
800 assert_eq!(loc.file_path, "test.rs");
801 assert_eq!(loc.start_line, 10);
802 }
803
804 #[test]
805 fn test_filter_by_kind() {
806 let changes = vec![
807 NodeChange {
808 name: "func1".to_string(),
809 qualified_name: "func1".to_string(),
810 kind: "function".to_string(),
811 change_type: ChangeType::Added,
812 base_location: None,
813 target_location: None,
814 signature_before: None,
815 signature_after: None,
816 },
817 NodeChange {
818 name: "class1".to_string(),
819 qualified_name: "class1".to_string(),
820 kind: "class".to_string(),
821 change_type: ChangeType::Added,
822 base_location: None,
823 target_location: None,
824 signature_before: None,
825 signature_after: None,
826 },
827 ];
828
829 let filtered = filter_changes(changes, &["function".to_string()], &[]);
830 assert_eq!(filtered.len(), 1);
831 assert_eq!(filtered[0].kind, "function");
832 }
833
834 #[test]
835 fn test_filter_by_change_type() {
836 let changes = vec![
837 NodeChange {
838 name: "func1".to_string(),
839 qualified_name: "func1".to_string(),
840 kind: "function".to_string(),
841 change_type: ChangeType::Added,
842 base_location: None,
843 target_location: None,
844 signature_before: None,
845 signature_after: None,
846 },
847 NodeChange {
848 name: "func2".to_string(),
849 qualified_name: "func2".to_string(),
850 kind: "function".to_string(),
851 change_type: ChangeType::Removed,
852 base_location: None,
853 target_location: None,
854 signature_before: None,
855 signature_after: None,
856 },
857 ];
858
859 let filtered = filter_changes(changes, &[], &["added".to_string()]);
860 assert_eq!(filtered.len(), 1);
861 assert_eq!(filtered[0].name, "func1");
862 }
863
864 #[test]
865 fn test_limit_changes() {
866 let changes = vec![
867 NodeChange {
868 name: format!("func{}", 1),
869 qualified_name: String::new(),
870 kind: "function".to_string(),
871 change_type: ChangeType::Added,
872 base_location: None,
873 target_location: None,
874 signature_before: None,
875 signature_after: None,
876 },
877 NodeChange {
878 name: format!("func{}", 2),
879 qualified_name: String::new(),
880 kind: "function".to_string(),
881 change_type: ChangeType::Added,
882 base_location: None,
883 target_location: None,
884 signature_before: None,
885 signature_after: None,
886 },
887 NodeChange {
888 name: format!("func{}", 3),
889 qualified_name: String::new(),
890 kind: "function".to_string(),
891 change_type: ChangeType::Added,
892 base_location: None,
893 target_location: None,
894 signature_before: None,
895 signature_after: None,
896 },
897 ];
898
899 let limited = limit_changes(changes, 2);
900 assert_eq!(limited.len(), 2);
901 assert_eq!(limited[0].name, "func1");
902 assert_eq!(limited[1].name, "func2");
903 }
904
905 #[test]
906 fn test_csv_escaping() {
907 assert_eq!(escape_csv_field("simple", ',', false), "simple");
909 assert_eq!(escape_csv_field("has,comma", ',', false), "\"has,comma\"");
910 assert_eq!(
911 escape_csv_field("has\"quote", ',', false),
912 "\"has\"\"quote\""
913 );
914
915 assert_eq!(escape_csv_field("=SUM(A1)", ',', false), "'=SUM(A1)");
917 assert_eq!(escape_csv_field("+123", ',', false), "'+123");
918
919 assert_eq!(escape_csv_field("=SUM(A1)", ',', true), "=SUM(A1)");
921 }
922
923 #[test]
924 fn test_tsv_escaping() {
925 assert_eq!(escape_tsv_field("simple", false), "simple");
927 assert_eq!(escape_tsv_field("has\ttab", false), "has tab");
928 assert_eq!(escape_tsv_field("has\nnewline", false), "has newline");
929 assert_eq!(escape_tsv_field("has\rcarriage", false), "hascarriage");
930 }
931
932 #[test]
933 fn test_capitalize_change_type() {
934 assert_eq!(capitalize_change_type("added"), "Added");
935 assert_eq!(capitalize_change_type("removed"), "Removed");
936 assert_eq!(
937 capitalize_change_type("signature_changed"),
938 "Signature Changed"
939 );
940 }
941
942 #[test]
943 fn test_csv_column_to_diff_column_mapping() {
944 assert_eq!(csv_to_diff_column(CsvColumn::Name), Some(DiffColumn::Name));
946 assert_eq!(
947 csv_to_diff_column(CsvColumn::QualifiedName),
948 Some(DiffColumn::QualifiedName)
949 );
950 assert_eq!(csv_to_diff_column(CsvColumn::Kind), Some(DiffColumn::Kind));
951 assert_eq!(csv_to_diff_column(CsvColumn::File), Some(DiffColumn::File));
952 assert_eq!(csv_to_diff_column(CsvColumn::Line), Some(DiffColumn::Line));
953
954 assert_eq!(
956 csv_to_diff_column(CsvColumn::ChangeType),
957 Some(DiffColumn::ChangeType)
958 );
959 assert_eq!(
960 csv_to_diff_column(CsvColumn::SignatureBefore),
961 Some(DiffColumn::SignatureBefore)
962 );
963 assert_eq!(
964 csv_to_diff_column(CsvColumn::SignatureAfter),
965 Some(DiffColumn::SignatureAfter)
966 );
967
968 assert_eq!(csv_to_diff_column(CsvColumn::Column), None);
970 assert_eq!(csv_to_diff_column(CsvColumn::EndLine), None);
971 assert_eq!(csv_to_diff_column(CsvColumn::EndColumn), None);
972 assert_eq!(csv_to_diff_column(CsvColumn::Language), None);
973 assert_eq!(csv_to_diff_column(CsvColumn::Preview), None);
974 }
975
976 #[test]
977 fn test_diff_column_headers() {
978 assert_eq!(column_header(DiffColumn::Name), "name");
980 assert_eq!(column_header(DiffColumn::QualifiedName), "qualified_name");
981 assert_eq!(column_header(DiffColumn::Kind), "kind");
982 assert_eq!(column_header(DiffColumn::ChangeType), "change_type");
983 assert_eq!(column_header(DiffColumn::File), "file");
984 assert_eq!(column_header(DiffColumn::Line), "line");
985 assert_eq!(
986 column_header(DiffColumn::SignatureBefore),
987 "signature_before"
988 );
989 assert_eq!(column_header(DiffColumn::SignatureAfter), "signature_after");
990 }
991
992 #[test]
993 fn test_get_column_value_for_diff_specific_columns() {
994 let change = DiffDisplayChange {
995 name: "test_func".to_string(),
996 qualified_name: "mod::test_func".to_string(),
997 kind: "function".to_string(),
998 change_type: "signature_changed".to_string(),
999 base_location: Some(DiffLocation {
1000 file_path: "test.rs".to_string(),
1001 start_line: 10,
1002 end_line: 15,
1003 }),
1004 target_location: Some(DiffLocation {
1005 file_path: "test.rs".to_string(),
1006 start_line: 10,
1007 end_line: 17,
1008 }),
1009 signature_before: Some("fn test_func()".to_string()),
1010 signature_after: Some("fn test_func(x: i32)".to_string()),
1011 };
1012
1013 assert_eq!(get_column_value(&change, DiffColumn::Name), "test_func");
1015 assert_eq!(
1016 get_column_value(&change, DiffColumn::QualifiedName),
1017 "mod::test_func"
1018 );
1019 assert_eq!(get_column_value(&change, DiffColumn::Kind), "function");
1020 assert_eq!(get_column_value(&change, DiffColumn::File), "test.rs");
1021 assert_eq!(get_column_value(&change, DiffColumn::Line), "10");
1022
1023 assert_eq!(
1025 get_column_value(&change, DiffColumn::ChangeType),
1026 "signature_changed"
1027 );
1028 assert_eq!(
1029 get_column_value(&change, DiffColumn::SignatureBefore),
1030 "fn test_func()"
1031 );
1032 assert_eq!(
1033 get_column_value(&change, DiffColumn::SignatureAfter),
1034 "fn test_func(x: i32)"
1035 );
1036 }
1037
1038 #[test]
1039 fn test_parse_diff_specific_columns() {
1040 let spec = Some("change_type,signature_before,signature_after".to_string());
1042 let result = parse_columns(spec.as_ref());
1043 assert!(result.is_ok(), "Should parse diff-specific columns");
1044
1045 let cols = result.unwrap();
1046 assert!(cols.is_some(), "Should return Some(vec)");
1047
1048 let cols = cols.unwrap();
1049 assert_eq!(cols.len(), 3, "Should have 3 columns");
1050 assert_eq!(cols[0], CsvColumn::ChangeType);
1051 assert_eq!(cols[1], CsvColumn::SignatureBefore);
1052 assert_eq!(cols[2], CsvColumn::SignatureAfter);
1053 }
1054
1055 #[test]
1056 fn test_parse_mixed_standard_and_diff_columns() {
1057 let spec = Some("name,kind,change_type,file,signature_before".to_string());
1059 let result = parse_columns(spec.as_ref());
1060 assert!(result.is_ok(), "Should parse mixed columns");
1061
1062 let cols = result.unwrap().unwrap();
1063 assert_eq!(cols.len(), 5, "Should have 5 columns");
1064 assert_eq!(cols[0], CsvColumn::Name);
1065 assert_eq!(cols[1], CsvColumn::Kind);
1066 assert_eq!(cols[2], CsvColumn::ChangeType);
1067 assert_eq!(cols[3], CsvColumn::File);
1068 assert_eq!(cols[4], CsvColumn::SignatureBefore);
1069 }
1070
1071 large_stack_test! {
1072 #[test]
1073 fn test_diff_csv_output_with_diff_columns() {
1074 use crate::args::Cli;
1075 use crate::output::OutputStreams;
1076 use clap::Parser;
1077
1078 let change = DiffDisplayChange {
1080 name: "modified_func".to_string(),
1081 qualified_name: "test::modified_func".to_string(),
1082 kind: "function".to_string(),
1083 change_type: "modified".to_string(),
1084 base_location: Some(DiffLocation {
1085 file_path: "src/lib.rs".to_string(),
1086 start_line: 42,
1087 end_line: 45,
1088 }),
1089 target_location: Some(DiffLocation {
1090 file_path: "src/lib.rs".to_string(),
1091 start_line: 42,
1092 end_line: 48,
1093 }),
1094 signature_before: Some("fn modified_func(x: i32)".to_string()),
1095 signature_after: Some("fn modified_func(x: i32, y: i32)".to_string()),
1096 };
1097
1098 let result = DiffDisplayResult {
1099 base_ref: "HEAD~1".to_string(),
1100 target_ref: "HEAD".to_string(),
1101 changes: vec![change],
1102 summary: DiffDisplaySummary {
1103 added: 0,
1104 removed: 0,
1105 modified: 1,
1106 renamed: 0,
1107 signature_changed: 0,
1108 },
1109 total: 1,
1110 truncated: false,
1111 };
1112
1113 let cli = Cli::parse_from([
1115 "sqry",
1116 "--csv",
1117 "--headers",
1118 "--columns",
1119 "name,change_type,signature_before,signature_after",
1120 ]);
1121 let mut streams = OutputStreams::new();
1122
1123 let output_result = format_csv_output_shared(&cli, &mut streams, &result, ',');
1124 assert!(output_result.is_ok(), "CSV formatting should succeed");
1125
1126 }
1129 }
1130}