use crate::fs::FileSystem;
use anyhow::{Context, anyhow, bail};
use serde::{Serialize, Serializer};
use std::fmt;
use std::ops::Deref;
use std::path::{Component, Path};
use winnow::Result as PResult;
use winnow::combinator::{alt, delimited, preceded, repeat};
use winnow::prelude::*;
use winnow::token::{one_of, take_while};
const DEV_NULL: &str = "/dev/null";
const DIFF_PATH_PREFIXES: [&str; 6] = ["a/", "b/", "i/", "w/", "c/", "o/"];
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RepoPath(String);
impl RepoPath {
pub fn from_relative(path: &Path) -> anyhow::Result<Self> {
let mut segments = Vec::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(segment) => segments.push(
segment
.to_str()
.ok_or_else(|| anyhow!("path \"{}\" is not valid UTF-8", path.display()))?,
),
_ => bail!("path \"{}\" escapes the repository root", path.display()),
}
}
if segments.is_empty() {
bail!("empty repository path");
}
Ok(Self(segments.join("/")))
}
pub fn from_reference(reference: &str) -> anyhow::Result<Self> {
Self::from_relative(Path::new(reference))
}
pub fn from_diff_target(
source: &str,
target: &str,
file_system: &impl FileSystem,
) -> anyhow::Result<Option<Self>> {
if target == DEV_NULL {
return Ok(None);
}
let decoded_target = unquote_git_path(target)?;
let decoded_source = if source == DEV_NULL {
None
} else {
unquote_git_path(source).ok()
};
let repeats_prefix = decoded_source.as_ref().is_some_and(|source| {
source.split_once('/').map(|(prefix, _)| prefix)
== decoded_target.split_once('/').map(|(prefix, _)| prefix)
});
let stripped = if repeats_prefix {
None
} else {
DIFF_PATH_PREFIXES
.iter()
.find_map(|prefix| decoded_target.strip_prefix(prefix))
};
let Some(without_prefix) = stripped else {
bail!(
"diff target \"{decoded_target}\" has no recognized path prefix ({}).",
DIFF_PATH_PREFIXES.join(", ")
);
};
let path = Self::from_relative(Path::new(without_prefix))?;
let path = if decoded_source.is_none() {
let unprefixed = Self::from_relative(Path::new(&decoded_target))
.ok()
.filter(|candidate| file_system.exists(candidate.as_path()));
match unprefixed {
Some(unprefixed) if file_system.exists(path.as_path()) => bail!(
"cannot tell whether diff target \"{decoded_target}\" carries a path prefix: it \
reads as both \"{path}\" and \"{unprefixed}\", and both exist. A newly added \
file's header records no prefix."
),
Some(unprefixed) => unprefixed,
None => path,
}
} else {
path
};
Ok(Some(path))
}
pub fn as_path(&self) -> &Path {
Path::new(&self.0)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Deref for RepoPath {
type Target = Path;
fn deref(&self) -> &Self::Target {
self.as_path()
}
}
impl fmt::Display for RepoPath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl Serialize for RepoPath {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
fn unquote_git_path(raw: &str) -> anyhow::Result<String> {
if !raw.starts_with('"') {
return Ok(raw.to_string());
}
let bytes = quoted_path
.parse(raw)
.map_err(|error| anyhow!("diff path {raw} is not validly quoted: {error}"))?;
String::from_utf8(bytes).with_context(|| format!("diff path {raw} is not valid UTF-8"))
}
fn quoted_path(input: &mut &str) -> PResult<Vec<u8>> {
delimited(
'"',
repeat(0.., path_chunk).fold(Vec::new, |mut decoded: Vec<u8>, chunk: Chunk| {
match chunk {
Chunk::Byte(byte) => decoded.push(byte),
Chunk::Literal(text) => decoded.extend_from_slice(text.as_bytes()),
}
decoded
}),
'"',
)
.parse_next(input)
}
enum Chunk<'a> {
Byte(u8),
Literal(&'a str),
}
fn path_chunk<'a>(input: &mut &'a str) -> PResult<Chunk<'a>> {
alt((
preceded('\\', escape).map(Chunk::Byte),
take_while(1.., |c: char| c != '"' && c != '\\').map(Chunk::Literal),
))
.parse_next(input)
}
fn escape(input: &mut &str) -> PResult<u8> {
alt((
take_while(1..=3, |c: char| c.is_digit(8))
.verify_map(|digits: &str| u8::try_from(u32::from_str_radix(digits, 8).ok()?).ok()),
one_of(['a', 'b', 'f', 'n', 'r', 't', 'v', '\\', '"']).map(|escape: char| match escape {
'a' => 0x07,
'b' => 0x08,
'f' => 0x0C,
'n' => b'\n',
'r' => b'\r',
't' => b'\t',
'v' => 0x0B,
other => other as u8,
}),
))
.parse_next(input)
}
#[cfg(test)]
mod repo_path_tests {
use super::*;
use crate::fs::test_utils::FakeFileSystem;
use std::collections::HashMap;
fn tree(files: &[&str]) -> FakeFileSystem {
FakeFileSystem::new(
files
.iter()
.map(|path| ((*path).to_string(), String::new()))
.collect::<HashMap<_, _>>(),
)
}
#[test]
fn diff_target_strips_the_default_prefix() -> anyhow::Result<()> {
let files = tree(&["src/main.rs"]);
assert_eq!(
RepoPath::from_diff_target("a/src/main.rs", "b/src/main.rs", &files)?
.unwrap()
.as_str(),
"src/main.rs"
);
Ok(())
}
#[test]
fn diff_target_keeps_a_real_directory_named_like_the_prefix() -> anyhow::Result<()> {
let files = tree(&["b/rules.py"]);
assert_eq!(
RepoPath::from_diff_target("a/b/rules.py", "b/b/rules.py", &files)?
.unwrap()
.as_str(),
"b/rules.py"
);
Ok(())
}
#[test]
fn diff_target_strips_a_mnemonic_prefix() -> anyhow::Result<()> {
let files = tree(&["rules.py"]);
assert_eq!(
RepoPath::from_diff_target("i/rules.py", "w/rules.py", &files)?
.unwrap()
.as_str(),
"rules.py"
);
Ok(())
}
#[test]
fn diff_target_rejects_a_repeated_prefix() {
let files = tree(&["b/rules.py", "rules.py"]);
let error = RepoPath::from_diff_target("b/rules.py", "b/rules.py", &files).unwrap_err();
assert!(
format!("{error:#}").contains("no recognized path prefix"),
"unexpected error: {error:#}"
);
}
#[test]
fn added_file_resolves_to_the_reading_that_exists() -> anyhow::Result<()> {
let files = tree(&["b/x.py"]);
assert_eq!(
RepoPath::from_diff_target("/dev/null", "b/x.py", &files)?
.unwrap()
.as_str(),
"b/x.py"
);
let files = tree(&["x.py"]);
assert_eq!(
RepoPath::from_diff_target("/dev/null", "b/x.py", &files)?
.unwrap()
.as_str(),
"x.py"
);
Ok(())
}
#[test]
fn added_file_with_two_real_readings_is_ambiguous() {
let files = tree(&["b/x.py", "x.py"]);
let error = RepoPath::from_diff_target("/dev/null", "b/x.py", &files).unwrap_err();
let message = format!("{error:#}");
assert!(
message.contains("cannot tell whether diff target"),
"unexpected error: {message}"
);
assert!(
message.contains("newly added"),
"unexpected error: {message}"
);
}
#[test]
fn diff_target_rejects_a_custom_prefix() {
let files = tree(&["rules.py"]);
let error = RepoPath::from_diff_target("old/rules.py", "new/rules.py", &files).unwrap_err();
let message = format!("{error:#}");
assert!(
message.contains("no recognized path prefix"),
"unexpected error: {message}"
);
assert!(
message.contains("new/rules.py"),
"unexpected error: {message}"
);
}
#[test]
fn diff_target_rejects_output_without_a_prefix() {
let files = tree(&["rules.py"]);
let error = RepoPath::from_diff_target("rules.py", "rules.py", &files).unwrap_err();
assert!(
format!("{error:#}").contains("no recognized path prefix"),
"unexpected error: {error:#}"
);
}
#[test]
fn diff_target_of_a_deleted_file_is_skipped() -> anyhow::Result<()> {
let files = tree(&["rules.py"]);
assert_eq!(
RepoPath::from_diff_target("a/rules.py", "/dev/null", &files)?,
None
);
Ok(())
}
#[test]
fn diff_target_of_a_new_file_drops_the_prefix() -> anyhow::Result<()> {
let files = tree(&["src/added.py"]);
assert_eq!(
RepoPath::from_diff_target("/dev/null", "b/src/added.py", &files)?
.unwrap()
.as_str(),
"src/added.py"
);
Ok(())
}
#[test]
fn diff_target_decodes_a_quoted_path() -> anyhow::Result<()> {
let files = tree(&["café.py"]);
assert_eq!(
RepoPath::from_diff_target(r#""a/caf\303\251.py""#, r#""b/caf\303\251.py""#, &files)?
.unwrap()
.as_str(),
"café.py"
);
Ok(())
}
#[test]
fn diff_target_rejects_a_path_escaping_the_repository() {
let files = tree(&["rules.py"]);
assert!(
RepoPath::from_diff_target("a/../../etc/passwd", "b/../../etc/passwd", &files).is_err()
);
}
#[test]
fn from_relative_keeps_a_plain_path() -> anyhow::Result<()> {
assert_eq!(
RepoPath::from_relative(Path::new("src/main.rs"))?.as_str(),
"src/main.rs"
);
Ok(())
}
#[test]
fn from_relative_drops_current_directory_segments() -> anyhow::Result<()> {
assert_eq!(
RepoPath::from_relative(Path::new("./src/./main.rs"))?.as_str(),
"src/main.rs"
);
Ok(())
}
#[test]
fn from_relative_rejects_parent_directory_segments() {
assert!(RepoPath::from_relative(Path::new("../secret.txt")).is_err());
}
#[test]
fn from_relative_rejects_absolute_paths() {
assert!(RepoPath::from_relative(Path::new("/etc/passwd")).is_err());
}
#[test]
fn from_relative_rejects_an_empty_path() {
assert!(RepoPath::from_relative(Path::new("")).is_err());
}
#[test]
fn from_reference_normalizes_a_leading_current_directory() -> anyhow::Result<()> {
assert_eq!(
RepoPath::from_reference("./target.py")?,
RepoPath::from_reference("target.py")?
);
Ok(())
}
#[test]
fn equal_paths_hash_equally() -> anyhow::Result<()> {
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert(RepoPath::from_reference("./a/b.py")?, 1);
assert_eq!(map.get(&RepoPath::from_reference("a/b.py")?), Some(&1));
Ok(())
}
#[test]
fn display_uses_forward_slashes() -> anyhow::Result<()> {
assert_eq!(
RepoPath::from_relative(Path::new("a/b.py"))?.to_string(),
"a/b.py"
);
Ok(())
}
#[test]
fn serializes_as_a_plain_string() -> anyhow::Result<()> {
let value = serde_json::to_value(RepoPath::from_relative(Path::new("a/b.py"))?)?;
assert_eq!(value, serde_json::json!("a/b.py"));
Ok(())
}
#[test]
fn serializes_as_a_map_key() -> anyhow::Result<()> {
use std::collections::HashMap;
let map = HashMap::from([(RepoPath::from_relative(Path::new("a/b.py"))?, 1)]);
assert_eq!(serde_json::to_value(map)?, serde_json::json!({"a/b.py": 1}));
Ok(())
}
#[test]
fn dereferences_to_a_path() -> anyhow::Result<()> {
let path = RepoPath::from_relative(Path::new("a/b.py"))?;
assert_eq!(path.extension().and_then(|e| e.to_str()), Some("py"));
Ok(())
}
#[test]
fn unquote_passes_through_an_unquoted_path() -> anyhow::Result<()> {
assert_eq!(unquote_git_path("b/src/main.rs")?, "b/src/main.rs");
Ok(())
}
#[test]
fn unquote_decodes_octal_byte_escapes() -> anyhow::Result<()> {
assert_eq!(unquote_git_path(r#""b/caf\303\251.py""#)?, "b/café.py");
Ok(())
}
#[test]
fn unquote_decodes_character_escapes() -> anyhow::Result<()> {
assert_eq!(unquote_git_path(r#""b/a\tb.py""#)?, "b/a\tb.py");
assert_eq!(unquote_git_path(r#""b/a\"b.py""#)?, "b/a\"b.py");
assert_eq!(unquote_git_path(r#""b/a\\b.py""#)?, "b/a\\b.py");
Ok(())
}
#[test]
fn unquote_decodes_a_short_octal_escape() -> anyhow::Result<()> {
assert_eq!(unquote_git_path(r#""b/a\7b.py""#)?, "b/a\u{7}b.py");
Ok(())
}
#[test]
fn unquote_rejects_invalid_utf8() {
assert!(unquote_git_path(r#""b/\251.py""#).is_err());
}
#[test]
fn unquote_rejects_an_unknown_escape() {
assert!(unquote_git_path(r#""b/a\qb.py""#).is_err());
}
#[test]
fn unquote_rejects_a_trailing_backslash() {
assert!(unquote_git_path(r#""b/a\""#).is_err());
}
}