use anyhow::{Result, bail};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CaseInsensitiveMode {
#[default]
Auto,
Off,
On,
}
impl CaseInsensitiveMode {
pub fn parse(s: &str) -> Result<Self> {
match s.to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"true" => Ok(Self::On),
"false" => Ok(Self::Off),
other => bail!(
"invalid case_insensitive value {other:?}: expected \"auto\", \"true\", or \"false\""
),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::On => "true",
Self::Off => "false",
}
}
}
#[derive(Debug, Default, Clone)]
pub struct CaseInsensitiveIndex {
map: HashMap<String, Vec<String>>,
}
impl CaseInsensitiveIndex {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, rel_path: &str) {
let key = rel_path.to_ascii_lowercase();
let candidates = self.map.entry(key).or_default();
if !candidates.iter().any(|c| c == rel_path) {
candidates.push(rel_path.to_owned());
}
}
pub fn lookup_unique(&self, rel_path: &str) -> Option<&str> {
let key = rel_path.to_ascii_lowercase();
let candidates = self.map.get(&key)?;
if candidates.len() == 1 {
Some(&candidates[0])
} else {
None
}
}
pub fn lookup_all(&self, rel_path: &str) -> &[String] {
let key = rel_path.to_ascii_lowercase();
self.map.get(&key).map_or(&[], Vec::as_slice)
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
pub fn len(&self) -> usize {
self.map.len()
}
}
pub fn probe_case_insensitive(dir: &Path) -> Result<bool> {
use std::io::Write as _;
use std::time::{SystemTime, UNIX_EPOCH};
for attempt in 0..16u32 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let suffix = format!(
"{:x}-{:08x}-{:x}-{:x}",
now.as_secs(),
now.subsec_nanos(),
std::process::id(),
attempt
);
let lower_name = format!(".hyalo-case-probe-{suffix}");
let upper_name = lower_name.to_ascii_uppercase();
let lower_path = dir.join(&lower_name);
let upper_path = dir.join(&upper_name);
if lower_path.exists() || upper_path.exists() {
continue;
}
let Ok(mut file) = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lower_path)
else {
continue;
};
let _ = file.write_all(b"x");
drop(file);
let result = std::fs::metadata(&upper_path).is_ok();
let _ = std::fs::remove_file(&lower_path);
return Ok(result);
}
Ok(false)
}
pub fn mode_enabled(mode: CaseInsensitiveMode, dir: &Path) -> bool {
match mode {
CaseInsensitiveMode::Off => false,
CaseInsensitiveMode::On => true,
CaseInsensitiveMode::Auto => probe_case_insensitive(dir).unwrap_or(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_and_lookup_unique() {
let mut idx = CaseInsensitiveIndex::new();
idx.insert("Foo/Bar.md");
idx.insert("foo/baz.md");
assert_eq!(idx.lookup_unique("foo/bar.md"), Some("Foo/Bar.md"));
assert_eq!(idx.lookup_unique("FOO/BAZ.MD"), Some("foo/baz.md"));
}
#[test]
fn ambiguous_returns_none() {
let mut idx = CaseInsensitiveIndex::new();
idx.insert("Foo.md");
idx.insert("foo.md");
assert!(idx.lookup_unique("foo.md").is_none());
assert_eq!(idx.lookup_all("foo.md").len(), 2);
}
#[test]
fn empty_index_returns_none() {
let idx = CaseInsensitiveIndex::new();
assert!(idx.lookup_unique("anything.md").is_none());
assert!(idx.lookup_all("anything.md").is_empty());
assert!(idx.is_empty());
assert_eq!(idx.len(), 0);
}
#[test]
fn deduplication() {
let mut idx = CaseInsensitiveIndex::new();
idx.insert("Foo/Bar.md");
idx.insert("Foo/Bar.md"); assert_eq!(idx.lookup_unique("foo/bar.md"), Some("Foo/Bar.md"));
assert_eq!(idx.lookup_all("foo/bar.md").len(), 1);
}
#[test]
fn probe_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let result = probe_case_insensitive(tmp.path());
assert!(result.is_ok(), "probe returned Err: {:?}", result.err());
}
#[test]
fn mode_parse_valid() {
assert_eq!(
CaseInsensitiveMode::parse("auto").unwrap(),
CaseInsensitiveMode::Auto
);
assert_eq!(
CaseInsensitiveMode::parse("AUTO").unwrap(),
CaseInsensitiveMode::Auto
);
assert_eq!(
CaseInsensitiveMode::parse("true").unwrap(),
CaseInsensitiveMode::On
);
assert_eq!(
CaseInsensitiveMode::parse("True").unwrap(),
CaseInsensitiveMode::On
);
assert_eq!(
CaseInsensitiveMode::parse("false").unwrap(),
CaseInsensitiveMode::Off
);
assert_eq!(
CaseInsensitiveMode::parse("FALSE").unwrap(),
CaseInsensitiveMode::Off
);
}
#[test]
fn mode_parse_invalid() {
assert!(CaseInsensitiveMode::parse("maybe").is_err());
assert!(CaseInsensitiveMode::parse("yes").is_err());
assert!(CaseInsensitiveMode::parse("").is_err());
}
#[test]
fn mode_as_str_roundtrip() {
for &mode in &[
CaseInsensitiveMode::Auto,
CaseInsensitiveMode::On,
CaseInsensitiveMode::Off,
] {
let s = mode.as_str();
let parsed = CaseInsensitiveMode::parse(s).unwrap();
assert_eq!(mode, parsed);
}
}
#[test]
fn mode_enabled_on_off() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
assert!(!mode_enabled(CaseInsensitiveMode::Off, dir));
assert!(mode_enabled(CaseInsensitiveMode::On, dir));
}
}