use super::Entry;
use callisto_model::{Severity, SeverityParseError};
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum NameToken<'a> {
Bare(&'a str),
Quoted(String),
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum LineError {
UnclosedQuotedName,
AmbiguousNameQuoting {
raw: String,
},
MissingSeparator {
raw: String,
},
EmptyName,
InvalidSeverity {
name: String,
source: SeverityParseError,
},
}
pub(crate) fn split_name_and_rest(line: &str) -> Result<(NameToken<'_>, &str), LineError> {
let trimmed = line.trim_start();
if let Some(after_quote) = trimmed.strip_prefix('"') {
let end = after_quote.find('"').ok_or(LineError::UnclosedQuotedName)?;
let name = &after_quote[..end];
let rest = after_quote[end + 1..].trim_start();
let rest = rest
.strip_prefix(':')
.ok_or_else(|| LineError::AmbiguousNameQuoting {
raw: line.to_string(),
})?;
Ok((NameToken::Quoted(name.to_string()), rest))
} else {
let (name, rest) = trimmed
.rsplit_once(':')
.ok_or_else(|| LineError::MissingSeparator {
raw: line.to_string(),
})?;
Ok((NameToken::Bare(name.trim_end()), rest))
}
}
pub(crate) fn parse_entry_line(line: &str) -> Result<Entry, LineError> {
let (token, rest) = split_name_and_rest(line)?;
let name = match token {
NameToken::Bare(s) => s.to_string(),
NameToken::Quoted(s) => s,
};
if name.is_empty() {
return Err(LineError::EmptyName);
}
let severity =
rest.trim()
.parse::<Severity>()
.map_err(|source| LineError::InvalidSeverity {
name: name.clone(),
source,
})?;
Ok(Entry { name, severity })
}
pub(crate) fn needs_quoting(name: &str) -> bool {
let Some(first) = name.chars().next() else {
return true; };
matches!(
first,
'@' | '`'
| '"'
| '\''
| '&'
| '*'
| '!'
| '|'
| '>'
| '%'
| '#'
| '-'
| '?'
| ':'
| ','
| '['
| ']'
| '{'
| '}'
| ' '
) || name.contains(':')
|| name.contains('#')
|| name.ends_with(' ')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_bare_name_from_severity() {
let (token, rest) = split_name_and_rest("cargo/foo: patch").unwrap();
assert_eq!(token, NameToken::Bare("cargo/foo"));
assert_eq!(rest, " patch");
}
#[test]
fn splits_quoted_name_from_severity() {
let (token, rest) = split_name_and_rest("\"@myorg/foo\": minor").unwrap();
assert_eq!(token, NameToken::Quoted("@myorg/foo".to_string()));
assert_eq!(rest, " minor");
}
#[test]
fn quoted_name_containing_colon_is_not_mis_split() {
let (token, rest) = split_name_and_rest("\"maven/org.example:foo-core\": major").unwrap();
assert_eq!(
token,
NameToken::Quoted("maven/org.example:foo-core".to_string())
);
assert_eq!(rest, " major");
}
#[test]
fn bare_name_uses_rsplit_once_on_colon() {
let (token, rest) = split_name_and_rest("weird:but:bare: patch").unwrap();
assert_eq!(token, NameToken::Bare("weird:but:bare"));
assert_eq!(rest, " patch");
}
#[test]
fn unclosed_quote_is_an_error() {
let err = split_name_and_rest("\"@myorg/foo: minor").unwrap_err();
assert_eq!(err, LineError::UnclosedQuotedName);
}
#[test]
fn content_between_closing_quote_and_colon_is_ambiguous() {
let err = split_name_and_rest("\"@myorg/foo\" extra: minor").unwrap_err();
assert_eq!(
err,
LineError::AmbiguousNameQuoting {
raw: "\"@myorg/foo\" extra: minor".to_string()
}
);
}
#[test]
fn bare_line_with_no_colon_is_missing_separator() {
let err = split_name_and_rest("no colon here").unwrap_err();
assert_eq!(
err,
LineError::MissingSeparator {
raw: "no colon here".to_string()
}
);
}
#[test]
fn parses_full_entry_line() {
let entry = parse_entry_line("cargo/foo: patch").unwrap();
assert_eq!(entry.name, "cargo/foo");
assert_eq!(entry.severity, Severity::Patch);
}
#[test]
fn parse_entry_line_rejects_empty_name() {
let err = parse_entry_line("\"\": minor").unwrap_err();
assert_eq!(err, LineError::EmptyName);
}
#[test]
fn parse_entry_line_rejects_invalid_severity() {
let err = parse_entry_line("cargo/foo: critical").unwrap_err();
assert!(matches!(err, LineError::InvalidSeverity { .. }));
}
#[test]
fn parse_entry_line_severity_is_case_insensitive() {
let entry = parse_entry_line("cargo/foo: MAJOR").unwrap();
assert_eq!(entry.severity, Severity::Major);
}
#[test]
fn needs_quoting_flags_npm_scoped_names() {
assert!(needs_quoting("@myorg/foo"));
}
#[test]
fn needs_quoting_allows_bare_cargo_style_names() {
assert!(!needs_quoting("cargo/foo"));
}
#[test]
fn needs_quoting_flags_names_containing_colon() {
assert!(needs_quoting("maven/org.example:foo-core"));
}
#[test]
fn needs_quoting_flags_empty_name() {
assert!(needs_quoting(""));
}
#[test]
fn needs_quoting_flags_trailing_space() {
assert!(needs_quoting("foo "));
}
}