diff --git a/src/layer.rs b/src/layer.rs index 2edfa2e8..3ead3685 100644 --- a/src/layer.rs +++ b/src/layer.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -29,6 +29,10 @@ pub(crate) static DEFAULT_GLYPHS_DIRNAME: &str = "glyphs"; pub struct LayerSet { /// A collection of [`Layer`]s. The first [`Layer`] is the default. layers: Vec, + /// A set of lowercased layer paths (excluding the default layer, as it is + /// always unique) for clash detection. This relies on Layer.path being + /// immutable. + path_set: HashSet, } #[allow(clippy::len_without_is_empty)] // never empty @@ -66,7 +70,7 @@ impl LayerSet { .ok_or(FontLoadError::MissingDefaultLayer)?; layers.rotate_left(default_idx); - Ok(LayerSet { layers }) + Ok(LayerSet { layers, path_set: HashSet::new() }) } /// Returns a new [`LayerSet`] from a `layers` collection. @@ -75,7 +79,7 @@ impl LayerSet { pub fn new(mut layers: Vec) -> Self { assert!(!layers.is_empty()); layers.first_mut().unwrap().path = DEFAULT_GLYPHS_DIRNAME.into(); - LayerSet { layers } + LayerSet { layers, path_set: HashSet::new() } } /// Returns the number of layers in the set. @@ -125,7 +129,10 @@ impl LayerSet { } else if self.layers.iter().any(|l| l.name == name) { Err(NamingError::Duplicate(name.to_string())) } else { - let layer = Layer::new(name, None)?; + let name = Name::new(name).map_err(|_| NamingError::Invalid(name.into()))?; + let path = crate::util::default_file_name_for_layer_name(&name, &self.path_set); + let layer = Layer::new(name, path); + self.path_set.insert(layer.path.to_string_lossy().to_lowercase()); self.layers.push(layer); Ok(self.layers.last_mut().unwrap()) } @@ -135,11 +142,18 @@ impl LayerSet { /// /// The default layer cannot be removed. pub fn remove(&mut self, name: &str) -> Option { - self.layers + let removed_layer = self + .layers .iter() .skip(1) .position(|l| l.name.as_ref() == name) - .map(|idx| self.layers.remove(idx + 1)) + .map(|idx| self.layers.remove(idx + 1)); + + if let Some(layer) = &removed_layer { + self.path_set.remove(&layer.path.to_string_lossy().to_lowercase()); + } + + removed_layer } /// Rename a layer. @@ -148,8 +162,9 @@ impl LayerSet { /// be replaced. /// /// Returns an error if `overwrite` is false but a layer with the new - /// name exists, if no layer with the old name exists, or if the new name - /// is not a valid [`Name`]. + /// name exists, if no layer with the old name exists, if the new name + /// is not a valid [`Name`] or when anything but the default layer should + /// be renamed to "public.default". pub fn rename_layer( &mut self, old: &str, @@ -170,13 +185,16 @@ impl LayerSet { // Dance around the borrow checker by using indices instead of references. let layer_pos = self.layers.iter().position(|l| l.name.as_ref() == old).unwrap(); - - // Default layer: just change the name. Non-default layer: change name and path. - let layer = &mut self.layers[layer_pos]; + // Only non-default layers can change path... if layer_pos != 0 { - layer.path = crate::util::default_file_name_for_layer_name(&name).into(); + let old_path = self.layers[layer_pos].path.to_string_lossy().to_lowercase(); + self.path_set.remove(&old_path); + let new_path = crate::util::default_file_name_for_layer_name(&name, &self.path_set); + self.path_set.insert(new_path.to_string_lossy().to_lowercase()); + self.layers[layer_pos].path = new_path; } - layer.name = name; + // ... but all can change name. + self.layers[layer_pos].name = name; Ok(()) } @@ -186,7 +204,7 @@ impl LayerSet { impl Default for LayerSet { fn default() -> Self { let layers = vec![Layer::default()]; - LayerSet { layers } + LayerSet { layers, path_set: HashSet::new() } } } @@ -204,6 +222,9 @@ pub struct Layer { pub(crate) name: Name, pub(crate) path: PathBuf, contents: BTreeMap, + /// A set of lowercased glif file names (excluding the default layer, as it + /// is always unique) for clash detection. + path_set: HashSet, /// An optional color, specified in the layer's [`layerinfo.plist`][info]. /// /// [info]: https://unifiedfontobject.org/versions/ufo3/glyphs/layerinfo.plist/ @@ -217,24 +238,18 @@ pub struct Layer { impl Layer { /// Returns a new [`Layer`] with the provided `name` and `path`. /// - /// The `path` argument, if provided, will be the directory within the UFO - /// that the layer is saved. If it is not provided, it will be derived from - /// the layer name. - pub(crate) fn new(name: &str, path: Option) -> Result { - let path = match path { - Some(path) => path, - None if &*name == DEFAULT_LAYER_NAME => DEFAULT_GLYPHS_DIRNAME.into(), - _ => crate::util::default_file_name_for_layer_name(name).into(), - }; - let name = Name::new(name).map_err(|_| NamingError::Invalid(name.into()))?; - Ok(Layer { + /// The `path` argument will be the directory within the UFO that the layer + /// is saved. + pub(crate) fn new(name: Name, path: PathBuf) -> Self { + Layer { glyphs: BTreeMap::new(), name, path, contents: BTreeMap::new(), + path_set: HashSet::new(), color: None, lib: Default::default(), - }) + } } /// Returns a new [`Layer`] that is loaded from `path` with the provided `name`. @@ -269,6 +284,7 @@ impl Layer { // names and deserialize to a vec; that would not be a one-liner, though. let contents: BTreeMap = plist::from_file(&contents_path) .map_err(|source| LayerLoadError::ParsePlist { name: CONTENTS_FILE, source })?; + let path_set = contents.values().map(|p| p.to_string_lossy().to_lowercase()).collect(); #[cfg(feature = "rayon")] let iter = contents.par_iter(); @@ -306,7 +322,7 @@ impl Layer { // for us to get this far, the path must have a file name let path = path.file_name().unwrap().into(); - Ok(Layer { glyphs, name, path, contents, color, lib }) + Ok(Layer { glyphs, name, path, contents, path_set, color, lib }) } fn parse_layer_info(path: &Path) -> Result<(Option, Plist), LayerLoadError> { @@ -451,8 +467,9 @@ impl Layer { ) { let glyph = glyph.into(); if !self.contents.contains_key(&glyph.name) { - let path = crate::util::default_file_name_for_glyph_name(&glyph.name); - self.contents.insert(glyph.name.clone(), path.into()); + let path = crate::util::default_file_name_for_glyph_name(&glyph.name, &self.path_set); + self.path_set.insert(path.to_string_lossy().to_lowercase()); + self.contents.insert(glyph.name.clone(), path); } self.glyphs.insert(glyph.name.clone(), glyph); } @@ -460,6 +477,7 @@ impl Layer { /// Remove all glyphs in the layer. Leave color and the lib untouched. pub fn clear(&mut self) { self.contents.clear(); + self.path_set.clear(); self.glyphs.clear() } @@ -470,7 +488,9 @@ impl Layer { /// although it will still be removed. In this case, consider using the /// `remove_glyph_raw` method instead. pub fn remove_glyph(&mut self, name: &str) -> Option { - self.contents.remove(name); + if let Some(path) = self.contents.remove(name) { + self.path_set.remove(&path.to_string_lossy().to_lowercase()); + } #[cfg(feature = "druid")] return self.glyphs.remove(name).and_then(|g| Arc::try_unwrap(g).ok()); #[cfg(not(feature = "druid"))] @@ -480,6 +500,9 @@ impl Layer { /// Remove the named glyph and return it, including the containing `Arc`. #[cfg(feature = "druid")] pub fn remove_glyph_raw(&mut self, name: &str) -> Option> { + if let Some(path) = self.contents.remove(name) { + self.path_set.remove(&path.to_string_lossy().to_lowercase()); + } self.glyphs.remove(name) } @@ -551,7 +574,7 @@ impl Layer { impl Default for Layer { fn default() -> Self { - Layer::new(DEFAULT_LAYER_NAME, None).unwrap() + Layer::new(Name::new_raw(DEFAULT_LAYER_NAME), DEFAULT_GLYPHS_DIRNAME.into()) } } @@ -767,4 +790,34 @@ mod tests { assert_eq!(layer_set.default_layer().path().as_os_str(), "glyphs"); assert!(layer_set.get("public.default").is_none()); } + + #[test] + fn layerset_duplicate_paths() { + let mut layer_set = LayerSet::default(); + + layer_set.new_layer("Ab").unwrap(); + assert_eq!(layer_set.get("Ab").unwrap().path().as_os_str(), "glyphs.A_b"); + + layer_set.new_layer("a_b").unwrap(); + assert_eq!(layer_set.get("a_b").unwrap().path().as_os_str(), "glyphs.a_b01"); + + layer_set.remove("Ab"); + layer_set.new_layer("Ab").unwrap(); + assert_eq!(layer_set.get("Ab").unwrap().path().as_os_str(), "glyphs.A_b"); + } + + #[test] + fn layer_duplicate_paths() { + let mut layer = Layer::default(); + + layer.insert_glyph(Glyph::new_named("Ab")); + assert_eq!(layer.contents.get("Ab").unwrap().as_os_str(), "A_b.glif"); + + layer.insert_glyph(Glyph::new_named("a_b")); + assert_eq!(layer.contents.get("a_b").unwrap().as_os_str(), "a_b01.glif"); + + layer.remove_glyph("Ab"); + layer.insert_glyph(Glyph::new_named("Ab")); + assert_eq!(layer.contents.get("Ab").unwrap().as_os_str(), "A_b.glif"); + } } diff --git a/src/name.rs b/src/name.rs index 6d44720f..0a012b7e 100644 --- a/src/name.rs +++ b/src/name.rs @@ -45,9 +45,13 @@ impl Name { fn is_valid(name: &str) -> bool { !(name.is_empty() - || name - .bytes() - .any(|b| (0x0..=0x1f).contains(&b) || (0x80..=0x9f).contains(&b) || b == 0x7f)) + // Important: check the chars(), not the bytes(), as UTF-8 encoding + // bytes of course contain control characters. + || name.chars().any(|b| { + (0x0..=0x1f).contains(&(b as u32)) + || (0x80..=0x9f).contains(&(b as u32)) + || b as u32 == 0x7f + })) } impl AsRef for Name { @@ -111,6 +115,7 @@ mod tests { fn assert_eq_str() { assert_eq!(Name::new_raw("hi"), "hi"); assert_eq!("hi", Name::new_raw("hi")); + assert_eq!("hi 💖", Name::new_raw("hi 💖")); assert_eq!(vec![Name::new_raw("a"), Name::new_raw("b")], vec!["a", "b"]); assert_eq!(vec!["a", "b"], vec![Name::new_raw("a"), Name::new_raw("b")]); } diff --git a/src/util.rs b/src/util.rs index 1d08ecf7..6647838c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,5 +1,9 @@ //! Common utilities. +use std::{collections::HashSet, path::PathBuf}; + +use crate::Name; + /// Given a `plist::Dictionary`, recursively sort keys. /// /// This ensures we have a consistent serialization order. @@ -12,87 +16,296 @@ pub(crate) fn recursive_sort_plist_keys(plist: &mut plist::Dictionary) { } } -//NOTE: this is hacky, and intended mostly as a placeholder. It was adapted from -// https://github.com/unified-font-object/ufoLib/blob/master/Lib/ufoLib/filenames.py /// Given a glyph `name`, return an appropriate file name. -pub(crate) fn default_file_name_for_glyph_name(name: impl AsRef) -> String { - let name = name.as_ref(); - user_name_to_file_name(name, "", ".glif") +pub(crate) fn default_file_name_for_glyph_name(name: &Name, existing: &HashSet) -> PathBuf { + user_name_to_file_name(name, "", ".glif", existing) } /// Given a layer `name`, return an appropriate file name. -pub(crate) fn default_file_name_for_layer_name(name: &str) -> String { - user_name_to_file_name(name, "glyphs.", "") +pub(crate) fn default_file_name_for_layer_name(name: &Name, existing: &HashSet) -> PathBuf { + user_name_to_file_name(name, "glyphs.", "", existing) } -//FIXME: this needs to also handle duplicate names, probably by passing in some -// 'exists' fn, like: `impl Fn(&str) -> bool` -fn user_name_to_file_name(name: &str, prefix: &str, suffix: &str) -> String { - static SPECIAL_ILLEGAL: &[char] = &['\\', '*', '+', '/', ':', '<', '>', '?', '[', ']', '|']; - const MAX_LEN: usize = 255; +/// Given a `name`, return an appropriate file name. +/// +/// Expects `existing` to be a set of paths (potentially lossily) converted to +/// _lowercased [`String`]s_. The file names are going to end up in a UTF-8 +/// encoded Apple property list XML file, so file names will be treated as +/// Unicode strings. +/// +/// # Panics +/// +/// Panics if a case-insensitive file name clash was detected and no unique +/// value could be created after 99 numbering attempts. +fn user_name_to_file_name( + name: &Name, + prefix: &str, + suffix: &str, + existing: &HashSet, +) -> PathBuf { + let mut result = String::with_capacity(prefix.len() + name.len() + suffix.len()); - let mut result = String::with_capacity(name.len() + prefix.len() + suffix.len()); - result.push_str(prefix); + // Filter illegal characters from name. + static SPECIAL_ILLEGAL: &[char] = + &[':', '?', '"', '(', ')', '[', ']', '*', '/', '\\', '+', '<', '>', '|']; + + // Assert that the prefix and suffix are safe, as they should be controlled + // by norad. + debug_assert!( + !prefix.chars().any(|c| SPECIAL_ILLEGAL.contains(&c)), + "prefix must not contain illegal chars" + ); + debug_assert!( + suffix.is_empty() || suffix.starts_with('.'), + "suffix must be empty or start with a period" + ); + debug_assert!( + !suffix.chars().any(|c| SPECIAL_ILLEGAL.contains(&c)), + "suffix must not contain illegal chars" + ); + debug_assert!(!suffix.ends_with(['.', ' ']), "suffix must not end in period or space"); + result.push_str(prefix); for c in name.chars() { match c { + // Replace an initial period with an underscore if there is no + // prefix to be added, e.g. for the bare glyph name ".notdef". '.' if result.is_empty() => result.push('_'), - c if (c as u32) < 32 || (c as u32) == 0x7f || SPECIAL_ILLEGAL.contains(&c) => { - result.push('_') - } - c if c.is_ascii_uppercase() => { + // Replace illegal characters with an underscore. + c if SPECIAL_ILLEGAL.contains(&c) => result.push('_'), + // Append an underscore to all uppercase characters. + c if c.is_uppercase() => { result.push(c); result.push('_'); } + // Append the rest unchanged. c => result.push(c), } } - //TODO: check for illegal names, duplicate names - if result.len() + suffix.len() > MAX_LEN { - let mut boundary = 255 - suffix.len(); + // Test for reserved names and parts. The relevant part is the prefix + name + // (or "stem") of the file, so e.g. "com1.glif" would be replaced by + // "_com1.glif", but "hello.com1.glif", "com10.glif" and "acom1.glif" stay + // as they are. For algorithmic simplicity, ignore the presence of the + // suffix and potentially replace more than we strictly need to. + // + // List taken from + // . + static SPECIAL_RESERVED: &[&str] = &[ + "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", + "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", + ]; + if let Some(stem) = result.split('.').next() { + // At this stage, we only need to look for lowercase matches, as every + // uppercase letter will be followed by an underscore, automatically + // making the name safe. + if SPECIAL_RESERVED.contains(&stem) { + result.insert(0, '_'); + } + } + + // Clip prefix + name to 255 characters. + const MAX_LEN: usize = 255; + if result.len().saturating_add(suffix.len()) > MAX_LEN { + let mut boundary = MAX_LEN.saturating_sub(suffix.len()); while !result.is_char_boundary(boundary) { boundary -= 1; } result.truncate(boundary); } + + // Replace trailing periods and spaces by underscores unless we have a + // suffix (which we asserted is safe). + if suffix.is_empty() && result.ends_with(['.', ' ']) { + let mut boundary = result.len(); + for (i, c) in result.char_indices().rev() { + if c != '.' && c != ' ' { + break; + } + boundary = i; + } + let underscores = "_".repeat(result.len() - boundary); + result.replace_range(boundary..result.len(), &underscores); + } + result.push_str(suffix); - result + + // Test for clashes. Use a counter with 2 digits to look for a name not yet + // taken. The UFO specification recommends using 15 digits and lists a + // second way should one exhaust them, but it is unlikely to be needed in + // practice. 1e15 numbers is a ridicuously high number where holding all + // those glyph names in memory would exhaust it. + if existing.contains(&result.to_lowercase()) { + // First, cut off the suffix (plus the space needed for the number + // counter if necessary). + const NUMBER_LEN: usize = 2; + if result.len().saturating_sub(suffix.len()).saturating_add(NUMBER_LEN) > MAX_LEN { + let mut boundary = MAX_LEN.saturating_sub(suffix.len()).saturating_sub(NUMBER_LEN); + while !result.is_char_boundary(boundary) { + boundary -= 1; + } + result.truncate(boundary); + } else { + // Cutting off the suffix should land on a `char` boundary. + result.truncate(result.len().saturating_sub(suffix.len())); + } + + let mut found_unique = false; + for counter in 1..100u8 { + result.push_str(&format!("{:0>2}", counter)); + result.push_str(suffix); + if !existing.contains(&result.to_lowercase()) { + found_unique = true; + break; + } + result.truncate(result.len().saturating_sub(suffix.len()) - NUMBER_LEN); + } + if !found_unique { + // Note: if this is ever hit, try appending a UUIDv4 before panicing. + panic!("Could not find a unique file name after 99 tries") + } + } + + result.into() } #[cfg(test)] mod tests { use super::*; + + fn glif_stem(name: &str) -> String { + let container: HashSet = HashSet::new(); + default_file_name_for_glyph_name(&Name::new_raw(name), &container) + .to_string_lossy() + .trim_end_matches(".glif") + .into() + } + + fn file_name(name: &str, prefix: &str, suffix: &str) -> String { + let container: HashSet = HashSet::new(); + user_name_to_file_name(&Name::new_raw(name), prefix, suffix, &container) + .to_string_lossy() + .to_string() + } + + #[test] + fn path_for_name_basic() { + assert_eq!(glif_stem("newGlyph.1"), "newG_lyph.1".to_string()); + assert_eq!(glif_stem("a"), "a".to_string()); + assert_eq!(glif_stem("A"), "A_".to_string()); + assert_eq!(glif_stem("AE"), "A_E_".to_string()); + assert_eq!(glif_stem("Ae"), "A_e".to_string()); + assert_eq!(glif_stem("ae"), "ae".to_string()); + assert_eq!(glif_stem("aE"), "aE_".to_string()); + assert_eq!(glif_stem("a.alt"), "a.alt".to_string()); + assert_eq!(glif_stem("A.alt"), "A_.alt".to_string()); + assert_eq!(glif_stem("A.Alt"), "A_.A_lt".to_string()); + assert_eq!(glif_stem("A.aLt"), "A_.aL_t".to_string()); + assert_eq!(glif_stem("A.alT"), "A_.alT_".to_string()); + assert_eq!(glif_stem("T_H"), "T__H_".to_string()); + assert_eq!(glif_stem("T_h"), "T__h".to_string()); + assert_eq!(glif_stem("t_h"), "t_h".to_string()); + assert_eq!(glif_stem("F_F_I"), "F__F__I_".to_string()); + assert_eq!(glif_stem("f_f_i"), "f_f_i".to_string()); + assert_eq!(glif_stem("Aacute_V.swash"), "A_acute_V_.swash".to_string()); + assert_eq!(glif_stem(".notdef"), "_notdef".to_string()); + assert_eq!(glif_stem("..notdef"), "_.notdef".to_string()); + assert_eq!(glif_stem("con"), "_con".to_string()); + assert_eq!(glif_stem("CON"), "C_O_N_".to_string()); + assert_eq!(glif_stem("con.alt"), "_con.alt".to_string()); + assert_eq!(glif_stem("alt.con"), "alt.con".to_string()); + } + + #[test] + fn path_for_name_starting_dots() { + assert_eq!(glif_stem("..notdef"), "_.notdef".to_string()); + assert_eq!(file_name(".notdef", "glyphs.", ""), "glyphs..notdef".to_string()); + } + #[test] - fn path_for_name() { - fn trimmed_name(name: &str) -> String { - default_file_name_for_glyph_name(name).trim_end_matches(".glif").into() + fn path_for_name_unicode() { + assert_eq!(file_name("А Б ВГ абвг", "", ""), "А_ Б_ В_Г_ абвг".to_string()); + } + + #[test] + fn path_for_name_reserved() { + assert_eq!(file_name("con", "", ".glif"), "_con.glif".to_string()); + assert_eq!(file_name("Con", "", ".glif"), "C_on.glif".to_string()); + assert_eq!(file_name("cOn", "", ".glif"), "cO_n.glif".to_string()); + assert_eq!(file_name("con._", "", ".glif"), "_con._.glif".to_string()); + assert_eq!(file_name("alt.con", "", ".glif"), "alt.con.glif".to_string()); + assert_eq!(file_name("con", "con.", ".con"), "_con.con.con".to_string()); + + assert_eq!(file_name("com1", "", ""), "_com1".to_string()); + assert_eq!(file_name("com1", "", ".glif"), "_com1.glif".to_string()); + assert_eq!(file_name("com1.", "", ".glif"), "_com1..glif".to_string()); + assert_eq!(file_name("com10", "", ".glif"), "com10.glif".to_string()); + assert_eq!(file_name("acom1", "", ".glif"), "acom1.glif".to_string()); + assert_eq!(file_name("com1", "hello.", ".glif"), "hello.com1.glif".to_string()); + } + + #[test] + fn path_for_name_trailing_periods_spaces() { + assert_eq!(file_name("alt.", "", ""), "alt_".to_string()); + assert_eq!(file_name("alt.", "", ".glif"), "alt..glif".to_string()); + assert_eq!(file_name("alt.. ", "", ".glif"), "alt.. .glif".to_string()); + assert_eq!(file_name("alt.. ", "", ""), "alt____".to_string()); + assert_eq!(file_name("alt.. a. ", "", ""), "alt.. a__".to_string()); + } + + #[test] + fn path_for_name_max_length() { + let spacy_glif_name = format!("{}.glif", " ".repeat(250)); + assert_eq!(file_name(&" ".repeat(255), "", ".glif"), spacy_glif_name); + assert_eq!(file_name(&" ".repeat(256), "", ".glif"), spacy_glif_name); + let dotty_glif_name = format!("_{}.glif", ".".repeat(249)); + assert_eq!(file_name(&".".repeat(255), "", ".glif"), dotty_glif_name); + assert_eq!(file_name(&".".repeat(256), "", ".glif"), dotty_glif_name); + let underscore_glif_name = "_".repeat(255); + assert_eq!(file_name(&" ".repeat(255), "", ""), underscore_glif_name); + assert_eq!(file_name(&".".repeat(255), "", ""), underscore_glif_name); + assert_eq!(file_name(&" ".repeat(256), "", ""), underscore_glif_name); + assert_eq!(file_name(&".".repeat(256), "", ""), underscore_glif_name); + assert_eq!(file_name(&format!("{}💖", " ".repeat(254)), "", ".glif"), spacy_glif_name); + } + + #[test] + fn path_for_name_all_ascii() { + let almost_all_ascii: String = (32..0x7F).map(|i| char::from_u32(i).unwrap()).collect(); + assert_eq!(glif_stem(&almost_all_ascii), " !_#$%&'____,-._0123456789_;_=__@A_B_C_D_E_F_G_H_I_J_K_L_M_N_O_P_Q_R_S_T_U_V_W_X_Y_Z____^_`abcdefghijklmnopqrstuvwxyz{_}~"); + } + + #[test] + fn path_for_name_clashes() { + let mut container = HashSet::new(); + let mut existing = HashSet::new(); + for name in ["Ab", "a_b"] { + let path = user_name_to_file_name(&Name::new_raw(name), "", ".glif", &existing); + existing.insert(path.to_string_lossy().to_string().to_lowercase()); + container.insert(path.to_string_lossy().to_string()); } - assert_eq!(trimmed_name("newGlyph.1"), "newG_lyph.1".to_string()); - assert_eq!(trimmed_name("a"), "a".to_string()); - assert_eq!(trimmed_name("A"), "A_".to_string()); - assert_eq!(trimmed_name("AE"), "A_E_".to_string()); - assert_eq!(trimmed_name("Ae"), "A_e".to_string()); - assert_eq!(trimmed_name("ae"), "ae".to_string()); - assert_eq!(trimmed_name("aE"), "aE_".to_string()); - assert_eq!(trimmed_name("a.alt"), "a.alt".to_string()); - assert_eq!(trimmed_name("A.alt"), "A_.alt".to_string()); - assert_eq!(trimmed_name("A.Alt"), "A_.A_lt".to_string()); - assert_eq!(trimmed_name("A.aLt"), "A_.aL_t".to_string()); - assert_eq!(trimmed_name("A.alT"), "A_.alT_".to_string()); - assert_eq!(trimmed_name("T_H"), "T__H_".to_string()); - assert_eq!(trimmed_name("T_h"), "T__h".to_string()); - assert_eq!(trimmed_name("t_h"), "t_h".to_string()); - assert_eq!(trimmed_name("F_F_I"), "F__F__I_".to_string()); - assert_eq!(trimmed_name("f_f_i"), "f_f_i".to_string()); - assert_eq!(trimmed_name("Aacute_V.swash"), "A_acute_V_.swash".to_string()); - assert_eq!(trimmed_name(".notdef"), "_notdef".to_string()); - - //FIXME: we're ignoring 'reserved filenames' for now - //assert_eq!(trimmed_name("con"), "_con".to_string()); - //assert_eq!(trimmed_name("CON"), "C_O_N_".to_string()); - //assert_eq!(trimmed_name("con.alt"), "_con.alt".to_string()); - //assert_eq!(trimmed_name("alt.con"), "alt._con".to_string()); + let mut container_expected = HashSet::new(); + container_expected.insert("A_b.glif".to_string()); + container_expected.insert("a_b01.glif".to_string()); + + assert_eq!(container, container_expected); + } + + #[test] + fn path_for_name_clashes_max_len() { + let mut container = HashSet::new(); + let mut existing = HashSet::new(); + for name in ["A".repeat(300), "a_".repeat(150)] { + let path = user_name_to_file_name(&Name::new_raw(&name), "", ".glif", &existing); + existing.insert(path.to_string_lossy().to_string().to_lowercase()); + container.insert(path.to_string_lossy().to_string()); + } + + let mut container_expected = HashSet::new(); + container_expected.insert(format!("{}.glif", "A_".repeat(125))); + container_expected.insert(format!("{}01.glif", "a_".repeat(125))); + + assert_eq!(container, container_expected); } }