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