Skip to main content

differential_engine/
rename_view.rs

1//! The rename-detected classification view (ADR 0003).
2//!
3//! Two inputs, both plumbing:
4//! - `diff-tree -r -z --raw --full-index --no-renames`: authoritative modes,
5//!   full oids and status per canonical file.
6//! - `diff-tree -r -M -z --name-status`: rename pairs with similarity scores.
7//!
8//! Both only ANNOTATE the canonical view; they never add or remove entries.
9
10use std::collections::HashMap;
11
12use crate::EngineError;
13use crate::model::{DiffView, Disposition};
14
15/// One record from `--raw -z --full-index --no-renames`.
16#[derive(Debug, Clone)]
17pub struct RawRecord {
18    pub old_mode: String,
19    pub new_mode: String,
20    pub old_oid: String,
21    pub new_oid: String,
22    pub status: u8,
23    pub path: Vec<u8>,
24}
25
26const ZERO_OID: &str = "0000000000000000000000000000000000000000";
27
28impl RawRecord {
29    pub fn disposition(&self) -> Disposition {
30        match self.status {
31            b'A' => Disposition::Added,
32            b'D' => Disposition::Deleted,
33            _ => Disposition::Modified,
34        }
35    }
36}
37
38/// Parse `:100644 100755 <old> <new> M\0path\0` records.
39pub fn parse_raw_z(raw: &[u8]) -> Result<Vec<RawRecord>, EngineError> {
40    let mut out = Vec::new();
41    let mut fields = raw.split(|&b| b == 0);
42    while let Some(meta) = fields.next() {
43        if meta.is_empty() {
44            continue;
45        }
46        let meta = meta
47            .strip_prefix(b":".as_slice())
48            .ok_or_else(|| bad(meta))?;
49        let parts: Vec<&[u8]> = meta.split(|&b| b == b' ').collect();
50        if parts.len() != 5 {
51            return Err(bad(meta));
52        }
53        let path = fields.next().ok_or_else(|| bad(meta))?;
54        out.push(RawRecord {
55            old_mode: String::from_utf8_lossy(parts[0]).into_owned(),
56            new_mode: String::from_utf8_lossy(parts[1]).into_owned(),
57            old_oid: String::from_utf8_lossy(parts[2]).into_owned(),
58            new_oid: String::from_utf8_lossy(parts[3]).into_owned(),
59            status: *parts[4].first().ok_or_else(|| bad(meta))?,
60            path: path.to_vec(),
61        });
62    }
63    Ok(out)
64}
65
66fn bad(meta: &[u8]) -> EngineError {
67    EngineError::Parse {
68        line: 0,
69        msg: format!(
70            "unparseable raw diff record: {}",
71            String::from_utf8_lossy(meta)
72        ),
73    }
74}
75
76/// Overlay authoritative modes/oids/dispositions onto the parsed canonical view.
77pub fn merge_raw(view: &mut DiffView, records: &[RawRecord]) -> Result<(), EngineError> {
78    let by_path: HashMap<&[u8], &RawRecord> =
79        records.iter().map(|r| (r.path.as_slice(), r)).collect();
80    for f in &mut view.files {
81        let Some(r) = by_path.get(f.path.as_slice()) else {
82            return Err(EngineError::Invariant(format!(
83                "file {} present in patch but missing from raw records",
84                String::from_utf8_lossy(&f.path)
85            )));
86        };
87        f.disposition = r.disposition();
88        if r.old_mode != "000000" {
89            f.old_mode = Some(r.old_mode.clone());
90        }
91        f.new_mode = (r.new_mode != "000000").then(|| r.new_mode.clone());
92        // A mode that did not change is not "old_mode" in the schema sense.
93        if f.old_mode == f.new_mode {
94            f.old_mode = None;
95        }
96        if f.disposition == Disposition::Deleted {
97            f.old_mode = Some(r.old_mode.clone());
98        }
99        f.old_oid = (r.old_oid != ZERO_OID).then(|| r.old_oid.clone());
100        f.new_oid = (r.new_oid != ZERO_OID).then(|| r.new_oid.clone());
101    }
102    if view.files.len() != records.len() {
103        return Err(EngineError::Invariant(format!(
104            "patch has {} files but raw listing has {} — enumeration hole",
105            view.files.len(),
106            records.len()
107        )));
108    }
109    Ok(())
110}
111
112/// One rename pair from the `-M` view.
113#[derive(Debug, Clone, PartialEq)]
114pub struct RenamePair {
115    pub old_path: Vec<u8>,
116    pub new_path: Vec<u8>,
117    /// 0-100.
118    pub similarity: u8,
119}
120
121/// Parse `-M -z --name-status` output, keeping only `R<score>` records.
122pub fn parse_renames_z(raw: &[u8]) -> Result<Vec<RenamePair>, EngineError> {
123    let mut out = Vec::new();
124    let mut fields = raw.split(|&b| b == 0);
125    while let Some(status) = fields.next() {
126        if status.is_empty() {
127            continue;
128        }
129        match status.first() {
130            Some(b'R') | Some(b'C') => {
131                let score: u8 = std::str::from_utf8(&status[1..])
132                    .ok()
133                    .and_then(|s| s.parse().ok())
134                    .ok_or_else(|| EngineError::Parse {
135                        line: 0,
136                        msg: format!(
137                            "unparseable rename score: {}",
138                            String::from_utf8_lossy(status)
139                        ),
140                    })?;
141                let old_path = fields.next().ok_or_else(missing)?.to_vec();
142                let new_path = fields.next().ok_or_else(missing)?.to_vec();
143                out.push(RenamePair {
144                    old_path,
145                    new_path,
146                    similarity: score,
147                });
148            }
149            _ => {
150                // A/D/M/T: one path field, no annotation to extract.
151                fields.next().ok_or_else(missing)?;
152            }
153        }
154    }
155    Ok(out)
156}
157
158fn missing() -> EngineError {
159    EngineError::Parse {
160        line: 0,
161        msg: "rename record missing path".into(),
162    }
163}
164
165/// Annotate both halves of each detected rename in the canonical view.
166pub fn merge_renames(view: &mut DiffView, pairs: &[RenamePair]) {
167    for p in pairs {
168        for f in &mut view.files {
169            if f.disposition == Disposition::Added && f.path == p.new_path {
170                f.rename_from = Some(p.old_path.clone());
171                f.rename_similarity = Some(p.similarity);
172            } else if f.disposition == Disposition::Deleted && f.path == p.old_path {
173                f.rename_to = Some(p.new_path.clone());
174                f.rename_similarity = Some(p.similarity);
175            }
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn raw_records_parse() {
186        let raw = b":100644 100755 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb M\0tools/run.sh\0:000000 100644 0000000000000000000000000000000000000000 cccccccccccccccccccccccccccccccccccccccc A\0new.txt\0";
187        let rs = parse_raw_z(raw).unwrap();
188        assert_eq!(rs.len(), 2);
189        assert_eq!(rs[0].status, b'M');
190        assert_eq!(rs[0].new_mode, "100755");
191        assert_eq!(rs[1].status, b'A');
192        assert_eq!(rs[1].path, b"new.txt");
193    }
194
195    #[test]
196    fn rename_records_parse_with_scores() {
197        let raw =
198            b"R062\0src/lexer.rs\0src/token/lexer.rs\0M\0src/main.rs\0R100\0old.txt\0new.txt\0";
199        let rs = parse_renames_z(raw).unwrap();
200        assert_eq!(
201            rs,
202            vec![
203                RenamePair {
204                    old_path: b"src/lexer.rs".to_vec(),
205                    new_path: b"src/token/lexer.rs".to_vec(),
206                    similarity: 62
207                },
208                RenamePair {
209                    old_path: b"old.txt".to_vec(),
210                    new_path: b"new.txt".to_vec(),
211                    similarity: 100
212                },
213            ]
214        );
215    }
216}