1use std::collections::HashMap;
5
6use unicode_normalization::UnicodeNormalization;
7
8use crate::error::{ErrorCode, MdzError};
9
10pub const SUPPORT_FILE_NAMES: [&str; 5] = ["readme", "agents", "license", "changelog", "contributing"];
13
14pub fn nfc(path: &str) -> String {
16 path.nfc().collect()
17}
18
19pub(crate) fn ascii_lower(text: &str) -> String {
21 text.to_ascii_lowercase()
22}
23
24pub fn same_path(a: &str, b: &str) -> bool {
26 a == b || nfc(a) == nfc(b)
27}
28
29pub(crate) fn find_path<'a, I>(paths: I, wanted: &str) -> Option<&'a str>
31where
32 I: IntoIterator<Item = &'a str>,
33{
34 let target = nfc(wanted);
35 paths.into_iter().find(|p| nfc(p) == target)
36}
37
38pub fn is_markdown(path: &str) -> bool {
41 let lower = ascii_lower(path);
42 lower.ends_with(".md") || lower.ends_with(".markdown")
43}
44
45fn markdown_stem(lower: &str) -> &str {
46 lower
47 .strip_suffix(".markdown")
48 .or_else(|| lower.strip_suffix(".md"))
49 .unwrap_or(lower)
50}
51
52pub fn is_support_file(path: &str) -> bool {
55 if path.contains('/') || !is_markdown(path) {
56 return false;
57 }
58 let lower = ascii_lower(path);
59 SUPPORT_FILE_NAMES.contains(&markdown_stem(&lower))
60}
61
62fn is_control(c: char) -> bool {
63 (c as u32) < 0x20 || c as u32 == 0x7f
64}
65
66fn is_reserved(c: char) -> bool {
67 matches!(c, '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
68}
69
70pub fn validate_path(path: &str) -> Result<(), MdzError> {
75 if path.is_empty() {
76 return Err(MdzError::new(ErrorCode::PathEmpty, "path is empty"));
77 }
78 if path.starts_with('/') {
79 return Err(MdzError::new(
80 ErrorCode::PathLeadingSlash,
81 format!("'{path}' starts with '/'"),
82 ));
83 }
84 if path.chars().any(is_control) {
85 return Err(MdzError::new(
86 ErrorCode::PathControlChar,
87 format!("{path:?} contains a control character"),
88 ));
89 }
90 if path.chars().any(is_reserved) {
91 return Err(MdzError::new(
92 ErrorCode::PathReservedChar,
93 format!("'{path}' contains a reserved character (\\ : * ? \" < > |)"),
94 ));
95 }
96 let body = path.strip_suffix('/').unwrap_or(path);
97 if body.split('/').any(|s| s == "..") {
98 return Err(MdzError::new(
99 ErrorCode::PathTraversal,
100 format!("'{path}' contains a '..' segment"),
101 ));
102 }
103 if body.split('/').any(|s| s.is_empty() || s == ".") {
104 return Err(MdzError::new(
105 ErrorCode::PathInvalid,
106 format!("'{path}' contains an empty or '.' segment"),
107 ));
108 }
109 Ok(())
110}
111
112pub fn validate_entry_set<'a, I>(names: I) -> Result<(), MdzError>
118where
119 I: IntoIterator<Item = &'a str>,
120{
121 let mut files: Vec<(String, &str)> = Vec::new();
122 let mut file_index: HashMap<String, &str> = HashMap::new();
123 let mut directories: HashMap<String, &str> = HashMap::new();
124 for name in names {
125 let is_dir = name.ends_with('/');
126 let key = nfc(if is_dir { &name[..name.len() - 1] } else { name });
127 if !is_dir {
128 if let Some(other) = file_index.get(&key) {
129 return Err(MdzError::new(
130 ErrorCode::DuplicatePath,
131 format!("'{name}' and '{other}' are the same path (Section 5.4)"),
132 ));
133 }
134 file_index.insert(key.clone(), name);
135 files.push((key.clone(), name));
136 } else {
137 directories.entry(key.clone()).or_insert(name);
138 }
139 let mut end = 0;
140 while let Some(i) = key[end..].find('/') {
141 end += i;
142 directories.entry(key[..end].to_string()).or_insert(name);
143 end += 1;
144 }
145 }
146 for (key, name) in &files {
147 if let Some(dir) = directories.get(key) {
148 return Err(MdzError::new(
149 ErrorCode::PathConflict,
150 format!("'{name}' is a file and also a directory of '{dir}' (Section 5.4)"),
151 ));
152 }
153 }
154 Ok(())
155}
156
157pub(crate) fn strip_dot_slash(value: &str) -> &str {
159 value.strip_prefix("./").unwrap_or(value)
160}
161
162pub(crate) fn entry_point_problem(value: &str) -> Option<String> {
165 if value.is_empty() {
166 return Some("entryPoint must be a non-empty string".into());
167 }
168 let path = strip_dot_slash(value);
169 if path.ends_with('/') {
170 return Some(format!("entryPoint '{value}' names a directory"));
171 }
172 if let Err(e) = validate_path(path) {
173 return Some(format!(
174 "entryPoint '{value}' is not a valid archive path ({})",
175 e.message()
176 ));
177 }
178 if !is_markdown(path) {
179 return Some(format!("entryPoint '{value}' is not a .md or .markdown file"));
180 }
181 None
182}
183
184pub(crate) fn cover_problem(value: &str) -> Option<String> {
186 if value.is_empty() {
187 return Some("cover must be a non-empty string".into());
188 }
189 let path = strip_dot_slash(value);
190 if path.ends_with('/') {
191 return Some(format!("cover '{value}' names a directory"));
192 }
193 validate_path(path)
194 .err()
195 .map(|e| format!("cover '{value}' is not a valid archive path ({})", e.message()))
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn support_files_are_ascii_case_insensitive() {
204 assert!(is_support_file("README.md"));
205 assert!(is_support_file("readme.MARKDOWN"));
206 assert!(is_support_file("AGENTS.MD"));
207 assert!(!is_support_file("docs/README.md"));
208 assert!(!is_support_file("README-draft.md"));
209 assert!(!is_support_file("L\u{130}CENSE.md"));
211 }
212
213 #[test]
214 fn nfc_and_nfd_match_but_case_does_not() {
215 assert!(same_path("caf\u{e9}.md", "cafe\u{301}.md"));
216 assert!(!same_path("Index.md", "index.md"));
217 }
218}