differential_engine/
rename_view.rs1use std::collections::HashMap;
11
12use crate::EngineError;
13use crate::model::{DiffView, Disposition};
14
15#[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
38pub 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
76pub 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 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#[derive(Debug, Clone, PartialEq)]
114pub struct RenamePair {
115 pub old_path: Vec<u8>,
116 pub new_path: Vec<u8>,
117 pub similarity: u8,
119}
120
121pub 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 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
165pub 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}