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
186/// Validates a single path segment, rejecting only what cannot be a concept id.
187///
188/// The reference `bundle/paths.py` restricts segments to
189/// `[A-Za-z0-9_][A-Za-z0-9_.\-]*`, but that rule is an artifact of the reference
190/// implementation rather than a requirement: the specification states no
191/// character constraint on filenames, and conformance is a question of
192/// frontmatter. [`ConceptId::from_path`] accordingly accepts non-portable
193/// UTF-8 names, so applying the ASCII rule here only meant that ids the loader
194/// had just produced could not be parsed back, and that links to those
195/// concepts vanished from the graph without even being reported as broken.
196///
197/// What stays rejected is the set that cannot round-trip through the
198/// `/`-joined string form or through [`ConceptId::to_path`]: an empty segment,
199/// the traversal names `.` and `..`, the path separators `/` and `\`, and
200/// control characters. Spaces, emoji, and other Unicode are accepted.
201///
202/// The ASCII convention is still worth following, so `validate_bundle` reports
203/// segments outside it as a warning instead of refusing to parse them.
204///
205/// # Errors
206///
207/// Returns [`ConceptIdError`] for the small set of segments that cannot
208/// round-trip through the `/`-joined string form or [`ConceptId::to_path`]:
209/// empty, `.` and `..`, path separators, and control characters.
210pub fn validate_segment(seg: &str) -> Result<(), ConceptIdError> {
211    let reject = |reason: &str| {
212        Err(ConceptIdError(format!(
213            "Invalid concept id segment: {seg:?} ({reason})"
214        )))
215    };
216    if seg.is_empty() {
217        return reject("empty");
218    }
219    if seg == "." || seg == ".." {
220        return reject("`.` and `..` cannot name a concept");
221    }
222    for c in seg.chars() {
223        // `\` is a separator on Windows, so allowing it would let one segment
224        // silently become two in `to_path`.
225        if c == '/' || c == '\\' {
226            return reject("contains a path separator");
227        }
228        if c.is_control() {
229            return reject("contains a control character");
230        }
231    }
232    Ok(())
233}
234
235/// Whether a segment is within the reference implementation's
236/// `[A-Za-z0-9_][A-Za-z0-9_.\-]*` convention.
237///
238/// [`validate_segment`] no longer enforces this, because the spec does not, but
239/// a name outside it needs `<...>` or percent-encoding to be linked from
240/// markdown and is not guaranteed to survive every filesystem unchanged. It is
241/// reported as guidance, never as an error.
242#[must_use]
243pub fn is_portable_segment(seg: &str) -> bool {
244    let mut chars = seg.chars();
245    match chars.next() {
246        Some(c) if c.is_ascii_alphanumeric() || c == '_' => {}
247        _ => return false,
248    }
249    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
250}