1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Result, bail};
5
6#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct Slug(String);
15
16impl Slug {
17 pub fn from_path(path: &Path, wiki_root: &Path) -> Result<Self> {
22 let rel = path
23 .strip_prefix(wiki_root)
24 .map_err(|_| anyhow::anyhow!("path is not under wiki root"))?;
25 let raw = if rel.file_name() == Some(std::ffi::OsStr::new("index.md")) {
26 rel.parent()
27 .ok_or_else(|| anyhow::anyhow!("index.md has no parent"))?
28 .to_string_lossy()
29 .into_owned()
30 } else {
31 rel.with_extension("").to_string_lossy().into_owned()
32 };
33 Self::try_from(raw.as_str())
34 }
35
36 pub fn resolve(&self, wiki_root: &Path) -> Result<PathBuf> {
41 let flat = wiki_root.join(format!("{}.md", self.0));
42 if flat.is_file() {
43 return Ok(flat);
44 }
45 let bundle = wiki_root.join(&self.0).join("index.md");
46 if bundle.is_file() {
47 return Ok(bundle);
48 }
49 bail!("page not found for slug: {}", self.0)
50 }
51
52 pub fn title(&self) -> String {
56 let last = self.0.rsplit('/').next().unwrap_or(&self.0);
57 title_case(last)
58 }
59
60 pub fn as_str(&self) -> &str {
62 &self.0
63 }
64}
65
66impl TryFrom<&str> for Slug {
67 type Error = anyhow::Error;
68
69 fn try_from(s: &str) -> Result<Self> {
70 let s = s.trim();
71 if s.is_empty() {
72 bail!("slug cannot be empty");
73 }
74 if s.starts_with('/') {
75 bail!("slug cannot start with /: {s}");
76 }
77 if s.contains("../") || s.contains("..\\") {
78 bail!("slug cannot contain path traversal: {s}");
79 }
80 if let Some(last) = s.rsplit('/').next()
82 && let Some(dot) = last.rfind('.')
83 {
84 let ext = &last[dot + 1..];
85 if !ext.is_empty() {
86 bail!("slug cannot have a file extension: {s}");
87 }
88 }
89 Ok(Slug(s.to_string()))
90 }
91}
92
93impl fmt::Display for Slug {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 f.write_str(&self.0)
96 }
97}
98
99impl AsRef<str> for Slug {
100 fn as_ref(&self) -> &str {
101 &self.0
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct WikiUri {
111 pub wiki: Option<String>,
115 pub slug: Slug,
117}
118
119impl WikiUri {
120 pub fn parse(input: &str) -> Result<Self> {
122 let input = input.trim();
123 if let Some(stripped) = input.strip_prefix("wiki://") {
124 if stripped.is_empty() {
125 bail!("invalid wiki URI: {input}");
126 }
127 let parts: Vec<&str> = stripped.splitn(2, '/').collect();
128 if parts.len() == 2 && !parts[1].is_empty() {
129 Ok(WikiUri {
131 wiki: Some(parts[0].to_string()),
132 slug: Slug::try_from(parts[1])?,
133 })
134 } else {
135 Ok(WikiUri {
137 wiki: None,
138 slug: Slug::try_from(stripped.trim_end_matches('/'))?,
139 })
140 }
141 } else {
142 Ok(WikiUri {
144 wiki: None,
145 slug: Slug::try_from(input)?,
146 })
147 }
148 }
149
150 pub fn resolve(
157 input: &str,
158 wiki_flag: Option<&str>,
159 global: &crate::config::GlobalConfig,
160 ) -> Result<(crate::config::WikiEntry, Slug)> {
161 use crate::spaces;
162
163 if input.starts_with("wiki://") {
164 let parsed = Self::parse(input)?;
165 if let Some(ref name) = parsed.wiki {
166 if let Ok(entry) = spaces::resolve_name(name, global) {
167 return Ok((entry, parsed.slug));
168 }
169 let full_slug = format!("{name}/{}", parsed.slug);
171 let slug = Slug::try_from(full_slug.as_str())?;
172 let default = &global.global.default_wiki;
173 let entry = spaces::resolve_name(default, global)?;
174 return Ok((entry, slug));
175 }
176 let default = &global.global.default_wiki;
177 let entry = spaces::resolve_name(default, global)?;
178 Ok((entry, parsed.slug))
179 } else {
180 let wiki_name = wiki_flag.unwrap_or(&global.global.default_wiki);
181 let entry = spaces::resolve_name(wiki_name, global)?;
182 let slug = Slug::try_from(input)?;
183 Ok((entry, slug))
184 }
185 }
186}
187
188#[derive(Debug)]
190pub enum ReadTarget {
191 Page(PathBuf),
193 Asset(String, String),
195}
196
197pub fn resolve_read_target(input: &str, wiki_root: &Path) -> Result<ReadTarget> {
202 if let Ok(slug) = Slug::try_from(input)
204 && let Ok(path) = slug.resolve(wiki_root)
205 {
206 return Ok(ReadTarget::Page(path));
207 }
208
209 if let Some(pos) = input.rfind('/') {
211 let filename = &input[pos + 1..];
212 if let Some(dot) = filename.rfind('.') {
213 let ext = &filename[dot + 1..];
214 if !ext.is_empty() && ext != "md" {
215 let parent_slug = &input[..pos];
216 let path = wiki_root.join(parent_slug).join(filename);
217 if path.is_file() {
218 return Ok(ReadTarget::Asset(
219 parent_slug.to_string(),
220 filename.to_string(),
221 ));
222 }
223 bail!("asset not found: {input}");
224 }
225 }
226 }
227
228 bail!("page not found: {input}")
229}
230
231fn title_case(segment: &str) -> String {
232 segment
233 .split('-')
234 .map(|w| {
235 let mut c = w.chars();
236 match c.next() {
237 None => String::new(),
238 Some(first) => {
239 let upper: String = first.to_uppercase().collect();
240 upper + c.as_str()
241 }
242 }
243 })
244 .collect::<Vec<_>>()
245 .join(" ")
246}