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