Skip to main content

okf_core/
concept_id.rs

1//! Concept identifiers and their mapping to/from file paths.
2//!
3//! A *concept id* is the path of a concept's file within the bundle with the
4//! `.md` suffix removed, e.g. `tables/users.md` has id `tables/users`.
5//! This module ports the reference `bundle/paths.py`. Its ASCII segment rule is
6//! kept as [`is_portable_segment`], a guidance check, rather than as a parse
7//! error: see [`validate_segment`]. Ported to Rust and modified from the
8//! original Apache-2.0 Python source; see the NOTICE file.
9
10use std::fmt;
11use std::path::{Component, Path, PathBuf};
12
13/// Error returned when a concept-id segment is malformed.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct ConceptIdError(pub String);
16
17impl fmt::Display for ConceptIdError {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        write!(f, "{}", self.0)
20    }
21}
22
23impl std::error::Error for ConceptIdError {}
24
25/// A concept identifier: an ordered list of path segments (e.g.
26/// `["tables", "users"]` for `tables/users`).
27#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
28pub struct ConceptId {
29    segments: Vec<String>,
30}
31
32impl ConceptId {
33    /// Builds a concept id from segments, validating each.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`ConceptIdError`] if `segments` is empty or any segment fails
38    /// [`validate_segment`].
39    pub fn new(segments: Vec<String>) -> Result<Self, ConceptIdError> {
40        if segments.is_empty() {
41            return Err(ConceptIdError(
42                "concept_id must have at least one segment".into(),
43            ));
44        }
45        for seg in &segments {
46            validate_segment(seg)?;
47        }
48        Ok(Self { segments })
49    }
50
51    /// Parses a concept id from a `/`-separated string. Empty segments are
52    /// dropped (so leading/trailing/duplicate slashes are tolerated), matching
53    /// the reference `parse_concept_id`.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`ConceptIdError`] if `s` resolves to no segments or any segment
58    /// fails [`validate_segment`].
59    pub fn parse(s: &str) -> Result<Self, ConceptIdError> {
60        let segments: Vec<String> = s
61            .split('/')
62            .filter(|p| !p.is_empty())
63            .map(String::from)
64            .collect();
65        if segments.is_empty() {
66            return Err(ConceptIdError(format!("Empty concept id: {s:?}")));
67        }
68        for seg in &segments {
69            validate_segment(seg)?;
70        }
71        Ok(Self { segments })
72    }
73
74    /// The id's segments.
75    #[must_use]
76    pub fn segments(&self) -> &[String] {
77        &self.segments
78    }
79
80    /// The final segment (the concept's own name, without directories).
81    pub fn name(&self) -> &str {
82        self.segments.last().map_or("", String::as_str)
83    }
84
85    /// The id of the directory that contains this concept, if any.
86    #[must_use]
87    pub fn parent(&self) -> Option<Self> {
88        if self.segments.len() <= 1 {
89            None
90        } else {
91            Some(Self {
92                segments: self.segments[..self.segments.len() - 1].to_vec(),
93            })
94        }
95    }
96
97    /// Resolves this id to a file path under `bundle_root` (appending `.md`).
98    ///
99    /// # Panics
100    ///
101    /// Never panics in practice: the constructor rejects empty segment lists,
102    /// so [`ConceptId::segments`] always has at least one element.
103    #[must_use]
104    pub fn to_path(&self, bundle_root: &Path) -> PathBuf {
105        let mut path = bundle_root.to_path_buf();
106        let (name, dirs) = self
107            .segments
108            .split_last()
109            .expect("ConceptId is constructed non-empty");
110        for d in dirs {
111            path.push(d);
112        }
113        path.push(format!("{name}.md"));
114        path
115    }
116
117    /// Derives a concept id from a file path relative to `bundle_root`,
118    /// stripping the `.md` suffix.
119    ///
120    /// The path must be a normalized, UTF-8 `.md` path whose segments can be
121    /// represented by a [`ConceptId`]. A file already on disk is a concept
122    /// whatever its portable spelling, and conformance is a question of
123    /// frontmatter, not filenames, so names such as `my notes.md` remain valid.
124    /// Rejecting non-UTF-8 names rather than replacing them is important: a
125    /// replacement character would produce an id that does not point back to
126    /// the original file.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`ConceptIdError`] if `path` is not under `bundle_root` or
131    /// resolves to no segments, is not a `.md` file, contains a non-normal path
132    /// component, or cannot be represented as UTF-8 without loss.
133    pub fn from_path(bundle_root: &Path, path: &Path) -> Result<Self, ConceptIdError> {
134        let rel = path
135            .strip_prefix(bundle_root)
136            .map_err(|_| ConceptIdError(format!("{} is not under bundle root", path.display())))?;
137        let mut segments = Vec::new();
138        for component in rel.components() {
139            let Component::Normal(segment) = component else {
140                return Err(ConceptIdError(format!(
141                    "{} contains a non-normal path component",
142                    path.display()
143                )));
144            };
145            let segment = segment.to_str().ok_or_else(|| {
146                ConceptIdError(format!(
147                    "{} contains a path segment that is not valid UTF-8",
148                    path.display()
149                ))
150            })?;
151            segments.push(segment.to_string());
152        }
153
154        let Some(last) = segments.last_mut() else {
155            return Err(ConceptIdError(
156                "concept_id must have at least one segment".into(),
157            ));
158        };
159        let Some(stripped) = last.strip_suffix(".md") else {
160            return Err(ConceptIdError(format!(
161                "{} does not name a markdown concept",
162                path.display()
163            )));
164        };
165        *last = stripped.to_string();
166
167        // Use the validating constructor so a path-derived id has the same
168        // segment invariants as one parsed from a string.
169        Self::new(segments)
170    }
171}
172
173impl fmt::Display for ConceptId {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        f.write_str(&self.segments.join("/"))
176    }
177}
178
179impl std::str::FromStr for ConceptId {
180    type Err = ConceptIdError;
181    fn from_str(s: &str) -> Result<Self, Self::Err> {
182        Self::parse(s)
183    }
184}
185
186impl TryFrom<&str> for ConceptId {
187    type Error = ConceptIdError;
188    fn try_from(s: &str) -> Result<Self, Self::Error> {
189        Self::parse(s)
190    }
191}
192
193impl TryFrom<String> for ConceptId {
194    type Error = ConceptIdError;
195    fn try_from(s: String) -> Result<Self, Self::Error> {
196        Self::parse(&s)
197    }
198}
199
200impl From<ConceptId> for String {
201    fn from(id: ConceptId) -> Self {
202        id.to_string()
203    }
204}
205
206impl AsRef<[String]> for ConceptId {
207    fn as_ref(&self) -> &[String] {
208        self.segments()
209    }
210}
211
212impl std::ops::Deref for ConceptId {
213    type Target = [String];
214    fn deref(&self) -> &[String] {
215        self.segments()
216    }
217}
218
219/// Validates a single path segment, rejecting only what cannot be a concept id.
220///
221/// The reference `bundle/paths.py` restricts segments to
222/// `[A-Za-z0-9_][A-Za-z0-9_.\-]*`, but that rule is an artifact of the reference
223/// implementation rather than a requirement: the specification states no
224/// character constraint on filenames, and conformance is a question of
225/// frontmatter. [`ConceptId::from_path`] accordingly accepts non-portable
226/// UTF-8 names, so applying the ASCII rule here only meant that ids the loader
227/// had just produced could not be parsed back, and that links to those
228/// concepts vanished from the graph without even being reported as broken.
229///
230/// What stays rejected is the set that cannot round-trip through the
231/// `/`-joined string form or through [`ConceptId::to_path`]: an empty segment,
232/// the traversal names `.` and `..`, the path separators `/` and `\`, and
233/// control characters. Spaces, emoji, and other Unicode are accepted.
234///
235/// The ASCII convention is still worth following, so `validate_bundle` reports
236/// segments outside it as a warning instead of refusing to parse them.
237///
238/// # Errors
239///
240/// Returns [`ConceptIdError`] for the small set of segments that cannot
241/// round-trip through the `/`-joined string form or [`ConceptId::to_path`]:
242/// empty, `.` and `..`, path separators, and control characters.
243pub fn validate_segment(seg: &str) -> Result<(), ConceptIdError> {
244    let reject = |reason: &str| {
245        Err(ConceptIdError(format!(
246            "Invalid concept id segment: {seg:?} ({reason})"
247        )))
248    };
249    if seg.is_empty() {
250        return reject("empty");
251    }
252    if seg == "." || seg == ".." {
253        return reject("`.` and `..` cannot name a concept");
254    }
255    for c in seg.chars() {
256        // `\` is a separator on Windows, so allowing it would let one segment
257        // silently become two in `to_path`.
258        if c == '/' || c == '\\' {
259            return reject("contains a path separator");
260        }
261        if c.is_control() {
262            return reject("contains a control character");
263        }
264    }
265    Ok(())
266}
267
268/// Whether a segment is within the reference implementation's
269/// `[A-Za-z0-9_][A-Za-z0-9_.\-]*` convention.
270///
271/// [`validate_segment`] no longer enforces this, because the spec does not, but
272/// a name outside it needs `<...>` or percent-encoding to be linked from
273/// markdown and is not guaranteed to survive every filesystem unchanged. It is
274/// reported as guidance, never as an error.
275#[must_use]
276pub fn is_portable_segment(seg: &str) -> bool {
277    let mut chars = seg.chars();
278    match chars.next() {
279        Some(c) if c.is_ascii_alphanumeric() || c == '_' => {}
280        _ => return false,
281    }
282    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
283}