use std::fmt;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DocComment {
pub models: String,
pub neglects: String,
pub io_in: String,
pub io_out: String,
pub tags: Vec<String>,
}
pub fn parse(line: &str) -> Result<DocComment, String> {
let content = line.trim_start().trim_start_matches(';').trim();
let fields: Vec<&str> = content.split(" | ").collect();
let [models, neglects, io, tags] = fields.as_slice() else {
return Err("expected exactly four ` | `-delimited fields".to_string());
};
let models = models
.strip_prefix("Models:")
.ok_or("first field must start with `Models:`")?
.trim();
if models.is_empty() {
return Err("`Models:` field is empty".to_string());
}
let neglects = neglects
.strip_prefix("Neglects:")
.ok_or("second field must start with `Neglects:`")?
.trim();
if neglects.is_empty() {
return Err("`Neglects:` field is empty".to_string());
}
let io = io
.strip_prefix("IO:")
.ok_or("third field must start with `IO:`")?
.trim();
let Some((input, output)) = io.split_once("->") else {
return Err("`IO:` field must be `<input> -> <output>`".to_string());
};
let (input, output) = (input.trim(), output.trim());
if input.is_empty() || output.is_empty() {
return Err("`IO:` field's input and output must both be non-empty".to_string());
}
let tags = tags
.strip_prefix("Tags:")
.ok_or("fourth field must start with `Tags:`")?
.trim();
if tags.is_empty() {
return Err("`Tags:` field is empty".to_string());
}
let tags: Vec<&str> = tags.split(',').map(str::trim).collect();
if tags.iter().any(|tag| tag.is_empty()) {
return Err("`Tags:` field has an empty tag between commas".to_string());
}
Ok(DocComment {
models: models.to_string(),
neglects: neglects.to_string(),
io_in: input.to_string(),
io_out: output.to_string(),
tags: tags.into_iter().map(String::from).collect(),
})
}
impl fmt::Display for DocComment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Models: {} | Neglects: {} | IO: {} -> {} | Tags: {}",
self.models,
self.neglects,
self.io_in,
self.io_out,
self.tags.join(", ")
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_well_formed_line_parses_into_its_four_fields() {
let d = parse("; Models: a plucked string | Neglects: body coupling | IO: note -> mix | Tags: pluck, string").unwrap();
assert_eq!(d.models, "a plucked string");
assert_eq!(d.neglects, "body coupling");
assert_eq!((d.io_in.as_str(), d.io_out.as_str()), ("note", "mix"));
assert_eq!(d.tags, ["pluck", "string"]);
}
#[test]
fn a_parsed_line_prints_back_to_what_parse_reads() {
let line = "Models: a | Neglects: b | IO: t -> mix | Tags: x, y";
assert_eq!(parse(line).unwrap().to_string(), line);
}
#[test]
fn each_missing_piece_names_itself() {
for (line, why) in [
("free text", "four"),
("Model: a | Neglects: b | IO: t -> m | Tags: x", "`Models:`"),
(
"Models: a | Neglects: | IO: t -> m | Tags: x",
"`Neglects:` field is empty",
),
(
"Models: a | Neglects: b | IO: t | Tags: x",
"`<input> -> <output>`",
),
(
"Models: a | Neglects: b | IO: t -> m | Tags: x,,y",
"empty tag",
),
] {
let err = parse(line).unwrap_err();
assert!(err.contains(why), "{line:?} answered {err:?}");
}
}
#[test]
fn any_tag_count_or_shape_is_one_the_grammar_accepts() {
let d = parse("Models: a | Neglects: b | IO: t -> m | Tags: w, x, y, Free Text").unwrap();
assert_eq!(d.tags, ["w", "x", "y", "Free Text"]);
}
}