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}