Skip to main content

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}