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