Skip to main content

parley/git/
diff.rs

1use crate::domain::config::AppConfig;
2use crate::domain::diff::{DiffDocument, DiffFile, DiffHunk, DiffLine, DiffLineKind};
3use anyhow::{Context, Result, anyhow};
4use git2::{Commit, DiffFormat, DiffOptions, Repository};
5use std::collections::BTreeSet;
6use std::path::{Component, Path, PathBuf};
7use tokio::fs;
8use tokio::task::spawn_blocking;
9use tracing::{debug, info};
10
11const MAX_ROOT_FILE_PREVIEW_BYTES: u64 = 2 * 1024 * 1024;
12const MAX_ROOT_FILE_PREVIEW_LINES: usize = 20_000;
13
14#[derive(Debug, Clone, PartialEq, Eq, Default)]
15pub enum DiffSource {
16    #[default]
17    WorkingTree,
18    RootDirectory,
19    Commit {
20        rev: String,
21    },
22    Range {
23        base: String,
24        head: String,
25    },
26}
27
28impl DiffSource {
29    #[must_use]
30    pub fn working_tree() -> Self {
31        Self::WorkingTree
32    }
33}
34
35/// # Errors
36///
37/// Returns an error when the git repository cannot be discovered, the requested revision cannot be
38/// resolved, the diff cannot be rendered, or the rendered patch cannot be parsed.
39pub async fn load_git_diff(config: &AppConfig, source: &DiffSource) -> Result<DiffDocument> {
40    debug!(?source, "loading git diff");
41    let document = match source {
42        DiffSource::RootDirectory => load_root_directory_document(config).await?,
43        _ => {
44            let source_for_worker = source.clone();
45            let config = config.clone();
46            spawn_blocking(move || load_git_diff_sync(&config, &source_for_worker))
47                .await
48                .context("failed to join git diff worker task")??
49        }
50    };
51    info!(files = document.files.len(), ?source, "git diff loaded");
52    Ok(document)
53}
54
55/// # Errors
56///
57/// Returns an error for the same repository discovery, diff rendering, and parsing failures as
58/// [`load_git_diff`].
59pub async fn load_git_diff_head(config: &AppConfig) -> Result<DiffDocument> {
60    load_git_diff(config, &DiffSource::WorkingTree).await
61}
62
63/// # Errors
64///
65/// Returns an error when the git repository cannot be discovered or root paths cannot be listed.
66pub async fn load_root_directory_file_list(config: &AppConfig) -> Result<DiffDocument> {
67    let (_workdir, source_paths) = collect_root_directory_source_paths(config).await?;
68    let files = source_paths
69        .iter()
70        .map(|path| root_directory_placeholder_file(path))
71        .collect();
72    Ok(DiffDocument { files })
73}
74
75/// # Errors
76///
77/// Returns an error when the git repository cannot be discovered, the path cannot be inspected, or
78/// the file cannot be read.
79pub async fn load_root_directory_file(
80    config: &AppConfig,
81    relative_path: String,
82) -> Result<Option<DiffFile>> {
83    let Some(relative_path) = safe_root_relative_path(&relative_path) else {
84        return Ok(None);
85    };
86    let workdir = spawn_blocking(|| {
87        let repo = Repository::discover(".").context("failed to discover git repository")?;
88        let workdir = repo
89            .workdir()
90            .context("root directory reviews require a non-bare git repository")?;
91        Ok::<_, anyhow::Error>(workdir.to_path_buf())
92    })
93    .await
94    .context("failed to resolve root workdir")??;
95
96    let filtered = spawn_blocking({
97        let config = config.clone();
98        let relative_path = relative_path.clone();
99        move || filter_paths_for_root_directory(&config, vec![relative_path])
100    })
101    .await
102    .context("failed to filter root file path")??;
103    if filtered.is_empty() {
104        return Ok(None);
105    }
106
107    root_directory_file(&workdir, &relative_path).await
108}
109
110fn load_git_diff_sync(config: &AppConfig, source: &DiffSource) -> Result<DiffDocument> {
111    let repo = Repository::discover(".").context("failed to discover git repository")?;
112    load_git_diff_for_repo(&repo, config, source)
113}
114
115fn load_git_diff_for_repo(
116    repo: &Repository,
117    config: &AppConfig,
118    source: &DiffSource,
119) -> Result<DiffDocument> {
120    if matches!(source, DiffSource::RootDirectory) {
121        return Err(anyhow!(
122            "root directory reviews must use the async root directory loader"
123        ));
124    }
125
126    let text = load_diff_text(repo, source)?;
127    let mut document = parse_unified_diff(&text)?;
128    let ignore_repo = matches!(source, DiffSource::WorkingTree).then_some(repo);
129    filter_ignored_files(&mut document, config, ignore_repo)?;
130    Ok(document)
131}
132
133fn load_diff_text(repo: &Repository, source: &DiffSource) -> Result<String> {
134    let mut diff_opts = DiffOptions::new();
135    diff_opts.context_lines(3).include_typechange(true);
136
137    let diff = match source {
138        DiffSource::WorkingTree => {
139            configure_worktree_diff_options(&mut diff_opts);
140            let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
141            repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))
142                .context("failed to compute repository diff")?
143        }
144        DiffSource::RootDirectory => return Ok(String::new()),
145        DiffSource::Commit { rev } => {
146            let commit = resolve_commit(repo, rev)?;
147            let new_tree = commit.tree().context("failed to read commit tree")?;
148            let old_tree = commit
149                .parent(0)
150                .ok()
151                .map(|parent| parent.tree().context("failed to read parent tree"))
152                .transpose()?;
153            repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut diff_opts))
154                .with_context(|| format!("failed to diff commit {rev}"))?
155        }
156        DiffSource::Range { base, head } => {
157            let base_tree = resolve_commit(repo, base)?
158                .tree()
159                .with_context(|| format!("failed to read base tree for {base}"))?;
160            let head_tree = resolve_commit(repo, head)?
161                .tree()
162                .with_context(|| format!("failed to read head tree for {head}"))?;
163            repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut diff_opts))
164                .with_context(|| format!("failed to diff range {base}..{head}"))?
165        }
166    };
167
168    render_diff_text(diff)
169}
170
171async fn load_root_directory_document(config: &AppConfig) -> Result<DiffDocument> {
172    let (workdir, source_paths) = collect_root_directory_source_paths(config).await?;
173
174    let mut files = Vec::new();
175    for path in source_paths {
176        if let Some(file) = root_directory_file(&workdir, &path).await? {
177            files.push(file);
178        }
179    }
180
181    Ok(DiffDocument { files })
182}
183
184async fn collect_root_directory_source_paths(
185    config: &AppConfig,
186) -> Result<(PathBuf, BTreeSet<PathBuf>)> {
187    let (workdir, mut paths) = spawn_blocking({
188        let config = config.clone();
189        move || {
190            let repo = Repository::discover(".").context("failed to discover git repository")?;
191            let workdir = repo
192                .workdir()
193                .context("root directory reviews require a non-bare git repository")?;
194            let tracked = tracked_file_paths(&repo)?;
195            let _ = config;
196            Ok::<_, anyhow::Error>((workdir.to_path_buf(), tracked))
197        }
198    })
199    .await
200    .context("failed to collect tracked root paths")??;
201
202    collect_untracked_file_paths(&workdir, workdir.as_path(), config, &mut paths).await?;
203
204    let candidate_paths = {
205        let mut candidate_paths = Vec::with_capacity(paths.len());
206        candidate_paths.extend(paths);
207        candidate_paths
208    };
209    let source_paths = spawn_blocking({
210        let config = config.clone();
211        move || filter_paths_for_root_directory(&config, candidate_paths)
212    })
213    .await
214    .context("failed to filter git-aware root directory paths")??;
215
216    Ok((workdir, source_paths))
217}
218
219fn filter_paths_for_root_directory(
220    config: &AppConfig,
221    mut paths: Vec<PathBuf>,
222) -> Result<BTreeSet<PathBuf>> {
223    let repo = Repository::discover(".").context("failed to discover git repository")?;
224    let mut filtered_paths = BTreeSet::new();
225    for path in paths.drain(..) {
226        if should_ignore_file(path.to_string_lossy().as_ref(), config, Some(&repo))? {
227            continue;
228        }
229        filtered_paths.insert(path);
230    }
231    Ok(filtered_paths)
232}
233
234fn tracked_file_paths(repo: &Repository) -> Result<BTreeSet<PathBuf>> {
235    let index = repo.index().context("failed to read git index")?;
236    let mut paths = BTreeSet::new();
237    for entry in index.iter() {
238        let path = std::str::from_utf8(&entry.path).context("git index path is not utf-8")?;
239        paths.insert(PathBuf::from(path));
240    }
241    Ok(paths)
242}
243
244async fn collect_untracked_file_paths(
245    workdir: &Path,
246    dir: &Path,
247    config: &AppConfig,
248    paths: &mut BTreeSet<PathBuf>,
249) -> Result<()> {
250    let mut entries = fs::read_dir(dir)
251        .await
252        .with_context(|| format!("failed to read {}", dir.display()))?;
253    while let Some(entry) = entries
254        .next_entry()
255        .await
256        .with_context(|| format!("failed to read entry in {}", dir.display()))?
257    {
258        let path = entry.path();
259        let relative_path = path
260            .strip_prefix(workdir)
261            .with_context(|| format!("failed to relativize {}", path.display()))?;
262
263        if should_skip_root_directory_path(relative_path, config) {
264            continue;
265        }
266
267        let file_type = entry
268            .file_type()
269            .await
270            .with_context(|| format!("failed to inspect {}", path.display()))?;
271        if file_type.is_dir() {
272            Box::pin(collect_untracked_file_paths(workdir, &path, config, paths)).await?;
273            continue;
274        }
275        if !file_type.is_file() {
276            continue;
277        }
278        paths.insert(relative_path.to_path_buf());
279    }
280    Ok(())
281}
282
283async fn root_directory_file(workdir: &Path, relative_path: &Path) -> Result<Option<DiffFile>> {
284    let path = workdir.join(relative_path);
285    let metadata = match fs::metadata(&path).await {
286        Ok(metadata) => metadata,
287        Err(error) => {
288            if error.kind() == std::io::ErrorKind::NotFound {
289                return Ok(None);
290            }
291            return Err(error).with_context(|| format!("failed to inspect {}", path.display()));
292        }
293    };
294    if !metadata.is_file() {
295        return Ok(None);
296    }
297    let display_path = normalize_relative_path(relative_path);
298    if metadata.len() > MAX_ROOT_FILE_PREVIEW_BYTES {
299        return Ok(Some(root_directory_large_file_preview(
300            &display_path,
301            metadata.len(),
302            "file is too large to preview",
303        )));
304    }
305
306    let bytes = fs::read(&path)
307        .await
308        .with_context(|| format!("failed to read {}", path.display()))?;
309    if bytes.contains(&0) {
310        return Ok(None);
311    }
312    let content = match String::from_utf8(bytes) {
313        Ok(content) => content,
314        Err(_) => return Ok(None),
315    };
316    if content
317        .lines()
318        .take(MAX_ROOT_FILE_PREVIEW_LINES + 1)
319        .count()
320        > MAX_ROOT_FILE_PREVIEW_LINES
321    {
322        return Ok(Some(root_directory_large_file_preview(
323            &display_path,
324            metadata.len(),
325            "file has too many lines to preview",
326        )));
327    }
328    Ok(Some(diff_file_from_content(&display_path, &content)))
329}
330
331fn root_directory_placeholder_file(relative_path: &Path) -> DiffFile {
332    let display_path = normalize_relative_path(relative_path);
333    DiffFile {
334        path: display_path.clone(),
335        header_lines: vec![format!("file {display_path}")],
336        hunks: Vec::new(),
337    }
338}
339
340fn safe_root_relative_path(path: &str) -> Option<PathBuf> {
341    let path = Path::new(path);
342    if path.is_absolute() {
343        return None;
344    }
345    let mut safe = PathBuf::new();
346    for component in path.components() {
347        let Component::Normal(value) = component else {
348            return None;
349        };
350        safe.push(value);
351    }
352    Some(safe)
353}
354
355fn diff_file_from_content(path: &str, content: &str) -> DiffFile {
356    let lines = content.lines().collect::<Vec<_>>();
357    let line_count = u32::try_from(lines.len()).unwrap_or(u32::MAX);
358    let mut hunk = DiffHunk {
359        old_start: 1,
360        old_count: line_count,
361        new_start: 1,
362        new_count: line_count,
363        header: format!("@@ -1,{line_count} +1,{line_count} @@"),
364        lines: Vec::with_capacity(lines.len() + 1),
365    };
366    hunk.lines.push(DiffLine {
367        kind: DiffLineKind::HunkHeader,
368        old_line: None,
369        new_line: None,
370        raw: hunk.header.clone(),
371        code: hunk.header.clone(),
372    });
373    for (index, line) in lines.into_iter().enumerate() {
374        let line_number = u32::try_from(index + 1).unwrap_or(u32::MAX);
375        hunk.lines.push(DiffLine {
376            kind: DiffLineKind::Context,
377            old_line: None,
378            new_line: Some(line_number),
379            raw: format!(" {line}"),
380            code: line.to_string(),
381        });
382    }
383
384    DiffFile {
385        path: path.to_string(),
386        header_lines: vec![format!("file {path}")],
387        hunks: vec![hunk],
388    }
389}
390
391fn root_directory_large_file_preview(path: &str, byte_len: u64, reason: &str) -> DiffFile {
392    let size = format_root_file_size(byte_len);
393    diff_file_from_content(
394        path,
395        &format!("{reason}; {size}. Use search or open the file directly."),
396    )
397}
398
399fn format_root_file_size(byte_len: u64) -> String {
400    const KIB: u64 = 1024;
401    const MIB: u64 = 1024 * 1024;
402    if byte_len >= MIB {
403        format!("{:.1} MiB", byte_len as f64 / MIB as f64)
404    } else if byte_len >= KIB {
405        format!("{:.1} KiB", byte_len as f64 / KIB as f64)
406    } else {
407        format!("{byte_len} B")
408    }
409}
410
411fn normalize_relative_path(path: &Path) -> String {
412    path.components()
413        .filter_map(|component| match component {
414            Component::Normal(value) => Some(value.to_string_lossy().into_owned()),
415            _ => None,
416        })
417        .collect::<Vec<_>>()
418        .join("/")
419}
420
421fn should_skip_root_directory_path(path: &Path, config: &AppConfig) -> bool {
422    let mut components = path.components();
423    let Some(Component::Normal(first)) = components.next() else {
424        return false;
425    };
426    if first == ".git" || first == "worktrees" {
427        return true;
428    }
429    config.ignore_parley_dir && first == ".parley"
430}
431
432fn configure_worktree_diff_options(diff_opts: &mut DiffOptions) {
433    diff_opts
434        .include_untracked(true)
435        .recurse_untracked_dirs(true)
436        .show_untracked_content(true);
437}
438
439fn render_diff_text(diff: git2::Diff<'_>) -> Result<String> {
440    let mut patch_bytes = Vec::new();
441    diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
442        match line.origin() {
443            '+' | '-' | ' ' => patch_bytes.push(line.origin() as u8),
444            _ => {}
445        }
446        patch_bytes.extend_from_slice(line.content());
447        true
448    })
449    .context("failed to render patch text")?;
450
451    Ok(String::from_utf8_lossy(&patch_bytes).into_owned())
452}
453
454fn resolve_commit<'repo>(repo: &'repo Repository, rev: &str) -> Result<Commit<'repo>> {
455    repo.revparse_single(rev)
456        .with_context(|| format!("failed to resolve revision {rev}"))?
457        .peel_to_commit()
458        .with_context(|| format!("revision {rev} does not resolve to a commit"))
459}
460
461/// # Errors
462///
463/// Returns an error when a hunk header is malformed or line numbers cannot be parsed.
464pub fn parse_unified_diff(text: &str) -> Result<DiffDocument> {
465    let mut files = Vec::new();
466
467    let mut current_file: Option<DiffFile> = None;
468    let mut current_hunk: Option<DiffHunk> = None;
469    let mut old_cursor: u32 = 0;
470    let mut new_cursor: u32 = 0;
471
472    for line in text.lines() {
473        if line.starts_with("diff --git ") {
474            if let Some(hunk) = current_hunk.take()
475                && let Some(file) = current_file.as_mut()
476            {
477                file.hunks.push(hunk);
478            }
479            if let Some(file) = current_file.take() {
480                files.push(file);
481            }
482            current_file = Some(DiffFile {
483                path: parse_diff_git_path(line).unwrap_or_default(),
484                header_lines: vec![line.to_string()],
485                hunks: Vec::new(),
486            });
487            continue;
488        }
489
490        if line.starts_with("@@") {
491            if current_file.is_none() {
492                current_file = Some(DiffFile {
493                    path: String::new(),
494                    header_lines: Vec::new(),
495                    hunks: Vec::new(),
496                });
497            }
498
499            if let Some(hunk) = current_hunk.take()
500                && let Some(file) = current_file.as_mut()
501            {
502                file.hunks.push(hunk);
503            }
504
505            let (old_start, old_count, new_start, new_count) = parse_hunk_header(line)?;
506            old_cursor = old_start;
507            new_cursor = new_start;
508
509            let mut hunk = DiffHunk {
510                old_start,
511                old_count,
512                new_start,
513                new_count,
514                header: line.to_string(),
515                lines: Vec::new(),
516            };
517            hunk.lines.push(DiffLine {
518                kind: DiffLineKind::HunkHeader,
519                old_line: None,
520                new_line: None,
521                raw: line.to_string(),
522                code: line.to_string(),
523            });
524            current_hunk = Some(hunk);
525            continue;
526        }
527
528        if let Some(file) = current_file.as_mut()
529            && current_hunk.is_none()
530        {
531            if line.starts_with("+++ ") {
532                if let Some(path) = parse_patch_path(line, "+++ ") {
533                    file.path = path;
534                }
535                file.header_lines.push(line.to_string());
536                continue;
537            }
538
539            if line.starts_with("--- ") {
540                if file.path.is_empty()
541                    && let Some(path) = parse_patch_path(line, "--- ")
542                {
543                    file.path = path;
544                }
545                file.header_lines.push(line.to_string());
546                continue;
547            }
548
549            file.header_lines.push(line.to_string());
550            continue;
551        }
552
553        if let Some(hunk) = current_hunk.as_mut() {
554            let parsed = if let Some(code) = line.strip_prefix('+') {
555                let line_value = DiffLine {
556                    kind: DiffLineKind::Added,
557                    old_line: None,
558                    new_line: Some(new_cursor),
559                    raw: line.to_string(),
560                    code: code.to_string(),
561                };
562                new_cursor += 1;
563                line_value
564            } else if let Some(code) = line.strip_prefix('-') {
565                let line_value = DiffLine {
566                    kind: DiffLineKind::Removed,
567                    old_line: Some(old_cursor),
568                    new_line: None,
569                    raw: line.to_string(),
570                    code: code.to_string(),
571                };
572                old_cursor += 1;
573                line_value
574            } else if let Some(code) = line.strip_prefix(' ') {
575                let line_value = DiffLine {
576                    kind: DiffLineKind::Context,
577                    old_line: Some(old_cursor),
578                    new_line: Some(new_cursor),
579                    raw: line.to_string(),
580                    code: code.to_string(),
581                };
582                old_cursor += 1;
583                new_cursor += 1;
584                line_value
585            } else {
586                DiffLine {
587                    kind: DiffLineKind::Meta,
588                    old_line: None,
589                    new_line: None,
590                    raw: line.to_string(),
591                    code: line.to_string(),
592                }
593            };
594
595            hunk.lines.push(parsed);
596        }
597    }
598
599    if let Some(hunk) = current_hunk.take()
600        && let Some(file) = current_file.as_mut()
601    {
602        file.hunks.push(hunk);
603    }
604
605    if let Some(file) = current_file.take() {
606        files.push(file);
607    }
608
609    Ok(DiffDocument { files })
610}
611
612fn filter_ignored_files(
613    document: &mut DiffDocument,
614    config: &AppConfig,
615    repo: Option<&Repository>,
616) -> Result<()> {
617    if !config.ignore_parley_dir && repo.is_none() {
618        return Ok(());
619    }
620
621    let mut retained = Vec::with_capacity(document.files.len());
622    for file in document.files.drain(..) {
623        if should_ignore_file(&file.path, config, repo)? {
624            continue;
625        }
626        retained.push(file);
627    }
628    document.files = retained;
629    Ok(())
630}
631
632fn is_parley_internal_path(path: &str) -> bool {
633    path == ".parley" || path.starts_with(".parley/")
634}
635
636fn should_ignore_file(path: &str, config: &AppConfig, repo: Option<&Repository>) -> Result<bool> {
637    if config.ignore_parley_dir && is_parley_internal_path(path) {
638        return Ok(true);
639    }
640
641    let Some(repo) = repo else {
642        return Ok(false);
643    };
644    repo.status_should_ignore(Path::new(path))
645        .with_context(|| format!("failed to evaluate gitignore rules for {path}"))
646}
647
648fn parse_hunk_header(line: &str) -> Result<(u32, u32, u32, u32)> {
649    let Some(rest) = line.strip_prefix("@@ -") else {
650        return Err(anyhow!("invalid hunk header format: {line}"));
651    };
652    let Some((left, right_tail)) = rest.split_once(" +") else {
653        return Err(anyhow!("invalid hunk header body: {line}"));
654    };
655    let Some((right, _tail)) = right_tail.split_once(" @@") else {
656        return Err(anyhow!("invalid hunk header end: {line}"));
657    };
658
659    let (old_start, old_count) = parse_range(left)?;
660    let (new_start, new_count) = parse_range(right)?;
661    Ok((old_start, old_count, new_start, new_count))
662}
663
664fn parse_range(value: &str) -> Result<(u32, u32)> {
665    if let Some((start, count)) = value.split_once(',') {
666        Ok((start.parse()?, count.parse()?))
667    } else {
668        Ok((value.parse()?, 1))
669    }
670}
671
672fn parse_patch_path(line: &str, marker: &str) -> Option<String> {
673    let raw = line.strip_prefix(marker)?.trim();
674    parse_diff_path(raw)
675}
676
677fn parse_diff_git_path(line: &str) -> Option<String> {
678    let raw = line.strip_prefix("diff --git ")?;
679    let (_, right) = split_diff_paths(raw)?;
680    parse_diff_path(right)
681}
682
683fn split_diff_paths(raw: &str) -> Option<(&str, &str)> {
684    let raw = raw.trim();
685    if raw.is_empty() {
686        return None;
687    }
688
689    if let Some(rest) = raw.strip_prefix('"') {
690        let end_left = rest.find('"')?;
691        let left = &raw[..=end_left + 1];
692        let rest = rest[end_left + 1..].trim_start();
693        let rest = rest.strip_prefix('"')?;
694        let end_right = rest.find('"')?;
695        let right = &rest[..=end_right];
696        return Some((left, right));
697    }
698
699    let (left, right) = raw.split_once(' ')?;
700    Some((left, right.trim_start()))
701}
702
703fn parse_diff_path(raw: &str) -> Option<String> {
704    let raw = raw.trim();
705    if raw == "/dev/null" {
706        return None;
707    }
708
709    let unquoted = raw
710        .strip_prefix('"')
711        .and_then(|v| v.strip_suffix('"'))
712        .unwrap_or(raw);
713    let normalized = unquoted
714        .strip_prefix("a/")
715        .or_else(|| unquoted.strip_prefix("b/"))
716        .unwrap_or(unquoted);
717    Some(normalized.to_string())
718}
719
720#[cfg(test)]
721async fn load_root_directory_document_for_repo(
722    repo: &Repository,
723    config: &AppConfig,
724) -> Result<DiffDocument> {
725    let workdir = repo
726        .workdir()
727        .context("root directory reviews require a non-bare git repository")?;
728    let mut paths = tracked_file_paths(repo)?;
729    collect_untracked_file_paths(workdir, workdir, config, &mut paths).await?;
730
731    let mut files = Vec::new();
732    for path in paths {
733        if should_ignore_file(path.to_string_lossy().as_ref(), config, Some(repo))? {
734            continue;
735        }
736        if let Some(file) = root_directory_file(workdir, &path).await? {
737            files.push(file);
738        }
739    }
740
741    Ok(DiffDocument { files })
742}
743
744#[cfg(test)]
745mod tests {
746    use super::{
747        DiffSource, MAX_ROOT_FILE_PREVIEW_BYTES, filter_ignored_files, load_git_diff_for_repo,
748        load_root_directory_document_for_repo, parse_unified_diff, root_directory_file,
749        root_directory_placeholder_file, safe_root_relative_path,
750    };
751    use crate::domain::config::AppConfig;
752    use crate::domain::diff::DiffLineKind;
753    use anyhow::{Result, anyhow};
754    use git2::{Oid, Repository, Signature};
755    use std::fs;
756    use std::path::PathBuf;
757    use tempfile::tempdir;
758
759    #[test]
760    fn parse_unified_diff_should_parse_added_and_removed_lines_with_numbers() -> Result<()> {
761        let input = "diff --git a/src/lib.rs b/src/lib.rs\nindex 123..456 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,2 +1,3 @@\n fn a() {}\n-fn b() {}\n+fn b() {\"x\";}\n+fn c() {}\n";
762
763        let doc = parse_unified_diff(input)?;
764
765        assert_eq!(doc.files.len(), 1);
766        assert_eq!(doc.files[0].path, "src/lib.rs");
767        assert!(
768            doc.files[0]
769                .header_lines
770                .iter()
771                .any(|line| line.starts_with("index "))
772        );
773        assert_eq!(doc.files[0].hunks.len(), 1);
774        let hunk = &doc.files[0].hunks[0];
775        assert_eq!(hunk.lines[0].kind, DiffLineKind::HunkHeader);
776        assert_eq!(hunk.lines[2].kind, DiffLineKind::Removed);
777        assert_eq!(hunk.lines[2].old_line, Some(2));
778        assert_eq!(hunk.lines[2].new_line, None);
779        assert_eq!(hunk.lines[3].kind, DiffLineKind::Added);
780        assert_eq!(hunk.lines[3].old_line, None);
781        assert_eq!(hunk.lines[3].new_line, Some(2));
782        Ok(())
783    }
784
785    #[test]
786    fn parse_unified_diff_should_use_old_path_for_deleted_files() -> Result<()> {
787        let input = "diff --git a/src/old.rs b/src/old.rs\nindex 123..456 100644\n--- a/src/old.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-fn old() {}\n";
788
789        let doc = parse_unified_diff(input)?;
790
791        assert_eq!(doc.files.len(), 1);
792        assert_eq!(doc.files[0].path, "src/old.rs");
793        Ok(())
794    }
795
796    #[test]
797    fn parse_unified_diff_should_parse_quoted_paths() -> Result<()> {
798        let input = "diff --git \"a/src/with space.rs\" \"b/src/with space.rs\"\nindex 123..456 100644\n--- \"a/src/with space.rs\"\n+++ \"b/src/with space.rs\"\n@@ -1 +1 @@\n-fn before() {}\n+fn after() {}\n";
799
800        let doc = parse_unified_diff(input)?;
801
802        assert_eq!(doc.files.len(), 1);
803        assert_eq!(doc.files[0].path, "src/with space.rs");
804        Ok(())
805    }
806
807    #[test]
808    fn parse_unified_diff_should_use_diff_header_path_for_binary_new_files() -> Result<()> {
809        let input = "diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png\nnew file mode 100644\nindex 0000000..6be5e50\nBinary files /dev/null and b/src-tauri/icons/128x128.png differ\n";
810
811        let doc = parse_unified_diff(input)?;
812
813        assert_eq!(doc.files.len(), 1);
814        assert_eq!(doc.files[0].path, "src-tauri/icons/128x128.png");
815        assert!(doc.files[0].hunks.is_empty());
816        Ok(())
817    }
818
819    #[test]
820    fn filter_ignored_files_removes_parley_entries_by_default() -> Result<()> {
821        let input = "diff --git a/.parley/config.toml b/.parley/config.toml\n--- a/.parley/config.toml\n+++ b/.parley/config.toml\n@@ -1 +1 @@\n-old\n+new\ndiff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n";
822        let mut doc = parse_unified_diff(input)?;
823
824        filter_ignored_files(&mut doc, &AppConfig::default(), None)?;
825
826        assert_eq!(doc.files.len(), 1);
827        assert_eq!(doc.files[0].path, "src/lib.rs");
828        Ok(())
829    }
830
831    #[test]
832    fn filter_ignored_files_can_keep_parley_entries_when_configured() -> Result<()> {
833        let input = "diff --git a/.parley/config.toml b/.parley/config.toml\n--- a/.parley/config.toml\n+++ b/.parley/config.toml\n@@ -1 +1 @@\n-old\n+new\n";
834        let mut doc = parse_unified_diff(input)?;
835        let config = AppConfig {
836            ignore_parley_dir: false,
837            ..AppConfig::default()
838        };
839
840        filter_ignored_files(&mut doc, &config, None)?;
841
842        assert_eq!(doc.files.len(), 1);
843        assert_eq!(doc.files[0].path, ".parley/config.toml");
844        Ok(())
845    }
846
847    #[test]
848    fn filter_ignored_files_removes_gitignored_paths() -> Result<()> {
849        let temp = tempdir()?;
850        let repo = Repository::init(temp.path())?;
851        fs::write(
852            temp.path().join(".gitignore"),
853            "ignored.txt\nignored-dir/\n",
854        )?;
855        fs::write(temp.path().join("ignored.txt"), "ignored\n")?;
856        fs::create_dir_all(temp.path().join("ignored-dir"))?;
857        fs::write(temp.path().join("ignored-dir/file.txt"), "ignored\n")?;
858        fs::write(temp.path().join("tracked.txt"), "tracked\n")?;
859
860        let input = "diff --git a/ignored.txt b/ignored.txt\nnew file mode 100644\nindex 0000000..1111111\nBinary files /dev/null and b/ignored.txt differ\ndiff --git a/ignored-dir/file.txt b/ignored-dir/file.txt\nnew file mode 100644\nindex 0000000..2222222\nBinary files /dev/null and b/ignored-dir/file.txt differ\ndiff --git a/tracked.txt b/tracked.txt\nnew file mode 100644\nindex 0000000..3333333\nBinary files /dev/null and b/tracked.txt differ\n";
861        let mut doc = parse_unified_diff(input)?;
862
863        filter_ignored_files(&mut doc, &AppConfig::default(), Some(&repo))?;
864
865        assert_eq!(doc.files.len(), 1);
866        assert_eq!(doc.files[0].path, "tracked.txt");
867        Ok(())
868    }
869
870    #[test]
871    fn load_git_diff_for_commit_uses_first_parent_diff() -> Result<()> {
872        let temp = tempdir()?;
873        let repo = Repository::init(temp.path())?;
874
875        let first = commit_file(&repo, temp.path(), "src/lib.rs", "fn first() {}\n", "first")?;
876        let second = commit_file(
877            &repo,
878            temp.path(),
879            "src/lib.rs",
880            "fn second() {}\n",
881            "second",
882        )?;
883
884        let doc = load_git_diff_for_repo(
885            &repo,
886            &AppConfig::default(),
887            &DiffSource::Commit {
888                rev: second.to_string(),
889            },
890        )?;
891
892        assert_eq!(doc.files.len(), 1);
893        assert_eq!(doc.files[0].path, "src/lib.rs");
894        let lines = &doc.files[0].hunks[0].lines;
895        assert!(lines.iter().any(|line| line.raw == "-fn first() {}"));
896        assert!(lines.iter().any(|line| line.raw == "+fn second() {}"));
897
898        let root_doc = load_git_diff_for_repo(
899            &repo,
900            &AppConfig::default(),
901            &DiffSource::Commit {
902                rev: first.to_string(),
903            },
904        )?;
905
906        assert_eq!(root_doc.files.len(), 1);
907        assert!(
908            root_doc.files[0]
909                .hunks
910                .iter()
911                .flat_map(|hunk| hunk.lines.iter())
912                .any(|line| line.raw == "+fn first() {}")
913        );
914        Ok(())
915    }
916
917    #[test]
918    fn load_git_diff_for_range_uses_explicit_base_and_head() -> Result<()> {
919        let temp = tempdir()?;
920        let repo = Repository::init(temp.path())?;
921
922        let base = commit_file(&repo, temp.path(), "src/lib.rs", "fn one() {}\n", "one")?;
923        let _middle = commit_file(&repo, temp.path(), "src/lib.rs", "fn two() {}\n", "two")?;
924        let head = commit_file(&repo, temp.path(), "src/lib.rs", "fn three() {}\n", "three")?;
925
926        let doc = load_git_diff_for_repo(
927            &repo,
928            &AppConfig::default(),
929            &DiffSource::Range {
930                base: base.to_string(),
931                head: head.to_string(),
932            },
933        )?;
934
935        assert_eq!(doc.files.len(), 1);
936        let lines = &doc.files[0].hunks[0].lines;
937        assert!(lines.iter().any(|line| line.raw == "-fn one() {}"));
938        assert!(lines.iter().any(|line| line.raw == "+fn three() {}"));
939        assert!(!lines.iter().any(|line| line.raw == "+fn two() {}"));
940        Ok(())
941    }
942
943    #[test]
944    fn load_git_diff_tolerates_non_utf8_patch_content() -> Result<()> {
945        let temp = tempdir()?;
946        let repo = Repository::init(temp.path())?;
947        commit_file(&repo, temp.path(), "notes.txt", "hello\n", "base")?;
948        fs::write(temp.path().join("notes.txt"), b"hello \xFF\n")?;
949
950        let doc = load_git_diff_for_repo(&repo, &AppConfig::default(), &DiffSource::WorkingTree)?;
951
952        assert_eq!(doc.files.len(), 1);
953        let lines = &doc.files[0].hunks[0].lines;
954        assert!(lines.iter().any(|line| line.raw == "-hello"));
955        assert!(lines.iter().any(|line| line.raw == "+hello \u{FFFD}"));
956        Ok(())
957    }
958
959    #[tokio::test]
960    async fn load_root_directory_includes_tracked_and_untracked_files() -> Result<()> {
961        let temp = tempdir()?;
962        let repo = Repository::init(temp.path())?;
963
964        commit_file(&repo, temp.path(), ".gitignore", "ignored.log\n", "ignore")?;
965        commit_file(
966            &repo,
967            temp.path(),
968            "src/lib.rs",
969            "fn tracked() {}\n",
970            "tracked",
971        )?;
972        fs::write(temp.path().join("src/extra.rs"), "fn untracked() {}\n")?;
973        fs::write(temp.path().join("ignored.log"), "ignored\n")?;
974        fs::create_dir_all(temp.path().join("worktrees/other/src"))?;
975        fs::write(
976            temp.path().join("worktrees/other/src/lib.rs"),
977            "fn other_worktree() {}\n",
978        )?;
979
980        let doc = load_root_directory_document_for_repo(&repo, &AppConfig::default()).await?;
981
982        let paths = doc
983            .files
984            .iter()
985            .map(|file| file.path.as_str())
986            .collect::<Vec<_>>();
987        assert_eq!(paths, vec![".gitignore", "src/extra.rs", "src/lib.rs"]);
988
989        let tracked = doc
990            .files
991            .iter()
992            .find(|file| file.path == "src/lib.rs")
993            .ok_or_else(|| anyhow!("tracked file should be present"))?;
994        let tracked_lines = &tracked.hunks[0].lines;
995        assert!(tracked_lines.iter().any(|line| {
996            line.kind == DiffLineKind::Context
997                && line.old_line.is_none()
998                && line.new_line == Some(1)
999                && line.code == "fn tracked() {}"
1000        }));
1001        Ok(())
1002    }
1003
1004    #[test]
1005    fn root_directory_placeholder_file_defers_content_loading() {
1006        let file = root_directory_placeholder_file(std::path::Path::new("src/lib.rs"));
1007
1008        assert_eq!(file.path, "src/lib.rs");
1009        assert_eq!(file.header_lines, vec!["file src/lib.rs"]);
1010        assert!(file.hunks.is_empty());
1011    }
1012
1013    #[tokio::test]
1014    async fn large_root_directory_file_renders_preview_without_content() -> Result<()> {
1015        let temp = tempdir()?;
1016        let relative_path = std::path::Path::new("large.json");
1017        let path = temp.path().join(relative_path);
1018        fs::write(
1019            &path,
1020            "x".repeat((MAX_ROOT_FILE_PREVIEW_BYTES + 1) as usize),
1021        )?;
1022
1023        let file = root_directory_file(temp.path(), relative_path)
1024            .await?
1025            .ok_or_else(|| anyhow!("large file preview should be present"))?;
1026
1027        assert_eq!(file.path, "large.json");
1028        assert_eq!(file.hunks.len(), 1);
1029        assert!(
1030            file.hunks[0]
1031                .lines
1032                .iter()
1033                .any(|line| line.code.contains("file is too large to preview"))
1034        );
1035        Ok(())
1036    }
1037
1038    #[test]
1039    fn safe_root_relative_path_rejects_unsafe_paths() {
1040        assert_eq!(
1041            safe_root_relative_path("src/lib.rs"),
1042            Some(PathBuf::from("src/lib.rs"))
1043        );
1044        assert!(safe_root_relative_path("../secret").is_none());
1045        assert!(safe_root_relative_path("/tmp/secret").is_none());
1046    }
1047
1048    fn commit_file(
1049        repo: &Repository,
1050        root: &std::path::Path,
1051        relative_path: &str,
1052        content: &str,
1053        message: &str,
1054    ) -> Result<Oid> {
1055        let path = root.join(relative_path);
1056        if let Some(parent) = path.parent() {
1057            fs::create_dir_all(parent)?;
1058        }
1059        fs::write(&path, content)?;
1060
1061        let mut index = repo.index()?;
1062        index.add_path(std::path::Path::new(relative_path))?;
1063        index.write()?;
1064
1065        let tree_oid = index.write_tree()?;
1066        let tree = repo.find_tree(tree_oid)?;
1067        let signature = Signature::now("Parley Test", "parley@example.com")?;
1068        let parents = repo
1069            .head()
1070            .ok()
1071            .and_then(|head| head.target())
1072            .map(|oid| repo.find_commit(oid))
1073            .transpose()?
1074            .into_iter()
1075            .collect::<Vec<_>>();
1076        let parent_refs = parents.iter().collect::<Vec<_>>();
1077
1078        Ok(repo.commit(
1079            Some("HEAD"),
1080            &signature,
1081            &signature,
1082            message,
1083            &tree,
1084            &parent_refs,
1085        )?)
1086    }
1087}