use std::borrow::Cow;
use crate::diagnostics::{CoreError, LintResult, ValidationError};
use serde::de::DeserializeOwned;
#[inline]
pub fn normalize_line_endings(s: &str) -> Cow<'_, str> {
if !s.contains('\r') {
return Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\r' {
chars.next_if_eq(&'\n');
out.push('\n');
} else {
out.push(ch);
}
}
Cow::Owned(out)
}
#[allow(dead_code)] pub fn parse_frontmatter<T: DeserializeOwned>(content: &str) -> LintResult<(T, String)> {
let parts = split_frontmatter(content);
let parsed: T = serde_yaml::from_str(&parts.frontmatter)
.map_err(|e| CoreError::Validation(ValidationError::Other(e.into())))?;
Ok((parsed, parts.body.trim_start().to_string()))
}
#[derive(Debug, Clone)]
pub struct FrontmatterParts {
pub has_frontmatter: bool,
pub has_closing: bool,
pub frontmatter: String,
pub body: String,
pub frontmatter_start: usize,
pub body_start: usize,
}
pub fn split_frontmatter(content: &str) -> FrontmatterParts {
let trimmed = content.trim_start();
let trim_offset = content.len() - trimmed.len();
if !trimmed.starts_with("---") {
return FrontmatterParts {
has_frontmatter: false,
has_closing: false,
frontmatter: String::new(),
body: trimmed.to_string(),
frontmatter_start: trim_offset,
body_start: trim_offset,
};
}
let rest = &trimmed[3..];
let newline_len = if rest.starts_with("\r\n") {
2
} else if rest.starts_with('\n') {
1
} else {
0
};
let frontmatter_start = trim_offset + 3 + newline_len;
if let Some(end_pos) = rest.find("\n---") {
let frontmatter = rest.get(newline_len..end_pos).unwrap_or("");
let body = &rest[end_pos + 4..]; FrontmatterParts {
has_frontmatter: true,
has_closing: true,
frontmatter: frontmatter.to_string(),
body: body.to_string(),
frontmatter_start,
body_start: trim_offset + 3 + end_pos + 4,
}
} else {
let body = &rest[newline_len..];
FrontmatterParts {
has_frontmatter: true,
has_closing: false,
frontmatter: String::new(),
body: body.to_string(),
frontmatter_start,
body_start: frontmatter_start,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
struct TestFrontmatter {
name: String,
description: String,
}
#[test]
fn test_parse_frontmatter() {
let content = r#"---
name: test-skill
description: A test skill
---
Body content here"#;
let (fm, body): (TestFrontmatter, String) = parse_frontmatter(content).unwrap();
assert_eq!(fm.name, "test-skill");
assert_eq!(fm.description, "A test skill");
assert_eq!(body, "Body content here");
}
#[test]
fn test_no_frontmatter() {
let content = "Just body content";
let result: LintResult<(TestFrontmatter, String)> = parse_frontmatter(content);
assert!(result.is_err()); }
#[test]
fn test_split_frontmatter_basic() {
let content = "---\nname: test\n---\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert_eq!(parts.frontmatter, "name: test");
assert_eq!(parts.body, "\nbody");
assert_eq!(parts.frontmatter_start, 4);
assert_eq!(&content[parts.body_start..], parts.body);
}
#[test]
fn test_split_frontmatter_no_closing() {
let content = "---\nname: test";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(!parts.has_closing);
assert!(parts.frontmatter.is_empty());
assert_eq!(parts.body, "name: test");
assert_eq!(parts.body_start, 4); assert_eq!(&content[parts.body_start..], parts.body);
}
#[test]
fn test_split_frontmatter_no_closing_crlf() {
let content = "---\r\nname: test";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(!parts.has_closing);
assert!(parts.frontmatter.is_empty());
assert_eq!(parts.body, "name: test");
assert_eq!(parts.body_start, 5); assert_eq!(&content[parts.body_start..], parts.body);
}
#[test]
fn test_split_frontmatter_empty() {
let content = "";
let parts = split_frontmatter(content);
assert!(!parts.has_frontmatter);
assert!(!parts.has_closing);
}
#[test]
fn test_split_frontmatter_empty_body_lf() {
let content = "---\n---\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert_eq!(parts.frontmatter, "");
assert_eq!(parts.body, "\nbody");
assert_eq!(&content[parts.body_start..], parts.body);
}
#[test]
fn test_split_frontmatter_empty_body_crlf() {
let content = "---\r\n---\r\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert_eq!(parts.frontmatter, "");
assert_eq!(parts.body, "\r\nbody");
assert_eq!(&content[parts.body_start..], parts.body);
}
#[test]
fn test_split_frontmatter_whitespace_prefix() {
let content = " \n---\nkey: val\n---\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
}
#[test]
fn test_split_frontmatter_multiple_dashes() {
let content = "---\nfirst: 1\n---\nmiddle\n---\nlast";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert!(parts.body.contains("middle"));
}
#[test]
fn test_split_frontmatter_crlf() {
let content = "---\r\nname: test\r\n---\r\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert!(parts.body.contains("body"));
}
#[test]
fn test_split_frontmatter_crlf_byte_offsets() {
let content = "---\r\nname: test\r\n---\r\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert!(parts.frontmatter_start <= content.len());
assert!(parts.body_start <= content.len());
assert_eq!(parts.frontmatter_start, 5);
assert_eq!(parts.frontmatter, "name: test\r");
}
#[test]
fn test_split_frontmatter_no_newline_after_opener() {
let content = "---key: val\n---\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert_eq!(parts.frontmatter_start, 3); assert_eq!(parts.frontmatter, "key: val");
assert_eq!(&content[parts.body_start..], parts.body);
}
#[test]
fn test_split_frontmatter_unicode_values() {
let content = "---\nname: \u{4f60}\u{597d}\ndescription: caf\u{00e9}\n---\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert!(
parts.frontmatter.contains("\u{4f60}\u{597d}"),
"Frontmatter should contain CJK characters"
);
assert!(
parts.frontmatter.contains("caf\u{00e9}"),
"Frontmatter should contain accented character"
);
}
#[test]
fn test_split_frontmatter_escaped_quotes() {
let content = "---\nname: \"test\\\"skill\"\ndescription: test\n---\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert!(
parts.frontmatter.contains("test\\\"skill"),
"Frontmatter should preserve escaped quotes"
);
}
#[test]
fn test_split_frontmatter_long_lines() {
let long_value = "x".repeat(5000);
let content = format!("---\nname: {}\n---\nbody", long_value);
let parts = split_frontmatter(&content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert!(parts.frontmatter.contains(&long_value));
}
#[test]
fn test_split_frontmatter_empty_values() {
let content = "---\nname:\ndescription: test\n---\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert!(parts.frontmatter.contains("name:"));
}
#[test]
fn test_split_frontmatter_nested_yaml() {
let content = "---\nmetadata:\n key1: val1\n key2: val2\n---\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert!(parts.frontmatter.contains("key1: val1"));
assert!(parts.frontmatter.contains("key2: val2"));
}
#[test]
fn test_split_frontmatter_mixed_line_endings() {
let content = "---\nname: test\r\ndescription: val\n---\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
}
#[test]
fn test_split_frontmatter_emoji_in_yaml_keys() {
let content = "---\n\u{1f525}fire: hot\n\u{1f680}rocket: fast\n---\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert!(parts.frontmatter.contains("\u{1f525}fire"));
assert!(parts.frontmatter.contains("\u{1f680}rocket"));
assert!(content.is_char_boundary(parts.frontmatter_start));
assert!(content.is_char_boundary(parts.body_start));
}
#[test]
fn test_split_frontmatter_emoji_in_yaml_values() {
let content = "---\nstatus: \u{2705} done\nmood: \u{1f60a}\n---\nbody";
let parts = split_frontmatter(content);
assert!(parts.has_frontmatter);
assert!(parts.has_closing);
assert!(parts.frontmatter.contains("\u{2705}"));
assert!(parts.frontmatter.contains("\u{1f60a}"));
}
#[test]
fn test_normalize_lf_only_returns_borrowed() {
let input = "hello\nworld\n";
let result = normalize_line_endings(input);
assert!(
matches!(result, Cow::Borrowed(_)),
"LF-only input should return Cow::Borrowed"
);
assert_eq!(&*result, input);
}
#[test]
fn test_normalize_crlf_returns_owned() {
let input = "hello\r\nworld\r\n";
let result = normalize_line_endings(input);
assert!(
matches!(result, Cow::Owned(_)),
"CRLF input should return Cow::Owned"
);
assert_eq!(&*result, "hello\nworld\n");
}
#[test]
fn test_normalize_lone_cr() {
let input = "hello\rworld\r";
let result = normalize_line_endings(input);
assert_eq!(&*result, "hello\nworld\n");
}
#[test]
fn test_normalize_mixed_line_endings() {
let input = "line1\r\nline2\rline3\nline4";
let result = normalize_line_endings(input);
assert_eq!(&*result, "line1\nline2\nline3\nline4");
assert!(!result.contains('\r'));
}
#[test]
fn test_normalize_empty_string() {
let input = "";
let result = normalize_line_endings(input);
assert!(
matches!(result, Cow::Borrowed(_)),
"Empty string should return Cow::Borrowed"
);
assert_eq!(&*result, "");
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn split_frontmatter_never_panics(content in ".*") {
let _ = split_frontmatter(&content);
}
#[test]
fn split_frontmatter_valid_offsets(content in ".*") {
let parts = split_frontmatter(&content);
prop_assert!(parts.frontmatter_start <= content.len());
prop_assert!(parts.body_start <= content.len());
}
#[test]
fn frontmatter_with_dashes_detected(
yaml in "[a-z]+: [a-z]+",
) {
let content = format!("---\n{}\n---\nbody", yaml);
let parts = split_frontmatter(&content);
prop_assert!(parts.has_frontmatter);
prop_assert!(parts.has_closing);
}
#[test]
fn no_frontmatter_without_leading_dashes(
content in "[^-].*"
) {
let parts = split_frontmatter(&content);
prop_assert!(!parts.has_frontmatter);
}
#[test]
fn unclosed_frontmatter_has_empty_frontmatter(
yaml in "[a-z]+: [a-z]+"
) {
let content = format!("---\n{}", yaml);
let parts = split_frontmatter(&content);
prop_assert!(parts.has_frontmatter);
prop_assert!(!parts.has_closing);
prop_assert!(parts.frontmatter.is_empty());
}
#[test]
fn normalize_line_endings_never_contains_cr(content in ".*") {
let normalized = normalize_line_endings(&content);
prop_assert!(
!normalized.contains('\r'),
"Normalized output must not contain \\r"
);
}
}
}