use crate::candidate_error::{CandidateError, CandidateFailure};
use crate::candidate_object::{CandidateMode, ObjectId, mode_from_git, parse_object_id};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CandidateChange {
Added,
Modified,
Deleted,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CandidateRecord {
pub path: Vec<u8>,
pub mode: CandidateMode,
pub change: CandidateChange,
pub object: Option<ObjectId>,
}
fn record_failure(failure: CandidateFailure, position: usize, detail: &str) -> CandidateError {
return CandidateError::new(
failure,
format!(
"cli-git could not list candidate files: changed-path record {position} {detail}. \
Inspect the entries with `git diff --cached --raw` or `git diff-tree --raw -r <commit>`."
)
.as_str(),
);
}
fn is_nul(byte: &u8) -> bool {
return *byte == 0;
}
fn is_space(byte: &u8) -> bool {
return *byte == b' ';
}
fn parse_record(
position: usize,
metadata: &[u8],
path: &[u8],
) -> Result<CandidateRecord, CandidateError> {
let Some(fields_text) = metadata.strip_prefix(b":") else {
return Err(record_failure(
CandidateFailure::ListingMalformed,
position,
"does not start with a colon",
));
};
let fields: Vec<&[u8]> = fields_text.split(is_space).collect();
if fields.len() != 5 || path.is_empty() {
return Err(record_failure(
CandidateFailure::ListingMalformed,
position,
"does not have five metadata fields and a pathname",
));
}
let status: &[u8] = fields[4];
if status == b"U" {
return Err(record_failure(
CandidateFailure::UnmergedPath,
position,
"is an unmerged (conflicted) index entry; resolve the conflict and stage the result",
));
}
let deleted: bool = status == b"D";
if !deleted && status != b"A" && status != b"M" && status != b"T" {
return Err(record_failure(
CandidateFailure::UnsupportedStatus,
position,
"has a change status other than A, M, T or D",
));
}
let mode_text: &[u8] = if deleted { fields[0] } else { fields[1] };
let Some(mode) = mode_from_git(mode_text) else {
return Err(record_failure(
CandidateFailure::UnsupportedMode,
position,
"has a file mode other than 100644, 100755, 120000 or 160000",
));
};
let (Some(_old_object), Some(new_object)) =
(parse_object_id(fields[2]), parse_object_id(fields[3]))
else {
return Err(record_failure(
CandidateFailure::ListingMalformed,
position,
"does not carry two complete object names",
));
};
let object: Option<ObjectId> = if deleted { None } else { Some(new_object) };
return Ok(CandidateRecord {
path: path.to_vec(),
mode,
change: change_of(status),
object,
});
}
fn change_of(status: &[u8]) -> CandidateChange {
if status == b"A" {
return CandidateChange::Added;
}
if status == b"D" {
return CandidateChange::Deleted;
}
return CandidateChange::Modified;
}
pub fn parse_raw_records(output: &[u8]) -> Result<Vec<CandidateRecord>, CandidateError> {
let mut records: Vec<CandidateRecord> = Vec::new();
if output.is_empty() {
return Ok(records);
}
let Some(body) = output.strip_suffix(&[0_u8]) else {
return Err(record_failure(
CandidateFailure::ListingMalformed,
0,
"is cut short: the listing does not end with a NUL byte",
));
};
let tokens: Vec<&[u8]> = body.split(is_nul).collect();
let (pairs, leftover): (&[[&[u8]; 2]], &[&[u8]]) = tokens.as_chunks::<2>();
if !leftover.is_empty() {
return Err(record_failure(
CandidateFailure::ListingMalformed,
pairs.len(),
"has no pathname token",
));
}
for (position, pair) in pairs.iter().enumerate() {
records.push(parse_record(position, pair[0], pair[1])?);
}
return Ok(records);
}
#[cfg(test)]
#[path = "candidate_record_tests.rs"]
mod tests;