use anyhow::{anyhow, Result};
use chrono::NaiveDate;
use regex::Regex;
use crate::engine::document::{self, deserialize_naive_date, DocMeta, DocType, Relation, Status};
use std::path::PathBuf;
pub struct IssueContext {
pub title: String,
pub labels: Vec<String>,
pub is_open: bool,
pub known_types: Vec<String>,
pub default_type: String,
}
const COMMENT_START: &str = "<!-- lazyspec\n";
const COMMENT_END: &str = "\n-->";
pub fn serialize(doc: &DocMeta, body: &str) -> String {
let mut yaml_lines: Vec<String> = Vec::new();
yaml_lines.push(format!("date: {}", doc.date));
if needs_frontmatter_status(&doc.status) {
yaml_lines.push(format!("status: {}", doc.status));
}
if !doc.provenance.is_empty() {
yaml_lines.push("provenance:".to_string());
for entry in &doc.provenance {
let yaml_value = serde_yaml::to_string(entry).unwrap_or_else(|_| entry.clone());
yaml_lines.push(format!("- {}", yaml_value.trim_end()));
}
}
if !doc.related.is_empty() {
yaml_lines.push("related:".to_string());
for rel in &doc.related {
yaml_lines.push(format!("- {}: {}", rel.rel_type, rel.target));
}
}
let yaml_block = yaml_lines.join("\n");
let comment = format!("{COMMENT_START}---\n{yaml_block}\n---{COMMENT_END}");
let body_trimmed = body.trim();
if body_trimmed.is_empty() {
comment
} else {
format!("{comment}\n\n{body_trimmed}")
}
}
pub fn deserialize(issue_body: &str, ctx: &IssueContext) -> Result<(DocMeta, String)> {
let (frontmatter, body) = extract_comment(issue_body)?;
let parsed: CommentFrontmatter = serde_yaml::from_str(&frontmatter)
.map_err(|e| anyhow!("failed to parse lazyspec comment frontmatter: {e}"))?;
let related = parsed
.related
.unwrap_or_default()
.iter()
.map(parse_relation)
.collect::<Result<Vec<_>>>()?;
let known_type_refs: Vec<&str> = ctx.known_types.iter().map(|s| s.as_str()).collect();
let (doc_type, tags) = extract_type_and_tags(&ctx.labels, &known_type_refs, &ctx.default_type);
let status = reconstruct_status(ctx.is_open, parsed.status.as_deref());
let meta = DocMeta {
path: PathBuf::new(),
title: ctx.title.clone(),
doc_type,
status,
author: parsed.author.unwrap_or_else(|| "unknown".to_string()),
date: parsed.date,
tags,
provenance: parsed.provenance.unwrap_or_default(),
related,
validate_ignore: false,
virtual_doc: false,
id: String::new(),
};
Ok((meta, body))
}
fn needs_frontmatter_status(status: &Status) -> bool {
!matches!(status, Status::Draft | Status::Complete)
}
fn reconstruct_status(is_open: bool, frontmatter_status: Option<&str>) -> Status {
if let Some(s) = frontmatter_status {
if let Ok(status) = s.parse::<Status>() {
return status;
}
}
if is_open {
Status::Draft
} else {
Status::Complete
}
}
fn extract_type_and_tags(
labels: &[String],
known_types: &[&str],
default_type: &str,
) -> (DocType, Vec<String>) {
let mut doc_type: Option<DocType> = None;
let mut tags = Vec::new();
for label in labels {
let lower = label.to_lowercase();
if let Some(suffix) = lower.strip_prefix("lazyspec:") {
if doc_type.is_none() && known_types.iter().any(|t| t.to_lowercase() == suffix) {
doc_type = Some(DocType::new(suffix));
}
} else {
tags.push(label.clone());
}
}
(doc_type.unwrap_or_else(|| DocType::new(default_type)), tags)
}
#[derive(serde::Deserialize)]
struct CommentFrontmatter {
#[serde(default)]
author: Option<String>,
#[serde(deserialize_with = "deserialize_naive_date")]
date: NaiveDate,
#[serde(default)]
status: Option<String>,
#[serde(default)]
provenance: Option<Vec<String>>,
#[serde(default)]
related: Option<Vec<serde_yaml::Value>>,
}
fn parse_relation(value: &serde_yaml::Value) -> Result<Relation> {
document::parse_relation(value)
}
fn extract_comment(issue_body: &str) -> Result<(String, String)> {
let re = Regex::new(r"(?s)<!--\s*lazyspec\s*\n---\n(.*?)\n---\s*\n-->").unwrap();
let caps = re
.captures(issue_body)
.ok_or_else(|| anyhow!("no lazyspec HTML comment found in issue body"))?;
let yaml = caps.get(1).unwrap().as_str().to_string();
let full_match = caps.get(0).unwrap();
let rest = issue_body[full_match.end()..].trim().to_string();
Ok((yaml, rest))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::document::RelationType;
use chrono::NaiveDate;
fn sample_doc() -> DocMeta {
DocMeta {
path: PathBuf::new(),
title: "Add caching layer".to_string(),
doc_type: DocType::new("rfc"),
status: Status::Draft,
author: "agent-7".to_string(),
date: NaiveDate::from_ymd_opt(2026, 3, 27).unwrap(),
tags: vec!["performance".to_string()],
provenance: vec![],
related: vec![Relation {
rel_type: RelationType::Implements,
target: "STORY-075".to_string(),
}],
validate_ignore: false,
virtual_doc: false,
id: "RFC-042".to_string(),
}
}
fn default_known_types() -> Vec<String> {
vec![
"rfc".to_string(),
"story".to_string(),
"iteration".to_string(),
"adr".to_string(),
"spec".to_string(),
]
}
fn sample_context() -> IssueContext {
IssueContext {
title: "Add caching layer".to_string(),
labels: vec!["lazyspec:rfc".to_string(), "performance".to_string()],
is_open: true,
known_types: default_known_types(),
default_type: "spec".to_string(),
}
}
#[test]
fn serialize_produces_comment_block() {
let doc = sample_doc();
let result = serialize(&doc, "Some body text.");
assert!(result.starts_with("<!-- lazyspec\n---\n"));
assert!(
!result.contains("author:"),
"serialize should not emit author"
);
assert!(result.contains("date: 2026-03-27"));
assert!(result.contains("- implements: STORY-075"));
assert!(result.ends_with("Some body text."));
}
#[test]
fn serialize_omits_lifecycle_status() {
let doc = sample_doc();
let result = serialize(&doc, "");
assert!(!result.contains("status:"));
}
#[test]
fn serialize_includes_non_lifecycle_status() {
let mut doc = sample_doc();
doc.status = Status::Rejected;
let result = serialize(&doc, "");
assert!(result.contains("status: rejected"));
}
#[test]
fn serialize_empty_body() {
let mut doc = sample_doc();
doc.related = vec![];
let result = serialize(&doc, "");
assert!(!result.contains("\n\n"));
assert!(result.ends_with("-->"));
}
#[test]
fn deserialize_round_trip() {
let doc = sample_doc();
let body = "Some body text.";
let serialized = serialize(&doc, body);
let ctx = sample_context();
let (meta, parsed_body) = deserialize(&serialized, &ctx).unwrap();
assert_eq!(meta.title, "Add caching layer");
assert_eq!(meta.author, "unknown");
assert_eq!(meta.date, NaiveDate::from_ymd_opt(2026, 3, 27).unwrap());
assert_eq!(meta.doc_type.as_str(), "rfc");
assert_eq!(meta.tags, vec!["performance"]);
assert_eq!(meta.related.len(), 1);
assert_eq!(meta.related[0].rel_type, RelationType::Implements);
assert_eq!(meta.related[0].target, "STORY-075");
assert_eq!(parsed_body, "Some body text.");
}
#[test]
fn deserialize_missing_comment_returns_error() {
let ctx = sample_context();
let result = deserialize("just some markdown", &ctx);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("no lazyspec HTML comment found"));
}
#[test]
fn deserialize_malformed_yaml_returns_error() {
let bad = "<!-- lazyspec\n---\n[invalid yaml\n---\n-->\n\nbody";
let ctx = sample_context();
let result = deserialize(bad, &ctx);
assert!(result.is_err());
}
#[test]
fn status_from_open_issue_without_frontmatter() {
assert_eq!(reconstruct_status(true, None), Status::Draft);
}
#[test]
fn status_from_closed_issue_without_frontmatter() {
assert_eq!(reconstruct_status(false, None), Status::Complete);
}
#[test]
fn status_from_frontmatter_overrides_open_closed() {
assert_eq!(
reconstruct_status(false, Some("rejected")),
Status::Rejected
);
assert_eq!(
reconstruct_status(false, Some("superseded")),
Status::Superseded
);
}
#[test]
fn extract_type_and_tags_finds_type() {
let labels = vec!["lazyspec:rfc".to_string(), "cache".to_string()];
let types = default_known_types();
let known: Vec<&str> = types.iter().map(|s| s.as_str()).collect();
let (dt, tags) = extract_type_and_tags(&labels, &known, "spec");
assert_eq!(dt.as_str(), "rfc");
assert_eq!(tags, vec!["cache"]);
}
#[test]
fn extract_type_and_tags_defaults_to_configured_type() {
let labels = vec!["random-label".to_string()];
let types = default_known_types();
let known: Vec<&str> = types.iter().map(|s| s.as_str()).collect();
let (dt, tags) = extract_type_and_tags(&labels, &known, "testgh");
assert_eq!(dt.as_str(), "testgh");
assert_eq!(tags, vec!["random-label"]);
}
#[test]
fn round_trip_with_non_lifecycle_status() {
let mut doc = sample_doc();
doc.status = Status::Superseded;
let serialized = serialize(&doc, "body");
let ctx = IssueContext {
title: doc.title.clone(),
labels: vec!["lazyspec:rfc".to_string(), "performance".to_string()],
is_open: false,
known_types: default_known_types(),
default_type: "spec".to_string(),
};
let (meta, _) = deserialize(&serialized, &ctx).unwrap();
assert_eq!(meta.status, Status::Superseded);
}
#[test]
fn round_trip_with_multiple_relations() {
let mut doc = sample_doc();
doc.related = vec![
Relation {
rel_type: RelationType::Implements,
target: "STORY-075".to_string(),
},
Relation {
rel_type: RelationType::Blocks,
target: "RFC-010".to_string(),
},
];
let serialized = serialize(&doc, "");
let ctx = sample_context();
let (meta, _) = deserialize(&serialized, &ctx).unwrap();
assert_eq!(meta.related.len(), 2);
assert_eq!(meta.related[1].rel_type, RelationType::Blocks);
assert_eq!(meta.related[1].target, "RFC-010");
}
#[test]
fn round_trip_with_no_relations() {
let mut doc = sample_doc();
doc.related = vec![];
let serialized = serialize(&doc, "body here");
let ctx = sample_context();
let (meta, body) = deserialize(&serialized, &ctx).unwrap();
assert!(meta.related.is_empty());
assert_eq!(body, "body here");
}
#[test]
fn round_trip_review_status() {
let mut doc = sample_doc();
doc.status = Status::Review;
let serialized = serialize(&doc, "body");
assert!(serialized.contains("status: review"));
let ctx = sample_context();
let (meta, _) = deserialize(&serialized, &ctx).unwrap();
assert_eq!(meta.status, Status::Review);
}
#[test]
fn round_trip_accepted_status() {
let mut doc = sample_doc();
doc.status = Status::Accepted;
let serialized = serialize(&doc, "body");
assert!(serialized.contains("status: accepted"));
let ctx = sample_context();
let (meta, _) = deserialize(&serialized, &ctx).unwrap();
assert_eq!(meta.status, Status::Accepted);
}
#[test]
fn round_trip_in_progress_status() {
let mut doc = sample_doc();
doc.status = Status::InProgress;
let serialized = serialize(&doc, "body");
assert!(serialized.contains("status: in-progress"));
let ctx = sample_context();
let (meta, _) = deserialize(&serialized, &ctx).unwrap();
assert_eq!(meta.status, Status::InProgress);
}
#[test]
fn serialize_omits_complete_status() {
let mut doc = sample_doc();
doc.status = Status::Complete;
let result = serialize(&doc, "");
assert!(!result.contains("status:"));
}
#[test]
fn extract_type_and_tags_filters_lazyspec_labels() {
let labels = vec![
"lazyspec:iteration".to_string(),
"lazyspec:unknown".to_string(),
"team-alpha".to_string(),
];
let types = default_known_types();
let known: Vec<&str> = types.iter().map(|s| s.as_str()).collect();
let (dt, tags) = extract_type_and_tags(&labels, &known, "spec");
assert_eq!(dt.as_str(), "iteration");
assert_eq!(tags, vec!["team-alpha"]);
}
#[test]
fn round_trip_body_with_html_comments() {
let doc = sample_doc();
let body = "Some text\n\n<!-- this is a regular HTML comment -->\n\nMore text";
let serialized = serialize(&doc, body);
let ctx = sample_context();
let (_, parsed_body) = deserialize(&serialized, &ctx).unwrap();
assert_eq!(parsed_body, body);
}
#[test]
fn round_trip_body_with_triple_dash_lines() {
let doc = sample_doc();
let body = "Section one\n\n---\n\nSection two\n\n---\n\nSection three";
let serialized = serialize(&doc, body);
let ctx = sample_context();
let (_, parsed_body) = deserialize(&serialized, &ctx).unwrap();
assert_eq!(parsed_body, body);
}
#[test]
fn unclosed_lazyspec_comment_returns_error() {
let bad = "<!-- lazyspec\n---\nauthor: someone\ndate: 2026-01-01\n---\nno closing arrow";
let ctx = sample_context();
let result = deserialize(bad, &ctx);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("no lazyspec HTML comment found"));
}
#[test]
fn empty_yaml_block_returns_error() {
let bad = "<!-- lazyspec\n---\n\n---\n-->\n\nbody text";
let ctx = sample_context();
let result = deserialize(bad, &ctx);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("failed to parse lazyspec comment frontmatter"));
}
#[test]
fn unknown_yaml_fields_are_ignored() {
let input = "<!-- lazyspec\n---\nauthor: agent-7\ndate: 2026-03-27\nfuture_field: some_value\nanother_unknown: 42\n---\n-->\n\nbody";
let ctx = sample_context();
let (meta, body) = deserialize(input, &ctx).unwrap();
assert_eq!(meta.author, "agent-7");
assert_eq!(body, "body");
}
#[test]
fn extra_whitespace_around_comment_block_tolerated() {
let input = "<!-- lazyspec \n---\nauthor: agent-7\ndate: 2026-03-27\n---\n-->\n\nbody";
let ctx = sample_context();
let (meta, body) = deserialize(input, &ctx).unwrap();
assert_eq!(meta.author, "agent-7");
assert_eq!(body, "body");
}
#[test]
fn multiple_lazyspec_blocks_first_wins() {
let input = "<!-- lazyspec\n---\nauthor: first-author\ndate: 2026-01-01\n---\n-->\n\nsome body\n\n<!-- lazyspec\n---\nauthor: second-author\ndate: 2026-12-31\n---\n-->";
let ctx = sample_context();
let (meta, body) = deserialize(input, &ctx).unwrap();
assert_eq!(meta.author, "first-author");
assert_eq!(meta.date, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert!(body.contains("<!-- lazyspec"));
assert!(body.contains("second-author"));
}
#[test]
fn custom_type_recognized_when_in_known_types() {
let labels = vec!["lazyspec:task".to_string(), "team-beta".to_string()];
let known = vec!["task", "rfc", "story"];
let (dt, tags) = extract_type_and_tags(&labels, &known, "spec");
assert_eq!(dt.as_str(), "task");
assert_eq!(tags, vec!["team-beta"]);
}
#[test]
fn deserialize_tolerates_missing_author() {
let input = "<!-- lazyspec\n---\ndate: 2026-03-27\n---\n-->\n\nbody";
let ctx = sample_context();
let (meta, body) = deserialize(input, &ctx).unwrap();
assert_eq!(meta.author, "unknown");
assert_eq!(body, "body");
}
#[test]
fn deserialize_backward_compat_with_author() {
let input = "<!-- lazyspec\n---\nauthor: old-value\ndate: 2026-03-27\n---\n-->\n\nbody";
let ctx = sample_context();
let (meta, body) = deserialize(input, &ctx).unwrap();
assert_eq!(meta.author, "old-value");
assert_eq!(body, "body");
}
#[test]
fn serialize_emits_provenance_block() {
let mut doc = sample_doc();
doc.provenance = vec!["A".to_string(), "B".to_string()];
let result = serialize(&doc, "");
assert!(
result.contains("provenance:"),
"expected provenance block, got: {}",
result
);
assert!(result.contains("- A"));
assert!(result.contains("- B"));
}
#[test]
fn serialize_omits_provenance_when_empty() {
let doc = sample_doc();
let result = serialize(&doc, "");
assert!(
!result.contains("provenance:"),
"should not emit empty provenance, got: {}",
result
);
}
#[test]
fn deserialize_reads_provenance() {
let input = "<!-- lazyspec\n---\nauthor: agent-7\ndate: 2026-03-27\nprovenance:\n- Workshop 2026-04-12\n- Jane Doe\n---\n-->\n\nbody";
let ctx = sample_context();
let (meta, _) = deserialize(input, &ctx).unwrap();
assert_eq!(
meta.provenance,
vec!["Workshop 2026-04-12".to_string(), "Jane Doe".to_string()]
);
}
#[test]
fn deserialize_missing_provenance_defaults_empty() {
let input = "<!-- lazyspec\n---\nauthor: agent-7\ndate: 2026-03-27\n---\n-->\n\nbody";
let ctx = sample_context();
let (meta, _) = deserialize(input, &ctx).unwrap();
assert!(meta.provenance.is_empty());
}
#[test]
fn roundtrip_preserves_provenance() {
let mut doc = sample_doc();
doc.provenance = vec![
"Workshop 2026-04-12".to_string(),
"Privacy Act 1988".to_string(),
];
let serialized = serialize(&doc, "body");
let ctx = sample_context();
let (meta, _) = deserialize(&serialized, &ctx).unwrap();
assert_eq!(meta.provenance, doc.provenance);
}
#[test]
fn custom_type_defaults_to_configured_type_when_not_in_known_types() {
let labels = vec!["lazyspec:task".to_string(), "team-beta".to_string()];
let known = vec!["rfc", "story"];
let (dt, tags) = extract_type_and_tags(&labels, &known, "testgh");
assert_eq!(dt.as_str(), "testgh");
assert_eq!(tags, vec!["team-beta"]);
}
}