Skip to main content

ctx/
diff.rs

1//! Diff-aware context generation for code review and change understanding.
2//!
3//! This module provides functionality to:
4//! - Parse git diff output to identify changed files
5//! - Find symbols affected by changes
6//! - Expand context using call graph analysis
7//! - Generate context focused on code changes
8//!
9//! # Usage
10//!
11//! ```ignore
12//! let config = DiffConfig::default();
13//! let result = diff_context("HEAD~1", &db, &analytics, config)?;
14//! for file in result.changed_files {
15//!     println!("{}: {:?}", file.path, file.change_type);
16//! }
17//! ```
18
19use std::collections::{HashMap, HashSet};
20use std::process::Command;
21
22use crate::analytics::Analytics;
23use crate::db::Database;
24use crate::error::{CtxError, Result};
25use crate::tokens::{count_tokens_with_encoding, select_by_token_budget, Encoding, HasTokenCount};
26
27/// Configuration for diff-aware context generation.
28#[derive(Debug, Clone)]
29pub struct DiffConfig {
30    /// Maximum tokens in output
31    pub max_tokens: usize,
32    /// Call graph context depth
33    pub depth: i32,
34    /// Only include changed files (no context expansion)
35    pub changes_only: bool,
36    /// Include staged changes only
37    pub staged: bool,
38    /// Include change summary in output
39    #[allow(dead_code)] // Config option for future use
40    pub summary: bool,
41    /// Tokenizer encoding
42    pub encoding: Encoding,
43}
44
45impl Default for DiffConfig {
46    fn default() -> Self {
47        Self {
48            max_tokens: 8000,
49            depth: 1,
50            changes_only: false,
51            staged: false,
52            summary: false,
53            encoding: Encoding::default(),
54        }
55    }
56}
57
58/// Type of change for a file.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ChangeType {
61    /// File was added
62    Added,
63    /// File was modified
64    Modified,
65    /// File was deleted
66    Deleted,
67    /// File was renamed
68    Renamed,
69    /// File was copied
70    Copied,
71    /// Unknown change type
72    Unknown,
73}
74
75impl ChangeType {
76    /// Parse change type from git status letter.
77    pub fn from_git_status(status: char) -> Self {
78        match status {
79            'A' => ChangeType::Added,
80            'M' => ChangeType::Modified,
81            'D' => ChangeType::Deleted,
82            'R' => ChangeType::Renamed,
83            'C' => ChangeType::Copied,
84            _ => ChangeType::Unknown,
85        }
86    }
87
88    /// Get a human-readable description.
89    pub fn as_str(&self) -> &'static str {
90        match self {
91            ChangeType::Added => "added",
92            ChangeType::Modified => "modified",
93            ChangeType::Deleted => "deleted",
94            ChangeType::Renamed => "renamed",
95            ChangeType::Copied => "copied",
96            ChangeType::Unknown => "unknown",
97        }
98    }
99}
100
101/// A changed file with metadata.
102#[derive(Debug, Clone)]
103pub struct ChangedFile {
104    /// File path (relative to repo root)
105    pub path: String,
106    /// Type of change
107    pub change_type: ChangeType,
108    /// Lines added
109    pub lines_added: usize,
110    /// Lines removed
111    pub lines_removed: usize,
112    /// Line ranges that changed (start, end) - 1-indexed
113    pub changed_ranges: Vec<(u32, u32)>,
114    /// Original path (for renames)
115    pub original_path: Option<String>,
116}
117
118impl ChangedFile {
119    /// Create a new changed file.
120    pub fn new(path: String, change_type: ChangeType) -> Self {
121        Self {
122            path,
123            change_type,
124            lines_added: 0,
125            lines_removed: 0,
126            changed_ranges: Vec::new(),
127            original_path: None,
128        }
129    }
130}
131
132/// A file selected for context with relevance information.
133#[derive(Debug, Clone)]
134pub struct ContextFile {
135    /// File path
136    pub path: String,
137    /// Priority score (1.0 = changed, 0.8 = direct caller/callee, 0.6 = indirect)
138    pub priority: f32,
139    /// Reason for inclusion
140    pub reason: ContextReason,
141    /// Token count
142    pub token_count: usize,
143}
144
145impl HasTokenCount for ContextFile {
146    fn token_count(&self) -> usize {
147        self.token_count
148    }
149}
150
151/// Reason why a file is included in context.
152#[derive(Debug, Clone)]
153#[allow(dead_code)] // Variant data used for debug output
154pub enum ContextReason {
155    /// File was directly changed
156    Changed(ChangeType),
157    /// Calls a changed symbol
158    CallsChanged { symbol: String, depth: i32 },
159    /// Called by a changed symbol
160    CalledByChanged { symbol: String, depth: i32 },
161}
162
163impl ContextReason {
164    /// Get a human-readable description.
165    #[allow(dead_code)] // Public API for future use
166    pub fn description(&self) -> String {
167        match self {
168            ContextReason::Changed(ct) => format!("changed ({})", ct.as_str()),
169            ContextReason::CallsChanged { symbol, depth } => {
170                format!("calls changed symbol '{}' (depth {})", symbol, depth)
171            }
172            ContextReason::CalledByChanged { symbol, depth } => {
173                format!("called by changed symbol '{}' (depth {})", symbol, depth)
174            }
175        }
176    }
177}
178
179/// A symbol affected by changes.
180#[derive(Debug, Clone)]
181pub struct AffectedSymbol {
182    /// Symbol name
183    pub name: String,
184    /// File containing the symbol
185    pub file_path: String,
186    /// Line number
187    #[allow(dead_code)] // Used for debug output
188    pub line: u32,
189    /// Symbol kind
190    pub kind: String,
191}
192
193/// Result of diff context analysis.
194#[derive(Debug, Clone)]
195pub struct DiffContext {
196    /// The revision that was analyzed
197    pub revision: String,
198    /// Changed files with their metadata
199    pub changed_files: Vec<ChangedFile>,
200    /// Symbols affected by changes
201    pub affected_symbols: Vec<AffectedSymbol>,
202    /// Files selected for context
203    pub context_files: Vec<ContextFile>,
204    /// Total token count
205    pub total_tokens: usize,
206    /// Whether output was truncated
207    pub truncated: bool,
208    /// Number of files omitted
209    pub omitted_count: usize,
210}
211
212/// Check if current directory is a git repository.
213pub fn is_git_repo() -> bool {
214    Command::new("git")
215        .args(["rev-parse", "--git-dir"])
216        .output()
217        .map(|o| o.status.success())
218        .unwrap_or(false)
219}
220
221/// Get the list of changed files for a revision.
222pub fn get_changed_files(revision: &str, staged: bool) -> Result<Vec<ChangedFile>> {
223    if !is_git_repo() {
224        return Err(CtxError::NotGitRepo);
225    }
226
227    // Build the git diff command
228    let mut args = vec!["diff", "--name-status"];
229
230    if staged {
231        args.push("--staged");
232    } else {
233        args.push(revision);
234    }
235
236    let output = Command::new("git").args(&args).output()?;
237
238    if !output.status.success() {
239        let stderr = String::from_utf8_lossy(&output.stderr);
240        if stderr.contains("unknown revision") || stderr.contains("bad revision") {
241            return Err(CtxError::InvalidRevision(revision.to_string()));
242        }
243        return Err(CtxError::git(stderr.to_string()));
244    }
245
246    let stdout = String::from_utf8_lossy(&output.stdout);
247    let files = parse_diff_output(&stdout);
248
249    if files.is_empty() {
250        return Err(CtxError::NoChanges);
251    }
252
253    // Get detailed diff info for each file
254    let mut detailed_files = Vec::new();
255    for mut file in files {
256        if file.change_type != ChangeType::Deleted {
257            if let Ok(ranges) = get_diff_line_ranges(revision, &file.path, staged) {
258                file.changed_ranges = ranges;
259            }
260            if let Ok((added, removed)) = get_diff_stats(revision, &file.path, staged) {
261                file.lines_added = added;
262                file.lines_removed = removed;
263            }
264        }
265        detailed_files.push(file);
266    }
267
268    Ok(detailed_files)
269}
270
271/// Parse git diff --name-status output.
272pub fn parse_diff_output(output: &str) -> Vec<ChangedFile> {
273    let mut files = Vec::new();
274
275    for line in output.lines() {
276        let line = line.trim();
277        if line.is_empty() {
278            continue;
279        }
280
281        // Format: "M\tpath" or "R100\told_path\tnew_path"
282        let parts: Vec<&str> = line.split('\t').collect();
283        if parts.is_empty() {
284            continue;
285        }
286
287        let status_str = parts[0];
288        let status_char = status_str.chars().next().unwrap_or('?');
289        let change_type = ChangeType::from_git_status(status_char);
290
291        let (path, original_path) = if change_type == ChangeType::Renamed && parts.len() >= 3 {
292            (parts[2].to_string(), Some(parts[1].to_string()))
293        } else if parts.len() >= 2 {
294            (parts[1].to_string(), None)
295        } else {
296            continue;
297        };
298
299        let mut file = ChangedFile::new(path, change_type);
300        file.original_path = original_path;
301        files.push(file);
302    }
303
304    files
305}
306
307/// Get the changed line ranges for a file.
308fn get_diff_line_ranges(revision: &str, path: &str, staged: bool) -> Result<Vec<(u32, u32)>> {
309    let mut args = vec!["diff", "-U0", "--no-color"];
310
311    if staged {
312        args.push("--staged");
313    } else {
314        args.push(revision);
315    }
316
317    args.push("--");
318    args.push(path);
319
320    let output = Command::new("git").args(&args).output()?;
321
322    if !output.status.success() {
323        return Ok(Vec::new());
324    }
325
326    let stdout = String::from_utf8_lossy(&output.stdout);
327    Ok(parse_diff_hunks(&stdout))
328}
329
330/// Parse diff hunks to extract line ranges.
331fn parse_diff_hunks(diff_output: &str) -> Vec<(u32, u32)> {
332    let mut ranges = Vec::new();
333
334    for line in diff_output.lines() {
335        // Hunk header: @@ -old_start,old_count +new_start,new_count @@
336        if line.starts_with("@@") {
337            if let Some(new_range) = parse_hunk_header(line) {
338                ranges.push(new_range);
339            }
340        }
341    }
342
343    ranges
344}
345
346/// Parse a hunk header to get the new file line range.
347fn parse_hunk_header(header: &str) -> Option<(u32, u32)> {
348    // @@ -1,5 +1,7 @@ or @@ -1 +1,7 @@
349    let parts: Vec<&str> = header.split_whitespace().collect();
350
351    for part in parts {
352        if part.starts_with('+') && !part.starts_with("+++") {
353            let range_str = &part[1..];
354            let range_parts: Vec<&str> = range_str.split(',').collect();
355
356            let start: u32 = range_parts.first()?.parse().ok()?;
357            let count: u32 = range_parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
358
359            if count == 0 {
360                return None; // Pure deletion, no new lines
361            }
362
363            return Some((start, start + count - 1));
364        }
365    }
366
367    None
368}
369
370/// Get diff statistics (lines added, removed) for a file.
371fn get_diff_stats(revision: &str, path: &str, staged: bool) -> Result<(usize, usize)> {
372    let mut args = vec!["diff", "--numstat"];
373
374    if staged {
375        args.push("--staged");
376    } else {
377        args.push(revision);
378    }
379
380    args.push("--");
381    args.push(path);
382
383    let output = Command::new("git").args(&args).output()?;
384
385    if !output.status.success() {
386        return Ok((0, 0));
387    }
388
389    let stdout = String::from_utf8_lossy(&output.stdout);
390    for line in stdout.lines() {
391        let parts: Vec<&str> = line.split_whitespace().collect();
392        if parts.len() >= 2 {
393            let added: usize = parts[0].parse().unwrap_or(0);
394            let removed: usize = parts[1].parse().unwrap_or(0);
395            return Ok((added, removed));
396        }
397    }
398
399    Ok((0, 0))
400}
401
402/// Find symbols that were affected by changes in a file.
403pub fn find_symbols_in_lines(
404    db: &Database,
405    file_path: &str,
406    changed_ranges: &[(u32, u32)],
407) -> Result<Vec<AffectedSymbol>> {
408    let symbols = db.find_symbols_in_file(file_path)?;
409
410    let mut affected = Vec::new();
411
412    for symbol in symbols {
413        // Check if any changed range overlaps with the symbol's lines
414        for &(start, end) in changed_ranges {
415            if ranges_overlap(symbol.line_start, symbol.line_end, start, end) {
416                affected.push(AffectedSymbol {
417                    name: symbol.name.clone(),
418                    file_path: symbol.file_path.clone(),
419                    line: symbol.line_start,
420                    kind: symbol.kind.as_str().to_string(),
421                });
422                break;
423            }
424        }
425    }
426
427    Ok(affected)
428}
429
430/// Check if two line ranges overlap.
431fn ranges_overlap(s1_start: u32, s1_end: u32, s2_start: u32, s2_end: u32) -> bool {
432    s1_start <= s2_end && s2_start <= s1_end
433}
434
435/// Generate diff-aware context.
436pub fn diff_context(
437    revision: &str,
438    db: &Database,
439    analytics: &Analytics,
440    config: DiffConfig,
441) -> Result<DiffContext> {
442    // 1. Get changed files
443    let changed_files = get_changed_files(revision, config.staged)?;
444
445    // 2. Find affected symbols
446    let mut affected_symbols = Vec::new();
447    for file in &changed_files {
448        if file.change_type != ChangeType::Deleted && !file.changed_ranges.is_empty() {
449            if let Ok(symbols) = find_symbols_in_lines(db, &file.path, &file.changed_ranges) {
450                affected_symbols.extend(symbols);
451            }
452        }
453    }
454
455    // 3. Build context file list
456    let mut context_files: HashMap<String, ContextFile> = HashMap::new();
457
458    // Add changed files first (priority 1.0)
459    for file in &changed_files {
460        if file.change_type != ChangeType::Deleted {
461            context_files.insert(
462                file.path.clone(),
463                ContextFile {
464                    path: file.path.clone(),
465                    priority: 1.0,
466                    reason: ContextReason::Changed(file.change_type),
467                    token_count: 0,
468                },
469            );
470        }
471    }
472
473    // 4. Expand context using call graph (if not changes_only)
474    if !config.changes_only {
475        for symbol in &affected_symbols {
476            expand_context_for_symbol(&mut context_files, analytics, symbol, config.depth);
477        }
478    }
479
480    // 5. Count tokens for each file
481    let root = std::env::current_dir().unwrap_or_default();
482    for ctx_file in context_files.values_mut() {
483        let path = root.join(&ctx_file.path);
484        if let Ok(content) = std::fs::read_to_string(&path) {
485            ctx_file.token_count =
486                count_tokens_with_encoding(&content, config.encoding).unwrap_or(0);
487        }
488    }
489
490    // 6. Sort by priority and select files within token limit
491    let mut files: Vec<ContextFile> = context_files.into_values().collect();
492    files.sort_by(|a, b| {
493        b.priority
494            .partial_cmp(&a.priority)
495            .unwrap_or(std::cmp::Ordering::Equal)
496    });
497
498    let (selected, total_tokens, omitted) = select_by_token_budget(files, config.max_tokens);
499
500    Ok(DiffContext {
501        revision: revision.to_string(),
502        changed_files,
503        affected_symbols,
504        context_files: selected,
505        total_tokens,
506        truncated: omitted > 0,
507        omitted_count: omitted,
508    })
509}
510
511/// Expand context for a symbol using call graph analysis.
512fn expand_context_for_symbol(
513    context_files: &mut HashMap<String, ContextFile>,
514    analytics: &Analytics,
515    symbol: &AffectedSymbol,
516    depth: i32,
517) {
518    // Find callers (impact analysis)
519    if let Ok(callers) = analytics.impact_analysis(&symbol.name, depth) {
520        for caller in callers {
521            let priority = 0.8 / (caller.distance as f32).max(1.0);
522            add_context_file(
523                context_files,
524                &caller.file_path,
525                priority,
526                ContextReason::CalledByChanged {
527                    symbol: symbol.name.clone(),
528                    depth: caller.distance,
529                },
530            );
531        }
532    }
533
534    // Find callees (call graph)
535    if let Ok(callees) = analytics.call_graph(&symbol.name, depth) {
536        for callee in callees {
537            let priority = 0.6 / (callee.depth as f32).max(1.0);
538            add_context_file(
539                context_files,
540                &callee.file_path,
541                priority,
542                ContextReason::CallsChanged {
543                    symbol: symbol.name.clone(),
544                    depth: callee.depth,
545                },
546            );
547        }
548    }
549}
550
551/// Add a context file, keeping highest priority.
552fn add_context_file(
553    context_files: &mut HashMap<String, ContextFile>,
554    path: &str,
555    priority: f32,
556    reason: ContextReason,
557) {
558    if let Some(existing) = context_files.get_mut(path) {
559        // Keep higher priority reason
560        if priority > existing.priority {
561            existing.priority = priority;
562            existing.reason = reason;
563        }
564    } else {
565        context_files.insert(
566            path.to_string(),
567            ContextFile {
568                path: path.to_string(),
569                priority,
570                reason,
571                token_count: 0,
572            },
573        );
574    }
575}
576
577/// Format a summary of changes for output.
578pub fn format_summary(context: &DiffContext) -> String {
579    let mut output = String::new();
580
581    output.push_str(&format!(
582        "Changes in {}: {} files changed\n\n",
583        context.revision,
584        context.changed_files.len()
585    ));
586
587    output.push_str("Changed files:\n");
588    for file in &context.changed_files {
589        let stats = if file.lines_added > 0 || file.lines_removed > 0 {
590            format!("+{} -{}", file.lines_added, file.lines_removed)
591        } else {
592            file.change_type.as_str().to_string()
593        };
594        output.push_str(&format!("  {} ({})\n", file.path, stats));
595    }
596
597    if !context.affected_symbols.is_empty() {
598        output.push_str("\nAffected symbols:\n");
599        let mut seen = HashSet::new();
600        for symbol in &context.affected_symbols {
601            let key = format!("{}:{}", symbol.file_path, symbol.name);
602            if seen.insert(key) {
603                output.push_str(&format!(
604                    "  {} ({}) in {}\n",
605                    symbol.name, symbol.kind, symbol.file_path
606                ));
607            }
608        }
609    }
610
611    output.push_str(&format!(
612        "\nContext: {} files ({} tokens)",
613        context.context_files.len(),
614        context.total_tokens
615    ));
616
617    if context.truncated {
618        output.push_str(&format!(", {} omitted", context.omitted_count));
619    }
620
621    output.push('\n');
622    output
623}
624
625/// PR information from GitHub.
626#[derive(Debug, Clone)]
627pub struct PrInfo {
628    /// PR number
629    pub number: u64,
630    /// PR title
631    pub title: String,
632    /// PR description/body
633    pub body: String,
634    /// Author login
635    pub author: String,
636    /// Base branch
637    pub base: String,
638    /// Head branch
639    pub head: String,
640    /// Changed files
641    pub files: Vec<PrFile>,
642    /// Comments
643    pub comments: Vec<PrComment>,
644}
645
646/// A file changed in a PR.
647#[derive(Debug, Clone)]
648pub struct PrFile {
649    /// File path
650    pub path: String,
651    /// Additions
652    pub additions: usize,
653    /// Deletions
654    pub deletions: usize,
655}
656
657/// A comment on a PR.
658#[derive(Debug, Clone)]
659pub struct PrComment {
660    /// Author login
661    pub author: String,
662    /// Comment body
663    pub body: String,
664    /// File path (if line comment)
665    pub path: Option<String>,
666    /// Line number (if line comment)
667    pub line: Option<u32>,
668}
669
670/// Get PR information using gh CLI.
671pub fn get_pr_info(pr: &str, repo: Option<&str>) -> Result<PrInfo> {
672    let mut args = vec![
673        "pr",
674        "view",
675        pr,
676        "--json",
677        "number,title,body,author,baseRefName,headRefName,files,comments",
678    ];
679
680    if let Some(r) = repo {
681        args.push("--repo");
682        args.push(r);
683    }
684
685    let output = Command::new("gh").args(&args).output().map_err(|e| {
686        if e.kind() == std::io::ErrorKind::NotFound {
687            CtxError::git(
688                "GitHub CLI (gh) not found. Install it from https://cli.github.com/".to_string(),
689            )
690        } else {
691            CtxError::Io(e)
692        }
693    })?;
694
695    if !output.status.success() {
696        let stderr = String::from_utf8_lossy(&output.stderr);
697        if stderr.contains("Could not resolve") || stderr.contains("not found") {
698            return Err(CtxError::InvalidRevision(format!("PR #{} not found", pr)));
699        }
700        return Err(CtxError::git(stderr.to_string()));
701    }
702
703    // Parse JSON response
704    let json: serde_json::Value = serde_json::from_slice(&output.stdout)
705        .map_err(|e| CtxError::git(format!("Failed to parse gh output: {}", e)))?;
706
707    Ok(PrInfo {
708        number: json["number"].as_u64().unwrap_or(0),
709        title: json["title"].as_str().unwrap_or("").to_string(),
710        body: json["body"].as_str().unwrap_or("").to_string(),
711        author: json["author"]["login"].as_str().unwrap_or("").to_string(),
712        base: json["baseRefName"].as_str().unwrap_or("main").to_string(),
713        head: json["headRefName"].as_str().unwrap_or("").to_string(),
714        files: parse_pr_files(&json["files"]),
715        comments: parse_pr_comments(&json["comments"]),
716    })
717}
718
719/// Parse PR files from JSON.
720fn parse_pr_files(json: &serde_json::Value) -> Vec<PrFile> {
721    let mut files = Vec::new();
722    if let Some(arr) = json.as_array() {
723        for item in arr {
724            files.push(PrFile {
725                path: item["path"].as_str().unwrap_or("").to_string(),
726                additions: item["additions"].as_u64().unwrap_or(0) as usize,
727                deletions: item["deletions"].as_u64().unwrap_or(0) as usize,
728            });
729        }
730    }
731    files
732}
733
734/// Parse PR comments from JSON.
735fn parse_pr_comments(json: &serde_json::Value) -> Vec<PrComment> {
736    let mut comments = Vec::new();
737    if let Some(arr) = json.as_array() {
738        for item in arr {
739            comments.push(PrComment {
740                author: item["author"]["login"].as_str().unwrap_or("").to_string(),
741                body: item["body"].as_str().unwrap_or("").to_string(),
742                path: item["path"].as_str().map(|s| s.to_string()),
743                line: item["line"].as_u64().map(|n| n as u32),
744            });
745        }
746    }
747    comments
748}
749
750/// Format PR info for context output.
751pub fn format_pr_header(pr: &PrInfo, include_comments: bool) -> String {
752    let mut output = String::new();
753
754    output.push_str(&format!(
755        "PR #{}: {} (by @{})\n",
756        pr.number, pr.title, pr.author
757    ));
758    output.push_str(&format!("Branch: {} -> {}\n", pr.head, pr.base));
759
760    if !pr.body.is_empty() {
761        output.push_str("\nDescription:\n");
762        for line in pr.body.lines().take(10) {
763            output.push_str(&format!("  {}\n", line));
764        }
765        if pr.body.lines().count() > 10 {
766            output.push_str("  ...\n");
767        }
768    }
769
770    output.push_str(&format!("\nFiles changed: {}\n", pr.files.len()));
771    for file in &pr.files {
772        output.push_str(&format!(
773            "  {} (+{} -{})\n",
774            file.path, file.additions, file.deletions
775        ));
776    }
777
778    if include_comments && !pr.comments.is_empty() {
779        output.push_str(&format!("\nComments ({}):\n", pr.comments.len()));
780        for comment in &pr.comments {
781            let location = match (&comment.path, comment.line) {
782                (Some(p), Some(l)) => format!(" at {}:{}", p, l),
783                _ => String::new(),
784            };
785            output.push_str(&format!("  @{}{}: ", comment.author, location));
786            let preview: String = comment.body.chars().take(80).collect();
787            output.push_str(&preview);
788            if comment.body.len() > 80 {
789                output.push_str("...");
790            }
791            output.push('\n');
792        }
793    }
794
795    output
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    #[test]
803    fn test_change_type_from_git_status() {
804        assert_eq!(ChangeType::from_git_status('A'), ChangeType::Added);
805        assert_eq!(ChangeType::from_git_status('M'), ChangeType::Modified);
806        assert_eq!(ChangeType::from_git_status('D'), ChangeType::Deleted);
807        assert_eq!(ChangeType::from_git_status('R'), ChangeType::Renamed);
808        assert_eq!(ChangeType::from_git_status('C'), ChangeType::Copied);
809        assert_eq!(ChangeType::from_git_status('X'), ChangeType::Unknown);
810    }
811
812    #[test]
813    fn test_parse_diff_output() {
814        let output = "M\tsrc/main.rs\nA\tsrc/new.rs\nD\tsrc/old.rs\n";
815        let files = parse_diff_output(output);
816
817        assert_eq!(files.len(), 3);
818        assert_eq!(files[0].path, "src/main.rs");
819        assert_eq!(files[0].change_type, ChangeType::Modified);
820        assert_eq!(files[1].path, "src/new.rs");
821        assert_eq!(files[1].change_type, ChangeType::Added);
822        assert_eq!(files[2].path, "src/old.rs");
823        assert_eq!(files[2].change_type, ChangeType::Deleted);
824    }
825
826    #[test]
827    fn test_parse_diff_output_rename() {
828        let output = "R100\tsrc/old_name.rs\tsrc/new_name.rs\n";
829        let files = parse_diff_output(output);
830
831        assert_eq!(files.len(), 1);
832        assert_eq!(files[0].path, "src/new_name.rs");
833        assert_eq!(files[0].change_type, ChangeType::Renamed);
834        assert_eq!(files[0].original_path, Some("src/old_name.rs".to_string()));
835    }
836
837    #[test]
838    fn test_parse_hunk_header() {
839        // Standard format
840        assert_eq!(parse_hunk_header("@@ -1,5 +1,7 @@ fn main"), Some((1, 7)));
841
842        // Single line
843        assert_eq!(parse_hunk_header("@@ -1 +1 @@"), Some((1, 1)));
844
845        // Addition at end of file
846        assert_eq!(parse_hunk_header("@@ -10,0 +11,5 @@"), Some((11, 15)));
847
848        // Pure deletion (count 0)
849        assert_eq!(parse_hunk_header("@@ -1,5 +1,0 @@"), None);
850    }
851
852    #[test]
853    fn test_parse_diff_hunks() {
854        let diff = r#"diff --git a/src/main.rs b/src/main.rs
855index abc123..def456 100644
856--- a/src/main.rs
857+++ b/src/main.rs
858@@ -1,5 +1,7 @@
859 fn main() {
860+    // New comment
861+    setup();
862     run();
863@@ -10,3 +12,5 @@
864 fn helper() {
865+    // More changes
866"#;
867        let ranges = parse_diff_hunks(diff);
868        assert_eq!(ranges.len(), 2);
869        assert_eq!(ranges[0], (1, 7));
870        assert_eq!(ranges[1], (12, 16));
871    }
872
873    #[test]
874    fn test_ranges_overlap() {
875        // Overlapping
876        assert!(ranges_overlap(1, 10, 5, 15));
877        assert!(ranges_overlap(5, 15, 1, 10));
878
879        // Touching
880        assert!(ranges_overlap(1, 5, 5, 10));
881
882        // Not overlapping
883        assert!(!ranges_overlap(1, 5, 6, 10));
884        assert!(!ranges_overlap(6, 10, 1, 5));
885    }
886
887    #[test]
888    fn test_context_reason_description() {
889        let changed = ContextReason::Changed(ChangeType::Modified);
890        assert!(changed.description().contains("modified"));
891
892        let calls = ContextReason::CallsChanged {
893            symbol: "foo".to_string(),
894            depth: 2,
895        };
896        assert!(calls.description().contains("foo"));
897        assert!(calls.description().contains("2"));
898    }
899
900    #[test]
901    fn test_select_by_token_budget() {
902        let files = vec![
903            ContextFile {
904                path: "a.rs".to_string(),
905                priority: 1.0,
906                reason: ContextReason::Changed(ChangeType::Modified),
907                token_count: 100,
908            },
909            ContextFile {
910                path: "b.rs".to_string(),
911                priority: 0.8,
912                reason: ContextReason::Changed(ChangeType::Added),
913                token_count: 200,
914            },
915            ContextFile {
916                path: "c.rs".to_string(),
917                priority: 0.6,
918                reason: ContextReason::Changed(ChangeType::Modified),
919                token_count: 150,
920            },
921        ];
922
923        // All fit
924        let (selected, total, omitted) = select_by_token_budget(files.clone(), 500);
925        assert_eq!(selected.len(), 3);
926        assert_eq!(total, 450);
927        assert_eq!(omitted, 0);
928
929        // Only first two fit
930        let (selected, total, omitted) = select_by_token_budget(files.clone(), 300);
931        assert_eq!(selected.len(), 2);
932        assert_eq!(total, 300);
933        assert_eq!(omitted, 1);
934    }
935
936    #[test]
937    fn test_diff_config_default() {
938        let config = DiffConfig::default();
939        assert_eq!(config.max_tokens, 8000);
940        assert_eq!(config.depth, 1);
941        assert!(!config.changes_only);
942        assert!(!config.staged);
943        assert!(!config.summary);
944    }
945}