use std::path::PathBuf;
use facet::Facet;
use crate::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Facet)]
pub struct SourceSpan {
pub offset: usize,
pub length: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
pub struct InlineCodeSpan {
pub content: String,
pub span: SourceSpan,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Facet)]
pub struct RuleId {
pub base: String,
pub version: u32,
}
impl std::fmt::Display for RuleId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.version == 1 {
f.write_str(&self.base)
} else {
write!(f, "{}+{}", self.base, self.version)
}
}
}
impl PartialEq<&str> for RuleId {
fn eq(&self, other: &&str) -> bool {
parse_rule_id(other).is_some_and(|parsed| parsed == *self)
}
}
impl PartialEq<RuleId> for &str {
fn eq(&self, other: &RuleId) -> bool {
parse_rule_id(self).is_some_and(|parsed| parsed == *other)
}
}
pub fn parse_rule_id(id: &str) -> Option<RuleId> {
if id.is_empty() {
return None;
}
if let Some((base, version_str)) = id.rsplit_once('+') {
if base.is_empty() || base.contains('+') || version_str.is_empty() {
return None;
}
let version = version_str.parse::<u32>().ok()?;
if version == 0 {
return None;
}
Some(RuleId {
base: base.to_string(),
version,
})
} else if id.contains('+') {
None
} else {
Some(RuleId {
base: id.to_string(),
version: 1,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Facet)]
#[repr(u8)]
pub enum Rfc2119Keyword {
Must,
MustNot,
Should,
ShouldNot,
May,
}
impl Rfc2119Keyword {
pub fn is_negative(&self) -> bool {
matches!(self, Rfc2119Keyword::MustNot | Rfc2119Keyword::ShouldNot)
}
pub fn as_str(&self) -> &'static str {
match self {
Rfc2119Keyword::Must => "MUST",
Rfc2119Keyword::MustNot => "MUST NOT",
Rfc2119Keyword::Should => "SHOULD",
Rfc2119Keyword::ShouldNot => "SHOULD NOT",
Rfc2119Keyword::May => "MAY",
}
}
}
pub fn detect_rfc2119_keywords(text: &str) -> Vec<Rfc2119Keyword> {
let mut keywords = Vec::new();
let words: Vec<&str> = text.split_whitespace().collect();
let mut i = 0;
while i < words.len() {
let word = words[i].trim_matches(|c: char| !c.is_alphanumeric());
if i + 1 < words.len() {
let next_word = words[i + 1].trim_matches(|c: char| !c.is_alphanumeric());
if (word == "MUST" || word == "SHALL") && next_word == "NOT" {
keywords.push(Rfc2119Keyword::MustNot);
i += 2;
continue;
}
if word == "SHOULD" && next_word == "NOT" {
keywords.push(Rfc2119Keyword::ShouldNot);
i += 2;
continue;
}
if word == "NOT" && next_word == "RECOMMENDED" {
keywords.push(Rfc2119Keyword::ShouldNot);
i += 2;
continue;
}
}
match word {
"MUST" | "SHALL" | "REQUIRED" => keywords.push(Rfc2119Keyword::Must),
"SHOULD" | "RECOMMENDED" => keywords.push(Rfc2119Keyword::Should),
"MAY" | "OPTIONAL" => keywords.push(Rfc2119Keyword::May),
_ => {}
}
i += 1;
}
keywords
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Facet)]
#[repr(u8)]
pub enum ReqStatus {
Draft,
#[default]
Stable,
Deprecated,
Removed,
}
impl ReqStatus {
pub fn parse(s: &str) -> Option<Self> {
match s {
"draft" => Some(ReqStatus::Draft),
"stable" => Some(ReqStatus::Stable),
"deprecated" => Some(ReqStatus::Deprecated),
"removed" => Some(ReqStatus::Removed),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
ReqStatus::Draft => "draft",
ReqStatus::Stable => "stable",
ReqStatus::Deprecated => "deprecated",
ReqStatus::Removed => "removed",
}
}
}
impl std::fmt::Display for ReqStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Facet)]
#[repr(u8)]
pub enum ReqLevel {
#[default]
Must,
Should,
May,
}
impl ReqLevel {
pub fn parse(s: &str) -> Option<Self> {
match s {
"must" | "shall" | "required" => Some(ReqLevel::Must),
"should" | "recommended" => Some(ReqLevel::Should),
"may" | "optional" => Some(ReqLevel::May),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
ReqLevel::Must => "must",
ReqLevel::Should => "should",
ReqLevel::May => "may",
}
}
}
impl std::fmt::Display for ReqLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Facet)]
pub struct ReqMetadata {
pub status: Option<ReqStatus>,
pub level: Option<ReqLevel>,
pub since: Option<String>,
pub until: Option<String>,
pub tags: Vec<String>,
}
impl ReqMetadata {
pub fn counts_for_coverage(&self) -> bool {
!matches!(
self.status,
Some(ReqStatus::Draft) | Some(ReqStatus::Removed)
)
}
pub fn is_required(&self) -> bool {
match self.level {
Some(ReqLevel::Must) | None => true,
Some(ReqLevel::Should) | Some(ReqLevel::May) => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
pub struct ReqDefinition {
pub id: RuleId,
pub anchor_id: String,
pub marker_span: SourceSpan,
pub span: SourceSpan,
pub line: usize,
pub metadata: ReqMetadata,
pub raw: String,
pub html: String,
}
#[derive(Debug, Clone, Facet)]
pub struct ReqWarning {
pub file: PathBuf,
pub req_id: RuleId,
pub line: usize,
pub span: SourceSpan,
pub kind: ReqWarningKind,
}
#[derive(Debug, Clone, Facet)]
#[repr(u8)]
pub enum ReqWarningKind {
NoRfc2119Keyword,
NegativeReq(Rfc2119Keyword),
}
#[derive(Debug, Clone, Facet)]
pub struct ExtractedReqs {
pub output: String,
pub reqs: Vec<ReqDefinition>,
pub warnings: Vec<ReqWarning>,
}
pub fn parse_req_marker(inner: &str) -> Result<(RuleId, ReqMetadata)> {
let inner = inner.trim();
let (req_id, attrs_str) = match inner.find(' ') {
Some(idx) => (&inner[..idx], inner[idx + 1..].trim()),
None => (inner, ""),
};
let req_id = parse_rule_id(req_id).ok_or_else(|| {
Error::DuplicateReq("empty or invalid requirement identifier".to_string())
})?;
let mut metadata = ReqMetadata::default();
if !attrs_str.is_empty() {
for attr in attrs_str.split_whitespace() {
if let Some((key, value)) = attr.split_once('=') {
match key {
"status" => {
metadata.status = Some(ReqStatus::parse(value).ok_or_else(|| {
Error::CodeBlockHandler {
language: "req".to_string(),
message: format!(
"invalid status '{}' for requirement '{}', expected: draft, stable, deprecated, removed",
value, req_id
),
}
})?);
}
"level" => {
metadata.level = Some(ReqLevel::parse(value).ok_or_else(|| {
Error::CodeBlockHandler {
language: "req".to_string(),
message: format!(
"invalid level '{}' for requirement '{}', expected: must, should, may",
value, req_id
),
}
})?);
}
"since" => {
metadata.since = Some(value.to_string());
}
"until" => {
metadata.until = Some(value.to_string());
}
"tags" => {
metadata.tags = value.split(',').map(|s| s.trim().to_string()).collect();
}
_ => {
return Err(Error::CodeBlockHandler {
language: "req".to_string(),
message: format!(
"unknown attribute '{}' for requirement '{}', expected: status, level, since, until, tags",
key, req_id
),
});
}
}
} else {
return Err(Error::CodeBlockHandler {
language: "req".to_string(),
message: format!(
"invalid attribute format '{}' for requirement '{}', expected: key=value",
attr, req_id
),
});
}
}
}
Ok((req_id, metadata))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_rfc2119_must() {
let keywords = detect_rfc2119_keywords("Channel IDs MUST be allocated sequentially.");
assert_eq!(keywords, vec![Rfc2119Keyword::Must]);
}
#[test]
fn test_detect_rfc2119_must_not() {
let keywords = detect_rfc2119_keywords("Clients MUST NOT send invalid data.");
assert_eq!(keywords, vec![Rfc2119Keyword::MustNot]);
}
#[test]
fn test_detect_rfc2119_should() {
let keywords = detect_rfc2119_keywords("Implementations SHOULD use TLS.");
assert_eq!(keywords, vec![Rfc2119Keyword::Should]);
}
#[test]
fn test_detect_rfc2119_should_not() {
let keywords = detect_rfc2119_keywords("Clients SHOULD NOT retry immediately.");
assert_eq!(keywords, vec![Rfc2119Keyword::ShouldNot]);
}
#[test]
fn test_detect_rfc2119_may() {
let keywords = detect_rfc2119_keywords("Implementations MAY cache responses.");
assert_eq!(keywords, vec![Rfc2119Keyword::May]);
}
#[test]
fn test_detect_rfc2119_multiple() {
let keywords =
detect_rfc2119_keywords("Clients MUST validate input and SHOULD log errors.");
assert_eq!(keywords, vec![Rfc2119Keyword::Must, Rfc2119Keyword::Should]);
}
#[test]
fn test_detect_rfc2119_case_sensitive() {
let keywords = detect_rfc2119_keywords("The server must respond.");
assert!(keywords.is_empty());
}
#[test]
fn test_metadata_counts_for_coverage() {
let mut meta = ReqMetadata::default();
assert!(meta.counts_for_coverage());
meta.status = Some(ReqStatus::Stable);
assert!(meta.counts_for_coverage());
meta.status = Some(ReqStatus::Deprecated);
assert!(meta.counts_for_coverage());
meta.status = Some(ReqStatus::Draft);
assert!(!meta.counts_for_coverage());
meta.status = Some(ReqStatus::Removed);
assert!(!meta.counts_for_coverage());
}
#[test]
fn test_metadata_is_required() {
let mut meta = ReqMetadata::default();
assert!(meta.is_required());
meta.level = Some(ReqLevel::Must);
assert!(meta.is_required());
meta.level = Some(ReqLevel::Should);
assert!(!meta.is_required());
meta.level = Some(ReqLevel::May);
assert!(!meta.is_required());
}
}