face_core/cluster_id.rs
1//! Cluster identifiers (§6.1 of `docs/design.md`).
2//!
3//! A [`ClusterId`] is an ordered list of `axis:value` segments, comma-separated
4//! when rendered. Values containing `,`, `:`, or `"` are quoted with double
5//! quotes; embedded `"` is escaped as `""` (CSV-style).
6//!
7//! Both the canonical comma form and the structured `axis=value` repeat form
8//! parse into the same value, and round-trip through [`fmt::Display`].
9
10use std::fmt::{self, Write as _};
11use std::str::FromStr;
12
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15/// A single `axis:value` pair within a [`ClusterId`].
16///
17/// `ClusterIdSegment` is `#[non_exhaustive]` — construct values via
18/// [`ClusterIdSegment::new`].
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20#[non_exhaustive]
21pub struct ClusterIdSegment {
22 /// Axis name (e.g. `file`, `score`).
23 pub axis: String,
24 /// Value within that axis (e.g. `src/cli.rs`, `excellent`).
25 pub value: String,
26}
27
28impl ClusterIdSegment {
29 /// Construct a segment from any types that convert into `String`.
30 ///
31 /// `ClusterIdSegment` is `#[non_exhaustive]`; downstream callers
32 /// (notably integration tests) build segments through this
33 /// constructor.
34 ///
35 /// # Examples
36 ///
37 /// ```
38 /// use face_core::ClusterIdSegment;
39 ///
40 /// let s = ClusterIdSegment::new("file", "src/cli.rs");
41 /// assert_eq!(s.axis, "file");
42 /// assert_eq!(s.value, "src/cli.rs");
43 /// ```
44 pub fn new(axis: impl Into<String>, value: impl Into<String>) -> Self {
45 Self {
46 axis: axis.into(),
47 value: value.into(),
48 }
49 }
50}
51
52/// Ordered list of `axis:value` segments identifying a cluster's path
53/// from the root.
54///
55/// The empty id (`ClusterId::default()`) addresses the root, before any
56/// axis has been applied.
57///
58/// # Examples
59///
60/// ```
61/// use face_core::ClusterId;
62///
63/// let id: ClusterId = "file:src/cli.rs,score:excellent".parse().unwrap();
64/// assert_eq!(id.depth(), 2);
65/// assert_eq!(id.to_string(), "file:src/cli.rs,score:excellent");
66/// ```
67#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
68pub struct ClusterId(Vec<ClusterIdSegment>);
69
70impl ClusterId {
71 /// Construct a new cluster id from an ordered segment list.
72 pub fn new(segments: Vec<ClusterIdSegment>) -> Self {
73 Self(segments)
74 }
75
76 /// Borrow the underlying segments in nesting order.
77 pub fn segments(&self) -> &[ClusterIdSegment] {
78 &self.0
79 }
80
81 /// Number of segments. The root id has depth `0`.
82 pub fn depth(&self) -> usize {
83 self.0.len()
84 }
85
86 /// `true` when this id has no segments (i.e. addresses the root).
87 pub fn is_root(&self) -> bool {
88 self.0.is_empty()
89 }
90
91 /// Return a new id with the last segment dropped, or `None` if this
92 /// id is already the root.
93 pub fn parent(&self) -> Option<ClusterId> {
94 if self.0.is_empty() {
95 None
96 } else {
97 Some(ClusterId(self.0[..self.0.len() - 1].to_vec()))
98 }
99 }
100
101 /// Parse the canonical comma form (§6.1).
102 ///
103 /// A single-segment bare form (no `:`) is valid — the segment's
104 /// axis is left as the empty string and the CLI fills it in at
105 /// bind-time. The bare form is allowed only when there is exactly
106 /// one segment; mixing bare with multi-segment input is an error.
107 ///
108 /// # Errors
109 ///
110 /// Returns [`ClusterIdError`] if the input is empty, mixes bare
111 /// and `axis:value` forms, has an explicit empty axis (`:value`),
112 /// or contains an unterminated quoted value.
113 ///
114 /// # Examples
115 ///
116 /// ```
117 /// use face_core::ClusterId;
118 ///
119 /// let id = ClusterId::parse_canonical("file:src/cli.rs,score:excellent").unwrap();
120 /// assert_eq!(id.depth(), 2);
121 ///
122 /// // Bare single-segment form: axis is empty, CLI fills at bind-time.
123 /// let bare = ClusterId::parse_canonical("excellent").unwrap();
124 /// assert_eq!(bare.segments()[0].axis, "");
125 /// assert_eq!(bare.segments()[0].value, "excellent");
126 /// ```
127 pub fn parse_canonical(s: &str) -> Result<Self, ClusterIdError> {
128 if s.is_empty() {
129 return Err(ClusterIdError::Empty);
130 }
131 let mut segments = Vec::new();
132 let mut chars = s.chars().peekable();
133 let mut segment_idx = 0usize;
134
135 loop {
136 // A leading `"` starts a bare quoted value (no axis).
137 if segment_idx == 0 && chars.peek() == Some(&'"') {
138 let value = read_value(&mut chars, segment_idx)?;
139 segments.push(ClusterIdSegment {
140 axis: String::new(),
141 value,
142 });
143 match chars.next() {
144 None => break,
145 // Bare form is only allowed when there's exactly
146 // one segment. A trailing `,` means we're mixing
147 // bare with multi-segment.
148 Some(',') => {
149 return Err(ClusterIdError::MissingSeparator { segment: 0 });
150 }
151 Some(other) => {
152 return Err(ClusterIdError::GarbageAfterQuote {
153 segment: segment_idx,
154 ch: other,
155 });
156 }
157 }
158 }
159
160 let (axis, after_colon) = read_axis(&mut chars, segment_idx)?;
161
162 if !after_colon {
163 // No `:` was seen.
164 //
165 // - At idx 0 with content and end-of-input, it's the
166 // bare single-segment form: store as
167 // { axis: "", value: <axis-buffer> } and we're done.
168 // - At idx > 0 with empty input (e.g. trailing comma
169 // leaving nothing to read), it's an empty trailing
170 // segment: surface as `EmptyAxis`.
171 // - Anywhere else (mixed bare-with-multi or an axis
172 // without `:` at idx > 0), surface as
173 // `MissingSeparator`.
174 if segment_idx == 0 && chars.peek().is_none() && !axis.is_empty() {
175 segments.push(ClusterIdSegment {
176 axis: String::new(),
177 value: axis,
178 });
179 break;
180 }
181 if axis.is_empty() {
182 return Err(ClusterIdError::EmptyAxis {
183 segment: segment_idx,
184 });
185 }
186 return Err(ClusterIdError::MissingSeparator {
187 segment: segment_idx,
188 });
189 }
190
191 // We saw a `:`. An empty axis here means explicit `:value`
192 // form, which is not the bare form (bare form has no `:`).
193 if axis.is_empty() {
194 return Err(ClusterIdError::EmptyAxis {
195 segment: segment_idx,
196 });
197 }
198
199 let value = read_value(&mut chars, segment_idx)?;
200 segments.push(ClusterIdSegment { axis, value });
201
202 match chars.next() {
203 None => break,
204 Some(',') => {
205 segment_idx += 1;
206 continue;
207 }
208 Some(other) => {
209 // read_value consumed up to either ',' or end; any
210 // other char here means a stray unquoted character.
211 return Err(ClusterIdError::GarbageAfterQuote {
212 segment: segment_idx,
213 ch: other,
214 });
215 }
216 }
217 }
218
219 Ok(ClusterId(segments))
220 }
221
222 /// Parse a list of structured `axis=value` parts (§6.2).
223 ///
224 /// Each part is a single `axis=value` pair; the order of parts is
225 /// preserved as the nesting order.
226 ///
227 /// # Errors
228 ///
229 /// Returns [`ClusterIdError::StructuredMissingEquals`] if any part
230 /// has no `=` separator, [`ClusterIdError::EmptyAxis`] if a part
231 /// has an empty axis side, and [`ClusterIdError::Empty`] if no
232 /// parts are supplied.
233 pub fn parse_structured<I, S>(parts: I) -> Result<Self, ClusterIdError>
234 where
235 I: IntoIterator<Item = S>,
236 S: AsRef<str>,
237 {
238 let mut segments = Vec::new();
239 for (idx, part) in parts.into_iter().enumerate() {
240 let part_str = part.as_ref();
241 let Some((axis, value)) = part_str.split_once('=') else {
242 return Err(ClusterIdError::StructuredMissingEquals {
243 segment: part_str.to_string(),
244 });
245 };
246 if axis.is_empty() {
247 return Err(ClusterIdError::EmptyAxis { segment: idx });
248 }
249 segments.push(ClusterIdSegment {
250 axis: axis.to_string(),
251 value: value.to_string(),
252 });
253 }
254 if segments.is_empty() {
255 return Err(ClusterIdError::Empty);
256 }
257 Ok(ClusterId(segments))
258 }
259}
260
261impl fmt::Display for ClusterId {
262 /// Emit the canonical comma form with §6.1 CSV-style quoting:
263 /// values containing `,`, `:`, or `"` are wrapped in double quotes;
264 /// double quotes inside are escaped as `""`.
265 ///
266 /// A single-segment id whose axis is empty emits just the value
267 /// (no `:` prefix), matching the bare form accepted by
268 /// [`Self::parse_canonical`] so that
269 /// `parse_canonical(&id.to_string()) == Ok(id)` round-trips.
270 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271 // Bare form: exactly one segment with an empty axis.
272 if self.0.len() == 1 && self.0[0].axis.is_empty() {
273 return write_value(f, &self.0[0].value);
274 }
275 for (i, seg) in self.0.iter().enumerate() {
276 if i > 0 {
277 f.write_str(",")?;
278 }
279 // axis is never quoted in this spec.
280 f.write_str(&seg.axis)?;
281 f.write_str(":")?;
282 write_value(f, &seg.value)?;
283 }
284 Ok(())
285 }
286}
287
288impl FromStr for ClusterId {
289 type Err = ClusterIdError;
290 fn from_str(s: &str) -> Result<Self, Self::Err> {
291 Self::parse_canonical(s)
292 }
293}
294
295impl Serialize for ClusterId {
296 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
297 s.collect_str(self)
298 }
299}
300
301impl<'de> Deserialize<'de> for ClusterId {
302 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
303 let s = String::deserialize(d)?;
304 s.parse().map_err(serde::de::Error::custom)
305 }
306}
307
308/// Errors returned while parsing a [`ClusterId`].
309#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
310#[non_exhaustive]
311pub enum ClusterIdError {
312 /// The input was the empty string (or empty structured-parts list).
313 #[error("empty cluster id")]
314 Empty,
315 /// A segment had an axis but no `:` separator before the value.
316 #[error("missing `:` separator at segment {segment}")]
317 MissingSeparator {
318 /// Zero-based index of the offending segment.
319 segment: usize,
320 },
321 /// A quoted value did not have a closing `"`.
322 #[error("unterminated quoted value at segment {segment}")]
323 UnterminatedQuote {
324 /// Zero-based index of the offending segment.
325 segment: usize,
326 },
327 /// A segment had an empty axis side.
328 #[error("empty axis at segment {segment}")]
329 EmptyAxis {
330 /// Zero-based index of the offending segment.
331 segment: usize,
332 },
333 /// A character other than `,` followed a closing quote.
334 #[error("unexpected character `{ch}` after closing quote at segment {segment}")]
335 GarbageAfterQuote {
336 /// Zero-based index of the offending segment.
337 segment: usize,
338 /// The unexpected character.
339 ch: char,
340 },
341 /// A structured-form part lacked the required `=` separator.
342 #[error("structured segment `{segment}` missing `=` separator")]
343 StructuredMissingEquals {
344 /// The offending part as supplied by the caller.
345 segment: String,
346 },
347}
348
349// ---------- private helpers ----------
350
351/// Read an axis up to the first `:`. Returns the collected axis and
352/// whether a `:` was actually consumed (i.e. not end-of-input or `,`).
353fn read_axis(
354 chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
355 segment_idx: usize,
356) -> Result<(String, bool), ClusterIdError> {
357 let mut axis = String::new();
358 while let Some(&c) = chars.peek() {
359 match c {
360 ':' => {
361 chars.next();
362 return Ok((axis, true));
363 }
364 ',' => {
365 // axis with no `:` — surface a missing-separator error.
366 return Err(ClusterIdError::MissingSeparator {
367 segment: segment_idx,
368 });
369 }
370 _ => {
371 axis.push(c);
372 chars.next();
373 }
374 }
375 }
376 Ok((axis, false))
377}
378
379/// Read a value starting at the current position. Stops at the first
380/// unquoted `,` or end-of-input. Handles `"..."` quoted values with
381/// `""` escapes.
382fn read_value(
383 chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
384 segment_idx: usize,
385) -> Result<String, ClusterIdError> {
386 let mut value = String::new();
387 if chars.peek() == Some(&'"') {
388 chars.next(); // consume opening quote
389 loop {
390 match chars.next() {
391 None => {
392 return Err(ClusterIdError::UnterminatedQuote {
393 segment: segment_idx,
394 });
395 }
396 Some('"') => {
397 if chars.peek() == Some(&'"') {
398 chars.next();
399 value.push('"');
400 } else {
401 // closing quote consumed; caller decides what
402 // follows (must be ',' or end-of-input).
403 return Ok(value);
404 }
405 }
406 Some(c) => value.push(c),
407 }
408 }
409 } else {
410 while let Some(&c) = chars.peek() {
411 if c == ',' {
412 break;
413 }
414 value.push(c);
415 chars.next();
416 }
417 Ok(value)
418 }
419}
420
421/// Write `value` with §6.1 quoting rules.
422fn write_value(f: &mut fmt::Formatter<'_>, value: &str) -> fmt::Result {
423 let needs_quote = value.contains([',', ':', '"']);
424 if !needs_quote {
425 return f.write_str(value);
426 }
427 f.write_str("\"")?;
428 for c in value.chars() {
429 if c == '"' {
430 f.write_str("\"\"")?;
431 } else {
432 f.write_char(c)?;
433 }
434 }
435 f.write_str("\"")
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn parses_two_axis_canonical() {
444 let id = ClusterId::parse_canonical("file:src/cli.rs,score:excellent").unwrap();
445 assert_eq!(id.depth(), 2);
446 assert_eq!(id.segments()[0].axis, "file");
447 assert_eq!(id.segments()[0].value, "src/cli.rs");
448 assert_eq!(id.segments()[1].axis, "score");
449 assert_eq!(id.segments()[1].value, "excellent");
450 }
451
452 #[test]
453 fn round_trips_via_display() {
454 let s = "file:src/cli.rs,score:excellent";
455 let id: ClusterId = s.parse().unwrap();
456 assert_eq!(id.to_string(), s);
457 }
458
459 #[test]
460 fn quotes_values_with_special_chars() {
461 let id = ClusterId::new(vec![ClusterIdSegment {
462 axis: "file".into(),
463 value: "a,b:c\"d".into(),
464 }]);
465 let rendered = id.to_string();
466 assert_eq!(rendered, "file:\"a,b:c\"\"d\"");
467 let round: ClusterId = rendered.parse().unwrap();
468 assert_eq!(round, id);
469 }
470
471 #[test]
472 fn parses_structured_form() {
473 let id = ClusterId::parse_structured(["file=src/cli.rs", "score=excellent"]).unwrap();
474 assert_eq!(id.depth(), 2);
475 assert_eq!(id.segments()[1].value, "excellent");
476 }
477
478 #[test]
479 fn rejects_empty_string() {
480 let err = ClusterId::parse_canonical("").unwrap_err();
481 assert_eq!(err, ClusterIdError::Empty);
482 }
483
484 #[test]
485 fn parses_bare_single_segment() {
486 let id = ClusterId::parse_canonical("excellent").unwrap();
487 assert_eq!(id.segments().len(), 1);
488 assert_eq!(id.segments()[0].axis, "");
489 assert_eq!(id.segments()[0].value, "excellent");
490 assert_eq!(id.to_string(), "excellent");
491 }
492
493 #[test]
494 fn rejects_mixed_bare_with_multi() {
495 let err = ClusterId::parse_canonical("excellent,file:src/cli.rs").unwrap_err();
496 assert_eq!(err, ClusterIdError::MissingSeparator { segment: 0 });
497 }
498}