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