Skip to main content

branchdiff/diff/
mod.rs

1//! Diff computation module for branchdiff
2//!
3//! This module computes 4-way diffs showing changes across:
4//! - base (merge-base with main/master)
5//! - head (committed on branch)
6//! - index (staged)
7//! - working (working tree)
8
9mod algorithm;
10mod cancellation;
11mod inline;
12mod line_builder;
13mod output;
14mod provenance;
15
16pub use algorithm::{compute_four_way_diff, DiffInput};
17pub use inline::InlineSpan;
18
19pub(crate) use inline::compute_inline_diff_merged;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum LineSource {
23    Base,
24    Committed,
25    Staged,
26    Unstaged,
27    DeletedBase,
28    DeletedCommitted,
29    DeletedStaged,
30    CanceledCommitted,
31    CanceledStaged,
32    FileHeader,
33    Elided,
34}
35
36impl LineSource {
37    /// True for any line representing a change (addition, deletion, or canceled)
38    pub fn is_change(self) -> bool {
39        matches!(
40            self,
41            Self::Committed
42                | Self::Staged
43                | Self::Unstaged
44                | Self::DeletedBase
45                | Self::DeletedCommitted
46                | Self::DeletedStaged
47                | Self::CanceledCommitted
48                | Self::CanceledStaged
49        )
50    }
51
52    /// True for additions (committed, staged, or unstaged)
53    pub fn is_addition(self) -> bool {
54        matches!(self, Self::Committed | Self::Staged | Self::Unstaged)
55    }
56
57    /// True for deletions
58    pub fn is_deletion(self) -> bool {
59        matches!(
60            self,
61            Self::DeletedBase | Self::DeletedCommitted | Self::DeletedStaged
62        )
63    }
64
65    /// True for unstaged changes (working tree modifications)
66    pub fn is_unstaged(self) -> bool {
67        matches!(self, Self::Unstaged)
68    }
69
70    /// True for file/section headers
71    pub fn is_header(self) -> bool {
72        matches!(self, Self::FileHeader)
73    }
74
75    /// True for lines belonging to jj's current commit (@).
76    /// In jj, Staged = current commit additions, DeletedCommitted = current commit deletions,
77    /// CanceledStaged = added in current commit then removed in child.
78    pub fn is_current_commit(self) -> bool {
79        matches!(
80            self,
81            Self::Staged | Self::DeletedCommitted | Self::CanceledStaged
82        )
83    }
84}
85
86#[derive(Debug, Clone)]
87pub struct DiffLine {
88    pub source: LineSource,
89    pub content: String,
90    pub prefix: char,
91    pub line_number: Option<usize>,
92    pub file_path: Option<String>,
93    pub inline_spans: Vec<InlineSpan>,
94    pub old_content: Option<String>,
95    pub change_source: Option<LineSource>,
96    /// Whether this line belongs to the current jj bookmark's scope.
97    /// `None` when bookmark boundary info is unavailable.
98    pub in_current_bookmark: Option<bool>,
99}
100
101impl DiffLine {
102    pub fn new(source: LineSource, content: String, prefix: char, line_number: Option<usize>) -> Self {
103        Self {
104            source,
105            content,
106            prefix,
107            line_number,
108            file_path: None,
109            inline_spans: Vec::new(),
110            old_content: None,
111            change_source: None,
112            in_current_bookmark: None,
113        }
114    }
115
116    pub fn with_old_content(mut self, old: &str) -> Self {
117        self.old_content = Some(old.to_string());
118        self
119    }
120
121    pub fn with_change_source(mut self, change_source: LineSource) -> Self {
122        self.change_source = Some(change_source);
123        self
124    }
125
126    pub fn is_change(&self) -> bool {
127        self.source.is_change() || self.change_source.is_some_and(|cs| cs.is_change())
128    }
129
130    /// True if this line belongs to jj's current commit (@).
131    /// Catches both direct current-commit lines and Base lines with inline modifications from @.
132    pub fn is_current_commit(&self) -> bool {
133        self.source.is_current_commit()
134            || self.change_source.is_some_and(|cs| cs.is_current_commit())
135    }
136
137    /// True if this line belongs to the current jj bookmark's scope.
138    pub fn is_current_bookmark(&self) -> bool {
139        self.in_current_bookmark == Some(true)
140    }
141
142    pub fn ensure_inline_spans(&mut self) {
143        if self.inline_spans.is_empty()
144            && let Some(ref old) = self.old_content
145        {
146            let source = self.change_source.unwrap_or(self.source);
147            let result = compute_inline_diff_merged(old, &self.content, source);
148            self.inline_spans = result.spans;
149        }
150    }
151
152    pub fn with_file_path(mut self, path: &str) -> Self {
153        self.file_path = Some(path.to_string());
154        self
155    }
156
157    /// Check if this line is an image marker (for UI rendering)
158    pub fn is_image_marker(&self) -> bool {
159        self.content == "[image]" && self.file_path.is_some()
160    }
161}
162// Builder methods (file_header, deleted_file_header, renamed_file_header,
163// image_marker, elided) are in line_builder.rs
164
165// Cancellation detection functions (index_line_in_working, collect_canceled_*,
166// find_insertion_position, insert_canceled_lines) are in cancellation.rs
167
168// Algorithm functions (build_deletion_diff, check_file_deletion, compute_four_way_diff)
169// are in algorithm.rs
170
171#[derive(Debug)]
172pub struct FileDiff {
173    pub lines: Vec<DiffLine>,
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    // LineSource classification tests
181    #[test]
182    fn test_line_source_is_change() {
183        // These should be changes
184        assert!(LineSource::Committed.is_change());
185        assert!(LineSource::Staged.is_change());
186        assert!(LineSource::Unstaged.is_change());
187        assert!(LineSource::DeletedBase.is_change());
188        assert!(LineSource::DeletedCommitted.is_change());
189        assert!(LineSource::DeletedStaged.is_change());
190        assert!(LineSource::CanceledCommitted.is_change());
191        assert!(LineSource::CanceledStaged.is_change());
192
193        // These should NOT be changes
194        assert!(!LineSource::Base.is_change());
195        assert!(!LineSource::FileHeader.is_change());
196        assert!(!LineSource::Elided.is_change());
197    }
198
199    #[test]
200    fn test_line_source_is_addition() {
201        assert!(LineSource::Committed.is_addition());
202        assert!(LineSource::Staged.is_addition());
203        assert!(LineSource::Unstaged.is_addition());
204
205        assert!(!LineSource::Base.is_addition());
206        assert!(!LineSource::DeletedBase.is_addition());
207        assert!(!LineSource::CanceledCommitted.is_addition());
208    }
209
210    #[test]
211    fn test_line_source_is_deletion() {
212        assert!(LineSource::DeletedBase.is_deletion());
213        assert!(LineSource::DeletedCommitted.is_deletion());
214        assert!(LineSource::DeletedStaged.is_deletion());
215
216        assert!(!LineSource::Base.is_deletion());
217        assert!(!LineSource::Committed.is_deletion());
218        assert!(!LineSource::CanceledCommitted.is_deletion());
219    }
220
221    #[test]
222    fn test_line_source_is_unstaged() {
223        assert!(LineSource::Unstaged.is_unstaged());
224
225        assert!(!LineSource::Base.is_unstaged());
226        assert!(!LineSource::Committed.is_unstaged());
227        assert!(!LineSource::Staged.is_unstaged());
228    }
229
230    #[test]
231    fn test_line_source_is_header() {
232        assert!(LineSource::FileHeader.is_header());
233
234        assert!(!LineSource::Base.is_header());
235        assert!(!LineSource::Committed.is_header());
236        assert!(!LineSource::Elided.is_header());
237    }
238
239    #[test]
240    fn test_line_source_is_current_commit() {
241        assert!(LineSource::Staged.is_current_commit());
242        assert!(LineSource::DeletedCommitted.is_current_commit());
243        assert!(LineSource::CanceledStaged.is_current_commit());
244
245        assert!(!LineSource::Base.is_current_commit());
246        assert!(!LineSource::Committed.is_current_commit());
247        assert!(!LineSource::Unstaged.is_current_commit());
248        assert!(!LineSource::DeletedBase.is_current_commit());
249        assert!(!LineSource::DeletedStaged.is_current_commit());
250        assert!(!LineSource::CanceledCommitted.is_current_commit());
251        assert!(!LineSource::FileHeader.is_current_commit());
252        assert!(!LineSource::Elided.is_current_commit());
253    }
254
255    #[test]
256    fn test_diff_line_is_current_commit_via_source() {
257        let staged = DiffLine::new(LineSource::Staged, "added".to_string(), '+', None);
258        assert!(staged.is_current_commit());
259
260        let del = DiffLine::new(LineSource::DeletedCommitted, "removed".to_string(), '-', None);
261        assert!(del.is_current_commit());
262
263        let base = DiffLine::new(LineSource::Base, "context".to_string(), ' ', None);
264        assert!(!base.is_current_commit());
265
266        let committed = DiffLine::new(LineSource::Committed, "earlier".to_string(), '+', None);
267        assert!(!committed.is_current_commit());
268    }
269
270    #[test]
271    fn test_diff_line_is_current_commit_via_change_source() {
272        let mut base_with_staged_mod = DiffLine::new(LineSource::Base, "modified".to_string(), ' ', Some(1));
273        base_with_staged_mod.change_source = Some(LineSource::Staged);
274        assert!(base_with_staged_mod.is_current_commit());
275
276        let mut base_with_committed_mod = DiffLine::new(LineSource::Base, "modified".to_string(), ' ', Some(1));
277        base_with_committed_mod.change_source = Some(LineSource::Committed);
278        assert!(!base_with_committed_mod.is_current_commit());
279
280        let mut base_with_unstaged_mod = DiffLine::new(LineSource::Base, "modified".to_string(), ' ', Some(1));
281        base_with_unstaged_mod.change_source = Some(LineSource::Unstaged);
282        assert!(!base_with_unstaged_mod.is_current_commit());
283    }
284
285    #[test]
286    fn test_is_current_bookmark() {
287        let mut line = DiffLine::new(LineSource::Committed, "test".to_string(), '+', None);
288        assert!(!line.is_current_bookmark(), "None should be false");
289
290        line.in_current_bookmark = Some(true);
291        assert!(line.is_current_bookmark(), "Some(true) should be true");
292
293        line.in_current_bookmark = Some(false);
294        assert!(!line.is_current_bookmark(), "Some(false) should be false");
295    }
296
297    fn compute_diff_with_inline(
298        path: &str,
299        base: Option<&str>,
300        head: Option<&str>,
301        index: Option<&str>,
302        working: Option<&str>,
303    ) -> FileDiff {
304        let mut diff = compute_four_way_diff(DiffInput {
305            path,
306            base,
307            head,
308            index,
309            working,
310            old_path: None,
311        });
312        for line in &mut diff.lines {
313            line.ensure_inline_spans();
314        }
315        diff
316    }
317
318    fn content_lines(diff: &FileDiff) -> Vec<&DiffLine> {
319        diff.lines.iter().filter(|l| !l.source.is_header()).collect()
320    }
321
322    #[test]
323    fn test_compute_file_diff_with_rename() {
324        let content = "line1\nline2";
325        let diff = compute_four_way_diff(DiffInput {
326            path: "new/path.rs",
327            base: Some(content),
328            head: Some(content),
329            index: Some(content),
330            working: Some(content),
331            old_path: Some("old/path.rs"),
332        });
333        assert_eq!(diff.lines[0].source, LineSource::FileHeader);
334        assert_eq!(diff.lines[0].content, "old/path.rs → new/path.rs");
335    }
336
337    #[test]
338    fn test_renamed_file_with_content_change() {
339        // Simulates: base == head == index, but working has a change
340        // This is the case for an unstaged rename with modifications
341        let original = "line 1\nline 2\nline 3\nline 4\nline 5";
342        let modified = "line 1\nline 2 modified\nline 3\nline 4\nline 5";
343
344        let diff = compute_four_way_diff(DiffInput {
345            path: "renamed.txt",
346            base: Some(original),    // original content
347            head: Some(original),    // same as base (rename not committed)
348            index: Some(original),   // same as head (rename not staged)
349            working: Some(modified), // modified content
350            old_path: Some("original.txt"),
351        });
352
353        // Modifications have source=Base but change_source=Unstaged and old_content set
354        let modified_lines: Vec<_> = diff
355            .lines
356            .iter()
357            .filter(|l| l.old_content.is_some() || l.change_source.is_some())
358            .collect();
359
360        assert!(
361            !modified_lines.is_empty(),
362            "Expected at least one modified line"
363        );
364
365        // Verify the modification is tracked correctly
366        let mod_line = modified_lines
367            .iter()
368            .find(|l| l.content.contains("line 2 modified"))
369            .expect("Should have modification for line 2");
370
371        assert_eq!(
372            mod_line.old_content.as_deref(),
373            Some("line 2"),
374            "Should track original content"
375        );
376        assert_eq!(
377            mod_line.change_source,
378            Some(LineSource::Unstaged),
379            "Should mark as unstaged modification"
380        );
381    }
382
383    #[test]
384    fn test_committed_rename_with_content_change() {
385        // Simulates: rename committed with modification
386        // base has old content, head/index/working have new content
387        let original = "line 1\nline 2\nline 3";
388        let modified = "line 1\nline 2 modified\nline 3";
389
390        let diff = compute_four_way_diff(DiffInput {
391            path: "renamed.txt",
392            base: Some(original),    // original at old path
393            head: Some(modified),    // modified (rename+change committed)
394            index: Some(modified),   // same as head
395            working: Some(modified), // same as head
396            old_path: Some("original.txt"),
397        });
398
399        // Should show modification as Committed (happened in commit)
400        let modified_lines: Vec<_> = diff
401            .lines
402            .iter()
403            .filter(|l| l.old_content.is_some() || l.change_source.is_some())
404            .collect();
405
406        assert!(
407            !modified_lines.is_empty(),
408            "Expected modification to be tracked"
409        );
410
411        let mod_line = modified_lines
412            .iter()
413            .find(|l| l.content.contains("line 2 modified"))
414            .expect("Should have modification for line 2");
415
416        assert_eq!(
417            mod_line.change_source,
418            Some(LineSource::Committed),
419            "Should mark as committed modification"
420        );
421    }
422
423    #[test]
424    fn test_staged_rename_with_content_change() {
425        // Simulates: rename staged with modification
426        // base/head have old content, index/working have new content
427        let original = "line 1\nline 2\nline 3";
428        let modified = "line 1\nline 2 modified\nline 3";
429
430        let diff = compute_four_way_diff(DiffInput {
431            path: "renamed.txt",
432            base: Some(original),    // original at old path
433            head: Some(original),    // still at old path (not committed)
434            index: Some(modified),   // modified (rename+change staged)
435            working: Some(modified), // same as index
436            old_path: Some("original.txt"),
437        });
438
439        // Should show modification as Staged
440        let modified_lines: Vec<_> = diff
441            .lines
442            .iter()
443            .filter(|l| l.old_content.is_some() || l.change_source.is_some())
444            .collect();
445
446        assert!(
447            !modified_lines.is_empty(),
448            "Expected modification to be tracked"
449        );
450
451        let mod_line = modified_lines
452            .iter()
453            .find(|l| l.content.contains("line 2 modified"))
454            .expect("Should have modification for line 2");
455
456        assert_eq!(
457            mod_line.change_source,
458            Some(LineSource::Staged),
459            "Should mark as staged modification"
460        );
461    }
462
463    #[test]
464    fn test_pure_rename_no_content_change() {
465        // Pure rename: same content everywhere, just different path
466        let content = "line 1\nline 2\nline 3";
467
468        let diff = compute_four_way_diff(DiffInput {
469            path: "renamed.txt",
470            base: Some(content),
471            head: Some(content),
472            index: Some(content),
473            working: Some(content),
474            old_path: Some("original.txt"),
475        });
476
477        // Should have header and unchanged lines only
478        let modified_lines: Vec<_> = diff
479            .lines
480            .iter()
481            .filter(|l| l.old_content.is_some() || l.change_source.is_some())
482            .collect();
483
484        assert!(
485            modified_lines.is_empty(),
486            "Pure rename should have no modifications, got {:?}",
487            modified_lines
488        );
489
490        // Verify the header shows the rename
491        assert_eq!(diff.lines[0].source, LineSource::FileHeader);
492        assert_eq!(diff.lines[0].content, "original.txt → renamed.txt");
493    }
494
495    #[test]
496    fn test_canceled_committed_line() {
497        let base = "line1\nline2";
498        let head = "line1\nline2\ncommitted_line";
499        let working = "line1\nline2";
500
501        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(head), Some(working));
502
503        let canceled_lines: Vec<_> = diff.lines.iter()
504            .filter(|l| l.source == LineSource::CanceledCommitted)
505            .collect();
506
507        assert_eq!(canceled_lines.len(), 1);
508        assert_eq!(canceled_lines[0].content, "committed_line");
509        assert_eq!(canceled_lines[0].prefix, '±');
510    }
511
512    #[test]
513    fn test_canceled_staged_line() {
514        let base = "line1\nline2";
515        let index = "line1\nline2\nstaged_line";
516        let working = "line1\nline2";
517
518        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(index), Some(working));
519
520        let canceled_lines: Vec<_> = diff.lines.iter()
521            .filter(|l| l.source == LineSource::CanceledStaged)
522            .collect();
523
524        assert_eq!(canceled_lines.len(), 1);
525        assert_eq!(canceled_lines[0].content, "staged_line");
526        assert_eq!(canceled_lines[0].prefix, '±');
527    }
528
529    #[test]
530    fn test_committed_then_modified_not_canceled() {
531        let base = "line1\nline2";
532        let head = "line1\nline2\nversion1";
533        let working = "line1\nline2\nversion2";
534
535        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(head), Some(working));
536
537        let canceled_lines: Vec<_> = diff.lines.iter()
538            .filter(|l| l.source == LineSource::CanceledCommitted)
539            .collect();
540
541        assert_eq!(canceled_lines.len(), 0, "modified line should not be canceled");
542    }
543
544    #[test]
545    fn test_staged_then_modified_not_canceled() {
546        let base = "line1\nline2";
547        let index = "line1\nline2\nversion1";
548        let working = "line1\nline2\nversion2";
549
550        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(index), Some(working));
551
552        let canceled_lines: Vec<_> = diff.lines.iter()
553            .filter(|l| l.source == LineSource::CanceledStaged)
554            .collect();
555
556        assert_eq!(canceled_lines.len(), 0, "modified line should not be canceled");
557    }
558
559    #[test]
560    fn test_modified_line_shows_merged_with_inline_spans() {
561        let base = "line1\nold content\nline3";
562        let working = "line1\nnew content\nline3";
563
564        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
565        let lines = content_lines(&diff);
566
567        let with_spans: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
568
569        assert_eq!(with_spans.len(), 1);
570        assert_eq!(with_spans[0].content, "new content");
571        assert!(with_spans[0].prefix == ' ');
572    }
573
574    #[test]
575    fn test_modified_line_position_preserved() {
576        let base = "before\nprocess_data(input)\nafter";
577        let working = "before\nprocess_data(input, options)\nafter";
578
579        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
580        let lines = content_lines(&diff);
581
582        let contents: Vec<_> = lines.iter().map(|l| l.content.as_str()).collect();
583        assert_eq!(contents, vec!["before", "process_data(input, options)", "after"]);
584
585        let modified = lines.iter().find(|l| l.content == "process_data(input, options)").unwrap();
586        assert!(!modified.inline_spans.is_empty());
587    }
588
589    #[test]
590    fn test_multiple_modifications_show_merged() {
591        let base = "line1\nprocess_item(data1)\nline3\nprocess_item(data2)\nline5";
592        let working = "line1\nprocess_item(data1, options)\nline3\nprocess_item(data2, options)\nline5";
593
594        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
595        let lines = content_lines(&diff);
596
597        let with_spans: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
598
599        assert_eq!(with_spans.len(), 2);
600        assert_eq!(with_spans[0].content, "process_item(data1, options)");
601        assert_eq!(with_spans[1].content, "process_item(data2, options)");
602    }
603
604    #[test]
605    fn test_committed_modification_shows_merged() {
606        let base = "line1\nfunction getData()\nline3";
607        let head = "line1\nfunction getData(params)\nline3";
608
609        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(head), Some(head));
610        let lines = content_lines(&diff);
611
612        let with_spans: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
613        assert_eq!(with_spans.len(), 1);
614        assert_eq!(with_spans[0].content, "function getData(params)");
615
616        let changed: Vec<_> = with_spans[0].inline_spans.iter()
617            .filter(|s| s.source == Some(LineSource::Committed))
618            .collect();
619        assert!(!changed.is_empty());
620    }
621
622    #[test]
623    fn test_committed_modification_with_additions_before() {
624        // Simulates: new lines added at top, AND existing lines modified
625        // This is the "workon" bug scenario - realistic file structure
626        let base = r#"layout {
627    pane size=1 borderless=true {
628        plugin location="tab-bar"
629    }
630    pane split_direction="vertical" {
631        pane split_direction="horizontal" size="50%" {
632            pane size="70%" {
633                command "claude"
634            }
635            pane size="30%" {
636            }
637        }
638        pane size="50%" {
639            command "branchdiff"
640        }
641    }
642    pane size=1 borderless=true {
643        plugin location="status-bar"
644    }
645}"#;
646
647        let head = r#"keybinds {
648    unbind "Alt f"
649}
650
651layout {
652    pane size=1 borderless=true {
653        plugin location="tab-bar"
654    }
655    pane split_direction="vertical" {
656        pane split_direction="horizontal" size="50%" {
657            pane size="80%" {
658                command "claude"
659            }
660            pane size="20%" {
661            }
662        }
663        pane size="50%" {
664            command "branchdiff"
665        }
666    }
667    pane size=1 borderless=true {
668        plugin location="status-bar"
669    }
670}"#;
671
672        let diff = compute_diff_with_inline("workon.kdl", Some(base), Some(head), Some(head), Some(head));
673        let lines = content_lines(&diff);
674
675        // The "keybinds" lines should be Committed additions
676        let additions: Vec<_> = lines.iter()
677            .filter(|l| l.source == LineSource::Committed && l.prefix == '+')
678            .collect();
679        assert!(additions.iter().any(|l| l.content.contains("keybinds")),
680            "Should have committed addition for 'keybinds', got additions: {:?}",
681            additions.iter().map(|l| &l.content).collect::<Vec<_>>());
682
683        // The modified line (70% -> 80%) should show as modified with inline spans
684        // It should NOT be shown as Base
685        let modified_line = lines.iter()
686            .find(|l| l.content.contains("80%"));
687        assert!(modified_line.is_some(), "Should have a line containing '80%'");
688
689        let modified = modified_line.unwrap();
690        // Modified lines show as Base (gray context) with inline highlighting
691        assert_eq!(modified.source, LineSource::Base,
692            "Modified line '{}' should be Base (with inline highlighting), not {:?}",
693            modified.content, modified.source);
694        // Must have old_content set for inline diff computation
695        assert!(modified.old_content.is_some(),
696            "Modified line should have old_content set");
697        assert!(!modified.inline_spans.is_empty(),
698            "Modified line '{}' should have inline spans showing the change from 70% to 80%",
699            modified.content);
700
701        // Also check the 30% -> 20% modification
702        let modified_line_2 = lines.iter()
703            .find(|l| l.content.contains("20%"));
704        assert!(modified_line_2.is_some(), "Should have a line containing '20%'");
705
706        let modified2 = modified_line_2.unwrap();
707        assert_eq!(modified2.source, LineSource::Base,
708            "Modified line '{}' should be Base (with inline highlighting), not {:?}",
709            modified2.content, modified2.source);
710        assert!(modified2.old_content.is_some(),
711            "Modified line should have old_content set");
712        assert!(!modified2.inline_spans.is_empty(),
713            "Modified line '{}' should have inline spans showing the change from 30% to 20%",
714            modified2.content);
715    }
716
717    #[test]
718    fn test_staged_modification_shows_merged() {
719        let base = "line1\nfunction getData()\nline3";
720        let index = "line1\nfunction getData(params)\nline3";
721
722        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(index), Some(index));
723        let lines = content_lines(&diff);
724
725        let with_spans: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
726        assert_eq!(with_spans.len(), 1);
727        assert_eq!(with_spans[0].content, "function getData(params)");
728
729        let changed: Vec<_> = with_spans[0].inline_spans.iter()
730            .filter(|s| s.source == Some(LineSource::Staged))
731            .collect();
732        assert!(!changed.is_empty());
733    }
734
735    #[test]
736    fn test_context_lines_preserved() {
737        let base = "line1\nline2\nline3\nline4\nline5";
738        let working = "line1\nline2\nmodified\nline4\nline5";
739
740        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
741        let lines = content_lines(&diff);
742
743        let pure_context: Vec<_> = lines.iter()
744            .filter(|l| l.source == LineSource::Base && l.inline_spans.is_empty())
745            .collect();
746
747        assert_eq!(pure_context.len(), 4);
748        assert!(pure_context.iter().all(|l| l.prefix == ' '));
749    }
750
751    #[test]
752    fn test_line_numbers_correct_after_deletion() {
753        let base = "line1\nto_delete\nline3";
754        let working = "line1\nline3";
755
756        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
757        let lines = content_lines(&diff);
758
759        let deleted: Vec<_> = lines.iter().filter(|l| l.prefix == '-').collect();
760        assert!(deleted.iter().all(|l| l.line_number.is_none()));
761
762        let with_numbers: Vec<_> = lines.iter()
763            .filter(|l| l.line_number.is_some())
764            .collect();
765
766        assert_eq!(with_numbers.len(), 2);
767        assert_eq!(with_numbers[0].line_number, Some(1));
768        assert_eq!(with_numbers[1].line_number, Some(2));
769    }
770
771    #[test]
772    fn test_line_numbers_correct_after_addition() {
773        let base = "line1\nline2";
774        let working = "line1\nnew_line\nline2";
775
776        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
777        let lines = content_lines(&diff);
778
779        let with_numbers: Vec<_> = lines.iter()
780            .filter(|l| l.line_number.is_some())
781            .map(|l| (l.content.as_str(), l.line_number.unwrap()))
782            .collect();
783
784        assert_eq!(with_numbers, vec![
785            ("line1", 1),
786            ("new_line", 2),
787            ("line2", 3),
788        ]);
789    }
790
791    #[test]
792    fn test_modify_committed_line_in_working_tree() {
793        let base = "line1\n";
794        let head = "line1\ncommitted line\n";
795        let working = "line1\ncommitted line # with comment\n";
796
797        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(head), Some(working));
798        let lines = content_lines(&diff);
799
800        let merged: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
801        assert_eq!(merged.len(), 1);
802        assert_eq!(merged[0].content, "committed line # with comment");
803
804        let unchanged_spans: Vec<_> = merged[0].inline_spans.iter()
805            .filter(|s| s.source.is_none() && !s.is_deletion)
806            .collect();
807        let unstaged_spans: Vec<_> = merged[0].inline_spans.iter()
808            .filter(|s| s.source == Some(LineSource::Unstaged))
809            .collect();
810
811        assert!(!unchanged_spans.is_empty());
812        assert!(!unstaged_spans.is_empty());
813    }
814
815    #[test]
816    fn test_modify_staged_line_in_working_tree() {
817        let base = "line1\n";
818        let head = "line1\n";
819        let index = "line1\nstaged line\n";
820        let working = "line1\nstaged line modified\n";
821
822        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(index), Some(working));
823        let lines = content_lines(&diff);
824
825        let merged: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
826        assert_eq!(merged.len(), 1);
827        assert_eq!(merged[0].content, "staged line modified");
828
829        let unchanged_spans: Vec<_> = merged[0].inline_spans.iter()
830            .filter(|s| s.source.is_none() && !s.is_deletion)
831            .collect();
832        let unstaged_spans: Vec<_> = merged[0].inline_spans.iter()
833            .filter(|s| s.source == Some(LineSource::Unstaged))
834            .collect();
835
836        assert!(!unchanged_spans.is_empty());
837        assert!(!unstaged_spans.is_empty());
838    }
839
840    #[test]
841    fn test_modify_base_line_in_commit() {
842        let base = "do_thing(data)\n";
843        let head = "do_thing(data, params)\n";
844
845        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(head), Some(head));
846        let lines = content_lines(&diff);
847
848        let with_spans: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
849        assert_eq!(with_spans.len(), 1);
850        assert_eq!(with_spans[0].content, "do_thing(data, params)");
851
852        let changed: Vec<_> = with_spans[0].inline_spans.iter()
853            .filter(|s| s.source == Some(LineSource::Committed))
854            .collect();
855        assert!(!changed.is_empty());
856    }
857
858    #[test]
859    fn test_chain_of_modifications() {
860        let base = "original\n";
861        let head = "committed version\n";
862        let index = "staged version\n";
863        let working = "working version\n";
864
865        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(index), Some(working));
866        let lines = content_lines(&diff);
867
868        let with_spans: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
869        assert_eq!(with_spans.len(), 1);
870        assert_eq!(with_spans[0].content, "working version");
871
872        let changed: Vec<_> = with_spans[0].inline_spans.iter()
873            .filter(|s| s.source == Some(LineSource::Unstaged))
874            .collect();
875        assert!(!changed.is_empty());
876    }
877
878    #[test]
879    fn test_committed_line_unchanged_through_stages() {
880        let base = "line1\n";
881        let head = "line1\ncommitted line\n";
882
883        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(head), Some(head));
884        let lines = content_lines(&diff);
885
886        let added: Vec<_> = lines.iter().filter(|l| l.prefix == '+').collect();
887
888        assert_eq!(added.len(), 1);
889        assert_eq!(added[0].content, "committed line");
890        assert_eq!(added[0].source, LineSource::Committed);
891    }
892
893    #[test]
894    fn test_staged_line_unchanged_in_working() {
895        let base = "line1\n";
896        let head = "line1\n";
897        let index = "line1\nstaged line\n";
898
899        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(index), Some(index));
900        let lines = content_lines(&diff);
901
902        let added: Vec<_> = lines.iter().filter(|l| l.prefix == '+').collect();
903
904        assert_eq!(added.len(), 1);
905        assert_eq!(added[0].content, "staged line");
906        assert_eq!(added[0].source, LineSource::Staged);
907    }
908
909    #[test]
910    fn test_inline_diff_not_meaningful_falls_back_to_pair() {
911        let base = "abcdefgh\n";
912        let working = "xyz12345\n";
913
914        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
915        let lines = content_lines(&diff);
916
917        let deleted: Vec<_> = lines.iter().filter(|l| l.prefix == '-').collect();
918        let added: Vec<_> = lines.iter().filter(|l| l.prefix == '+').collect();
919
920        assert_eq!(deleted.len(), 1);
921        assert_eq!(added.len(), 1);
922        assert_eq!(deleted[0].content, "abcdefgh");
923        assert_eq!(added[0].content, "xyz12345");
924    }
925
926    #[test]
927    fn test_block_of_changes_no_inline_merge() {
928        let base = "context\nalpha: aaa,\nbeta: bbb,\ngamma: ccc,\nend";
929        let working = "context\nxray: xxx,\nyankee: yyy,\nzulu: zzz,\nend";
930
931        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
932        let lines = content_lines(&diff);
933
934        let deleted: Vec<_> = lines.iter().filter(|l| l.prefix == '-').collect();
935        let added: Vec<_> = lines.iter().filter(|l| l.prefix == '+').collect();
936        let merged: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
937
938        assert_eq!(deleted.len(), 3);
939        assert_eq!(added.len(), 3);
940        assert_eq!(merged.len(), 0);
941
942        for line in &added {
943            assert!(line.inline_spans.is_empty());
944        }
945    }
946
947    #[test]
948    fn test_single_line_modification_with_context_shows_inline() {
949        let base = "before\ndescribed_class.new(bond).execute\nafter";
950        let working = "before\ndescribed_class.new(bond).execute # and add some color commentary\nafter";
951
952        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
953        let lines = content_lines(&diff);
954
955        let deleted: Vec<_> = lines.iter().filter(|l| l.prefix == '-').collect();
956        assert_eq!(deleted.len(), 0);
957
958        let merged: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
959        assert_eq!(merged.len(), 1);
960
961        assert!(merged[0].content.contains("# and add some color commentary"));
962
963        let unchanged: Vec<_> = merged[0].inline_spans.iter()
964            .filter(|s| s.source.is_none())
965            .collect();
966        let changed: Vec<_> = merged[0].inline_spans.iter()
967            .filter(|s| s.source.is_some())
968            .collect();
969
970        assert!(!unchanged.is_empty());
971        assert!(!changed.is_empty());
972
973        let unchanged_text: String = unchanged.iter().map(|s| s.text.as_str()).collect();
974        assert!(unchanged_text.contains("described_class.new(bond).execute"));
975    }
976
977    #[test]
978    fn test_single_line_committed_modification_shows_inline() {
979        let base = "before\ndescribed_class.new(bond).execute\nafter";
980        let head = "before\ndescribed_class.new(bond).execute # and add some color commentary\nafter";
981
982        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(head), Some(head));
983        let lines = content_lines(&diff);
984
985        let deleted: Vec<_> = lines.iter().filter(|l| l.prefix == '-').collect();
986        assert_eq!(deleted.len(), 0);
987
988        let merged: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
989        assert_eq!(merged.len(), 1);
990
991        assert!(merged[0].content.contains("# and add some color commentary"));
992
993        let unchanged: Vec<_> = merged[0].inline_spans.iter()
994            .filter(|s| s.source.is_none())
995            .collect();
996        let changed: Vec<_> = merged[0].inline_spans.iter()
997            .filter(|s| s.source.is_some())
998            .collect();
999
1000        assert!(!unchanged.is_empty());
1001        assert!(!changed.is_empty());
1002
1003        let committed_spans: Vec<_> = merged[0].inline_spans.iter()
1004            .filter(|s| s.source == Some(LineSource::Committed))
1005            .collect();
1006        assert!(!committed_spans.is_empty());
1007
1008        let unchanged_text: String = unchanged.iter().map(|s| s.text.as_str()).collect();
1009        assert!(unchanged_text.contains("described_class.new(bond).execute"));
1010    }
1011
1012    #[test]
1013    fn test_modification_with_adjacent_empty_line_inserts_shows_inline() {
1014        let base = "before\ndescribed_class.new(bond).execute\nafter";
1015        let head = "before\n\ndescribed_class.new(bond).execute # comment\n\nafter";
1016
1017        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(head), Some(head));
1018        let lines = content_lines(&diff);
1019
1020        let merged: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
1021        assert_eq!(merged.len(), 1);
1022
1023        assert!(merged[0].content.contains("# comment"));
1024
1025        let unchanged: Vec<_> = merged[0].inline_spans.iter()
1026            .filter(|s| s.source.is_none())
1027            .collect();
1028        let changed: Vec<_> = merged[0].inline_spans.iter()
1029            .filter(|s| s.source.is_some())
1030            .collect();
1031
1032        assert!(!unchanged.is_empty());
1033        assert!(!changed.is_empty());
1034    }
1035
1036    #[test]
1037    fn test_unstaged_modification_of_committed_line_shows_inline() {
1038        let base = "before\nafter";
1039        let head = "before\ndescribed_class.new(bond).execute\nafter";
1040        let index = "before\ndescribed_class.new(bond).execute\nafter";
1041        let working = "before\ndescribed_class.new(bond).execute # and add some color commentary\nafter";
1042
1043        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(index), Some(working));
1044        let lines = content_lines(&diff);
1045
1046        let merged: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
1047        assert_eq!(merged.len(), 1);
1048
1049        assert_eq!(merged[0].content, "described_class.new(bond).execute # and add some color commentary");
1050
1051        let unchanged_spans: Vec<_> = merged[0].inline_spans.iter()
1052            .filter(|s| s.source.is_none() && !s.is_deletion)
1053            .collect();
1054        let unstaged_spans: Vec<_> = merged[0].inline_spans.iter()
1055            .filter(|s| s.source == Some(LineSource::Unstaged))
1056            .collect();
1057
1058        assert!(!unchanged_spans.is_empty());
1059        let unchanged_text: String = unchanged_spans.iter().map(|s| s.text.as_str()).collect();
1060        assert!(unchanged_text.contains("described_class.new(bond).execute"));
1061
1062        assert!(!unstaged_spans.is_empty());
1063        let unstaged_text: String = unstaged_spans.iter().map(|s| s.text.as_str()).collect();
1064        assert!(unstaged_text.contains("# and add some color commentary"));
1065    }
1066
1067    #[test]
1068    fn test_unstaged_modification_of_base_line_shows_gray_and_yellow() {
1069        let base = "before\noriginal_code()\nafter";
1070        let head = "before\noriginal_code()\nafter";
1071        let index = "before\noriginal_code()\nafter";
1072        let working = "before\noriginal_code() # added comment\nafter";
1073
1074        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(index), Some(working));
1075        let lines = content_lines(&diff);
1076
1077        let merged: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
1078        assert_eq!(merged.len(), 1);
1079
1080        let base_spans: Vec<_> = merged[0].inline_spans.iter()
1081            .filter(|s| s.source.is_none() || s.source == Some(LineSource::Base))
1082            .collect();
1083        let unstaged_spans: Vec<_> = merged[0].inline_spans.iter()
1084            .filter(|s| s.source == Some(LineSource::Unstaged))
1085            .collect();
1086
1087        let committed_spans: Vec<_> = merged[0].inline_spans.iter()
1088            .filter(|s| s.source == Some(LineSource::Committed))
1089            .collect();
1090        assert!(committed_spans.is_empty(), "line from master shouldn't be Committed");
1091
1092        assert!(!base_spans.is_empty());
1093        assert!(!unstaged_spans.is_empty());
1094
1095        let unstaged_text: String = unstaged_spans.iter().map(|s| s.text.as_str()).collect();
1096        assert!(unstaged_text.contains("# added comment"));
1097    }
1098
1099    #[test]
1100    fn test_duplicate_lines_correct_source_attribution() {
1101        let base = "context 'first' do\n  it 'test' do\n  end\nend\n";
1102        let head = "context 'first' do\n  it 'test' do\n  end\n  it 'new test' do\n  end\nend\n";
1103        let index = head;
1104        let working = "context 'first' do\n  it 'test' do\n  end\n  it 'new test' do\n  end # added comment\nend\n";
1105
1106        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(index), Some(working));
1107        let lines = content_lines(&diff);
1108
1109        let merged: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
1110        assert_eq!(merged.len(), 1);
1111
1112        let unchanged_spans: Vec<_> = merged[0].inline_spans.iter()
1113            .filter(|s| s.source.is_none() && !s.is_deletion)
1114            .collect();
1115        let unstaged_spans: Vec<_> = merged[0].inline_spans.iter()
1116            .filter(|s| s.source == Some(LineSource::Unstaged))
1117            .collect();
1118
1119        assert!(!unchanged_spans.is_empty());
1120        assert!(!unstaged_spans.is_empty());
1121    }
1122
1123    #[test]
1124    fn test_duplicate_lines_earlier_base_line_doesnt_affect_committed_line() {
1125        let base = "context 'existing' do
1126  it 'existing test' do
1127    described_class.new(bond).execute
1128  end
1129end
1130";
1131        let head = "context 'existing' do
1132  it 'existing test' do
1133    described_class.new(bond).execute
1134  end
1135end
1136
1137context 'new' do
1138  it 'new test' do
1139    described_class.new(bond).execute
1140  end
1141end
1142";
1143        let index = head;
1144        // Modify the SECOND "described_class.new(bond).execute" (the one added in commit)
1145        let working = "context 'existing' do
1146  it 'existing test' do
1147    described_class.new(bond).execute
1148  end
1149end
1150
1151context 'new' do
1152  it 'new test' do
1153    described_class.new(bond).execute # added comment
1154  end
1155end
1156";
1157
1158        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(index), Some(working));
1159        let lines = content_lines(&diff);
1160
1161        let merged: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
1162        assert_eq!(merged.len(), 1);
1163
1164        assert!(merged[0].content.contains("described_class.new(bond).execute # added comment"));
1165
1166        let unchanged_spans: Vec<_> = merged[0].inline_spans.iter()
1167            .filter(|s| s.source.is_none() && !s.is_deletion)
1168            .collect();
1169        let unstaged_spans: Vec<_> = merged[0].inline_spans.iter()
1170            .filter(|s| s.source == Some(LineSource::Unstaged))
1171            .collect();
1172
1173        assert!(!unchanged_spans.is_empty());
1174
1175        assert!(!unstaged_spans.is_empty());
1176        let unstaged_text: String = unstaged_spans.iter().map(|s| s.text.as_str()).collect();
1177        assert!(unstaged_text.contains("# added comment"));
1178    }
1179
1180    #[test]
1181    fn test_last_test_in_committed_block_shows_committed_not_base() {
1182        let base = "context 'existing' do
1183  it 'existing test' do
1184    described_class.new(bond).execute
1185  end
1186end
1187";
1188        let head = "context 'existing' do
1189  it 'existing test' do
1190    described_class.new(bond).execute
1191  end
1192end
1193
1194context 'first new' do
1195  it 'first new test' do
1196    described_class.new(bond).execute
1197  end
1198end
1199
1200context 'second new' do
1201  it 'second new test' do
1202    described_class.new(bond).execute
1203  end
1204end
1205
1206context 'third new' do
1207  it 'third new test' do
1208    described_class.new(bond).execute
1209  end
1210end
1211";
1212        let index = head;
1213        let working = head;
1214
1215        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(index), Some(working));
1216        let lines = content_lines(&diff);
1217
1218        let execute_lines: Vec<_> = lines.iter()
1219            .filter(|l| l.content == "    described_class.new(bond).execute")
1220            .collect();
1221
1222        assert_eq!(execute_lines.len(), 4);
1223
1224        assert_eq!(execute_lines[0].source, LineSource::Base);
1225
1226        for line in execute_lines.iter().skip(1) {
1227            assert_eq!(line.source, LineSource::Committed);
1228        }
1229    }
1230
1231    #[test]
1232    fn test_committed_block_with_shared_end_line() {
1233        let base = "context 'existing' do
1234  it 'test' do
1235    described_class.new(bond).execute
1236  end
1237end
1238";
1239        let head = "context 'existing' do
1240  it 'test' do
1241    described_class.new(bond).execute
1242  end
1243end
1244
1245context 'new' do
1246  it 'uses bond data' do
1247    expected_address = bond.principal_mailing_address
1248
1249    described_class.new(bond).execute
1250
1251    notice = Commercial::PremiumDueNotice.new(bond)
1252    expect(notice.pdf_fields_hash[:address]).to eq(expected_address)
1253  end
1254end
1255";
1256        let index = head;
1257        let working = head;
1258
1259        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(index), Some(working));
1260        let lines = content_lines(&diff);
1261
1262        let new_test_lines: Vec<_> = lines.iter()
1263            .enumerate()
1264            .filter(|(_, l)| {
1265                l.content.contains("uses bond data") ||
1266                l.content.contains("expected_address") ||
1267                l.content.contains("notice = Commercial") ||
1268                l.content.contains("expect(notice")
1269            })
1270            .collect();
1271
1272        for (_, line) in &new_test_lines {
1273            assert_eq!(line.source, LineSource::Committed);
1274        }
1275
1276        let execute_lines: Vec<_> = lines.iter()
1277            .enumerate()
1278            .filter(|(_, l)| l.content.trim() == "described_class.new(bond).execute")
1279            .collect();
1280
1281        assert_eq!(execute_lines.len(), 2);
1282        assert_eq!(execute_lines[0].1.source, LineSource::Base);
1283        assert_eq!(execute_lines[1].1.source, LineSource::Committed);
1284    }
1285
1286    #[test]
1287    fn test_blank_line_in_committed_block_shows_committed() {
1288        let base = "context 'existing' do
1289  it 'test' do
1290    existing_code
1291
1292    described_class.new(bond).execute
1293  end
1294end
1295";
1296        let head = "context 'existing' do
1297  it 'test' do
1298    existing_code
1299
1300    described_class.new(bond).execute
1301  end
1302end
1303
1304context 'new' do
1305  it 'new test' do
1306    expected_address = bond.principal_mailing_address
1307
1308    described_class.new(bond).execute
1309
1310    notice = Commercial::PremiumDueNotice.new(bond)
1311  end
1312end
1313";
1314        let index = head;
1315        let working = head;
1316
1317        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(index), Some(working));
1318        let lines = content_lines(&diff);
1319
1320        let new_context_idx = lines.iter().position(|l| l.content.contains("context 'new'"));
1321        assert!(new_context_idx.is_some());
1322
1323        let new_context_idx = new_context_idx.unwrap();
1324
1325        for (i, line) in lines.iter().enumerate().skip(new_context_idx) {
1326            if line.content.trim().is_empty() {
1327                assert_eq!(line.source, LineSource::Committed,
1328                    "Blank line at {} should be Committed", i);
1329            }
1330        }
1331    }
1332
1333    #[test]
1334    fn test_third_test_in_block_of_three_shows_committed() {
1335        let base = "context 'existing' do
1336  it 'existing test' do
1337    described_class.new(bond).execute
1338  end
1339end
1340";
1341        let head = "context 'existing' do
1342  it 'existing test' do
1343    described_class.new(bond).execute
1344  end
1345end
1346
1347context 'first new' do
1348  it 'first test' do
1349    expected = bond.first_attribute
1350
1351    described_class.new(bond).execute
1352
1353    expect(result).to eq(expected)
1354  end
1355end
1356
1357context 'second new' do
1358  it 'second test' do
1359    expected = bond.second_attribute
1360
1361    described_class.new(bond).execute
1362
1363    expect(result).to eq(expected)
1364  end
1365end
1366
1367context 'third new' do
1368  it 'third test' do
1369    expected = bond.third_attribute
1370
1371    described_class.new(bond).execute
1372
1373    expect(result).to eq(expected)
1374  end
1375end
1376";
1377        let index = head;
1378        let working = head;
1379
1380        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(index), Some(working));
1381        let lines = content_lines(&diff);
1382
1383        let third_context_idx = lines.iter().position(|l| l.content.contains("context 'third new'"));
1384        assert!(third_context_idx.is_some());
1385        let third_context_idx = third_context_idx.unwrap();
1386
1387        for (_, line) in lines.iter().enumerate().skip(third_context_idx) {
1388            if line.content.contains("third test") ||
1389               line.content.contains("third_attribute") ||
1390               line.content.contains("described_class") ||
1391               line.content.contains("expect(result)") {
1392                assert_eq!(line.source, LineSource::Committed);
1393            }
1394        }
1395
1396        let execute_lines: Vec<_> = lines.iter().enumerate()
1397            .filter(|(_, l)| l.content.trim() == "described_class.new(bond).execute")
1398            .collect();
1399
1400        assert_eq!(execute_lines.len(), 4);
1401        assert_eq!(execute_lines[0].1.source, LineSource::Base);
1402        assert_eq!(execute_lines[1].1.source, LineSource::Committed);
1403        assert_eq!(execute_lines[2].1.source, LineSource::Committed);
1404        assert_eq!(execute_lines[3].1.source, LineSource::Committed);
1405    }
1406
1407    #[test]
1408    fn test_modified_line_shows_as_single_merged_line() {
1409        let base = "do_thing(data)\n";
1410        let working = "do_thing(data, parameters)\n";
1411
1412        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
1413        let lines = content_lines(&diff);
1414
1415        let deleted: Vec<_> = lines.iter().filter(|l| l.prefix == '-').collect();
1416        let modified: Vec<_> = lines.iter().filter(|l| !l.inline_spans.is_empty()).collect();
1417
1418        assert_eq!(deleted.len(), 0);
1419        assert_eq!(modified.len(), 1);
1420        assert_eq!(modified[0].content, "do_thing(data, parameters)");
1421
1422        let changed: Vec<_> = modified[0].inline_spans.iter()
1423            .filter(|s| s.source == Some(LineSource::Unstaged))
1424            .collect();
1425        assert!(!changed.is_empty());
1426
1427        let changed_text: String = changed.iter().map(|s| s.text.as_str()).collect();
1428        assert!(changed_text.contains(", parameters"));
1429    }
1430
1431    #[test]
1432    fn test_new_line_addition_no_inline_spans() {
1433        let base = "line1\n";
1434        let working = "line1\nnew line\n";
1435
1436        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
1437        let lines = content_lines(&diff);
1438
1439        let added: Vec<_> = lines.iter().filter(|l| l.prefix == '+').collect();
1440        assert_eq!(added.len(), 1);
1441        assert!(added[0].inline_spans.is_empty());
1442    }
1443
1444    #[test]
1445    fn test_pure_deletion_still_shows_minus() {
1446        let base = "line1\nto_delete\nline3\n";
1447        let working = "line1\nline3\n";
1448
1449        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
1450        let lines = content_lines(&diff);
1451
1452        let deleted: Vec<_> = lines.iter().filter(|l| l.prefix == '-').collect();
1453        assert_eq!(deleted.len(), 1);
1454        assert_eq!(deleted[0].content, "to_delete");
1455    }
1456
1457    #[test]
1458    fn test_two_adjacent_committed_modifications() {
1459        let base = r#"            principal_zip: "00000",
1460            effective_date: "2022-08-30",
1461            expiration_date: "2024-08-30",
1462"#;
1463        let head = r#"            principal_zip: "00000",
1464            effective_date: "2023-08-30",
1465            expiration_date: "2025-08-30",
1466"#;
1467
1468        let diff = compute_diff_with_inline("test.txt", Some(base), Some(head), Some(head), Some(head));
1469        let lines = content_lines(&diff);
1470
1471        let effective_lines: Vec<_> = lines.iter()
1472            .filter(|l| l.content.contains("effective_date"))
1473            .collect();
1474        let expiration_lines: Vec<_> = lines.iter()
1475            .filter(|l| l.content.contains("expiration_date"))
1476            .collect();
1477
1478        assert_eq!(effective_lines.len(), 1);
1479        assert!(effective_lines[0].content.contains("2023"));
1480        assert!(!effective_lines[0].inline_spans.is_empty());
1481
1482        assert_eq!(expiration_lines.len(), 1);
1483        assert!(expiration_lines[0].content.contains("2025"));
1484        assert!(!expiration_lines[0].inline_spans.is_empty());
1485
1486        let effective_has_committed = effective_lines[0].inline_spans.iter()
1487            .any(|s| s.source == Some(LineSource::Committed));
1488        let expiration_has_committed = expiration_lines[0].inline_spans.iter()
1489            .any(|s| s.source == Some(LineSource::Committed));
1490
1491        assert!(effective_has_committed);
1492        assert!(expiration_has_committed);
1493    }
1494
1495    #[test]
1496    fn test_deletion_positioned_correctly_with_insertions_before() {
1497        let base = "line1\nline2\nline3\nto_delete\nline5";
1498        let working = "line1\nline2\nNEW_LINE\nline3\nline5";
1499
1500        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
1501        let lines = content_lines(&diff);
1502
1503        let line_contents: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
1504
1505        let new_line_pos = line_contents.iter().position(|&c| c == "NEW_LINE").unwrap();
1506        let to_delete_pos = line_contents.iter().position(|&c| c == "to_delete").unwrap();
1507        let line3_pos = line_contents.iter().position(|&c| c == "line3").unwrap();
1508
1509        assert!(to_delete_pos > line3_pos);
1510        assert!(to_delete_pos > new_line_pos);
1511
1512        let deleted_line = &lines[to_delete_pos];
1513        assert_eq!(deleted_line.prefix, '-');
1514    }
1515
1516    #[test]
1517    fn test_deletion_before_insertion_at_same_position() {
1518        let base = "def principal_mailing_address\n  commercial_renewal.principal_mailing_address\nend";
1519        let working = "def principal_mailing_address\n  \"new content\"\nend";
1520
1521        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
1522        let lines = content_lines(&diff);
1523
1524        let line_contents: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
1525        let prefixes: Vec<char> = lines.iter().map(|l| l.prefix).collect();
1526
1527        let deleted_pos = line_contents.iter().position(|&c| c.contains("commercial_renewal")).unwrap();
1528        let inserted_pos = line_contents.iter().position(|&c| c.contains("new content")).unwrap();
1529
1530        assert!(deleted_pos < inserted_pos);
1531        assert_eq!(prefixes[deleted_pos], '-');
1532        assert_eq!(prefixes[inserted_pos], '+');
1533    }
1534
1535    #[test]
1536    fn test_deletion_appears_after_preceding_context_line() {
1537        let base = "def foo\n  body_line\nend";
1538        let working = "def foo\n  \"new body\"\nend";
1539
1540        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
1541        let lines = content_lines(&diff);
1542
1543        let line_contents: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
1544
1545        let def_pos = line_contents.iter().position(|&c| c.contains("def foo")).unwrap();
1546        let deleted_pos = line_contents.iter().position(|&c| c.contains("body_line")).unwrap();
1547        let inserted_pos = line_contents.iter().position(|&c| c.contains("new body")).unwrap();
1548        let end_pos = line_contents.iter().position(|&c| c == "end").unwrap();
1549
1550        assert!(deleted_pos > def_pos);
1551        assert!(deleted_pos < inserted_pos);
1552        assert!(deleted_pos < end_pos);
1553
1554        assert_eq!(def_pos, 0);
1555        assert_eq!(deleted_pos, 1);
1556        assert_eq!(inserted_pos, 2);
1557        assert_eq!(end_pos, 3);
1558    }
1559
1560    #[test]
1561    fn test_deletion_after_modified_line() {
1562        let base = "def principal_mailing_address\n  commercial_renewal.principal_mailing_address\nend";
1563        let working = "def pribond_descripal_mailtiong_address\n  \"new content\"\nend";
1564
1565        let diff = compute_diff_with_inline("test.txt", Some(base), Some(base), Some(base), Some(working));
1566        let lines = content_lines(&diff);
1567
1568        let line_contents: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
1569        let prefixes: Vec<char> = lines.iter().map(|l| l.prefix).collect();
1570
1571        let deleted_principal_pos = line_contents.iter().position(|&c| c.contains("principal_mailing_address")).unwrap();
1572        let inserted_pribond_pos = line_contents.iter().position(|&c| c.contains("pribond")).unwrap();
1573        let deleted_commercial_pos = line_contents.iter().position(|&c| c.contains("commercial_renewal")).unwrap();
1574        let inserted_new_content_pos = line_contents.iter().position(|&c| c.contains("new content")).unwrap();
1575        let end_pos = line_contents.iter().position(|&c| c == "end").unwrap();
1576
1577        assert_eq!(prefixes[deleted_principal_pos], '-', "principal should be deleted");
1578        assert_eq!(prefixes[inserted_pribond_pos], '+', "pribond should be inserted");
1579        assert_eq!(prefixes[deleted_commercial_pos], '-', "commercial_renewal should be deleted");
1580        assert_eq!(prefixes[inserted_new_content_pos], '+', "new content should be inserted");
1581        assert_eq!(prefixes[end_pos], ' ', "end should be unchanged context");
1582
1583        assert!(deleted_principal_pos < deleted_commercial_pos, "both deletions should come together");
1584        assert!(deleted_commercial_pos < inserted_pribond_pos, "deletions should come before insertions");
1585        assert!(inserted_pribond_pos < inserted_new_content_pos, "both insertions should come together");
1586    }
1587
1588    #[test]
1589    fn test_unstaged_modification_of_newly_committed_method_appears_in_correct_position() {
1590        let base = "def abeyance_required?
1591  abeyance? || active?
1592end
1593
1594def from_domino?
1595  legacy_unid.present?
1596end
1597";
1598        let head = "def abeyance_required?
1599  abeyance? || active?
1600end
1601
1602def can_request_letter_of_bondability?
1603  !commercial? && (active? || abeyance?)
1604end
1605
1606def from_domino?
1607  legacy_unid.present?
1608end
1609";
1610        let index = head;
1611        let working = "def abeyance_required?
1612  abeyance? || active?
1613end
1614
1615def can_request_letter_of_bondability?
1616  !inactive? && status != \"Destroy\"
1617end
1618
1619def from_domino?
1620  legacy_unid.present?
1621end
1622";
1623
1624        let diff = compute_diff_with_inline("principal.rb", Some(base), Some(head), Some(index), Some(working));
1625        let lines = content_lines(&diff);
1626
1627        let abeyance_pos = lines.iter().position(|l| l.content.contains("abeyance_required")).unwrap();
1628        let can_request_pos = lines.iter().position(|l| l.content.contains("can_request_letter_of_bondability")).unwrap();
1629        let from_domino_pos = lines.iter().position(|l| l.content.contains("from_domino")).unwrap();
1630
1631        let commercial_line_pos = lines.iter().position(|l| l.content.contains("!commercial?"));
1632        let inactive_line_pos = lines.iter().position(|l| l.content.contains("!inactive?"));
1633
1634        assert!(can_request_pos > abeyance_pos, "can_request method should come after abeyance_required");
1635        assert!(can_request_pos < from_domino_pos, "can_request method should come before from_domino");
1636
1637        if let Some(commercial_pos) = commercial_line_pos {
1638            assert!(
1639                commercial_pos > abeyance_pos && commercial_pos < from_domino_pos,
1640                "!commercial? line should appear between abeyance_required and from_domino, not at pos {} (abeyance={}, from_domino={})",
1641                commercial_pos, abeyance_pos, from_domino_pos
1642            );
1643        }
1644
1645        if let Some(inactive_pos) = inactive_line_pos {
1646            assert!(
1647                inactive_pos > abeyance_pos && inactive_pos < from_domino_pos,
1648                "!inactive? line should appear between abeyance_required and from_domino, not at pos {} (abeyance={}, from_domino={})",
1649                inactive_pos, abeyance_pos, from_domino_pos
1650            );
1651        }
1652    }
1653
1654    #[test]
1655    fn test_trailing_context_after_addition() {
1656        let base = "def foo\nend\nend";
1657        let working = "def foo\nnew_line\nend\nend";
1658
1659        let diff = compute_diff_with_inline("test.rb", Some(base), Some(base), Some(base), Some(working));
1660        let lines = content_lines(&diff);
1661
1662        assert_eq!(lines.len(), 4);
1663
1664        assert_eq!(lines[0].content, "def foo");
1665        assert_eq!(lines[0].source, LineSource::Base);
1666
1667        assert_eq!(lines[1].content, "new_line");
1668        assert_eq!(lines[1].prefix, '+');
1669
1670        assert_eq!(lines[2].content, "end");
1671        assert_eq!(lines[2].source, LineSource::Base);
1672
1673        assert_eq!(lines[3].content, "end");
1674        assert_eq!(lines[3].source, LineSource::Base);
1675    }
1676
1677    #[test]
1678    fn test_trailing_context_after_committed_addition() {
1679        let base = "def foo\nend\nend";
1680        let head = "def foo\nnew_line\nend\nend";
1681
1682        let diff = compute_diff_with_inline("test.rb", Some(base), Some(head), Some(head), Some(head));
1683        let lines = content_lines(&diff);
1684        assert_eq!(lines.len(), 4);
1685
1686        assert_eq!(lines[0].content, "def foo");
1687        assert_eq!(lines[0].source, LineSource::Base);
1688
1689        assert_eq!(lines[1].content, "new_line");
1690        assert_eq!(lines[1].source, LineSource::Committed);
1691        assert_eq!(lines[1].prefix, '+');
1692
1693        assert_eq!(lines[2].content, "end");
1694        assert_eq!(lines[2].source, LineSource::Base);
1695
1696        assert_eq!(lines[3].content, "end");
1697        assert_eq!(lines[3].source, LineSource::Base);
1698    }
1699
1700    #[test]
1701    fn test_addition_at_end_of_file_with_trailing_context() {
1702        let base = "class Foo\n  def bar\n  end\nend";
1703        let head = "class Foo\n  def bar\n    new_line\n  end\nend";
1704
1705        let diff = compute_diff_with_inline("test.rb", Some(base), Some(head), Some(head), Some(head));
1706        let lines = content_lines(&diff);
1707
1708        assert_eq!(lines.len(), 5);
1709
1710        assert_eq!(lines[0].content, "class Foo");
1711        assert_eq!(lines[1].content, "  def bar");
1712        assert_eq!(lines[2].content, "    new_line");
1713        assert_eq!(lines[2].source, LineSource::Committed);
1714        assert_eq!(lines[3].content, "  end");
1715        assert_eq!(lines[3].source, LineSource::Base);
1716        assert_eq!(lines[4].content, "end");
1717        assert_eq!(lines[4].source, LineSource::Base);
1718    }
1719
1720    #[test]
1721    fn test_addition_before_two_trailing_ends() {
1722        let base = "do\n  body\nend\nend";
1723        let head = "do\n  body\n  new_end\nend\nend";
1724
1725        let diff = compute_diff_with_inline("test.rb", Some(base), Some(head), Some(head), Some(head));
1726        let lines = content_lines(&diff);
1727
1728        assert_eq!(lines.len(), 5);
1729
1730        assert_eq!(lines[0].content, "do");
1731        assert_eq!(lines[0].source, LineSource::Base);
1732
1733        assert_eq!(lines[1].content, "  body");
1734        assert_eq!(lines[1].source, LineSource::Base);
1735
1736        assert_eq!(lines[2].content, "  new_end");
1737        assert_eq!(lines[2].source, LineSource::Committed);
1738        assert_eq!(lines[2].prefix, '+');
1739
1740        assert_eq!(lines[3].content, "end");
1741        assert_eq!(lines[3].source, LineSource::Base);
1742
1743        assert_eq!(lines[4].content, "end");
1744        assert_eq!(lines[4].source, LineSource::Base);
1745    }
1746
1747    #[test]
1748    fn test_final_file_ends_with_addition() {
1749        let base = "do\n  body\nend";
1750        let head = "do\n  body\nend\n  extra";
1751
1752        let diff = compute_diff_with_inline("test.rb", Some(base), Some(head), Some(head), Some(head));
1753        let lines = content_lines(&diff);
1754
1755        assert_eq!(lines.len(), 4);
1756
1757        assert_eq!(lines[0].content, "do");
1758        assert_eq!(lines[0].source, LineSource::Base);
1759
1760        assert_eq!(lines[1].content, "  body");
1761        assert_eq!(lines[1].source, LineSource::Base);
1762
1763        assert_eq!(lines[2].content, "end");
1764        assert_eq!(lines[2].source, LineSource::Base);
1765
1766        assert_eq!(lines[3].content, "  extra");
1767        assert_eq!(lines[3].source, LineSource::Committed);
1768        assert_eq!(lines[3].prefix, '+');
1769    }
1770
1771    #[test]
1772    fn test_file_without_trailing_newline() {
1773        let base = "line1\nline2\nend\nend\nend";
1774        let head = "line1\nline2\nnew_line\nend\nend\nend";
1775
1776        let diff = compute_diff_with_inline("test.rb", Some(base), Some(head), Some(head), Some(head));
1777        let lines = content_lines(&diff);
1778
1779        assert_eq!(lines.len(), 6);
1780        assert_eq!(lines[0].content, "line1");
1781        assert_eq!(lines[1].content, "line2");
1782        assert_eq!(lines[2].content, "new_line");
1783        assert_eq!(lines[2].source, LineSource::Committed);
1784        assert_eq!(lines[3].content, "end");
1785        assert_eq!(lines[3].source, LineSource::Base);
1786        assert_eq!(lines[4].content, "end");
1787        assert_eq!(lines[4].source, LineSource::Base);
1788        assert_eq!(lines[5].content, "end");
1789        assert_eq!(lines[5].source, LineSource::Base);
1790    }
1791
1792    #[test]
1793    fn test_exact_scenario_from_bug_report() {
1794        // EXACT scenario from the bug report:
1795        // Base file ends with:
1796        //   ...some tests...
1797        //   end  <- end of describe block
1798        //   end  <- end of RSpec.describe
1799        //
1800        // Head adds a new test BEFORE the final two ends:
1801        //   ...some tests...
1802        //   (empty line)
1803        //   it "new test" do
1804        //     expect(...)
1805        //   end   <- end of new test (ADDED)
1806        //   end   <- end of describe block (BASE)
1807        //   end   <- end of RSpec.describe (BASE)
1808
1809        let base = r##"  describe "#method" do
1810    it "test 1" do
1811      expect(true).to be(true)
1812    end
1813  end
1814end"##;
1815
1816        let head = r##"  describe "#method" do
1817    it "test 1" do
1818      expect(true).to be(true)
1819    end
1820
1821    it "new test" do
1822      expect(false).to be(false)
1823    end
1824  end
1825end"##;
1826
1827        let diff = compute_diff_with_inline("spec.rb", Some(base), Some(head), Some(head), Some(head));
1828        let lines = content_lines(&diff);
1829
1830        // Expected:
1831        // [0] describe "#method" do       (base)
1832        // [1]   it "test 1" do            (base)
1833        // [2]     expect(true)...         (base)
1834        // [3]   end                       (base)
1835        // [4] (empty line)                (committed +)
1836        // [5]   it "new test" do          (committed +)
1837        // [6]     expect(false)...        (committed +)
1838        // [7]   end                       (committed +)
1839        // [8] end                         (base) <-- trailing
1840        // [9] end                         (base) <-- trailing
1841
1842        assert!(lines.len() >= 10, "Should have at least 10 lines, got {}", lines.len());
1843
1844        // Check the trailing base lines exist
1845        let last_two: Vec<_> = lines.iter().rev().take(2).collect();
1846        assert_eq!(last_two[0].content, "end", "Last line should be 'end'");
1847        assert_eq!(last_two[0].source, LineSource::Base, "Last line should be Base");
1848        // Second to last is "  end" (with indent)
1849        assert_eq!(last_two[1].content, "  end", "Second to last should be '  end'");
1850        assert_eq!(last_two[1].source, LineSource::Base, "Second to last should be Base");
1851
1852        // Check that the added "end" (end of new test) is Committed
1853        let added_end = lines.iter().find(|l| l.content == "    end" && l.source == LineSource::Committed);
1854        assert!(added_end.is_some(), "Should have a committed '    end' line");
1855
1856        // CRITICAL: All 10 lines should be present
1857        assert_eq!(lines.len(), 10, "All 10 lines should be in the diff output");
1858    }
1859
1860    #[test]
1861    fn test_staging_changes_line_source_from_unstaged_to_staged() {
1862        let base = "line1\nline2";
1863        let modified = "line1\nline2\nline3";
1864
1865        // Before staging: change is only in working tree
1866        let diff_before = compute_four_way_diff(DiffInput {
1867            path: "test.txt",
1868            base: Some(base),
1869            head: Some(base),
1870            index: Some(base),      // index same as base
1871            working: Some(modified), // working has the change
1872            old_path: None,
1873        });
1874
1875        let unstaged_lines: Vec<_> = diff_before.lines.iter()
1876            .filter(|l| l.source == LineSource::Unstaged && l.content == "line3")
1877            .collect();
1878        assert_eq!(unstaged_lines.len(), 1, "line3 should be Unstaged before staging");
1879
1880        // After staging: change is in index and working tree
1881        let diff_after = compute_four_way_diff(DiffInput {
1882            path: "test.txt",
1883            base: Some(base),
1884            head: Some(base),
1885            index: Some(modified),   // index now has the change
1886            working: Some(modified), // working same as index
1887            old_path: None,
1888        });
1889
1890        let staged_lines: Vec<_> = diff_after.lines.iter()
1891            .filter(|l| l.source == LineSource::Staged && l.content == "line3")
1892            .collect();
1893        assert_eq!(staged_lines.len(), 1, "line3 should be Staged after staging");
1894
1895        // Verify line3 is NOT unstaged after staging
1896        let still_unstaged: Vec<_> = diff_after.lines.iter()
1897            .filter(|l| l.source == LineSource::Unstaged && l.content == "line3")
1898            .collect();
1899        assert_eq!(still_unstaged.len(), 0, "line3 should NOT be Unstaged after staging");
1900    }
1901
1902    #[test]
1903    fn test_unstaged_import_modification_shows_inline() {
1904        // Reproduces the bug: modifying an import line in working tree
1905        // should show as modified with inline highlighting, not as gray context
1906        let base = r#"use ratatui::{
1907    layout::Rect,
1908    style::{Color, Style},
1909    text::{Line, Span},
1910    widgets::{Block, Borders, Paragraph},
1911    Frame,
1912};"#;
1913
1914        let working = r#"use ratatui::{
1915    layout::Rect,
1916    style::{Color, Style},
1917    text::{Line, Span},
1918    widgets::{Block, Borders, Clear, Paragraph},
1919    Frame,
1920};"#;
1921
1922        let diff = compute_diff_with_inline(
1923            "test.rs",
1924            Some(base),
1925            Some(base),
1926            Some(base),
1927            Some(working),
1928        );
1929        let lines = content_lines(&diff);
1930
1931        // Find the modified line
1932        let modified_line = lines.iter()
1933            .find(|l| l.content.contains("Clear"));
1934        assert!(modified_line.is_some(), "Should have a line containing 'Clear'");
1935
1936        let modified = modified_line.unwrap();
1937
1938        // Key assertions: the line should have old_content and inline_spans
1939        assert!(modified.old_content.is_some(),
1940            "Modified import line should have old_content set, but it was None. \
1941            Source is {:?}, change_source is {:?}",
1942            modified.source, modified.change_source);
1943
1944        assert!(!modified.inline_spans.is_empty(),
1945            "Modified import line should have inline spans showing 'Clear, ' insertion. \
1946            Source is {:?}, change_source is {:?}",
1947            modified.source, modified.change_source);
1948
1949        // The change should be marked as Unstaged
1950        assert_eq!(modified.change_source, Some(LineSource::Unstaged),
1951            "Modification should be marked as Unstaged");
1952    }
1953
1954    #[test]
1955    fn test_unstaged_modification_plus_addition() {
1956        // More realistic: modification early in file + addition later
1957        // This matches the actual bug scenario where line 12 (import modification)
1958        // shows as gray but line 158 (addition) shows as yellow
1959        let base = r#"line 1
1960line 2
1961widgets::{Block, Borders, Paragraph},
1962line 4
1963line 5
1964line 6
1965line 7
1966line 8
1967render_widget(paragraph)"#;
1968
1969        let working = r#"line 1
1970line 2
1971widgets::{Block, Borders, Clear, Paragraph},
1972line 4
1973line 5
1974line 6
1975line 7
1976line 8
1977render_widget(Clear);
1978render_widget(paragraph)"#;
1979
1980        let diff = compute_diff_with_inline(
1981            "test.rs",
1982            Some(base),
1983            Some(base),
1984            Some(base),
1985            Some(working),
1986        );
1987        let lines = content_lines(&diff);
1988
1989        // Check the modification (line 3)
1990        let modified_line = lines.iter()
1991            .find(|l| l.content.contains("Clear, Paragraph"));
1992        assert!(modified_line.is_some(), "Should have modified line with 'Clear, Paragraph'");
1993
1994        let modified = modified_line.unwrap();
1995        assert!(modified.old_content.is_some(),
1996            "Modified line should have old_content set. Source: {:?}, change_source: {:?}",
1997            modified.source, modified.change_source);
1998        assert_eq!(modified.change_source, Some(LineSource::Unstaged),
1999            "Modification should be marked as Unstaged");
2000
2001        // Check the addition (new line)
2002        let added_line = lines.iter()
2003            .find(|l| l.content == "render_widget(Clear);");
2004        assert!(added_line.is_some(), "Should have added line 'render_widget(Clear);'");
2005
2006        let added = added_line.unwrap();
2007        assert_eq!(added.source, LineSource::Unstaged,
2008            "Pure addition should have source Unstaged");
2009        assert_eq!(added.prefix, '+', "Addition should have + prefix");
2010    }
2011
2012    #[test]
2013    fn test_exact_diff_view_scenario() {
2014        // Exact reproduction of the diff_view.rs scenario
2015        // Line 12: widgets::{Block, Borders, Paragraph}, -> widgets::{Block, Borders, Clear, Paragraph},
2016        // Line 158: addition of frame.render_widget(Clear, self.area);
2017        let base = r#"//! Diff view rendering with pure data model separation.
2018//!
2019//! The DiffViewModel provides a pure view model for rendering, enabling
2020//! easier unit testing without requiring a full App instance.
2021
2022use std::collections::HashSet;
2023
2024use ratatui::{
2025    layout::Rect,
2026    style::{Color, Style},
2027    text::{Line, Span},
2028    widgets::{Block, Borders, Paragraph},
2029    Frame,
2030};
2031
2032use crate::app::{App, DisplayableItem, FrameContext, Selection};
2033
2034// ... 140 lines of code ...
2035
2036        let block = Block::default()
2037            .title(title)
2038            .borders(Borders::ALL)
2039            .border_style(Style::default().fg(Color::DarkGray));
2040
2041        let paragraph = Paragraph::new(all_lines).block(block);
2042        frame.render_widget(paragraph, self.area);"#;
2043
2044        let working = r#"//! Diff view rendering with pure data model separation.
2045//!
2046//! The DiffViewModel provides a pure view model for rendering, enabling
2047//! easier unit testing without requiring a full App instance.
2048
2049use std::collections::HashSet;
2050
2051use ratatui::{
2052    layout::Rect,
2053    style::{Color, Style},
2054    text::{Line, Span},
2055    widgets::{Block, Borders, Clear, Paragraph},
2056    Frame,
2057};
2058
2059use crate::app::{App, DisplayableItem, FrameContext, Selection};
2060
2061// ... 140 lines of code ...
2062
2063        let block = Block::default()
2064            .title(title)
2065            .borders(Borders::ALL)
2066            .border_style(Style::default().fg(Color::DarkGray));
2067
2068        frame.render_widget(Clear, self.area);
2069        let paragraph = Paragraph::new(all_lines).block(block);
2070        frame.render_widget(paragraph, self.area);"#;
2071
2072        let diff = compute_diff_with_inline(
2073            "diff_view.rs",
2074            Some(base),
2075            Some(base),
2076            Some(base),
2077            Some(working),
2078        );
2079        let lines = content_lines(&diff);
2080
2081        // Debug: print all lines with their sources
2082        for (i, line) in lines.iter().enumerate() {
2083            if line.content.contains("Clear") || line.content.contains("widgets") {
2084                eprintln!("Line {}: source={:?}, change_source={:?}, old_content={}, content='{}'",
2085                    i, line.source, line.change_source, line.old_content.is_some(), line.content);
2086            }
2087        }
2088
2089        // Check the import modification (line 12 in real file)
2090        let modified_import = lines.iter()
2091            .find(|l| l.content.contains("Clear, Paragraph"));
2092        assert!(modified_import.is_some(), "Should have modified import line");
2093
2094        let modified = modified_import.unwrap();
2095        assert!(modified.old_content.is_some(),
2096            "Modified import should have old_content. Source: {:?}, change_source: {:?}, prefix: '{}'",
2097            modified.source, modified.change_source, modified.prefix);
2098
2099        // Check the addition (line 158 in real file)
2100        let added_line = lines.iter()
2101            .find(|l| l.content.contains("render_widget(Clear"));
2102        assert!(added_line.is_some(), "Should have added render_widget line");
2103
2104        let added = added_line.unwrap();
2105        assert_eq!(added.prefix, '+', "Addition should have + prefix");
2106    }
2107}