Skip to main content

llm_wiki/
slug.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Result, bail};
5
6/// A validated slug — path relative to wiki root, no extension.
7///
8/// Invariants enforced at construction:
9/// - No `../` path traversal
10/// - No file extension
11/// - No leading `/`
12/// - Non-empty
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct Slug(String);
15
16impl Slug {
17    /// Derive a slug from a file path relative to wiki root.
18    ///
19    /// - `concepts/moe.md` → `concepts/moe`
20    /// - `concepts/moe/index.md` → `concepts/moe`
21    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    /// Resolve this slug to a file path. Checks flat then bundle.
37    ///
38    /// 1. `<wiki_root>/<slug>.md`
39    /// 2. `<wiki_root>/<slug>/index.md`
40    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    /// Derive a display title from the last slug segment.
53    ///
54    /// `concepts/mixture-of-experts` → `Mixture of Experts`
55    pub fn title(&self) -> String {
56        let last = self.0.rsplit('/').next().unwrap_or(&self.0);
57        title_case(last)
58    }
59
60    /// Return the raw slug string slice.
61    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        // Reject if the last segment has a file extension
81        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/// A parsed `wiki://` URI or bare slug.
106///
107/// `wiki://research/concepts/moe` → wiki: Some("research"), slug: "concepts/moe"
108/// `concepts/moe` → wiki: None, slug: "concepts/moe"
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct WikiUri {
111    /// Candidate wiki name — None for bare slugs.
112    /// At parse time this is a candidate; WikiUri::resolve checks
113    /// whether it's a registered wiki name.
114    pub wiki: Option<String>,
115    /// The slug portion.
116    pub slug: Slug,
117}
118
119impl WikiUri {
120    /// Parse a string into a WikiUri. Accepts both `wiki://` URIs and bare slugs.
121    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                // wiki://candidate/slug — candidate may be wiki name or first slug segment
130                Ok(WikiUri {
131                    wiki: Some(parts[0].to_string()),
132                    slug: Slug::try_from(parts[1])?,
133                })
134            } else {
135                // wiki://slug (no slash, or trailing slash)
136                Ok(WikiUri {
137                    wiki: None,
138                    slug: Slug::try_from(stripped.trim_end_matches('/'))?,
139                })
140            }
141        } else {
142            // Bare slug
143            Ok(WikiUri {
144                wiki: None,
145                slug: Slug::try_from(input)?,
146            })
147        }
148    }
149
150    /// Resolve a URI or bare slug against the global config.
151    ///
152    /// - `wiki://` URIs: try candidate wiki name, fall back to default wiki
153    /// - Bare slugs: use `wiki_flag` or default wiki
154    ///
155    /// Returns `(WikiEntry, Slug)`.
156    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                // Not a wiki name — treat as slug segment
170                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/// Result of slug vs asset resolution for wiki_content_read.
189#[derive(Debug)]
190pub enum ReadTarget {
191    /// Slug resolved to a page.
192    Page(PathBuf),
193    /// Slug resolved to a co-located asset: (parent slug, filename).
194    Asset(String, String),
195}
196
197/// Two-step resolution: try page first, then asset fallback.
198///
199/// 1. Try `slug.resolve()` → page
200/// 2. If the last segment has a non-.md extension, split into parent slug + filename → asset
201pub fn resolve_read_target(input: &str, wiki_root: &Path) -> Result<ReadTarget> {
202    // Step 1: try as page (may fail if input has an extension)
203    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    // Step 2: check last segment for non-.md extension (asset)
210    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}