Skip to main content

sqry_cli/commands/
diff.rs

1//! Diff command implementation
2//!
3//! Compares code semantics between two git refs using AST analysis.
4
5use 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// ============================================================================
20// Display Types
21// ============================================================================
22
23/// CLI result structure for diff output
24#[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/// Individual change record for CLI output
35#[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/// Location information for CLI output
48#[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/// Summary statistics for CLI output
56#[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
65// ============================================================================
66// Main Command Implementation
67// ============================================================================
68
69/// Run the diff command.
70///
71/// Compares symbols between two git refs by:
72/// 1. Creating temporary git worktrees
73/// 2. Building `CodeGraph`s for each ref
74/// 3. Comparing graphs to detect changes
75/// 4. Formatting and outputting results
76///
77/// # Errors
78///
79/// Returns error if:
80/// - Not a git repository
81/// - Git refs are invalid
82/// - Graph building fails
83/// - Output formatting fails
84pub 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    // 1. Validate and resolve repository root
94    let root = resolve_repo_root(path, cli)?;
95
96    // 2. Create temporary git worktrees
97    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    // 3. Build graphs for both refs
109    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    // 4. Compare graphs
126    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    // 5. Filter results by kind and change type
148    let filtered_changes = filter_changes(result.changes, kinds, change_types);
149
150    // 6. Recompute summary from filtered changes (per Codex review: summary must match output)
151    let filtered_summary = compute_summary(&filtered_changes);
152
153    // 7. Apply limit and track truncation
154    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    // 8. Format and output
159    format_and_output(
160        cli,
161        base_ref,
162        target_ref,
163        limited_changes,
164        &filtered_summary,
165        total,
166        truncated,
167    )?;
168
169    // WorktreeManager drops here, automatically cleaning up worktrees
170    Ok(())
171}
172
173// ============================================================================
174// Helper Functions
175// ============================================================================
176
177/// Resolve repository root path
178///
179/// Walks up from the given path to find the git repository root.
180/// This allows --path to accept any path within a repository.
181fn 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    // Canonicalize to absolute path
189    let start_path = start_path
190        .canonicalize()
191        .context(format!("Failed to resolve path: {}", start_path.display()))?;
192
193    // Walk up to find .git directory
194    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
210/// Compute summary statistics from a list of changes
211///
212/// Used to recompute summary after filtering to ensure summary counts match actual output
213/// (per Codex review feedback)
214fn 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, // Always 0 for filtered changes list
224    };
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
240/// Filter changes by symbol kind and change type
241fn 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            // Filter by kind if specified
250            let kind_matches =
251                kinds.is_empty() || kinds.iter().any(|k| k.eq_ignore_ascii_case(&change.kind));
252
253            // Filter by change type if specified
254            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
264/// Limit number of changes and return truncated list
265fn 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
273/// Format and output results based on CLI flags
274fn 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    // Convert to display types
286    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    // Select formatter based on CLI flags
296    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
310// ============================================================================
311// Conversion Functions
312// ============================================================================
313
314fn 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
345// ============================================================================
346// Text Formatter
347// ============================================================================
348
349fn format_text_output(
350    streams: &mut OutputStreams,
351    result: &DiffDisplayResult,
352    use_color: bool,
353) -> Result<()> {
354    let mut output = String::new();
355
356    // Header
357    let _ = writeln!(
358        output,
359        "Comparing {}...{}\n",
360        result.base_ref, result.target_ref
361    );
362
363    // Summary section
364    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    // Group changes by type
376    let by_type = group_by_change_type(&result.changes);
377
378    // Define order for change types
379    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            // Section header with color
394            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            // Print each change
403            for change in changes {
404                format_change_text(&mut output, change, use_color);
405            }
406            output.push('\n');
407        }
408    }
409
410    // Truncation warning
411    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    // Name and kind
437    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    // Qualified name (if different from name)
449    if change.qualified_name != change.name {
450        let _ = writeln!(output, "    {}", change.qualified_name);
451    }
452
453    // Location
454    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    // Signatures (for signature changes)
463    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
506// ============================================================================
507// JSON Formatter
508// ============================================================================
509
510fn 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
517// ============================================================================
518// CSV/TSV Formatter
519// ============================================================================
520
521/// Format CSV/TSV output for diff command
522///
523/// Since `diff` has custom fields (`change_type`, `signature_before`, `signature_after`) that
524/// aren't part of the standard `CsvColumn` enum, we handle CSV output manually here
525/// while still using the same escaping logic as the shared formatter.
526fn 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    // Determine columns
536    let columns = if let Some(cols_spec) = &cli.columns {
537        // Parse user-specified columns - convert CsvColumn to DiffColumn where possible
538        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                // Convert CsvColumn to DiffColumn (only standard columns are supported via --columns)
544                let converted: Vec<DiffColumn> =
545                    cols.into_iter().filter_map(csv_to_diff_column).collect();
546
547                // Validate that at least some columns are supported
548                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                // Warn if some columns were dropped
557                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    // Write header
575    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    // Write data rows
582    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
599/// Get default columns for diff CSV output
600fn 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/// Columns available for diff CSV output
614#[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
626/// Convert `CsvColumn` to `DiffColumn` for `--columns` support.
627fn 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        // Columns not applicable to diff output
638        CsvColumn::Column
639        | CsvColumn::EndLine
640        | CsvColumn::EndColumn
641        | CsvColumn::Language
642        | CsvColumn::Preview => None,
643    }
644}
645
646/// Get header name for a diff column.
647fn 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
660/// Get column value for a diff change.
661fn 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
690/// Escape a field value for CSV/TSV output
691///
692/// Uses the same logic as the shared `CsvFormatter`.
693fn 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
701/// Escape CSV field (RFC 4180)
702fn 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
721/// Escape TSV field
722fn 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
739/// Formula injection protection
740const 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// ============================================================================
752// Tests
753// ============================================================================
754
755#[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        // Test CSV field escaping
898        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        // Test formula protection
906        assert_eq!(escape_csv_field("=SUM(A1)", ',', false), "'=SUM(A1)");
907        assert_eq!(escape_csv_field("+123", ',', false), "'+123");
908
909        // Test raw mode
910        assert_eq!(escape_csv_field("=SUM(A1)", ',', true), "=SUM(A1)");
911    }
912
913    #[test]
914    fn test_tsv_escaping() {
915        // Test TSV field escaping - tabs and newlines become spaces
916        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        // Test that standard columns map correctly
935        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        // Test that diff-specific columns map correctly
945        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        // Test that unsupported columns return None
959        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        // Verify header names for all diff columns
969        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        // Test standard columns
1004        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        // Test diff-specific columns
1014        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        // Test that parse_columns recognizes diff-specific column names
1031        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        // Test parsing a mix of standard and diff-specific columns
1048        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        // Create a test change
1069        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        // Test CSV output with diff-specific columns
1104        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        // Note: We can't easily verify the exact output here without capturing stdout,
1117        // but the fact that it doesn't error proves the columns are recognized
1118    }
1119    }
1120}