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
35pub 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
55pub async fn load_git_diff_head(config: &AppConfig) -> Result<DiffDocument> {
60 load_git_diff(config, &DiffSource::WorkingTree).await
61}
62
63pub 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
75pub 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
461pub 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}