use crate::error::{AgmError, ErrorCode, ErrorLocation};
#[derive(Debug, Clone, PartialEq)]
pub enum SidecarLineKind {
Header(String, String),
BlockDecl(String, String),
Field(String, String),
Continuation(String),
Blank,
Comment(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct SidecarLine {
pub kind: SidecarLineKind,
pub number: usize,
pub raw: String,
}
#[must_use]
pub fn classify_sidecar_line(line: &str) -> SidecarLineKind {
if line.trim().is_empty() {
return SidecarLineKind::Blank;
}
if let Some(rest_of_hash) = line.strip_prefix('#') {
let after_hash = if let Some(stripped) = line.strip_prefix("# ") {
stripped
} else {
return SidecarLineKind::Comment(rest_of_hash.trim_start().to_owned());
};
if let Some(colon_pos) = after_hash.find(": ") {
let key = &after_hash[..colon_pos];
let value = &after_hash[colon_pos + 2..];
if is_header_key(key) {
return SidecarLineKind::Header(key.to_owned(), value.to_owned());
}
}
if let Some(key) = after_hash.strip_suffix(':') {
if is_header_key(key) {
return SidecarLineKind::Header(key.to_owned(), String::new());
}
}
return SidecarLineKind::Comment(after_hash.to_owned());
}
if let Some(rest) = line.strip_prefix("state ") {
let id = rest.trim_end();
if !id.is_empty() {
return SidecarLineKind::BlockDecl("state".to_owned(), id.to_owned());
}
}
if let Some(rest) = line.strip_prefix("entry ") {
let id = rest.trim_end();
if !id.is_empty() {
return SidecarLineKind::BlockDecl("entry".to_owned(), id.to_owned());
}
}
if let Some(stripped) = line.strip_prefix(" ") {
return SidecarLineKind::Continuation(stripped.to_owned());
}
if let Some(colon_pos) = line.find(':') {
let key = &line[..colon_pos];
if is_field_key(key) {
let rest = &line[colon_pos + 1..];
let value = if let Some(stripped) = rest.strip_prefix(' ') {
stripped.to_owned()
} else {
rest.to_owned()
};
return SidecarLineKind::Field(key.to_owned(), value);
}
}
SidecarLineKind::Comment(line.to_owned())
}
fn is_header_key(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.')
}
fn is_field_key(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
pub fn lex_sidecar(input: &str) -> Result<Vec<SidecarLine>, Vec<AgmError>> {
let mut lines = Vec::new();
let mut errors = Vec::new();
for (index, raw) in input.lines().enumerate() {
let number = index + 1;
if raw.contains('\t') {
errors.push(AgmError::new(
ErrorCode::P004,
format!("Tab character in indentation at line {number} (spaces required)"),
ErrorLocation::new(None, Some(number), None),
));
continue;
}
let kind = classify_sidecar_line(raw);
lines.push(SidecarLine {
kind,
number,
raw: raw.to_owned(),
});
}
if errors.is_empty() {
Ok(lines)
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_classify_empty_string_is_blank() {
assert_eq!(classify_sidecar_line(""), SidecarLineKind::Blank);
}
#[test]
fn test_classify_whitespace_only_is_blank() {
assert_eq!(classify_sidecar_line(" "), SidecarLineKind::Blank);
}
#[test]
fn test_classify_hash_key_value_is_header() {
assert_eq!(
classify_sidecar_line("# agm.state: 1.0"),
SidecarLineKind::Header("agm.state".to_owned(), "1.0".to_owned())
);
}
#[test]
fn test_classify_header_package() {
assert_eq!(
classify_sidecar_line("# package: test.pkg"),
SidecarLineKind::Header("package".to_owned(), "test.pkg".to_owned())
);
}
#[test]
fn test_classify_header_session_id() {
assert_eq!(
classify_sidecar_line("# session_id: run-001"),
SidecarLineKind::Header("session_id".to_owned(), "run-001".to_owned())
);
}
#[test]
fn test_classify_comment_no_colon() {
assert_eq!(
classify_sidecar_line("# just a comment"),
SidecarLineKind::Comment("just a comment".to_owned())
);
}
#[test]
fn test_classify_comment_uppercase_key_not_header() {
assert_eq!(
classify_sidecar_line("# Package: test.pkg"),
SidecarLineKind::Comment("Package: test.pkg".to_owned())
);
}
#[test]
fn test_classify_bare_hash_is_comment() {
assert_eq!(
classify_sidecar_line("#"),
SidecarLineKind::Comment(String::new())
);
}
#[test]
fn test_classify_state_block_decl() {
assert_eq!(
classify_sidecar_line("state migration.025.data"),
SidecarLineKind::BlockDecl("state".to_owned(), "migration.025.data".to_owned())
);
}
#[test]
fn test_classify_entry_block_decl() {
assert_eq!(
classify_sidecar_line("entry project.db_version"),
SidecarLineKind::BlockDecl("entry".to_owned(), "project.db_version".to_owned())
);
}
#[test]
fn test_classify_field_with_value() {
assert_eq!(
classify_sidecar_line("execution_status: completed"),
SidecarLineKind::Field("execution_status".to_owned(), "completed".to_owned())
);
}
#[test]
fn test_classify_field_empty_value() {
assert_eq!(
classify_sidecar_line("execution_log:"),
SidecarLineKind::Field("execution_log".to_owned(), String::new())
);
}
#[test]
fn test_classify_field_retry_count() {
assert_eq!(
classify_sidecar_line("retry_count: 0"),
SidecarLineKind::Field("retry_count".to_owned(), "0".to_owned())
);
}
#[test]
fn test_classify_two_spaces_is_continuation() {
assert_eq!(
classify_sidecar_line(" continuation content"),
SidecarLineKind::Continuation("continuation content".to_owned())
);
}
#[test]
fn test_classify_four_spaces_is_continuation_strips_two() {
assert_eq!(
classify_sidecar_line(" deeper content"),
SidecarLineKind::Continuation(" deeper content".to_owned())
);
}
#[test]
fn test_lex_sidecar_simple_input_returns_ok() {
let input =
"# agm.state: 1.0\n# package: test.pkg\n\nstate node.one\nexecution_status: pending\n";
let lines = lex_sidecar(input).unwrap();
assert_eq!(lines.len(), 5);
assert_eq!(
lines[0].kind,
SidecarLineKind::Header("agm.state".to_owned(), "1.0".to_owned())
);
assert_eq!(lines[2].kind, SidecarLineKind::Blank);
}
#[test]
fn test_lex_sidecar_tab_returns_error_p004() {
let input = "# agm.state: 1.0\n\texecution_status: pending\n";
let errors = lex_sidecar(input).unwrap_err();
assert!(!errors.is_empty());
assert_eq!(errors[0].code, ErrorCode::P004);
}
#[test]
fn test_lex_sidecar_line_numbers_start_at_one() {
let input = "# agm.state: 1.0\n# package: test\n";
let lines = lex_sidecar(input).unwrap();
assert_eq!(lines[0].number, 1);
assert_eq!(lines[1].number, 2);
}
}