use icu_normalizer::ComposingNormalizerBorrowed;
use std::borrow::Cow;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
#[must_use]
pub fn has_non_ascii_utf8(s: &str) -> bool {
s.as_bytes().iter().any(|b| *b & 0x80 != 0)
}
#[must_use]
pub fn precompose_utf8_segment(s: &str) -> Cow<'_, str> {
if !has_non_ascii_utf8(s) {
return Cow::Borrowed(s);
}
let normalized = ComposingNormalizerBorrowed::new_nfc().normalize(s);
if normalized == s {
Cow::Borrowed(s)
} else {
Cow::Owned(normalized.into_owned())
}
}
#[must_use]
pub fn precompose_utf8_path(path: &str) -> Cow<'_, str> {
if !path.as_bytes().iter().any(|b| *b & 0x80 != 0) {
return Cow::Borrowed(path);
}
let mut buf = String::with_capacity(path.len());
for (i, seg) in path.split('/').enumerate() {
if i > 0 {
buf.push('/');
}
let c = precompose_utf8_segment(seg);
buf.push_str(c.as_ref());
}
if buf == path {
Cow::Borrowed(path)
} else {
Cow::Owned(buf)
}
}
pub fn probe_filesystem_normalizes_nfd_to_nfc(git_dir: &Path) -> std::io::Result<bool> {
const NFC: &str = "\u{00e4}";
const NFD: &str = "\u{0061}\u{0308}";
let nfc_path: PathBuf = git_dir.join(NFC);
let mut f = match OpenOptions::new()
.create_new(true)
.write(true)
.open(&nfc_path)
{
Ok(f) => f,
Err(_) => return Ok(false),
};
f.write_all(b"x")?;
let nfd_path = git_dir.join(NFD);
let aliases = nfd_path.exists();
let _ = fs::remove_file(&nfc_path);
Ok(aliases)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreePathForStaging {
pub abs: PathBuf,
pub index_relpath: String,
}
#[must_use]
pub fn resolve_worktree_path_for_staging(
work_tree: &Path,
rel: &str,
precompose_unicode: bool,
) -> WorktreePathForStaging {
let index_rel = |path: &str| -> String {
if precompose_unicode {
precompose_utf8_path(path).into_owned()
} else {
path.to_owned()
}
};
let abs = work_tree.join(rel);
if fs::symlink_metadata(&abs).is_ok() {
return WorktreePathForStaging {
abs,
index_relpath: index_rel(rel),
};
}
if !precompose_unicode {
return WorktreePathForStaging {
abs,
index_relpath: rel.to_owned(),
};
}
let components: Vec<&str> = rel.split('/').filter(|s| !s.is_empty()).collect();
if components.is_empty() {
return WorktreePathForStaging {
abs,
index_relpath: index_rel(rel),
};
}
let mut disk_abs = work_tree.to_path_buf();
let mut disk_rel = String::new();
for (i, component) in components.iter().enumerate() {
let want = precompose_utf8_segment(component);
let direct = disk_abs.join(component);
if fs::symlink_metadata(&direct).is_ok() {
disk_abs = direct;
if !disk_rel.is_empty() {
disk_rel.push('/');
}
disk_rel.push_str(component);
continue;
}
let mut found: Option<String> = None;
if let Ok(rd) = fs::read_dir(&disk_abs) {
for ent in rd.flatten() {
let n = ent.file_name().to_string_lossy().into_owned();
if precompose_utf8_segment(&n).as_ref() == want.as_ref() {
found = Some(n);
break;
}
}
}
let Some(name) = found else {
return WorktreePathForStaging {
abs: work_tree.join(rel),
index_relpath: index_rel(rel),
};
};
disk_abs = disk_abs.join(&name);
if !disk_rel.is_empty() {
disk_rel.push('/');
}
disk_rel.push_str(&name);
if i + 1 == components.len() {
return WorktreePathForStaging {
abs: disk_abs,
index_relpath: index_rel(&disk_rel),
};
}
}
WorktreePathForStaging {
abs: disk_abs,
index_relpath: index_rel(&disk_rel),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn precompose_nfd_filename_to_nfc() {
let nfd = "f.A\u{0308}";
let nfc = "f.\u{00c4}";
assert_eq!(precompose_utf8_path(nfd).as_ref(), nfc);
}
#[test]
fn resolve_nfc_index_path_for_nfd_on_disk() {
let td = tempfile::TempDir::new().expect("tempdir");
let wt = td.path();
let nfd = "cafe\u{0301}.txt";
fs::write(wt.join(nfd), b"x").expect("write");
let nfc = "caf\u{00e9}.txt";
let r = resolve_worktree_path_for_staging(wt, nfc, true);
assert!(r.abs.is_file());
assert_eq!(r.index_relpath.as_str(), nfc);
}
#[test]
fn probe_does_not_remove_existing_template_file() {
let td = tempfile::TempDir::new().expect("tempdir");
let git_dir = td.path().join(".git");
fs::create_dir_all(&git_dir).expect("mkdir");
const NFC: &str = "\u{00e4}";
fs::write(git_dir.join(NFC), b"from-template").expect("template file");
assert!(!probe_filesystem_normalizes_nfd_to_nfc(&git_dir).expect("probe"));
assert_eq!(fs::read(git_dir.join(NFC)).expect("read"), b"from-template");
}
}