use crate::{
themes::theme::{CursorMapping, CursorType},
warn,
};
use std::{collections::HashMap, fs, path::Path};
use anyhow::{Context, Result, anyhow, bail};
use configparser::ini::Ini;
pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec<CursorMapping>)> {
let inf_string = fs::read_to_string(inf_path)?;
let parent = inf_path
.parent()
.ok_or_else(|| anyhow!("no parent for inf_path={}", inf_path.display()))?;
let inf: HashMap<String, HashMap<String, Option<String>>> = Ini::new()
.read(inf_string)
.map_err(|e| anyhow!("failed to read inf, error e={e}"))?;
let defaultinstall: &HashMap<String, Option<String>> = inf
.get("defaultinstall")
.ok_or_else(|| anyhow!("no defaultinstall section found"))?;
let addreg = defaultinstall
.get("addreg")
.ok_or_else(|| anyhow!("no addreg key found in defaultinstall"))?
.as_ref()
.ok_or_else(|| anyhow!("no value for addreg key"))?;
let scheme = addreg
.split(',')
.filter_map(|k| inf.get(&k.to_ascii_lowercase()))
.flat_map(|v| v.keys())
.find(|k| k.contains(r#""control panel\cursors\schemes","#))
.ok_or_else(|| anyhow!("couldn't find cursor mappings"))?;
let subs = inf.get("strings");
let expanded_reg = expand_scheme(scheme, subs)?;
let mut reg_info = expanded_reg.split(',');
reg_info.next(); reg_info.next();
let name = reg_info
.next()
.ok_or_else(|| anyhow!("couldn't parse theme name; reg_info doesn't have enough info"))?
.strip_prefix('"') .and_then(|n| n.strip_suffix('"'))
.ok_or_else(|| anyhow!("expected theme name to be quoted"))?
.to_string();
reg_info.next();
let mut paths: Vec<_> = reg_info
.map(|s| {
s.rsplit_once('\\')
.ok_or_else(|| anyhow!("failed to extract filename from path, s={s}"))
.map(|s| s.1.to_ascii_lowercase())
})
.collect::<Result<_>>()?;
let end = paths.len() - 1;
paths[end] = paths[paths.len() - 1]
.strip_suffix('"')
.ok_or_else(|| anyhow!("expected closing quotation for paths, didn't find it"))?
.to_string();
let paths = resolve_paths(&inf, defaultinstall, &paths)?;
let mappings: Vec<_> = paths
.into_iter()
.zip(0..15)
.map(|(p, i)| 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: &[String],
) -> Result<Vec<String>> {
let copyfiles = defaultinstall
.get("copyfiles")
.cloned()
.flatten()
.ok_or_else(|| anyhow!("no copyfiles section"))?;
let mut mappings = HashMap::with_capacity(paths.len());
for field in copyfiles.split(',') {
if matches!(copyfiles.chars().next(), Some('@')) {
bail!("unsupported '@' syntax in copyfiles");
}
let field = field.trim();
let section = inf
.get(&field.to_ascii_lowercase())
.ok_or_else(|| anyhow!("copyfiles specifies '{field}' should exist, but doesn't"))?;
for k in section.keys() {
let entry: Vec<_> = k.split(',').map(|f| f.replace('\\', "/")).collect();
if entry.is_empty() {
bail!("empty entry in section={field}");
}
if entry.len() == 1 {
mappings.insert(dequote(&entry[0]), dequote(&entry[0]));
} else {
mappings.insert(dequote(&entry[0]), dequote(&entry[1]));
}
}
}
let mut new = Vec::with_capacity(paths.len());
for p in paths {
new.push(
mappings
.get(p.as_str())
.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(input: &str) -> String {
let mut input = input.trim();
if input.starts_with('"') && input.ends_with('"') && input.len() >= 2 {
input = &input[1..(input.len() - 1)];
}
input.replace("\"\"", "\"")
}
fn dequote_value(entry: (&String, &Option<String>)) -> Option<(String, String)> {
match entry {
(k, Some(v)) => Some((k.clone(), dequote(v))),
(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);
}
}