use crate::{
themes::theme::{CursorMapping, CursorType},
warn,
};
use std::{collections::HashMap, fs, path::Path};
use anyhow::{Context, Result, anyhow, bail};
use configparser::ini::{Ini, IniDefault};
use csv::{ReaderBuilder, StringRecord};
fn inf_new() -> Ini {
let mut defaults = IniDefault::default();
defaults.comment_symbols = vec![';'];
defaults.delimiters = vec!['='];
Ini::new_from_defaults(defaults)
}
fn decode_utf16(bytes: &[u8], decoder: impl Fn([u8; 2]) -> u16) -> Result<String> {
let (bytes, rem) = bytes.as_chunks::<2>();
if !rem.is_empty() {
warn!("u16 bytecount not divisible by 2");
}
Ok(String::from_utf16(
&bytes.iter().map(|&b| decoder(b)).collect::<Vec<_>>(),
)?)
}
fn read_to_string_utf16(path: &Path) -> Result<String> {
let mut bytes = fs::read(path)?;
Ok(match bytes.as_slice() {
[0xff, 0xfe, rest @ ..] => decode_utf16(rest, u16::from_le_bytes)?,
[0xfe, 0xff, rest @ ..] => decode_utf16(rest, u16::from_be_bytes)?,
[0xef, 0xbb, 0xbf, ..] => {
bytes.drain(0..3);
String::from_utf8(bytes)?
}
_ => String::from_utf8_lossy(&bytes).into_owned(),
})
}
fn split_csv(record_str: &str) -> Result<StringRecord> {
let mut rdr = ReaderBuilder::new()
.has_headers(false)
.from_reader(record_str.as_bytes());
let Some(record) = rdr.records().next() else {
bail!("no records found")
};
let mut record = record?;
record.trim();
if rdr.records().next().is_some() {
warn!("more than one record found, returning first");
}
Ok(record)
}
pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec<CursorMapping>)> {
let inf_string = read_to_string_utf16(inf_path)?;
let parent = inf_path
.parent()
.ok_or_else(|| anyhow!("no parent for inf_path={}", inf_path.display()))?;
let inf = inf_new()
.read(inf_string)
.map_err(|e| anyhow!("failed to read inf, error e={e}"))?;
let defaultinstall = inf
.get("defaultinstall")
.ok_or_else(|| anyhow!("no defaultinstall section found"))?;
let addreg_sections_string = defaultinstall
.get("addreg")
.and_then(|v| v.as_ref())
.ok_or_else(|| anyhow!("no addreg found in defaultinstall"))?;
let addreg_sections = split_csv(addreg_sections_string)?;
let scheme: Vec<_> = addreg_sections
.iter()
.filter_map(|k| inf.get(&k.to_ascii_lowercase()))
.flat_map(|v| v.keys())
.filter(|k| k.contains("control panel\\cursors\\schemes"))
.collect();
let scheme = match scheme.as_slice() {
[] => bail!("couldn't find any cursor mappings"),
[entry] => entry,
_ => bail!("more than one cursor mapping found: {scheme:?}"),
};
let subs = inf.get("strings");
let expanded_reg = expand_scheme(scheme, subs)?;
let reg_info = split_csv(&expanded_reg)?;
let (Some(name), Some(paths)) = (reg_info.get(2), reg_info.get(4)) else {
bail!("expected cursor registry entry to have at least five fields, reg_info={reg_info:?}");
};
let name = name.to_string();
let paths = split_csv(paths)?;
let dst_filenames: Vec<_> = paths
.iter()
.map(|p| {
if p.is_empty() {
return Ok(None);
}
p.rsplit_once('\\')
.map(|(_, filename)| Some(filename.to_ascii_lowercase()))
.ok_or_else(|| anyhow!("failed to extract filename from path, p={p}"))
})
.collect::<Result<_>>()?;
let src_paths = resolve_paths(&inf, defaultinstall, &dst_filenames)?;
let mappings: Vec<_> = src_paths
.into_iter()
.zip(0..15)
.filter_map(|(path, i)| {
path.map(|p| CursorMapping {
r#type: index_to_cursor_type(i),
path: parent.join(p),
})
})
.collect();
Ok((name, mappings))
}
#[rustfmt::skip]
const fn index_to_cursor_type(index: usize) -> CursorType {
use CursorType::*;
match index {
0 => Arrow, 1 => Help,
2 => LeftPtrWatch, 3 => Watch,
4 => Crosshair, 5 => Text,
6 => Pencil, 7 => Forbidden,
8 => NsResize, 9 => EwResize,
10 => NwseResize, 11 => NeswResize,
12 => Move, 13 => CenterPtr,
14 => Hand, _ => unreachable!(),
}
}
fn resolve_paths(
inf: &HashMap<String, HashMap<String, Option<String>>>,
defaultinstall: &HashMap<String, Option<String>>,
paths: &[Option<String>],
) -> Result<Vec<Option<String>>> {
let copyfiles = defaultinstall
.get("copyfiles")
.cloned()
.flatten()
.ok_or_else(|| anyhow!("no copyfiles section"))?;
let fields = split_csv(©files)?;
let mut mappings = HashMap::with_capacity(paths.len());
for field in &fields {
if matches!(field.chars().next(), Some('@')) {
bail!("unsupported '@' syntax in copyfiles");
}
let Some(section) = inf.get(&field.to_ascii_lowercase()) else {
warn!("copyfiles refers to section '{field}', but the section is missing");
continue;
};
for k in section.keys() {
let entry = split_csv(k)?;
let mut entry = entry.iter().map(|f| f.replace('\\', "/"));
let Some(dst) = entry.next() else {
bail!("empty entry in section={field}")
};
if let Some(src) = entry.next()
&& !src.is_empty()
{
mappings.insert(dst, src);
} else {
mappings.insert(dst.clone(), dst);
}
}
}
let mut new = Vec::with_capacity(paths.len());
for p in paths {
let Some(p) = p else {
new.push(None);
continue;
};
new.push(Some(
mappings
.get(p)
.ok_or_else(|| anyhow!("missing mapping for {p}"))?
.clone(),
));
}
Ok(new)
}
fn expand_scheme(reg: &str, subs: Option<&HashMap<String, Option<String>>>) -> Result<String> {
let Some(subs) = subs else {
let empty: HashMap<String, String> = HashMap::new();
return expand(reg, &empty).with_context(|| format!("for input reg={reg}"));
};
let subs: HashMap<_, _> = subs
.iter()
.filter_map(dequote_value)
.map(|(k, v)| (format!("%{k}%"), v))
.collect();
expand(reg, &subs).with_context(|| format!("for input reg={reg}"))
}
fn dequote_value(entry: (&String, &Option<String>)) -> Option<(String, String)> {
match entry {
(k, Some(v)) => {
let value = v.trim();
let value = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.unwrap_or(value)
.replace("\"\"", "\"");
Some((k.clone(), value))
}
(k, None) => {
warn!("key={k} has value None");
None
}
}
}
fn expand(input: &str, subs: &HashMap<String, String>) -> Result<String> {
let mut expanded = String::with_capacity(input.len());
let mut chars = input.char_indices();
while let Some((i, c)) = chars.next() {
if c != '%' {
expanded.push(c);
continue;
}
let start = i;
let Some((end, _)) = chars.find(|(_, c)| *c == '%') else {
bail!("unclosed '%' delimiter starting at i={i}");
};
let key = &input[start..=end].to_ascii_lowercase();
let value = subs
.get(key)
.map(String::as_str)
.or_else(|| (key == "%%").then_some("%"))
.or_else(|| {
if key.chars().all(|c| c.is_ascii_digit() || c == '%') {
Some(key)
} else {
None
}
})
.ok_or_else(|| anyhow!("no substitution exists for key={key}"))?;
expanded.push_str(value);
}
Ok(expanded)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::from_root;
#[test]
fn expand_ok() {
let mut subs = HashMap::new();
subs.insert("%name%".to_string(), "hachispin".to_string());
subs.insert("%mood%".to_string(), r"¯\_(ツ)_/¯".to_string());
let value = "Hello! My name is %name%. Right now? I feel pretty meh. %mood%.";
let expected = r"Hello! My name is hachispin. Right now? I feel pretty meh. ¯\_(ツ)_/¯.";
assert_eq!(expand(value, &subs).unwrap(), expected);
let value = "憧%name%";
let expected = "憧hachispin";
assert_eq!(expand(value, &subs).unwrap(), expected);
let value = "21%%, 22%%, 23%%, 24%%, 25%%… 憧れ悩み パンプアップ";
let expected = "21%, 22%, 23%, 24%, 25%… 憧れ悩み パンプアップ";
assert_eq!(expand(value, &subs).unwrap(), expected);
let value = "%%%name%%%%mood%%%";
let expected = r"%hachispin%¯\_(ツ)_/¯%";
assert_eq!(expand(value, &subs).unwrap(), expected);
let value = "Madam Herta is a {'peerless gem','unrivaled genius','inimitable beauty'}.";
assert_eq!(expand(value, &subs).unwrap(), value);
}
#[test]
fn expand_err() {
let mut subs = HashMap::new();
subs.insert("pitiful".to_string(), "so close!".to_string());
let value = "One forgot to escape their delimiter. Only 50% of their body was found.";
assert!(expand(value, &subs).is_err());
let value = "The next escaped but forgot to insert the matching %value%.";
assert!(expand(value, &subs).is_err());
let value = "The last didn't read the documentation. How %pitiful%.";
assert!(expand(value, &subs).is_err());
}
#[test]
fn good_inf() {
macro_rules! make_mappings {
($root:expr; $($variant:ident => $filename_suffix:literal),+ $(,)?) => {[
$(
CursorMapping {
r#type: crate::themes::theme::CursorType::$variant,
path: $root.join(concat!("neuro ", $filename_suffix, ".ani")),
},
)+
]}
}
let theme_dir = Path::new(from_root!("/testing/fixtures/neuro"));
let inf_path = theme_dir.join("Install.inf");
let (theme_name, mappings) = parse_inf_installer(&inf_path).unwrap();
assert_eq!(theme_name, "Neuro-sama Cursor");
let expected_mappings = make_mappings!(
theme_dir; Arrow => "normal",
Help => "help", LeftPtrWatch => "work",
Watch => "busy", Crosshair => "precision",
Text => "text", Pencil => "hand",
Forbidden => "unavailable", NsResize => "vert",
EwResize => "horz", NwseResize => "dgn1",
NeswResize => "dgn2", Move => "move",
CenterPtr => "alt", Hand => "link",
);
assert_eq!(mappings, expected_mappings);
}
}