use std::fmt::{self, Write as _};
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct ClusterIdSegment {
pub axis: String,
pub value: String,
}
impl ClusterIdSegment {
pub fn new(axis: impl Into<String>, value: impl Into<String>) -> Self {
Self {
axis: axis.into(),
value: value.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub struct ClusterId(Vec<ClusterIdSegment>);
impl ClusterId {
pub fn new(segments: Vec<ClusterIdSegment>) -> Self {
Self(segments)
}
pub fn segments(&self) -> &[ClusterIdSegment] {
&self.0
}
pub fn depth(&self) -> usize {
self.0.len()
}
pub fn is_root(&self) -> bool {
self.0.is_empty()
}
pub fn parent(&self) -> Option<ClusterId> {
if self.0.is_empty() {
None
} else {
Some(ClusterId(self.0[..self.0.len() - 1].to_vec()))
}
}
pub fn parse_canonical(s: &str) -> Result<Self, ClusterIdError> {
if s.is_empty() {
return Err(ClusterIdError::Empty);
}
let mut segments = Vec::new();
let mut chars = s.chars().peekable();
let mut segment_idx = 0usize;
loop {
if segment_idx == 0 && chars.peek() == Some(&'"') {
let value = read_value(&mut chars, segment_idx)?;
segments.push(ClusterIdSegment {
axis: String::new(),
value,
});
match chars.next() {
None => break,
Some(',') => {
return Err(ClusterIdError::MissingSeparator { segment: 0 });
}
Some(other) => {
return Err(ClusterIdError::GarbageAfterQuote {
segment: segment_idx,
ch: other,
});
}
}
}
let (axis, after_colon) = read_axis(&mut chars, segment_idx)?;
if !after_colon {
if segment_idx == 0 && chars.peek().is_none() && !axis.is_empty() {
segments.push(ClusterIdSegment {
axis: String::new(),
value: axis,
});
break;
}
if axis.is_empty() {
return Err(ClusterIdError::EmptyAxis {
segment: segment_idx,
});
}
return Err(ClusterIdError::MissingSeparator {
segment: segment_idx,
});
}
if axis.is_empty() {
return Err(ClusterIdError::EmptyAxis {
segment: segment_idx,
});
}
let value = read_value(&mut chars, segment_idx)?;
segments.push(ClusterIdSegment { axis, value });
match chars.next() {
None => break,
Some(',') => {
segment_idx += 1;
continue;
}
Some(other) => {
return Err(ClusterIdError::GarbageAfterQuote {
segment: segment_idx,
ch: other,
});
}
}
}
Ok(ClusterId(segments))
}
pub fn parse_structured<I, S>(parts: I) -> Result<Self, ClusterIdError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut segments = Vec::new();
for (idx, part) in parts.into_iter().enumerate() {
let part_str = part.as_ref();
let Some((axis, value)) = part_str.split_once('=') else {
return Err(ClusterIdError::StructuredMissingEquals {
segment: part_str.to_string(),
});
};
if axis.is_empty() {
return Err(ClusterIdError::EmptyAxis { segment: idx });
}
segments.push(ClusterIdSegment {
axis: axis.to_string(),
value: value.to_string(),
});
}
if segments.is_empty() {
return Err(ClusterIdError::Empty);
}
Ok(ClusterId(segments))
}
}
impl fmt::Display for ClusterId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.0.len() == 1 && self.0[0].axis.is_empty() {
return write_value(f, &self.0[0].value);
}
for (i, seg) in self.0.iter().enumerate() {
if i > 0 {
f.write_str(",")?;
}
f.write_str(&seg.axis)?;
f.write_str(":")?;
write_value(f, &seg.value)?;
}
Ok(())
}
}
impl FromStr for ClusterId {
type Err = ClusterIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse_canonical(s)
}
}
impl Serialize for ClusterId {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for ClusterId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
s.parse().map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum ClusterIdError {
#[error("empty cluster id")]
Empty,
#[error("missing `:` separator at segment {segment}")]
MissingSeparator {
segment: usize,
},
#[error("unterminated quoted value at segment {segment}")]
UnterminatedQuote {
segment: usize,
},
#[error("empty axis at segment {segment}")]
EmptyAxis {
segment: usize,
},
#[error("unexpected character `{ch}` after closing quote at segment {segment}")]
GarbageAfterQuote {
segment: usize,
ch: char,
},
#[error("structured segment `{segment}` missing `=` separator")]
StructuredMissingEquals {
segment: String,
},
}
fn read_axis(
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
segment_idx: usize,
) -> Result<(String, bool), ClusterIdError> {
let mut axis = String::new();
while let Some(&c) = chars.peek() {
match c {
':' => {
chars.next();
return Ok((axis, true));
}
',' => {
return Err(ClusterIdError::MissingSeparator {
segment: segment_idx,
});
}
_ => {
axis.push(c);
chars.next();
}
}
}
Ok((axis, false))
}
fn read_value(
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
segment_idx: usize,
) -> Result<String, ClusterIdError> {
let mut value = String::new();
if chars.peek() == Some(&'"') {
chars.next(); loop {
match chars.next() {
None => {
return Err(ClusterIdError::UnterminatedQuote {
segment: segment_idx,
});
}
Some('"') => {
if chars.peek() == Some(&'"') {
chars.next();
value.push('"');
} else {
return Ok(value);
}
}
Some(c) => value.push(c),
}
}
} else {
while let Some(&c) = chars.peek() {
if c == ',' {
break;
}
value.push(c);
chars.next();
}
Ok(value)
}
}
fn write_value(f: &mut fmt::Formatter<'_>, value: &str) -> fmt::Result {
let needs_quote = value.contains([',', ':', '"']);
if !needs_quote {
return f.write_str(value);
}
f.write_str("\"")?;
for c in value.chars() {
if c == '"' {
f.write_str("\"\"")?;
} else {
f.write_char(c)?;
}
}
f.write_str("\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_two_axis_canonical() {
let id = ClusterId::parse_canonical("file:src/cli.rs,score:excellent").unwrap();
assert_eq!(id.depth(), 2);
assert_eq!(id.segments()[0].axis, "file");
assert_eq!(id.segments()[0].value, "src/cli.rs");
assert_eq!(id.segments()[1].axis, "score");
assert_eq!(id.segments()[1].value, "excellent");
}
#[test]
fn round_trips_via_display() {
let s = "file:src/cli.rs,score:excellent";
let id: ClusterId = s.parse().unwrap();
assert_eq!(id.to_string(), s);
}
#[test]
fn quotes_values_with_special_chars() {
let id = ClusterId::new(vec![ClusterIdSegment {
axis: "file".into(),
value: "a,b:c\"d".into(),
}]);
let rendered = id.to_string();
assert_eq!(rendered, "file:\"a,b:c\"\"d\"");
let round: ClusterId = rendered.parse().unwrap();
assert_eq!(round, id);
}
#[test]
fn parses_structured_form() {
let id = ClusterId::parse_structured(["file=src/cli.rs", "score=excellent"]).unwrap();
assert_eq!(id.depth(), 2);
assert_eq!(id.segments()[1].value, "excellent");
}
#[test]
fn rejects_empty_string() {
let err = ClusterId::parse_canonical("").unwrap_err();
assert_eq!(err, ClusterIdError::Empty);
}
#[test]
fn parses_bare_single_segment() {
let id = ClusterId::parse_canonical("excellent").unwrap();
assert_eq!(id.segments().len(), 1);
assert_eq!(id.segments()[0].axis, "");
assert_eq!(id.segments()[0].value, "excellent");
assert_eq!(id.to_string(), "excellent");
}
#[test]
fn rejects_mixed_bare_with_multi() {
let err = ClusterId::parse_canonical("excellent,file:src/cli.rs").unwrap_err();
assert_eq!(err, ClusterIdError::MissingSeparator { segment: 0 });
}
}