Skip to main content

mant_core/
documents.rs

1//! Discovers registered Markdown below platform-native application data roots.
2
3use 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/// Precedence class for one registered Markdown document.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum RegisteredDocumentOrigin {
34    User,
35    System,
36}
37
38/// One Markdown document explicitly registered in `ManT`'s document namespace.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RegisteredDocument {
41    pub name: String,
42    pub path: PathBuf,
43    pub origin: RegisteredDocumentOrigin,
44}
45
46/// Find the highest-precedence registered Markdown document for `document`.
47#[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/// List effective registered documents after applying directory precedence.
54#[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(&registration, 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(&registration, 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}