Skip to main content

aft/
symbol_diff.rs

1//! Deterministic symbol-level summaries for Git revision ranges.
2//!
3//! This module reads blobs directly from Git so delivery-review packets describe
4//! committed revisions rather than the caller's working tree.
5
6use std::collections::BTreeMap;
7use std::ffi::OsString;
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11
12use crate::commands::outline::symbol_to_entry;
13use crate::parser::{
14    detect_language, extract_symbols_from_tree, parse_source_with_cached_parser, LangId,
15};
16use crate::symbols::Symbol;
17
18/// Fixed statement carried by every range packet.
19pub const RANGE_SYMBOL_PACKET_DISCLAIMER: &str =
20    "Derived from the diff; proves what changed, not that it works.";
21
22/// One changed symbol rendered with the fields used by the outline machinery.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24pub struct SymbolDiffEntry {
25    pub name: String,
26    pub kind: String,
27    pub signature_line: Option<String>,
28    /// Dot-separated outline scope, such as `Trait for Type` or `Outer.Inner`.
29    pub container_path: String,
30}
31
32/// Symbol and line-count changes for one file.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct FileSymbolDiff {
35    pub symbols_unavailable: bool,
36    pub old_line_count: usize,
37    pub new_line_count: usize,
38    pub line_count_delta: i64,
39    pub added: Vec<SymbolDiffEntry>,
40    pub removed: Vec<SymbolDiffEntry>,
41    pub modified: Vec<SymbolDiffEntry>,
42}
43
44/// The file-level change represented by a range packet entry.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "snake_case")]
47pub enum FileChangeKind {
48    Added,
49    Removed,
50    Modified,
51}
52
53/// A root-relative file entry in a range packet.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct RangeFileSymbolDiff {
56    /// Git's root-relative pathname, normalized to forward slashes for display.
57    pub path: String,
58    pub change: FileChangeKind,
59    /// Set on the destination half of a rename; the packet represents it as an added file.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub renamed_from: Option<String>,
62    /// Set on the source half of a rename; the packet represents it as a removed file.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub renamed_to: Option<String>,
65    #[serde(flatten)]
66    pub diff: FileSymbolDiff,
67}
68
69/// A deterministic delivery-review packet for one Git revision range.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71pub struct RangeSymbolPacket {
72    pub base_sha: String,
73    pub tip_sha: String,
74    pub files: Vec<RangeFileSymbolDiff>,
75    pub disclaimer: &'static str,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
79struct SymbolIdentity {
80    container_path: String,
81    name: String,
82    kind: String,
83}
84
85#[derive(Debug, Clone)]
86struct ParsedSymbol {
87    entry: SymbolDiffEntry,
88    body: Option<Vec<u8>>,
89}
90
91#[derive(Debug)]
92enum GitFileChange {
93    Added(Vec<u8>),
94    Removed(Vec<u8>),
95    Modified(Vec<u8>),
96    Renamed { old: Vec<u8>, new: Vec<u8> },
97    Copied { old: Vec<u8>, new: Vec<u8> },
98}
99
100/// Compare two file blobs using the shared tree-sitter parser and symbol extractors.
101///
102/// JSON and YAML are deliberately treated as data files here even though the outline
103/// command can offer lightweight summaries for them. A delivery packet must not imply
104/// source-symbol coverage where there is no code-symbol contract to review.
105pub(crate) fn symbol_diff_file(
106    lang: LangId,
107    path: &Path,
108    old: Option<&[u8]>,
109    new: Option<&[u8]>,
110) -> FileSymbolDiff {
111    let old_line_count = line_count(old.unwrap_or_default());
112    let new_line_count = line_count(new.unwrap_or_default());
113
114    if !supports_symbol_diff(lang) {
115        return unavailable_file_diff(old_line_count, new_line_count);
116    }
117
118    let old_by_identity = match old {
119        Some(source) => match parse_symbols(path, source, lang) {
120            Some(symbols) => symbols,
121            None => return unavailable_file_diff(old_line_count, new_line_count),
122        },
123        None => BTreeMap::new(),
124    };
125    let new_by_identity = match new {
126        Some(source) => match parse_symbols(path, source, lang) {
127            Some(symbols) => symbols,
128            None => return unavailable_file_diff(old_line_count, new_line_count),
129        },
130        None => BTreeMap::new(),
131    };
132
133    let mut old_by_identity = old_by_identity;
134    let mut added = Vec::new();
135    let mut modified = Vec::new();
136
137    for (identity, new_symbol) in new_by_identity {
138        match old_by_identity.remove(&identity) {
139            None => added.push(new_symbol.entry),
140            Some(old_symbol) if symbol_changed(&old_symbol, &new_symbol) => {
141                modified.push(new_symbol.entry)
142            }
143            Some(_) => {}
144        }
145    }
146
147    let removed = old_by_identity
148        .into_values()
149        .map(|symbol| symbol.entry)
150        .collect();
151
152    FileSymbolDiff {
153        symbols_unavailable: false,
154        old_line_count,
155        new_line_count,
156        line_count_delta: line_count_delta(old_line_count, new_line_count),
157        added,
158        removed,
159        modified,
160    }
161}
162
163/// Build a deterministic packet for `base_sha..tip_sha` without reading the worktree.
164///
165/// An unreadable Git range produces an empty packet rather than a partial packet. The
166/// caller can distinguish that case from an unchanged valid range by resolving the
167/// requested revisions before calling this low-level, infallible engine entry point.
168pub fn symbol_diff_range(repo_root: &Path, base_sha: &str, tip_sha: &str) -> RangeSymbolPacket {
169    let mut files = BTreeMap::new();
170
171    if let Some(changes) = git_name_status(repo_root, base_sha, tip_sha) {
172        for change in changes {
173            match change {
174                GitFileChange::Added(path) => {
175                    insert_file_entry(
176                        &mut files,
177                        repo_root,
178                        &path,
179                        None,
180                        read_git_blob(repo_root, tip_sha, &path),
181                        FileChangeKind::Added,
182                        None,
183                        None,
184                    );
185                }
186                GitFileChange::Removed(path) => {
187                    insert_file_entry(
188                        &mut files,
189                        repo_root,
190                        &path,
191                        read_git_blob(repo_root, base_sha, &path),
192                        None,
193                        FileChangeKind::Removed,
194                        None,
195                        None,
196                    );
197                }
198                GitFileChange::Modified(path) => {
199                    insert_file_entry(
200                        &mut files,
201                        repo_root,
202                        &path,
203                        read_git_blob(repo_root, base_sha, &path),
204                        read_git_blob(repo_root, tip_sha, &path),
205                        FileChangeKind::Modified,
206                        None,
207                        None,
208                    );
209                }
210                GitFileChange::Renamed { old, new } => {
211                    let old_display = git_path_display(&old);
212                    let new_display = git_path_display(&new);
213                    insert_file_entry(
214                        &mut files,
215                        repo_root,
216                        &old,
217                        read_git_blob(repo_root, base_sha, &old),
218                        None,
219                        FileChangeKind::Removed,
220                        None,
221                        Some(new_display),
222                    );
223                    insert_file_entry(
224                        &mut files,
225                        repo_root,
226                        &new,
227                        None,
228                        read_git_blob(repo_root, tip_sha, &new),
229                        FileChangeKind::Added,
230                        Some(old_display),
231                        None,
232                    );
233                }
234                GitFileChange::Copied { old, new } => {
235                    insert_file_entry(
236                        &mut files,
237                        repo_root,
238                        &new,
239                        None,
240                        read_git_blob(repo_root, tip_sha, &new),
241                        FileChangeKind::Added,
242                        Some(git_path_display(&old)),
243                        None,
244                    );
245                }
246            }
247        }
248    }
249
250    RangeSymbolPacket {
251        base_sha: base_sha.to_string(),
252        tip_sha: tip_sha.to_string(),
253        files: files.into_values().collect(),
254        disclaimer: RANGE_SYMBOL_PACKET_DISCLAIMER,
255    }
256}
257
258fn supports_symbol_diff(lang: LangId) -> bool {
259    !matches!(lang, LangId::Json | LangId::Yaml)
260}
261
262fn unavailable_file_diff(old_line_count: usize, new_line_count: usize) -> FileSymbolDiff {
263    FileSymbolDiff {
264        symbols_unavailable: true,
265        old_line_count,
266        new_line_count,
267        line_count_delta: line_count_delta(old_line_count, new_line_count),
268        added: Vec::new(),
269        removed: Vec::new(),
270        modified: Vec::new(),
271    }
272}
273
274fn parse_symbols(
275    path: &Path,
276    source: &[u8],
277    lang: LangId,
278) -> Option<BTreeMap<SymbolIdentity, ParsedSymbol>> {
279    let source = std::str::from_utf8(source).ok()?;
280    let tree = parse_source_with_cached_parser(path, source, lang).ok()?;
281    let symbols = extract_symbols_from_tree(source, &tree, lang).ok()?;
282    let line_starts = line_start_offsets(source);
283
284    let mut parsed = BTreeMap::new();
285    for symbol in symbols {
286        let (identity, parsed_symbol) = parsed_symbol(source, &line_starts, symbol);
287        // Extractors already deduplicate outline entries. Keeping the first value makes
288        // an unexpected duplicate deterministic without inventing a new display key.
289        parsed.entry(identity).or_insert(parsed_symbol);
290    }
291    Some(parsed)
292}
293
294fn parsed_symbol(
295    source: &str,
296    line_starts: &[usize],
297    symbol: Symbol,
298) -> (SymbolIdentity, ParsedSymbol) {
299    let outline_entry = symbol_to_entry(&symbol);
300    let container_path = symbol.scope_chain.join(".");
301    let identity = SymbolIdentity {
302        container_path: container_path.clone(),
303        name: outline_entry.name.clone(),
304        kind: outline_entry.kind.clone(),
305    };
306    let entry = SymbolDiffEntry {
307        name: outline_entry.name,
308        kind: outline_entry.kind,
309        signature_line: outline_entry.signature,
310        container_path,
311    };
312    let body = source_bytes_for_symbol(source, line_starts, &symbol);
313    (identity, ParsedSymbol { entry, body })
314}
315
316fn line_start_offsets(source: &str) -> Vec<usize> {
317    let mut offsets = vec![0];
318    offsets.extend(
319        source
320            .bytes()
321            .enumerate()
322            .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
323    );
324    offsets
325}
326
327fn source_bytes_for_symbol(
328    source: &str,
329    line_starts: &[usize],
330    symbol: &Symbol,
331) -> Option<Vec<u8>> {
332    let start = byte_offset_at(
333        source,
334        line_starts,
335        symbol.range.start_line,
336        symbol.range.start_col,
337    )?;
338    let end = byte_offset_at(
339        source,
340        line_starts,
341        symbol.range.end_line,
342        symbol.range.end_col,
343    )?;
344    (start <= end).then(|| source.as_bytes()[start..end].to_vec())
345}
346
347fn byte_offset_at(source: &str, line_starts: &[usize], line: u32, column: u32) -> Option<usize> {
348    let line_start = *line_starts.get(line as usize)?;
349    let offset = line_start.checked_add(column as usize)?;
350    (offset <= source.len()).then_some(offset)
351}
352
353fn symbol_changed(old: &ParsedSymbol, new: &ParsedSymbol) -> bool {
354    old.body != new.body || old.entry.signature_line != new.entry.signature_line
355}
356
357fn line_count(source: &[u8]) -> usize {
358    if source.is_empty() {
359        return 0;
360    }
361    source.iter().filter(|byte| **byte == b'\n').count() + usize::from(!source.ends_with(b"\n"))
362}
363
364fn line_count_delta(old_line_count: usize, new_line_count: usize) -> i64 {
365    let delta = new_line_count as i128 - old_line_count as i128;
366    delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64
367}
368
369fn insert_file_entry(
370    files: &mut BTreeMap<Vec<u8>, RangeFileSymbolDiff>,
371    _repo_root: &Path,
372    path_bytes: &[u8],
373    old: Option<Vec<u8>>,
374    new: Option<Vec<u8>>,
375    change: FileChangeKind,
376    renamed_from: Option<String>,
377    renamed_to: Option<String>,
378) {
379    let path = path_from_git_bytes(path_bytes);
380    let diff = match detect_language(&path) {
381        Some(lang) => symbol_diff_file(lang, &path, old.as_deref(), new.as_deref()),
382        None => unavailable_file_diff(
383            line_count(old.as_deref().unwrap_or_default()),
384            line_count(new.as_deref().unwrap_or_default()),
385        ),
386    };
387    files.insert(
388        path_bytes.to_vec(),
389        RangeFileSymbolDiff {
390            path: git_path_display(path_bytes),
391            change,
392            renamed_from,
393            renamed_to,
394            diff,
395        },
396    );
397}
398
399fn git_name_status(repo_root: &Path, base_sha: &str, tip_sha: &str) -> Option<Vec<GitFileChange>> {
400    let range = format!("{base_sha}..{tip_sha}");
401    let output = crate::effective_path::new_command("git")
402        .arg("-C")
403        .arg(repo_root)
404        .args([
405            "-c",
406            "core.quotepath=false",
407            "diff",
408            "--name-status",
409            "-z",
410            "-M",
411            "--no-ext-diff",
412            &range,
413        ])
414        .output()
415        .ok()?;
416    output
417        .status
418        .success()
419        .then(|| parse_name_status(&output.stdout))
420}
421
422fn parse_name_status(output: &[u8]) -> Vec<GitFileChange> {
423    let fields = output
424        .split(|byte| *byte == 0)
425        .filter(|field| !field.is_empty())
426        .collect::<Vec<_>>();
427    let mut changes = Vec::new();
428    let mut index = 0;
429
430    while let Some(status) = fields.get(index) {
431        index += 1;
432        let kind = status.first().copied();
433        match kind {
434            Some(b'R') => {
435                let (Some(old), Some(new)) = (fields.get(index), fields.get(index + 1)) else {
436                    break;
437                };
438                changes.push(GitFileChange::Renamed {
439                    old: (*old).to_vec(),
440                    new: (*new).to_vec(),
441                });
442                index += 2;
443            }
444            Some(b'C') => {
445                let (Some(old), Some(new)) = (fields.get(index), fields.get(index + 1)) else {
446                    break;
447                };
448                changes.push(GitFileChange::Copied {
449                    old: (*old).to_vec(),
450                    new: (*new).to_vec(),
451                });
452                index += 2;
453            }
454            Some(kind) => {
455                let Some(path) = fields.get(index) else {
456                    break;
457                };
458                let change = match kind {
459                    b'A' => GitFileChange::Added((*path).to_vec()),
460                    b'D' => GitFileChange::Removed((*path).to_vec()),
461                    _ => GitFileChange::Modified((*path).to_vec()),
462                };
463                changes.push(change);
464                index += 1;
465            }
466            None => break,
467        }
468    }
469
470    changes
471}
472
473fn read_git_blob(repo_root: &Path, sha: &str, path_bytes: &[u8]) -> Option<Vec<u8>> {
474    let object = git_object_spec(sha, path_bytes);
475    let output = crate::effective_path::new_command("git")
476        .arg("-C")
477        .arg(repo_root)
478        .args(["-c", "core.quotepath=false", "show", "--no-textconv"])
479        .arg(object)
480        .output()
481        .ok()?;
482    output.status.success().then_some(output.stdout)
483}
484
485fn git_path_display(path_bytes: &[u8]) -> String {
486    String::from_utf8_lossy(path_bytes).replace('\\', "/")
487}
488
489fn path_from_git_bytes(bytes: &[u8]) -> PathBuf {
490    #[cfg(unix)]
491    {
492        use std::os::unix::ffi::OsStringExt;
493
494        PathBuf::from(OsString::from_vec(bytes.to_vec()))
495    }
496
497    #[cfg(not(unix))]
498    {
499        PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
500    }
501}
502
503fn git_object_spec(sha: &str, path_bytes: &[u8]) -> OsString {
504    #[cfg(unix)]
505    {
506        use std::os::unix::ffi::OsStringExt;
507
508        let mut bytes = sha.as_bytes().to_vec();
509        bytes.push(b':');
510        bytes.extend_from_slice(path_bytes);
511        OsString::from_vec(bytes)
512    }
513
514    #[cfg(not(unix))]
515    {
516        OsString::from(format!("{sha}:{}", String::from_utf8_lossy(path_bytes)))
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use std::fs;
523    use std::path::Path;
524    use std::process::Command;
525    use std::time::{Duration, SystemTime};
526
527    use filetime::FileTime;
528    use tempfile::TempDir;
529
530    use super::*;
531
532    const RUST_BASE: &str = r#"
533pub fn unchanged() -> i32 { 0 }
534pub fn body_changed() -> i32 { 1 }
535pub fn signature_changed(value: i32) -> i32 { value }
536pub fn removed() -> i32 { 4 }
537"#;
538    const RUST_TIP: &str = r#"
539pub fn unchanged() -> i32 { 0 }
540pub fn body_changed() -> i32 { 2 }
541pub fn signature_changed(value: i64) -> i64 { value }
542pub fn added() -> i32 { 3 }
543"#;
544    const TS_BASE: &str = r#"
545export function unchanged(): number { return 0; }
546export function bodyChanged(): number { return 1; }
547export function signatureChanged(value: number): number { return value; }
548export function removed(): number { return 4; }
549"#;
550    const TS_TIP: &str = r#"
551export function unchanged(): number { return 0; }
552export function bodyChanged(): number { return 2; }
553export function signatureChanged(value: string): string { return value; }
554export function added(): number { return 3; }
555"#;
556
557    #[test]
558    fn rust_classifies_added_removed_and_both_kinds_of_modification() {
559        let diff = symbol_diff_file(
560            LangId::Rust,
561            Path::new("multi.rs"),
562            Some(RUST_BASE.as_bytes()),
563            Some(RUST_TIP.as_bytes()),
564        );
565
566        assert!(!diff.symbols_unavailable);
567        assert_eq!(entry_names(&diff.added), ["added"]);
568        assert_eq!(entry_names(&diff.removed), ["removed"]);
569        assert_eq!(
570            entry_names(&diff.modified),
571            ["body_changed", "signature_changed"]
572        );
573        assert_eq!(
574            diff.modified[1].signature_line.as_deref(),
575            Some("pub fn signature_changed(value: i64) -> i64 { value }")
576        );
577    }
578
579    #[test]
580    fn typescript_classifies_added_removed_and_both_kinds_of_modification() {
581        let diff = symbol_diff_file(
582            LangId::TypeScript,
583            Path::new("multi.ts"),
584            Some(TS_BASE.as_bytes()),
585            Some(TS_TIP.as_bytes()),
586        );
587
588        assert!(!diff.symbols_unavailable);
589        assert_eq!(entry_names(&diff.added), ["added"]);
590        assert_eq!(entry_names(&diff.removed), ["removed"]);
591        assert_eq!(
592            entry_names(&diff.modified),
593            ["bodyChanged", "signatureChanged"]
594        );
595        assert_eq!(
596            diff.modified[1].signature_line.as_deref(),
597            Some("function signatureChanged(value: string): string { return value; }")
598        );
599    }
600
601    #[test]
602    fn indexed_byte_offsets_match_the_scanning_contract() {
603        let sources = ["", "a", "a\n", "alpha\nbeta", "α\nβ\r\nlast"];
604        let columns = [0, 1, 2, 3, 8, 32];
605
606        for source in sources {
607            let line_starts = line_start_offsets(source);
608            let last_line = source.bytes().filter(|byte| *byte == b'\n').count() as u32;
609            for line in 0..=last_line + 2 {
610                for column in columns {
611                    assert_eq!(
612                        byte_offset_at(source, &line_starts, line, column),
613                        scanning_byte_offset_at(source, line, column),
614                        "source={source:?}, line={line}, column={column}"
615                    );
616                }
617            }
618        }
619    }
620
621    #[test]
622    fn json_returns_an_honest_line_count_only_fallback() {
623        let diff = symbol_diff_file(
624            LangId::Json,
625            Path::new("data.json"),
626            Some(b"{\"one\": 1}\n"),
627            Some(b"{\"one\": 1}\n{\"two\": 2}\n"),
628        );
629
630        assert!(diff.symbols_unavailable);
631        assert_eq!(diff.old_line_count, 1);
632        assert_eq!(diff.new_line_count, 2);
633        assert_eq!(diff.line_count_delta, 1);
634        assert!(diff.added.is_empty());
635        assert!(diff.removed.is_empty());
636        assert!(diff.modified.is_empty());
637    }
638
639    #[test]
640    fn range_is_stable_across_renders_and_worktree_mtime_changes() {
641        let _git_env = crate::test_env::hermetic_git_env_guard();
642        let (temp, base, tip) = committed_range("src/lib.rs", RUST_BASE, RUST_TIP);
643        let first = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
644            .expect("serialize first packet");
645        let second = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
646            .expect("serialize second packet");
647        assert_eq!(first, second);
648
649        let source = temp.path().join("src/lib.rs");
650        filetime::set_file_mtime(
651            &source,
652            FileTime::from_system_time(SystemTime::now() + Duration::from_secs(60)),
653        )
654        .expect("change worktree mtime");
655        let third = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
656            .expect("serialize third packet");
657        assert_eq!(first, third);
658    }
659
660    #[test]
661    fn range_uses_nul_delimited_unicode_paths_and_expands_renames() {
662        let _git_env = crate::test_env::hermetic_git_env_guard();
663        let temp = init_git_fixture();
664        let old_name = "src/before ü name.rs";
665        let new_name = "src/after ü name.rs";
666        write_file(temp.path(), old_name, "pub fn renamed() {}\n");
667        let base = commit_all(temp.path(), "base");
668        fs::rename(temp.path().join(old_name), temp.path().join(new_name)).expect("rename fixture");
669        let tip = commit_all(temp.path(), "rename");
670
671        let packet = symbol_diff_range(temp.path(), &base, &tip);
672        assert_eq!(packet.files.len(), 2);
673        assert_eq!(packet.files[0].path, new_name);
674        assert_eq!(packet.files[0].change, FileChangeKind::Added);
675        assert_eq!(packet.files[0].renamed_from.as_deref(), Some(old_name));
676        assert_eq!(entry_names(&packet.files[0].diff.added), ["renamed"]);
677        assert_eq!(packet.files[1].path, old_name);
678        assert_eq!(packet.files[1].change, FileChangeKind::Removed);
679        assert_eq!(packet.files[1].renamed_to.as_deref(), Some(new_name));
680        assert_eq!(entry_names(&packet.files[1].diff.removed), ["renamed"]);
681    }
682
683    #[test]
684    fn range_packet_disclaimer_is_byte_exact_and_bare_repositories_work() {
685        let _git_env = crate::test_env::hermetic_git_env_guard();
686        let (temp, base, tip) = committed_range("src/lib.rs", RUST_BASE, RUST_TIP);
687        let bare = tempfile::tempdir().expect("create bare parent");
688        let bare_repo = bare.path().join("range.git");
689        let mut command = Command::new("git");
690        let status = crate::test_env::apply_hermetic_git_env(command.current_dir(temp.path()))
691            .args(["clone", "--bare", "."])
692            .arg(&bare_repo)
693            .status()
694            .expect("clone bare fixture");
695        assert!(status.success(), "clone bare fixture failed");
696        let packet = symbol_diff_range(&bare_repo, &base, &tip);
697        let bytes = serde_json::to_vec(&packet).expect("serialize packet");
698
699        assert_eq!(packet.disclaimer, RANGE_SYMBOL_PACKET_DISCLAIMER);
700        assert!(bytes
701            .windows(RANGE_SYMBOL_PACKET_DISCLAIMER.len())
702            .any(|window| window == RANGE_SYMBOL_PACKET_DISCLAIMER.as_bytes()));
703        assert!(!String::from_utf8(bytes)
704            .expect("packet is UTF-8 JSON")
705            .contains(&temp.path().display().to_string()));
706    }
707
708    fn entry_names(entries: &[SymbolDiffEntry]) -> Vec<&str> {
709        entries.iter().map(|entry| entry.name.as_str()).collect()
710    }
711
712    fn scanning_byte_offset_at(source: &str, line: u32, column: u32) -> Option<usize> {
713        let bytes = source.as_bytes();
714        let mut line_start = 0;
715        for _ in 0..line {
716            let newline = bytes[line_start..].iter().position(|byte| *byte == b'\n')?;
717            line_start = line_start.checked_add(newline + 1)?;
718        }
719        let offset = line_start.checked_add(column as usize)?;
720        (offset <= bytes.len()).then_some(offset)
721    }
722
723    fn committed_range(
724        path: &str,
725        base_source: &str,
726        tip_source: &str,
727    ) -> (TempDir, String, String) {
728        let temp = init_git_fixture();
729        write_file(temp.path(), path, base_source);
730        let base = commit_all(temp.path(), "base");
731        write_file(temp.path(), path, tip_source);
732        let tip = commit_all(temp.path(), "tip");
733        (temp, base, tip)
734    }
735
736    fn init_git_fixture() -> TempDir {
737        let temp = tempfile::tempdir().expect("create git fixture");
738        run_git(temp.path(), ["init"].as_slice());
739        run_git(
740            temp.path(),
741            ["config", "user.email", "test@example.com"].as_slice(),
742        );
743        run_git(temp.path(), ["config", "user.name", "AFT Test"].as_slice());
744        temp
745    }
746
747    fn write_file(root: &Path, relative_path: &str, content: &str) {
748        let path = root.join(relative_path);
749        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
750        fs::write(path, content).expect("write fixture");
751    }
752
753    fn commit_all(root: &Path, message: &str) -> String {
754        run_git(root, ["add", "."].as_slice());
755        run_git(root, ["commit", "-m", message].as_slice());
756        run_git(root, ["rev-parse", "HEAD"].as_slice())
757    }
758
759    fn run_git(root: &Path, args: &[&str]) -> String {
760        let mut command = Command::new("git");
761        let output = crate::test_env::apply_hermetic_git_env(command.current_dir(root))
762            .args(args)
763            .output()
764            .expect("run git");
765        assert!(
766            output.status.success(),
767            "git {:?} failed: {}",
768            args,
769            String::from_utf8_lossy(&output.stderr)
770        );
771        String::from_utf8(output.stdout)
772            .expect("git stdout is UTF-8")
773            .trim()
774            .to_string()
775    }
776}