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