Skip to main content

mant_ir/
address.rs

1//! Stable logical identities for documents independent from storage paths.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6/// Return whether a value is a conventional native manual section.
7///
8/// Numeric sections may carry an ASCII-alphanumeric extension such as `1p`
9/// or `3type`; the historical single-letter `l` and `n` sections are also
10/// accepted. The 16-byte bound matches `ManT`'s public selector boundary.
11#[must_use]
12pub fn is_manual_section(value: &str) -> bool {
13    if value.is_empty() || value.len() > 16 {
14        return false;
15    }
16    let mut characters = value.chars();
17    match characters.next() {
18        Some(first) if first.is_ascii_digit() => {
19            characters.all(|character| character.is_ascii_alphanumeric())
20        }
21        Some('l' | 'n') => characters.next().is_none(),
22        _ => false,
23    }
24}
25
26/// Storage identity of one registered Markdown document.
27#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
28#[serde(
29    tag = "kind",
30    rename_all = "kebab-case",
31    rename_all_fields = "camelCase"
32)]
33pub enum MarkdownOrigin {
34    /// The user's primary `documents` tree.
35    Documents,
36    /// A configured source cache.
37    Source {
38        /// Configured source name.
39        name: String,
40    },
41}
42
43/// Stable selector for one discoverable document candidate.
44#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
45#[serde(
46    tag = "kind",
47    rename_all = "kebab-case",
48    rename_all_fields = "camelCase"
49)]
50pub enum DocumentAddress {
51    /// A Markdown document registered in the document catalog.
52    Markdown {
53        /// Extension-free path relative to the selected Markdown origin.
54        path: String,
55        /// Storage namespace containing the relative path.
56        origin: MarkdownOrigin,
57    },
58    /// An installed native manual page.
59    Manual {
60        /// Manual topic without its section suffix.
61        name: String,
62        /// Native manual category such as `1` or `3p`.
63        manual_section: String,
64    },
65}
66
67impl DocumentAddress {
68    /// Parse one complete catalog path into its logical document address.
69    ///
70    /// Accepted paths are `documents/<path>`, `sources/<source>/<path>`, and
71    /// `manual/<section>/<name>`. Physical filesystem paths are never
72    /// interpreted here.
73    #[must_use]
74    pub fn parse_catalog_path(value: &str) -> Option<Self> {
75        if let Some(path) = value.strip_prefix("documents/")
76            && !path.is_empty()
77        {
78            return Some(Self::Markdown {
79                path: path.to_owned(),
80                origin: MarkdownOrigin::Documents,
81            });
82        }
83        if let Some(rest) = value.strip_prefix("sources/") {
84            let (source, path) = rest.split_once('/')?;
85            if !source.is_empty() && !path.is_empty() {
86                return Some(Self::Markdown {
87                    path: path.to_owned(),
88                    origin: MarkdownOrigin::Source {
89                        name: source.to_owned(),
90                    },
91                });
92            }
93        }
94        if let Some(rest) = value.strip_prefix("manual/") {
95            let (manual_section, name) = rest.split_once('/')?;
96            if !manual_section.is_empty() && !name.is_empty() && !name.contains('/') {
97                return Some(Self::Manual {
98                    name: name.to_owned(),
99                    manual_section: manual_section.to_owned(),
100                });
101            }
102        }
103        None
104    }
105
106    /// Return the basename used as the document's short lookup name.
107    #[must_use]
108    pub fn name(&self) -> &str {
109        match self {
110            Self::Markdown { path, .. } => path.rsplit('/').next().unwrap_or(path),
111            Self::Manual { name, .. } => name,
112        }
113    }
114
115    /// Stable path relative to its storage namespace.
116    #[must_use]
117    pub fn relative_path(&self) -> String {
118        match self {
119            Self::Markdown { path, .. } => path.clone(),
120            Self::Manual {
121                name,
122                manual_section,
123            } => format!("{manual_section}/{name}"),
124        }
125    }
126
127    /// Complete, unambiguous path in `ManT`'s unified document tree.
128    #[must_use]
129    pub fn catalog_path(&self) -> String {
130        match self {
131            Self::Markdown {
132                path,
133                origin: MarkdownOrigin::Documents,
134            } => format!("documents/{path}"),
135            Self::Markdown {
136                path,
137                origin: MarkdownOrigin::Source { name },
138            } => format!("sources/{name}/{path}"),
139            Self::Manual {
140                name,
141                manual_section,
142            } => format!("manual/{manual_section}/{name}"),
143        }
144    }
145
146    /// Resolve an extension-free relative Markdown document reference inside
147    /// the current registered namespace.
148    ///
149    /// The result never crosses from personal documents into a configured
150    /// source, or between configured sources. References that would escape the
151    /// namespace root are rejected.
152    #[must_use]
153    pub fn resolve_document_reference(&self, reference: &str) -> Option<Self> {
154        let Self::Markdown { path, origin } = self else {
155            return None;
156        };
157        let mut components = path.split('/').collect::<Vec<_>>();
158        components.pop();
159        for component in reference.split('/') {
160            match component {
161                "." => {}
162                ".." => {
163                    components.pop()?;
164                }
165                value if !value.is_empty() => components.push(value),
166                _ => return None,
167            }
168        }
169        (!components.is_empty()).then(|| Self::Markdown {
170            path: components.join("/"),
171            origin: origin.clone(),
172        })
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::{DocumentAddress, MarkdownOrigin};
179
180    #[test]
181    fn catalog_paths_round_trip_through_logical_addresses() {
182        for address in [
183            DocumentAddress::Markdown {
184                path: "guides/mant".to_owned(),
185                origin: MarkdownOrigin::Documents,
186            },
187            DocumentAddress::Markdown {
188                path: "Get-Item".to_owned(),
189                origin: MarkdownOrigin::Source {
190                    name: "pwsh".to_owned(),
191                },
192            },
193            DocumentAddress::Manual {
194                name: "git".to_owned(),
195                manual_section: "1".to_owned(),
196            },
197        ] {
198            assert_eq!(
199                DocumentAddress::parse_catalog_path(&address.catalog_path()),
200                Some(address)
201            );
202        }
203    }
204
205    #[test]
206    fn malformed_catalog_paths_are_not_interpreted_as_addresses() {
207        for value in [
208            "git",
209            "documents/",
210            "sources/pwsh",
211            "sources//Get-Item",
212            "manual/1",
213            "manual//git",
214            "manual/1/git/add",
215        ] {
216            assert_eq!(DocumentAddress::parse_catalog_path(value), None, "{value}");
217        }
218    }
219
220    #[test]
221    fn markdown_references_remain_inside_their_registered_namespace() {
222        let current = DocumentAddress::Markdown {
223            path: "guides/git/start".to_owned(),
224            origin: MarkdownOrigin::Source {
225                name: "tooling".to_owned(),
226            },
227        };
228        assert_eq!(
229            current.resolve_document_reference("../reference/options"),
230            Some(DocumentAddress::Markdown {
231                path: "guides/reference/options".to_owned(),
232                origin: MarkdownOrigin::Source {
233                    name: "tooling".to_owned(),
234                },
235            })
236        );
237        assert_eq!(current.resolve_document_reference("../../../escape"), None);
238        assert_eq!(current.resolve_document_reference("/absolute"), None);
239    }
240}