1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6#[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 Documents,
16 Source {
18 name: String,
20 },
21}
22
23#[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 Markdown {
33 path: String,
35 origin: MarkdownOrigin,
37 },
38 Manual {
40 name: String,
42 manual_section: String,
44 },
45}
46
47impl DocumentAddress {
48 #[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 #[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 #[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 #[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 #[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}