1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6#[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#[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 Documents,
36 Source {
38 name: String,
40 },
41}
42
43#[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 Markdown {
53 path: String,
55 origin: MarkdownOrigin,
57 },
58 Manual {
60 name: String,
62 manual_section: String,
64 },
65}
66
67impl DocumentAddress {
68 #[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 #[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 #[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 #[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 #[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}