1const LANGUAGE_CODES: &str = include_str!("iso-639-1.txt");
14
15pub fn validate_language(language: &str) -> Result<(), String> {
25 if LANGUAGE_CODES
26 .split_whitespace()
27 .any(|code| code == language)
28 {
29 Ok(())
30 } else {
31 Err(format!(
32 "invalid ISO 639-1 language code {language:?}; use a lowercase code such as ko or en"
33 ))
34 }
35}
36
37pub fn document_path(path: &str, language: Option<&str>) -> Result<String, String> {
49 if path.is_empty()
50 || path.starts_with('/')
51 || path.contains(['\\', ':'])
52 || path.split('/').any(|part| part.is_empty() || part == "..")
53 {
54 return Err(
55 "document path must be a nonempty package-relative path using /, without ..".into(),
56 );
57 }
58 let filename = path.rsplit('/').next().unwrap_or_default();
59 let (stem, extension) = filename
60 .rsplit_once('.')
61 .filter(|(stem, extension)| !stem.is_empty() && !extension.is_empty())
62 .ok_or("document path must have a filename and extension, for example docs/guide.md")?;
63 match language {
64 None => Ok(path.to_owned()),
65 Some(language) => {
66 validate_language(language)?;
67 let directory = &path[..path.len() - filename.len()];
68 Ok(format!("{directory}{stem}.{language}.{extension}"))
69 }
70 }
71}
72
73pub fn directory_document_path(
91 path: &str,
92 directories: &[(&str, &str)],
93 language: Option<&str>,
94) -> Result<String, String> {
95 document_path(path, None)?;
96 if directories.is_empty() {
97 return Err("at least one language directory mapping is required".into());
98 }
99 for (index, &(code, directory)) in directories.iter().enumerate() {
100 validate_language(code)?;
101 if directories[..index]
102 .iter()
103 .any(|&(previous, _)| previous == code)
104 {
105 return Err(format!("duplicate language directory mapping for {code:?}"));
106 }
107 let directory = directory.strip_suffix('/').unwrap_or(directory);
109 if directory.is_empty()
110 || directory.starts_with('/')
111 || directory.contains(['\\', ':'])
112 || directory
113 .split('/')
114 .any(|part| part.is_empty() || part == "..")
115 {
116 return Err(
117 "language directory must be a nonempty package-relative path using /, without .."
118 .into(),
119 );
120 }
121 }
122 let Some(language) = language else {
123 return Ok(path.to_owned());
124 };
125 validate_language(language)?;
126 let directory = directories
127 .iter()
128 .find_map(|&(code, directory)| (code == language).then_some(directory))
129 .ok_or_else(|| format!("no document directory registered for language {language:?}"))?;
130 let filename = path.rsplit('/').next().unwrap();
131 Ok(format!(
132 "{}/{filename}",
133 directory.strip_suffix('/').unwrap_or(directory)
134 ))
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn accepts_assigned_codes_only() {
143 for code in ["ko", "en", "ja", "zh", "zu"] {
144 assert!(validate_language(code).is_ok());
145 }
146 for code in ["", "zz", "KO", "kor", "ko-KR", "../ko"] {
147 assert!(validate_language(code).is_err(), "{code}");
148 }
149 }
150
151 #[test]
152 fn table_is_sorted_unique_and_has_two_letter_codes() {
153 let codes: Vec<_> = LANGUAGE_CODES.split_whitespace().collect();
154 assert!(codes.len() > 180);
155 assert!(codes.windows(2).all(|pair| pair[0] < pair[1]));
156 assert!(
157 codes
158 .iter()
159 .all(|code| code.len() == 2 && code.bytes().all(|byte| byte.is_ascii_lowercase()))
160 );
161 }
162
163 #[test]
164 fn selects_only_the_filename_suffix() {
165 assert_eq!(
166 document_path("docs/guide.md", None).unwrap(),
167 "docs/guide.md"
168 );
169 assert_eq!(
170 document_path("docs.v1/api.guide.md", Some("ko")).unwrap(),
171 "docs.v1/api.guide.ko.md"
172 );
173 assert_eq!(
174 document_path("docs/안내.md", Some("ja")).unwrap(),
175 "docs/안내.ja.md"
176 );
177 }
178
179 #[test]
180 fn rejects_ambiguous_or_nonportable_paths() {
181 for path in [
182 "",
183 "/a.md",
184 "../a.md",
185 "docs/../a.md",
186 "a",
187 ".md",
188 "a.",
189 "a//b.md",
190 "C:/a.md",
191 "docs\\a.md",
192 ] {
193 assert!(document_path(path, Some("ko")).is_err(), "{path}");
194 }
195 assert!(document_path("a.md", Some("zz")).is_err());
196 }
197}
198
199#[cfg(test)]
200mod directory_tests {
201 use super::directory_document_path as select;
202
203 #[test]
204 fn selects_basename_and_explicit_directory() {
205 let mappings = [("ko", "docs/ko/"), ("en", "translations/english")];
206 assert_eq!(
207 select("docs/nested/api.guide.md", &mappings, None).unwrap(),
208 "docs/nested/api.guide.md"
209 );
210 assert_eq!(
211 select("docs/nested/api.guide.md", &mappings, Some("ko")).unwrap(),
212 "docs/ko/api.guide.md"
213 );
214 assert_eq!(
215 select("docs/안내.md", &mappings, Some("en")).unwrap(),
216 "translations/english/안내.md"
217 );
218 assert!(
219 select("a.md", &mappings, Some("ja"))
220 .unwrap_err()
221 .contains("no document directory registered")
222 );
223 assert!(
224 select("a.md", &mappings, Some("zz"))
225 .unwrap_err()
226 .contains("invalid ISO 639-1")
227 );
228 }
229
230 #[test]
231 fn validates_all_mappings_even_without_language_selection() {
232 assert!(select("a.md", &[], None).is_err());
233 assert!(select("a.md", &[("ko", "ko"), ("ko", "other")], None).is_err());
234 for code in ["zz", "KO", "kor", "ko-KR"] {
235 assert!(select("a.md", &[(code, "ko")], None).is_err());
236 }
237 for directory in [
238 "",
239 "/",
240 "/ko",
241 "../ko",
242 "docs/../ko",
243 "docs//ko",
244 "ko//",
245 "C:/ko",
246 "docs\\ko",
247 ] {
248 assert!(
249 select("a.md", &[("ko", directory)], None).is_err(),
250 "{directory}"
251 );
252 }
253 assert!(select("../a.md", &[("ko", "ko")], None).is_err());
254 }
255}