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