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