use crate::model::{GamePath, GamePathBuf, GamePathError};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum SchemaDirectory {
GameRoot,
Directory(GamePathBuf),
}
impl SchemaDirectory {
pub fn parse(text: &str) -> Result<Self, GamePathError> {
if matches!(text, "game" | "game/") {
return Ok(Self::GameRoot);
}
let relative = text.strip_prefix("game/").unwrap_or(text);
GamePathBuf::parse(relative).map(Self::Directory)
}
pub fn as_game_path(&self) -> Option<&GamePath> {
match self {
Self::GameRoot => None,
Self::Directory(path) => Some(path),
}
}
pub fn match_depth(&self, path_file: Option<&GamePath>, file: &GamePath) -> Option<usize> {
let Self::Directory(directory) = self else {
return None;
};
let mut components = file.iter();
let mut depth = 0;
for name in directory
.iter()
.chain(path_file.into_iter().flat_map(|path| path.iter()))
{
if !components
.next()
.is_some_and(|component| component.eq_ignore_ascii_case(name))
{
return None;
}
depth += 1;
}
(path_file.is_none() || components.next().is_none()).then_some(depth)
}
}
#[cfg(test)]
mod tests {
use super::SchemaDirectory;
use crate::model::{GamePath, GamePathErrorKind};
fn game_path(text: &str) -> &GamePath {
GamePath::new(text).expect("valid game path")
}
fn directory(text: &str) -> SchemaDirectory {
SchemaDirectory::parse(text).expect("valid rule path")
}
#[test]
fn exactly_one_game_prefix_is_removed() {
assert_eq!(
directory("game/common/ideas").as_game_path(),
Some(game_path("common/ideas"))
);
assert_eq!(
directory("events").as_game_path(),
Some(game_path("events"))
);
assert_eq!(
directory("game/game/events").as_game_path(),
Some(game_path("game/events"))
);
assert_eq!(directory("game"), SchemaDirectory::GameRoot);
assert_eq!(directory("game/"), SchemaDirectory::GameRoot);
}
#[test]
fn rule_paths_that_are_not_game_paths_are_rejected() {
for (text, kind) in [
(
r"game/common\ideas",
GamePathErrorKind::ReservedCharacter {
component: r"common\ideas".to_string(),
character: '\\',
},
),
("game/common/../events", GamePathErrorKind::ParentComponent),
("game/common/", GamePathErrorKind::EmptyComponent),
("/events", GamePathErrorKind::NotRelative),
("", GamePathErrorKind::Empty),
] {
assert_eq!(
SchemaDirectory::parse(text).expect_err(text).kind,
kind,
"{text}"
);
}
}
#[test]
fn a_directory_matches_itself_and_below_on_component_boundaries() {
let common = directory("game/common");
assert_eq!(common.match_depth(None, game_path("common/foo")), Some(1));
assert_eq!(common.match_depth(None, game_path("common")), Some(1));
assert_eq!(common.match_depth(None, game_path("commonplace/foo")), None);
assert_eq!(
directory("game/common/ideas").match_depth(None, game_path("common/ideas/x.txt")),
Some(2)
);
}
#[test]
fn names_match_ignoring_ascii_case() {
let ideas = directory("game/common/ideas");
assert_eq!(
ideas.match_depth(None, game_path("Common/IDEAS/00_Ideas.txt")),
Some(2)
);
assert_eq!(
directory("game/Common/Ideas").match_depth(None, game_path("common/ideas/x.txt")),
Some(2)
);
assert_eq!(
ideas.match_depth(
Some(game_path("Achievements.TXT")),
game_path("common/ideas/achievements.txt")
),
Some(3)
);
}
#[test]
fn the_game_root_matches_no_file() {
let root = SchemaDirectory::GameRoot;
for file in ["events/a.txt", "a.txt", "common/x/y.txt"] {
assert_eq!(root.match_depth(None, game_path(file)), None, "{file}");
assert_eq!(
root.match_depth(Some(game_path("a.txt")), game_path(file)),
None,
"{file}"
);
}
}
#[test]
fn path_file_matches_only_that_direct_child() {
let common = directory("game/common");
let file = Some(game_path("achievements.txt"));
assert_eq!(
common.match_depth(file, game_path("common/achievements.txt")),
Some(2)
);
for other in [
"common/sub/achievements.txt",
"common/achievements.txt.bak",
"common/other.txt",
"common",
"achievements.txt",
] {
assert_eq!(common.match_depth(file, game_path(other)), None, "{other}");
}
}
}