1use std::{
4 collections::{BTreeMap, HashMap, HashSet},
5 env,
6 ffi::{OsStr, OsString},
7 fs,
8 path::{Component, Path, PathBuf},
9};
10
11const LINUX_APPLICATION_DIR: &str = "mant";
12const MACOS_APPLICATION_DIR: &str = "ManT";
13const WINDOWS_APPLICATION_DIR: &str = "ManT";
14const DOCUMENTS_DIR: &str = "documents";
15const DEFAULT_SYSTEM_DATA_DIRS: [&str; 2] = ["/usr/local/share", "/usr/share"];
16const MACOS_SYSTEM_DATA_DIR: &str = "/Library/Application Support";
17const MARKDOWN_EXTENSIONS: [&str; 2] = ["md", "markdown"];
18const MAX_DIRECTORY_DEPTH: usize = 32;
19const MAX_VISITED_DIRECTORIES: usize = 4096;
20const MAX_DISCOVERED_DOCUMENTS: usize = 10_000;
21
22#[derive(Debug)]
23struct DocumentCandidate {
24 name: String,
25 depth: usize,
26 parent: PathBuf,
27 extension_priority: u8,
28 path: PathBuf,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum RegisteredDocumentOrigin {
34 User,
35 System,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RegisteredDocument {
41 pub name: String,
42 pub path: PathBuf,
43 pub origin: RegisteredDocumentOrigin,
44}
45
46#[must_use]
48pub fn find_registered_document(document: &str) -> Option<RegisteredDocument> {
49 let environment = env::vars_os().collect::<HashMap<_, _>>();
50 find_registered_document_with(document, &environment)
51}
52
53#[must_use]
55pub fn list_registered_documents() -> Vec<RegisteredDocument> {
56 let environment = env::vars_os().collect::<HashMap<_, _>>();
57 list_registered_documents_with(&environment)
58}
59
60fn find_registered_document_with(
61 document: &str,
62 environment: &HashMap<OsString, OsString>,
63) -> Option<RegisteredDocument> {
64 find_registered_document_in(document, registration_roots(environment))
65}
66
67fn find_registered_document_in(
68 document: &str,
69 roots: impl IntoIterator<Item = (PathBuf, RegisteredDocumentOrigin)>,
70) -> Option<RegisteredDocument> {
71 let document = document.trim();
72 if !is_safe_document_name(document) {
73 return None;
74 }
75 list_registered_documents_in(roots)
76 .into_iter()
77 .find(|candidate| candidate.name == document)
78}
79
80fn list_registered_documents_with(
81 environment: &HashMap<OsString, OsString>,
82) -> Vec<RegisteredDocument> {
83 list_registered_documents_in(registration_roots(environment))
84}
85
86fn list_registered_documents_in(
87 roots: impl IntoIterator<Item = (PathBuf, RegisteredDocumentOrigin)>,
88) -> Vec<RegisteredDocument> {
89 let mut documents = BTreeMap::<String, RegisteredDocument>::new();
90 for (root, origin) in roots {
91 for candidate in scan_registration_root(&root) {
92 documents
93 .entry(candidate.name.clone())
94 .or_insert(RegisteredDocument {
95 name: candidate.name,
96 path: candidate.path,
97 origin,
98 });
99 }
100 }
101 documents.into_values().collect()
102}
103
104fn registration_roots(
105 environment: &HashMap<OsString, OsString>,
106) -> Vec<(PathBuf, RegisteredDocumentOrigin)> {
107 if cfg!(windows) {
108 return windows_registration_roots(environment);
109 }
110 if cfg!(target_os = "macos") {
111 return macos_registration_roots(environment);
112 }
113 linux_registration_roots(environment)
114}
115
116fn windows_registration_roots(
117 environment: &HashMap<OsString, OsString>,
118) -> Vec<(PathBuf, RegisteredDocumentOrigin)> {
119 let mut roots = Vec::new();
120 let mut seen = HashSet::new();
121 if let Some(app_data) = absolute_environment_path(environment, "APPDATA") {
122 push_registration_root(
123 &mut roots,
124 &mut seen,
125 &app_data,
126 WINDOWS_APPLICATION_DIR,
127 RegisteredDocumentOrigin::User,
128 );
129 }
130 if let Some(program_data) = absolute_environment_path(environment, "PROGRAMDATA") {
131 push_registration_root(
132 &mut roots,
133 &mut seen,
134 &program_data,
135 WINDOWS_APPLICATION_DIR,
136 RegisteredDocumentOrigin::System,
137 );
138 }
139 roots
140}
141
142fn linux_registration_roots(
143 environment: &HashMap<OsString, OsString>,
144) -> Vec<(PathBuf, RegisteredDocumentOrigin)> {
145 let mut roots = Vec::new();
146 let mut seen = HashSet::new();
147
148 let user_data = absolute_environment_path(environment, "XDG_DATA_HOME").or_else(|| {
149 environment
150 .get(OsStr::new("HOME"))
151 .map(PathBuf::from)
152 .filter(|path| path.is_absolute())
153 .map(|home| home.join(".local/share"))
154 });
155 if let Some(root) = user_data {
156 push_registration_root(
157 &mut roots,
158 &mut seen,
159 &root,
160 LINUX_APPLICATION_DIR,
161 RegisteredDocumentOrigin::User,
162 );
163 }
164
165 let system_roots = environment.get(OsStr::new("XDG_DATA_DIRS")).map_or_else(
166 || {
167 DEFAULT_SYSTEM_DATA_DIRS
168 .into_iter()
169 .map(PathBuf::from)
170 .collect::<Vec<_>>()
171 },
172 |value| {
173 env::split_paths(value)
174 .filter(|path| path.is_absolute())
175 .collect()
176 },
177 );
178 for root in system_roots {
179 push_registration_root(
180 &mut roots,
181 &mut seen,
182 &root,
183 LINUX_APPLICATION_DIR,
184 RegisteredDocumentOrigin::System,
185 );
186 }
187 roots
188}
189
190fn macos_registration_roots(
191 environment: &HashMap<OsString, OsString>,
192) -> Vec<(PathBuf, RegisteredDocumentOrigin)> {
193 let mut roots = Vec::new();
194 let mut seen = HashSet::new();
195 if let Some(home) = absolute_environment_path(environment, "HOME") {
196 push_registration_root(
197 &mut roots,
198 &mut seen,
199 &home.join("Library/Application Support"),
200 MACOS_APPLICATION_DIR,
201 RegisteredDocumentOrigin::User,
202 );
203 }
204 push_registration_root(
205 &mut roots,
206 &mut seen,
207 Path::new(MACOS_SYSTEM_DATA_DIR),
208 MACOS_APPLICATION_DIR,
209 RegisteredDocumentOrigin::System,
210 );
211 roots
212}
213
214fn push_registration_root(
215 roots: &mut Vec<(PathBuf, RegisteredDocumentOrigin)>,
216 seen: &mut HashSet<PathBuf>,
217 root: &Path,
218 application_dir: &str,
219 origin: RegisteredDocumentOrigin,
220) {
221 let root = root.join(application_dir).join(DOCUMENTS_DIR);
222 if seen.insert(root.clone()) {
223 roots.push((root, origin));
224 }
225}
226
227fn scan_registration_root(root: &Path) -> Vec<DocumentCandidate> {
228 let mut visited = HashSet::new();
229 let mut candidates = Vec::new();
230 scan_registration_directory(root, root, 0, &mut visited, &mut candidates);
231 candidates.sort_unstable_by(|left, right| {
232 (
233 &left.name,
234 left.depth,
235 &left.parent,
236 left.extension_priority,
237 &left.path,
238 )
239 .cmp(&(
240 &right.name,
241 right.depth,
242 &right.parent,
243 right.extension_priority,
244 &right.path,
245 ))
246 });
247 candidates
248}
249
250fn scan_registration_directory(
251 root: &Path,
252 directory: &Path,
253 depth: usize,
254 visited: &mut HashSet<PathBuf>,
255 candidates: &mut Vec<DocumentCandidate>,
256) {
257 if depth > MAX_DIRECTORY_DEPTH
258 || visited.len() >= MAX_VISITED_DIRECTORIES
259 || candidates.len() >= MAX_DISCOVERED_DOCUMENTS
260 {
261 return;
262 }
263 let Ok(identity) = fs::canonicalize(directory) else {
264 return;
265 };
266 if !visited.insert(identity) {
267 return;
268 }
269 let Ok(entries) = fs::read_dir(directory) else {
270 return;
271 };
272 let mut entries = entries.flatten().collect::<Vec<_>>();
273 entries.sort_unstable_by_key(fs::DirEntry::file_name);
274 for entry in entries {
275 if candidates.len() >= MAX_DISCOVERED_DOCUMENTS {
276 break;
277 }
278 let path = entry.path();
279 let Ok(metadata) = fs::metadata(&path) else {
280 continue;
281 };
282 if metadata.is_dir() {
283 scan_registration_directory(root, &path, depth + 1, visited, candidates);
284 } else if metadata.is_file()
285 && let Some(name) = markdown_document_name(&path)
286 && let Some(extension_priority) = markdown_extension_priority(&path)
287 {
288 let relative = path.strip_prefix(root).unwrap_or(&path);
289 candidates.push(DocumentCandidate {
290 name,
291 depth,
292 parent: relative
293 .parent()
294 .unwrap_or_else(|| Path::new(""))
295 .to_owned(),
296 extension_priority,
297 path,
298 });
299 }
300 }
301}
302
303fn absolute_environment_path(
304 environment: &HashMap<OsString, OsString>,
305 name: &str,
306) -> Option<PathBuf> {
307 let value = environment.get(OsStr::new(name));
308 #[cfg(windows)]
309 let value = value.or_else(|| {
310 environment
311 .iter()
312 .find(|(candidate, _)| candidate.to_string_lossy().eq_ignore_ascii_case(name))
313 .map(|(_, value)| value)
314 });
315 value.map(PathBuf::from).filter(|path| path.is_absolute())
316}
317
318fn is_safe_document_name(document: &str) -> bool {
319 !document.is_empty()
320 && Path::new(document)
321 .components()
322 .all(|component| matches!(component, Component::Normal(_)))
323 && Path::new(document).file_name() == Some(OsStr::new(document))
324}
325
326fn markdown_document_name(path: &Path) -> Option<String> {
327 markdown_extension_priority(path)?;
328 let name = path.file_stem()?.to_str()?;
329 is_safe_document_name(name).then(|| name.to_owned())
330}
331
332fn markdown_extension_priority(path: &Path) -> Option<u8> {
333 let extension = path.extension()?.to_str()?;
334 MARKDOWN_EXTENSIONS
335 .iter()
336 .position(|candidate| extension.eq_ignore_ascii_case(candidate))
337 .and_then(|index| u8::try_from(index).ok())
338}
339
340#[cfg(test)]
341mod tests {
342 use std::{
343 collections::HashMap,
344 ffi::OsString,
345 fs,
346 path::{Path, PathBuf},
347 };
348
349 use super::{
350 RegisteredDocumentOrigin, find_registered_document_in, list_registered_documents_in,
351 };
352
353 #[cfg(unix)]
354 use super::{linux_registration_roots, macos_registration_roots};
355
356 #[cfg(windows)]
357 use super::windows_registration_roots;
358
359 fn environment(values: &[(&str, &Path)]) -> HashMap<OsString, OsString> {
360 values
361 .iter()
362 .map(|(name, value)| (OsString::from(name), value.as_os_str().to_owned()))
363 .collect()
364 }
365
366 fn temporary_root(label: &str) -> PathBuf {
367 std::env::temp_dir().join(format!(
368 "mant-document-{label}-{}-{:?}",
369 std::process::id(),
370 std::thread::current().id()
371 ))
372 }
373
374 fn registration_roots(
375 user: &Path,
376 system: Option<&Path>,
377 ) -> Vec<(PathBuf, RegisteredDocumentOrigin)> {
378 let mut roots = vec![(user.to_owned(), RegisteredDocumentOrigin::User)];
379 if let Some(system) = system {
380 roots.push((system.to_owned(), RegisteredDocumentOrigin::System));
381 }
382 roots
383 }
384
385 #[cfg(unix)]
386 #[test]
387 fn xdg_user_and_system_directories_follow_documented_precedence() {
388 let home = Path::new("/home/demo");
389 let user = Path::new("/data/user");
390 let system = Path::new("/data/system");
391 let environment = environment(&[
392 ("HOME", home),
393 ("XDG_DATA_HOME", user),
394 ("XDG_DATA_DIRS", system),
395 ]);
396
397 assert_eq!(
398 linux_registration_roots(&environment),
399 vec![
400 (user.join("mant/documents"), RegisteredDocumentOrigin::User),
401 (
402 system.join("mant/documents"),
403 RegisteredDocumentOrigin::System
404 ),
405 ]
406 );
407 }
408
409 #[cfg(unix)]
410 #[test]
411 fn macos_uses_application_support_document_roots() {
412 let home = Path::new("/Users/demo");
413 let environment = environment(&[("HOME", home)]);
414
415 assert_eq!(
416 macos_registration_roots(&environment),
417 vec![
418 (
419 home.join("Library/Application Support/ManT/documents"),
420 RegisteredDocumentOrigin::User,
421 ),
422 (
423 PathBuf::from("/Library/Application Support/ManT/documents"),
424 RegisteredDocumentOrigin::System,
425 ),
426 ]
427 );
428 }
429
430 #[cfg(windows)]
431 #[test]
432 fn windows_uses_roaming_and_machine_document_roots() {
433 let app_data = Path::new(r"C:\Users\demo\AppData\Roaming");
434 let program_data = Path::new(r"C:\ProgramData");
435 let environment = environment(&[("AppData", app_data), ("ProgramData", program_data)]);
436
437 assert_eq!(
438 windows_registration_roots(&environment),
439 vec![
440 (
441 app_data.join("ManT/documents"),
442 RegisteredDocumentOrigin::User,
443 ),
444 (
445 program_data.join("ManT/documents"),
446 RegisteredDocumentOrigin::System,
447 ),
448 ]
449 );
450 }
451
452 #[test]
453 fn lookup_rejects_paths_and_prefers_user_markdown() {
454 let root = temporary_root("lookup");
455 let user = root.join("user-documents");
456 let system = root.join("system-documents");
457 fs::create_dir_all(&user).expect("user documents");
458 fs::create_dir_all(&system).expect("system documents");
459 fs::write(user.join("tool.md"), "# User").expect("user document");
460 fs::write(system.join("tool.md"), "# System").expect("system document");
461 let roots = registration_roots(&user, Some(&system));
462
463 let document =
464 find_registered_document_in("tool", roots.clone()).expect("registered document");
465 assert_eq!(document.path, user.join("tool.md"));
466 assert_eq!(document.origin, RegisteredDocumentOrigin::User);
467 assert!(find_registered_document_in("../tool", roots).is_none());
468
469 fs::remove_dir_all(root).expect("remove fixture");
470 }
471
472 #[test]
473 fn listing_is_sorted_deduplicated_and_accepts_both_markdown_extensions() {
474 let root = temporary_root("list");
475 let user = root.join("user-documents");
476 let system = root.join("system-documents");
477 fs::create_dir_all(&user).expect("user documents");
478 fs::create_dir_all(&system).expect("system documents");
479 fs::write(user.join("zeta.markdown"), "# Zeta").expect("user document");
480 fs::write(user.join("alpha.md"), "# Alpha").expect("user document");
481 fs::write(user.join("alpha.markdown"), "# Lower priority")
482 .expect("alternate user document");
483 fs::write(system.join("alpha.md"), "# Shadowed").expect("system document");
484 fs::write(system.join("not-markdown.txt"), "ignored").expect("other file");
485
486 let documents = list_registered_documents_in(registration_roots(&user, Some(&system)));
487 assert_eq!(
488 documents
489 .iter()
490 .map(|document| document.name.as_str())
491 .collect::<Vec<_>>(),
492 ["alpha", "zeta"]
493 );
494 assert_eq!(documents[0].path, user.join("alpha.md"));
495
496 fs::remove_dir_all(root).expect("remove fixture");
497 }
498
499 #[test]
500 fn document_discovery_is_confined_to_the_documents_layer() {
501 let root = temporary_root("documents-layer");
502 let application = root.join("mant");
503 let documents = application.join("documents");
504 fs::create_dir_all(&documents).expect("document directory");
505 fs::write(application.join("current.md"), "# Outside scanner")
506 .expect("non-document application data");
507 fs::write(documents.join("current.md"), "# Current").expect("registered document");
508
509 let discovered = list_registered_documents_in(registration_roots(&documents, None));
510 assert_eq!(discovered.len(), 1);
511 assert_eq!(discovered[0].path, documents.join("current.md"));
512
513 fs::remove_dir_all(root).expect("remove fixture");
514 }
515
516 #[cfg(unix)]
517 #[test]
518 fn nested_directories_and_symlinks_are_discovered_without_cycles() {
519 use std::os::unix::fs::symlink;
520
521 let root = temporary_root("nested-links");
522 let registration = root.join("documents");
523 let external = root.join("external");
524 fs::create_dir_all(registration.join("team")).expect("nested registration");
525 fs::create_dir_all(&external).expect("external documents");
526 fs::write(registration.join("guide.markdown"), "# Shallow").expect("shallow document");
527 fs::write(registration.join("team/guide.md"), "# Nested").expect("nested duplicate");
528 fs::write(external.join("linked.md"), "# Linked").expect("linked document");
529 symlink(&external, registration.join("imported")).expect("linked directory");
530 symlink(®istration, external.join("cycle")).expect("directory cycle");
531 symlink(external.join("linked.md"), registration.join("alias.md")).expect("linked file");
532
533 let documents = list_registered_documents_in(registration_roots(®istration, None));
534 assert_eq!(
535 documents
536 .iter()
537 .map(|document| document.name.as_str())
538 .collect::<Vec<_>>(),
539 ["alias", "guide", "linked"]
540 );
541 assert_eq!(
542 documents
543 .iter()
544 .find(|document| document.name == "guide")
545 .expect("guide")
546 .path,
547 registration.join("guide.markdown")
548 );
549 assert_eq!(
550 documents
551 .iter()
552 .find(|document| document.name == "linked")
553 .expect("linked")
554 .path,
555 registration.join("imported/linked.md")
556 );
557
558 fs::remove_dir_all(root).expect("remove fixture");
559 }
560}