use anyhow::{Context, Result}; use regex::Regex; use std::path::PathBuf; pub struct ColorScheme { pub path: PathBuf, pub content: String, pub colors: Vec<(String, String)>, history: Vec, redo_history: Vec, } impl ColorScheme { pub fn load(path: impl Into) -> Result { Self::load_with_namespace(path, "") } pub fn load_with_namespace(path: impl Into, namespace: &str) -> Result { let path = path.into(); let mut content = std::fs::read_to_string(&path) .with_context(|| format!("reading {}", path.display()))?; if !namespace.is_empty() { content = content.replace(".ColorScheme-", &format!(".{namespace}-ColorScheme-")); content = content.replace("class=\"ColorScheme-", &format!("class=\"{namespace}-ColorScheme-")); } let colors = parse_colors(&content); Ok(Self { path, content, colors, history: Vec::new(), redo_history: Vec::new(), }) } pub fn update_color(&mut self, tag: &str, new_hex: &str) -> Result { let Some(old_hex) = self .colors .iter() .find(|(t, _)| t == tag) .map(|(_, h)| h.clone()) else { return Ok(false); }; let Some(new_content) = replace_rule_color(&self.content, tag, &old_hex, new_hex) else { return Ok(false); }; self.history .push(std::mem::replace(&mut self.content, new_content)); self.redo_history.clear(); self.colors = parse_colors(&self.content); self.save()?; Ok(true) } pub fn save(&self) -> Result<()> { std::fs::write(&self.path, &self.content) .with_context(|| format!("writing {}", self.path.display())) } pub fn undo(&mut self) -> Result { let Some(prev) = self.history.pop() else { return Ok(false); }; self.redo_history .push(std::mem::replace(&mut self.content, prev)); self.colors = parse_colors(&self.content); self.save()?; Ok(true) } pub fn redo(&mut self) -> Result { let Some(next) = self.redo_history.pop() else { return Ok(false); }; self.history .push(std::mem::replace(&mut self.content, next)); self.colors = parse_colors(&self.content); self.save()?; Ok(true) } } fn style_block(content: &str) -> Option<&str> { let re = Regex::new(r#"(?s)]*?id="current-color-scheme"[^>]*>(.*?)"#) .expect("static regex is valid"); re.captures(content).map(|c| c.get(1).unwrap().as_str()) } fn parse_colors(content: &str) -> Vec<(String, String)> { let Some(style) = style_block(content) else { return Vec::new(); }; let re = Regex::new(r#"\.([a-zA-Z0-9\-]*ColorScheme-[a-zA-Z0-9]+)\s*\{\s*color:\s*(#[a-fA-F0-9]{3,6})"#) .expect("static regex is valid"); re.captures_iter(style) .map(|c| (c[1].to_string(), c[2].to_string())) .collect() } fn replace_rule_color(content: &str, tag: &str, old_hex: &str, new_hex: &str) -> Option { let pattern = format!( r#"(\.{}\s*\{{\s*color:\s*){}"#, regex::escape(tag), regex::escape(old_hex) ); let re = Regex::new(&pattern).ok()?; if !re.is_match(content) { return None; } Some(re.replace(content, format!("${{1}}{new_hex}")).into_owned()) } #[cfg(test)] mod tests { use super::*; const SINGLE_COLOR_SVG: &str = r#" "#; const MULTI_COLOR_SVG: &str = r#" "#; #[test] fn parse_colors_finds_single_rule() { let colors = parse_colors(SINGLE_COLOR_SVG); assert_eq!( colors, vec![("ColorScheme-Text".to_string(), "#232629".to_string())] ); } #[test] fn parse_colors_finds_multiple_rules_in_order() { let colors = parse_colors(MULTI_COLOR_SVG); assert_eq!( colors, vec![ ("ColorScheme-Highlight".to_string(), "#3daee9".to_string()), ("ColorScheme-Text".to_string(), "#232629".to_string()), ] ); } #[test] fn parse_colors_empty_when_no_style_block() { let colors = parse_colors(""); assert!(colors.is_empty()); } #[test] fn replace_rule_color_only_touches_matched_class() { // Regression test for the bug the Rust rewrite fixes: two classes // sharing the same hex value must not both change when only one // is edited. let svg = r#".ColorScheme-Highlight { color: #3daee9; } .ColorScheme-Focus { color: #3daee9; }"#; let updated = replace_rule_color(svg, "ColorScheme-Highlight", "#3daee9", "#ff0000").unwrap(); assert!(updated.contains(".ColorScheme-Highlight { color: #ff0000; }")); assert!(updated.contains(".ColorScheme-Focus { color: #3daee9; }")); } #[test] fn replace_rule_color_returns_none_when_rule_not_found() { let svg = ".ColorScheme-Text { color: #232629; }"; assert!(replace_rule_color(svg, "ColorScheme-Missing", "#232629", "#ffffff").is_none()); } #[test] fn update_color_round_trip_with_undo_redo() { let dir = std::env::temp_dir().join(format!( "icon-color-tool-test-colorscheme-{}", std::process::id() )); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("icon.svg"); std::fs::write(&file, MULTI_COLOR_SVG).unwrap(); let mut scheme = ColorScheme::load(&file).unwrap(); assert_eq!(scheme.colors.len(), 2); let changed = scheme.update_color("ColorScheme-Highlight", "#ff0000").unwrap(); assert!(changed); assert_eq!( scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Highlight").unwrap().1, "#ff0000" ); // The other rule must be untouched. assert_eq!( scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Text").unwrap().1, "#232629" ); let on_disk = std::fs::read_to_string(&file).unwrap(); assert!(on_disk.contains("#ff0000")); let undone = scheme.undo().unwrap(); assert!(undone); assert_eq!( scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Highlight").unwrap().1, "#3daee9" ); let redone = scheme.redo().unwrap(); assert!(redone); assert_eq!( scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Highlight").unwrap().1, "#ff0000" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn update_color_unknown_tag_returns_false() { let dir = std::env::temp_dir().join(format!( "icon-color-tool-test-colorscheme-unknown-{}", std::process::id() )); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("icon.svg"); std::fs::write(&file, SINGLE_COLOR_SVG).unwrap(); let mut scheme = ColorScheme::load(&file).unwrap(); let changed = scheme.update_color("ColorScheme-DoesNotExist", "#ffffff").unwrap(); assert!(!changed); std::fs::remove_dir_all(&dir).ok(); } #[test] fn load_with_namespace_rewrites_classes() { let dir = std::env::temp_dir().join(format!( "icon-color-tool-test-namespace-{}", std::process::id() )); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("icon.svg"); std::fs::write(&file, SINGLE_COLOR_SVG).unwrap(); let scheme = ColorScheme::load_with_namespace(&file, "Emblem").unwrap(); assert!(scheme.content.contains(".Emblem-ColorScheme-Text")); assert!(scheme.content.contains("class=\"Emblem-ColorScheme-Text\"")); assert_eq!(scheme.colors[0].0, "Emblem-ColorScheme-Text"); std::fs::remove_dir_all(&dir).ok(); } }