Skip to main content

oo_ide/
vcs.rs

1//! VCS abstraction layer.
2//!
3//! The [`Vcs`] trait is synchronous — it is always called from within
4//! `tokio::task::spawn_blocking`. [`GitVcs`] is the `git2`-backed implementation.
5
6use std::fmt;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10// ---------------------------------------------------------------------------
11// Shared data types
12// ---------------------------------------------------------------------------
13
14#[derive(Debug, Clone)]
15pub struct StatusEntry {
16    pub path: PathBuf,
17    pub staged: bool,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum DiffKind {
22    Context,
23    Added,
24    Removed,
25    /// `@@` hunk header — not a real file line; used as a section separator.
26    HunkHeader,
27}
28
29#[derive(Debug, Clone)]
30pub struct DiffLine {
31    /// Line number in the new (workdir) file, if applicable.
32    pub line_no: Option<usize>,
33    pub kind: DiffKind,
34    pub content: String,
35}
36
37/// A single commit summary entry used by the git history viewer.
38#[derive(Debug, Clone)]
39pub struct CommitInfo {
40    /// Full 40-character hex OID.
41    pub oid: String,
42    pub summary: String,
43    pub author_name: String,
44    pub date_relative: String,
45}
46
47/// Change status for a file in a commit.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum FileChangeStatus {
50    Modified,
51    Added,
52    Deleted,
53    Renamed,
54}
55
56impl FileChangeStatus {
57    pub fn indicator(&self) -> &'static str {
58        match self {
59            Self::Modified => "M",
60            Self::Added => "A",
61            Self::Deleted => "D",
62            Self::Renamed => "R",
63        }
64    }
65}
66
67/// A file changed within a commit.
68#[derive(Debug, Clone)]
69pub struct FileChange {
70    pub path: PathBuf,
71    pub status: FileChangeStatus,
72}
73
74// ---------------------------------------------------------------------------
75// Trait
76// ---------------------------------------------------------------------------
77
78/// Backend-agnostic VCS interface.
79///
80/// All methods are synchronous; call them from `tokio::task::spawn_blocking`.
81/// `Send` is required so the implementation can be moved into blocking tasks.
82/// `Sync` is NOT required — if an async backend is added later, wrap in
83/// `Arc<Mutex<dyn Vcs>>` at the call site.
84pub trait Vcs: Send {
85    /// Returns `(staged, unstaged)` file lists.
86    fn status(&self) -> anyhow::Result<(Vec<StatusEntry>, Vec<StatusEntry>)>;
87
88    /// Diff for a single file.
89    /// `staged = true`  → HEAD ↔ index (what would be committed).
90    /// `staged = false` → index ↔ workdir (what is not yet staged).
91    fn diff_file(&self, path: &Path, staged: bool) -> anyhow::Result<Vec<DiffLine>>;
92
93    fn stage_file(&self, path: &Path) -> anyhow::Result<()>;
94    fn unstage_file(&self, path: &Path) -> anyhow::Result<()>;
95
96    /// Stage only the selected lines (by index into `diff`).
97    /// Uses direct git2 index blob manipulation — no subprocess required.
98    fn stage_lines(&self, path: &Path, selected: &[usize], diff: &[DiffLine])
99    -> anyhow::Result<()>;
100
101    /// Unstage only the selected lines (revert them to HEAD content in the index).
102    fn unstage_lines(
103        &self,
104        path: &Path,
105        selected: &[usize],
106        diff: &[DiffLine],
107    ) -> anyhow::Result<()>;
108
109    fn commit(&self, message: &str) -> anyhow::Result<()>;
110
111    /// Returns `(all_branches, current_branch)`.
112    fn branches(&self) -> anyhow::Result<(Vec<String>, String)>;
113
114    fn create_branch(&self, name: &str) -> anyhow::Result<()>;
115    fn checkout_branch(&self, name: &str) -> anyhow::Result<()>;
116
117    /// Return up to `max` commits reachable from HEAD, optionally filtered by
118    /// a case-insensitive substring that matches summary, author name, or OID.
119    fn log_commits(&self, max: usize, filter: &str) -> anyhow::Result<Vec<CommitInfo>>;
120
121    /// Return the list of files changed in the commit identified by the full
122    /// 40-char hex `oid`.
123    fn commit_files(&self, oid: &str) -> anyhow::Result<Vec<FileChange>>;
124
125    /// Return the diff for a single file within the commit identified by the
126    /// full 40-char hex `oid`.
127    fn commit_diff(&self, oid: &str, path: &Path) -> anyhow::Result<Vec<DiffLine>>;
128}
129
130// ---------------------------------------------------------------------------
131// GitVcs
132// ---------------------------------------------------------------------------
133
134pub struct GitVcs {
135    /// Root path — used for resolving relative paths.
136    pub root: PathBuf,
137    repo: git2::Repository,
138}
139
140// git2::Repository doesn't implement Debug.
141impl fmt::Debug for GitVcs {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        f.debug_struct("GitVcs")
144            .field("root", &self.root)
145            .finish_non_exhaustive()
146    }
147}
148
149impl GitVcs {
150    pub fn open(path: &Path) -> anyhow::Result<Self> {
151        let repo = git2::Repository::open(path)?;
152        Ok(Self {
153            root: path.to_owned(),
154            repo,
155        })
156    }
157}
158
159impl Vcs for GitVcs {
160    // -----------------------------------------------------------------------
161    // status
162    // -----------------------------------------------------------------------
163
164    fn status(&self) -> anyhow::Result<(Vec<StatusEntry>, Vec<StatusEntry>)> {
165        let mut opts = git2::StatusOptions::new();
166        opts.include_untracked(true)
167            .recurse_untracked_dirs(true)
168            .include_ignored(false);
169
170        let statuses = self.repo.statuses(Some(&mut opts))?;
171
172        let mut staged = Vec::new();
173        let mut unstaged = Vec::new();
174
175        for entry in statuses.iter() {
176            let path = match entry.path() {
177                Some(p) => PathBuf::from(p),
178                None => continue,
179            };
180            let s = entry.status();
181
182            let is_staged = s.intersects(
183                git2::Status::INDEX_NEW
184                    | git2::Status::INDEX_MODIFIED
185                    | git2::Status::INDEX_DELETED
186                    | git2::Status::INDEX_RENAMED
187                    | git2::Status::INDEX_TYPECHANGE,
188            );
189            let is_unstaged = s.intersects(
190                git2::Status::WT_MODIFIED
191                    | git2::Status::WT_DELETED
192                    | git2::Status::WT_RENAMED
193                    | git2::Status::WT_TYPECHANGE
194                    | git2::Status::WT_NEW,
195            );
196
197            if is_staged {
198                staged.push(StatusEntry {
199                    path: path.clone(),
200                    staged: true,
201                });
202            }
203            if is_unstaged {
204                unstaged.push(StatusEntry {
205                    path,
206                    staged: false,
207                });
208            }
209        }
210
211        Ok((staged, unstaged))
212    }
213
214    // -----------------------------------------------------------------------
215    // diff_file
216    // -----------------------------------------------------------------------
217
218    fn diff_file(&self, path: &Path, staged: bool) -> anyhow::Result<Vec<DiffLine>> {
219        let mut diff_opts = git2::DiffOptions::new();
220        diff_opts
221            .pathspec(path.to_string_lossy().as_ref())
222            // Include the full file as context so callers can fold at will.
223            .context_lines(999_999);
224
225        let diff = if staged {
226            // HEAD tree ↔ index
227            let head_tree = self.repo.head().ok().and_then(|h| h.peel_to_tree().ok());
228            self.repo
229                .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))?
230        } else {
231            // index ↔ workdir  (include untracked handled below)
232            self.repo
233                .diff_index_to_workdir(None, Some(&mut diff_opts))?
234        };
235
236        let mut lines: Vec<DiffLine> = Vec::new();
237
238        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
239            let content = String::from_utf8_lossy(line.content())
240                .trim_end_matches('\n')
241                .trim_end_matches('\r')
242                .to_string();
243
244            match line.origin() {
245                '+' => {
246                    let line_no = line.new_lineno().map(|n| n as usize);
247                    lines.push(DiffLine {
248                        line_no,
249                        kind: DiffKind::Added,
250                        content,
251                    });
252                }
253                '-' => {
254                    let line_no = line.old_lineno().map(|n| n as usize);
255                    lines.push(DiffLine {
256                        line_no,
257                        kind: DiffKind::Removed,
258                        content,
259                    });
260                }
261                ' ' => {
262                    let line_no = line.new_lineno().map(|n| n as usize);
263                    lines.push(DiffLine {
264                        line_no,
265                        kind: DiffKind::Context,
266                        content,
267                    });
268                }
269                'H' => {
270                    // Hunk header — strip leading @@ prefix for display
271                    lines.push(DiffLine {
272                        line_no: None,
273                        kind: DiffKind::HunkHeader,
274                        content,
275                    });
276                }
277                _ => {} // file headers, binary markers, etc.
278            }
279            true
280        })?;
281
282        // Untracked (new) files produce no diff against the index.
283        // Read the file content directly and present it as all-Added lines.
284        if !staged && lines.is_empty() {
285            let abs = self.root.join(path);
286            let in_index = self
287                .repo
288                .index()
289                .ok()
290                .and_then(|idx| idx.get_path(path, 0))
291                .is_some();
292            if abs.exists()
293                && !in_index
294                && let Ok(content) = fs::read_to_string(&abs)
295            {
296                lines.push(DiffLine {
297                    line_no: None,
298                    kind: DiffKind::HunkHeader,
299                    content: "@@ new file @@".to_string(),
300                });
301                for (i, line_content) in content.lines().enumerate() {
302                    lines.push(DiffLine {
303                        line_no: Some(i + 1),
304                        kind: DiffKind::Added,
305                        content: line_content.to_owned(),
306                    });
307                }
308            }
309        }
310
311        Ok(lines)
312    }
313
314    // -----------------------------------------------------------------------
315    // stage_file / unstage_file
316    // -----------------------------------------------------------------------
317
318    fn stage_file(&self, path: &Path) -> anyhow::Result<()> {
319        let mut index = self.repo.index()?;
320        let abs = self.root.join(path);
321        if abs.exists() {
322            index.add_path(path)?;
323        } else {
324            // Deleted file — remove from index.
325            index.remove_path(path)?;
326        }
327        index.write()?;
328        Ok(())
329    }
330
331    fn unstage_file(&self, path: &Path) -> anyhow::Result<()> {
332        // Reset path in index to HEAD state.
333        match self.repo.head() {
334            Ok(head_ref) => {
335                let head_commit = head_ref.peel_to_commit()?;
336                let mut checkout_opts = git2::build::CheckoutBuilder::new();
337                checkout_opts.path(path).force();
338                self.repo
339                    .reset_default(Some(head_commit.as_object()), [path])?;
340            }
341            Err(_) => {
342                // No HEAD (initial repo) — just remove from index.
343                let mut index = self.repo.index()?;
344                index.remove_path(path)?;
345                index.write()?;
346            }
347        }
348        Ok(())
349    }
350
351    // -----------------------------------------------------------------------
352    // stage_lines / unstage_lines — direct index blob manipulation
353    // -----------------------------------------------------------------------
354
355    fn stage_lines(
356        &self,
357        path: &Path,
358        selected: &[usize],
359        diff: &[DiffLine],
360    ) -> anyhow::Result<()> {
361        // 1. Read current index content (or HEAD content if nothing is staged).
362        let index_lines = self.index_file_lines(path)?;
363
364        // 2. Build new index content by applying selected additions/removals.
365        let new_content = apply_selected_lines(&index_lines, diff, selected, true)?;
366
367        // 3. Write blob back to index.
368        self.write_index_blob(path, &new_content)
369    }
370
371    fn unstage_lines(
372        &self,
373        path: &Path,
374        selected: &[usize],
375        diff: &[DiffLine],
376    ) -> anyhow::Result<()> {
377        // Same as stage_lines but inverse — reverts selected lines to HEAD.
378        let index_lines = self.index_file_lines(path)?;
379        let new_content = apply_selected_lines(&index_lines, diff, selected, false)?;
380        self.write_index_blob(path, &new_content)
381    }
382
383    // -----------------------------------------------------------------------
384    // commit
385    // -----------------------------------------------------------------------
386
387    fn commit(&self, message: &str) -> anyhow::Result<()> {
388        let sig = self.repo.signature()?;
389        let mut index = self.repo.index()?;
390        let tree_oid = index.write_tree()?;
391        let tree = self.repo.find_tree(tree_oid)?;
392
393        match self.repo.head() {
394            Ok(head_ref) => {
395                let parent = head_ref.peel_to_commit()?;
396                self.repo
397                    .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
398            }
399            Err(_) => {
400                // Initial commit — no parent.
401                self.repo
402                    .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?;
403            }
404        }
405        Ok(())
406    }
407
408    // -----------------------------------------------------------------------
409    // branches
410    // -----------------------------------------------------------------------
411
412    fn branches(&self) -> anyhow::Result<(Vec<String>, String)> {
413        let mut names = Vec::new();
414        for branch in self.repo.branches(Some(git2::BranchType::Local))? {
415            let (b, _) = branch?;
416            if let Some(name) = b.name()? {
417                names.push(name.to_owned());
418            }
419        }
420        names.sort();
421
422        let current = self
423            .repo
424            .head()
425            .ok()
426            .and_then(|h| h.shorthand().map(|s| s.to_owned()))
427            .unwrap_or_else(|| "(detached)".to_owned());
428
429        Ok((names, current))
430    }
431
432    fn create_branch(&self, name: &str) -> anyhow::Result<()> {
433        let head = self.repo.head()?.peel_to_commit()?;
434        self.repo.branch(name, &head, false)?;
435        Ok(())
436    }
437
438    fn checkout_branch(&self, name: &str) -> anyhow::Result<()> {
439        let obj = self.repo.revparse_single(&format!("refs/heads/{}", name))?;
440        let mut checkout = git2::build::CheckoutBuilder::new();
441        checkout.safe();
442        self.repo.checkout_tree(&obj, Some(&mut checkout))?;
443        self.repo.set_head(&format!("refs/heads/{}", name))?;
444        Ok(())
445    }
446
447    // -----------------------------------------------------------------------
448    // log_commits
449    // -----------------------------------------------------------------------
450
451    fn log_commits(&self, max: usize, filter: &str) -> anyhow::Result<Vec<CommitInfo>> {
452        let mut revwalk = self.repo.revwalk()?;
453        revwalk.push_head()?;
454        revwalk.set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)?;
455
456        let filter_lower = filter.to_lowercase();
457        let mut commits = Vec::new();
458
459        for oid_result in revwalk {
460            if commits.len() >= max {
461                break;
462            }
463            let oid = oid_result?;
464            let commit = self.repo.find_commit(oid)?;
465
466            let summary = commit.summary().unwrap_or("").to_owned();
467            let author_name = commit.author().name().unwrap_or("").to_owned();
468            let oid_str = oid.to_string();
469
470            if !filter_lower.is_empty() {
471                let matches = summary.to_lowercase().contains(&filter_lower)
472                    || author_name.to_lowercase().contains(&filter_lower)
473                    || oid_str.contains(&filter_lower);
474                if !matches {
475                    continue;
476                }
477            }
478
479            let date_relative = format_relative_time(commit.time().seconds());
480            commits.push(CommitInfo {
481                oid: oid_str,
482                summary,
483                author_name,
484                date_relative,
485            });
486        }
487
488        Ok(commits)
489    }
490
491    // -----------------------------------------------------------------------
492    // commit_files
493    // -----------------------------------------------------------------------
494
495    fn commit_files(&self, oid: &str) -> anyhow::Result<Vec<FileChange>> {
496        let oid = git2::Oid::from_str(oid)?;
497        let commit = self.repo.find_commit(oid)?;
498        let tree = commit.tree()?;
499
500        let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
501
502        let mut diff_opts = git2::DiffOptions::new();
503        let diff = self
504            .repo
505            .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
506
507        let mut files = Vec::new();
508        diff.foreach(
509            &mut |delta, _progress| {
510                let status = match delta.status() {
511                    git2::Delta::Added | git2::Delta::Untracked => FileChangeStatus::Added,
512                    git2::Delta::Deleted => FileChangeStatus::Deleted,
513                    git2::Delta::Renamed | git2::Delta::Copied => FileChangeStatus::Renamed,
514                    _ => FileChangeStatus::Modified,
515                };
516                let path = delta
517                    .new_file()
518                    .path()
519                    .or_else(|| delta.old_file().path())
520                    .map(PathBuf::from)
521                    .unwrap_or_default();
522                files.push(FileChange { path, status });
523                true
524            },
525            None,
526            None,
527            None,
528        )?;
529
530        Ok(files)
531    }
532
533    // -----------------------------------------------------------------------
534    // commit_diff
535    // -----------------------------------------------------------------------
536
537    fn commit_diff(&self, oid: &str, path: &Path) -> anyhow::Result<Vec<DiffLine>> {
538        let oid = git2::Oid::from_str(oid)?;
539        let commit = self.repo.find_commit(oid)?;
540        let tree = commit.tree()?;
541
542        let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
543
544        let mut diff_opts = git2::DiffOptions::new();
545        diff_opts
546            .pathspec(path.to_string_lossy().as_ref())
547            .context_lines(999_999);
548
549        let diff = self
550            .repo
551            .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
552
553        let mut lines: Vec<DiffLine> = Vec::new();
554        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
555            let content = String::from_utf8_lossy(line.content())
556                .trim_end_matches('\n')
557                .trim_end_matches('\r')
558                .to_string();
559            match line.origin() {
560                '+' => lines.push(DiffLine {
561                    line_no: line.new_lineno().map(|n| n as usize),
562                    kind: DiffKind::Added,
563                    content,
564                }),
565                '-' => lines.push(DiffLine {
566                    line_no: line.old_lineno().map(|n| n as usize),
567                    kind: DiffKind::Removed,
568                    content,
569                }),
570                ' ' => lines.push(DiffLine {
571                    line_no: line.new_lineno().map(|n| n as usize),
572                    kind: DiffKind::Context,
573                    content,
574                }),
575                'H' => lines.push(DiffLine {
576                    line_no: None,
577                    kind: DiffKind::HunkHeader,
578                    content,
579                }),
580                _ => {}
581            }
582            true
583        })?;
584
585        Ok(lines)
586    }
587}
588
589// ---------------------------------------------------------------------------
590// GitVcs helpers
591// ---------------------------------------------------------------------------
592
593impl GitVcs {
594    /// Return the current index content for `path` as lines, falling back to
595    /// HEAD content if the path isn't yet in the index, or empty for new files.
596    fn index_file_lines(&self, path: &Path) -> anyhow::Result<Vec<String>> {
597        let index = self.repo.index()?;
598        if let Some(entry) = index.get_path(path, 0) {
599            let blob = self.repo.find_blob(entry.id)?;
600            let text = std::str::from_utf8(blob.content())?.to_owned();
601            return Ok(split_lines(&text));
602        }
603        // Try HEAD tree.
604        if let Ok(head) = self.repo.head()
605            && let Ok(tree) = head.peel_to_tree()
606            && let Ok(entry) = tree.get_path(path)
607            && let Ok(obj) = entry.to_object(&self.repo)
608            && let Some(blob) = obj.as_blob()
609        {
610            let text = std::str::from_utf8(blob.content())?.to_owned();
611            return Ok(split_lines(&text));
612        }
613        Ok(Vec::new())
614    }
615
616    /// Write `content` as a blob into the index for `path`.
617    fn write_index_blob(&self, path: &Path, content: &str) -> anyhow::Result<()> {
618        let oid = self.repo.blob(content.as_bytes())?;
619        let mut index = self.repo.index()?;
620
621        // Build an IndexEntry — copy the existing one if present, else synthesise.
622        let mut entry = index.get_path(path, 0).unwrap_or_else(|| git2::IndexEntry {
623            ctime: git2::IndexTime::new(0, 0),
624            mtime: git2::IndexTime::new(0, 0),
625            dev: 0,
626            ino: 0,
627            mode: 0o100644,
628            uid: 0,
629            gid: 0,
630            file_size: 0,
631            id: git2::Oid::zero(),
632            flags: 0,
633            flags_extended: 0,
634            path: path.to_string_lossy().as_bytes().to_vec(),
635        });
636        entry.id = oid;
637        entry.file_size = content.len() as u32;
638
639        index.add(&entry)?;
640        index.write()?;
641        Ok(())
642    }
643}
644
645// ---------------------------------------------------------------------------
646// Line-level patch helpers
647// ---------------------------------------------------------------------------
648
649/// Apply (or reverse-apply) selected diff lines to `base_lines`.
650///
651/// `apply = true`  → stage: add/remove the selected changes into the base.
652/// `apply = false` → unstage: revert the selected changes (keep the rest).
653fn apply_selected_lines(
654    base_lines: &[String],
655    diff: &[DiffLine],
656    selected: &[usize],
657    apply: bool,
658) -> anyhow::Result<String> {
659    let selected_set: std::collections::HashSet<usize> = selected.iter().copied().collect();
660
661    // We replay the diff against `base_lines`.
662    // base_pos tracks our position in base_lines.
663    let mut result: Vec<String> = Vec::new();
664    let mut base_pos: usize = 0;
665
666    for (i, dl) in diff.iter().enumerate() {
667        match &dl.kind {
668            DiffKind::HunkHeader => {} // not a real content line — skip
669            DiffKind::Context => {
670                // Consume one line from base unchanged.
671                if base_pos < base_lines.len() {
672                    result.push(base_lines[base_pos].clone());
673                    base_pos += 1;
674                }
675            }
676            DiffKind::Added => {
677                if apply && selected_set.contains(&i) {
678                    result.push(dl.content.clone());
679                } else if !apply && !selected_set.contains(&i) {
680                    // Keep already-staged lines that are NOT being unstaged.
681                    result.push(dl.content.clone());
682                }
683                // If not selected (stage) or selected (unstage), drop the line.
684            }
685            DiffKind::Removed => {
686                if apply && selected_set.contains(&i) {
687                    // Remove this line from base — skip consuming it.
688                    base_pos += 1;
689                } else {
690                    // Keep it.
691                    if base_pos < base_lines.len() {
692                        result.push(base_lines[base_pos].clone());
693                        base_pos += 1;
694                    }
695                }
696            }
697        }
698    }
699
700    // Append any remaining base lines.
701    while base_pos < base_lines.len() {
702        result.push(base_lines[base_pos].clone());
703        base_pos += 1;
704    }
705
706    Ok(result.join("\n") + "\n")
707}
708
709fn split_lines(text: &str) -> Vec<String> {
710    // Preserve last empty line correctly.
711    let lines: Vec<String> = text.lines().map(|l| l.to_owned()).collect();
712    // `str::lines` drops a trailing newline; re-add empty last element if needed.
713    if text.ends_with('\n') && !lines.is_empty() {
714        // nothing — lines() already stripped the final newline token
715    }
716    lines
717}
718
719/// Format a Unix timestamp as a human-readable relative time string.
720fn format_relative_time(unix_secs: i64) -> String {
721    let now = std::time::SystemTime::now()
722        .duration_since(std::time::UNIX_EPOCH)
723        .map(|d| d.as_secs() as i64)
724        .unwrap_or(0);
725    let delta = now.saturating_sub(unix_secs);
726    if delta < 60 {
727        "just now".to_owned()
728    } else if delta < 3_600 {
729        format!("{}m ago", delta / 60)
730    } else if delta < 86_400 {
731        format!("{}h ago", delta / 3_600)
732    } else if delta < 86_400 * 30 {
733        format!("{}d ago", delta / 86_400)
734    } else if delta < 86_400 * 365 {
735        format!("{}mo ago", delta / (86_400 * 30))
736    } else {
737        format!("{}y ago", delta / (86_400 * 365))
738    }
739}
740
741// ---------------------------------------------------------------------------
742// Tests
743// ---------------------------------------------------------------------------
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    #[test]
750    fn test_apply_selected_add() {
751        let base = vec!["line1".to_owned(), "line2".to_owned()];
752        let diff = vec![
753            DiffLine {
754                line_no: Some(1),
755                kind: DiffKind::Context,
756                content: "line1".into(),
757            },
758            DiffLine {
759                line_no: Some(2),
760                kind: DiffKind::Added,
761                content: "new".into(),
762            },
763            DiffLine {
764                line_no: Some(3),
765                kind: DiffKind::Context,
766                content: "line2".into(),
767            },
768        ];
769        let result = apply_selected_lines(&base, &diff, &[1], true).unwrap();
770        assert_eq!(result, "line1\nnew\nline2\n");
771    }
772
773    #[test]
774    fn test_apply_selected_remove() {
775        let base = vec!["line1".to_owned(), "old".to_owned(), "line2".to_owned()];
776        let diff = vec![
777            DiffLine {
778                line_no: Some(1),
779                kind: DiffKind::Context,
780                content: "line1".into(),
781            },
782            DiffLine {
783                line_no: None,
784                kind: DiffKind::Removed,
785                content: "old".into(),
786            },
787            DiffLine {
788                line_no: Some(2),
789                kind: DiffKind::Context,
790                content: "line2".into(),
791            },
792        ];
793        let result = apply_selected_lines(&base, &diff, &[1], true).unwrap();
794        assert_eq!(result, "line1\nline2\n");
795    }
796}