use std::path::Path;
use crate::memory::ChangedFile;
pub(crate) fn parse_numstat(buf: &[u8]) -> Result<Vec<ChangedFile>, String> {
let mut rows = Vec::new();
let mut rest = buf;
while !rest.is_empty() {
let (added, deleted, tail) = counts(rest)?;
let (path, tail) = if tail.first() == Some(&0) {
let (_old, tail) = path_field(&tail[1..])?;
path_field(tail)?
} else {
path_field(tail)?
};
rows.push(ChangedFile {
path: path.to_owned(),
added,
deleted,
});
rest = tail;
}
Ok(rows)
}
fn counts(buf: &[u8]) -> Result<(usize, usize, &[u8]), String> {
let (added, buf) = split(buf, b'\t', "added count")?;
let (deleted, buf) = split(buf, b'\t', "deleted count")?;
Ok((count(added, "added")?, count(deleted, "deleted")?, buf))
}
fn count(field: &[u8], what: &str) -> Result<usize, String> {
if field == b"-" {
return Ok(0);
}
std::str::from_utf8(field)
.ok()
.and_then(|s| s.parse().ok())
.ok_or_else(|| format!("git numstat: malformed {what} count"))
}
fn split<'a>(buf: &'a [u8], sep: u8, what: &str) -> Result<(&'a [u8], &'a [u8]), String> {
buf.iter()
.position(|&b| b == sep)
.map(|i| (&buf[..i], &buf[i + 1..]))
.ok_or_else(|| format!("git numstat: unterminated {what}"))
}
#[cfg(unix)]
fn path_field(buf: &[u8]) -> Result<(&Path, &[u8]), String> {
use std::os::unix::ffi::OsStrExt;
let (field, tail) = split(buf, 0, "path")?;
if field.is_empty() {
return Err("git numstat: empty path".into());
}
Ok((Path::new(std::ffi::OsStr::from_bytes(field)), tail))
}
#[cfg(not(unix))]
fn path_field(buf: &[u8]) -> Result<(&Path, &[u8]), String> {
let (field, tail) = split(buf, 0, "path")?;
if field.is_empty() {
return Err("git numstat: empty path".into());
}
let text = std::str::from_utf8(field)
.map_err(|_| "git numstat: path is not valid UTF-8".to_string())?;
Ok((Path::new(text), tail))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn parses_real_git_bytes() {
let buf = concat_records();
let rows = parse_numstat(&buf).unwrap();
assert_eq!(rows.len(), 5, "{rows:?}");
assert_eq!(rows[0].path, Path::new("a.txt"));
assert_eq!((rows[0].added, rows[0].deleted), (2, 0));
assert_eq!(rows[1].path, Path::new("bin.dat"));
assert_eq!((rows[1].added, rows[1].deleted), (0, 0));
assert_eq!(rows[2].path, Path::new("new.txt"));
assert_eq!((rows[2].added, rows[2].deleted), (0, 0));
assert_eq!(rows[3].path, Path::new("nl\nname.txt"));
assert_eq!(rows[4].path, Path::new("tab\tname.txt"));
assert!(rows[3].added == 1 && rows[4].added == 1);
}
fn concat_records() -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(b"2\t0\ta.txt\x00");
buf.extend_from_slice(b"-\t-\tbin.dat\x00");
buf.extend_from_slice(b"0\t0\t\x00ren.txt\x00new.txt\x00");
buf.extend_from_slice(b"1\t0\tnl\nname.txt\x00");
buf.extend_from_slice(b"1\t0\ttab\tname.txt\x00");
buf
}
#[test]
fn unicode_path_stays_unquoted() {
let rows = parse_numstat(b"3\t1\tsrc/\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e.rs\x00").unwrap();
assert_eq!(rows[0].path, Path::new("src/日本語.rs"));
assert_eq!((rows[0].added, rows[0].deleted), (3, 1));
}
#[cfg(unix)]
#[test]
fn non_utf8_path_arrives_as_bytes() {
use std::os::unix::ffi::OsStrExt;
let rows = parse_numstat(b"1\t0\t\xff\xfe.rs\x00").unwrap();
assert_eq!(rows[0].path.as_os_str().as_bytes(), b"\xff\xfe.rs");
}
#[test]
fn binary_rename_keeps_destination() {
let rows = parse_numstat(b"-\t-\t\x00old.bin\x00new.bin\x00").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].path, Path::new("new.bin"));
assert_eq!((rows[0].added, rows[0].deleted), (0, 0));
}
#[test]
fn empty_stream_is_no_rows() {
assert!(parse_numstat(b"").unwrap().is_empty());
}
#[test]
fn malformed_output_is_an_error() {
assert!(parse_numstat(b"1\t0\ta.rs").is_err());
assert!(parse_numstat(b"x\t0\ta.rs\x00").is_err());
assert!(parse_numstat(b"-\t-\x00a.rs\x00").is_err());
assert!(parse_numstat(b"1\t0\ta.rs\x00garbage").is_err());
assert!(parse_numstat(b"1\t0\t\x00old\x00").is_err());
assert!(parse_numstat(b"1\t0\t\x00\x00new\x00").is_err());
assert!(parse_numstat(b"1 0\ta.rs\x00").is_err());
}
}