use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RecordId(pub u64);
impl std::fmt::Display for RecordId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecordStatus {
Open,
InProgress,
InReview,
Done,
WontFix,
}
impl RecordStatus {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::InProgress => "in_progress",
Self::InReview => "in_review",
Self::Done => "done",
Self::WontFix => "wont_fix",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Record {
pub id: RecordId,
pub title: String,
pub status: RecordStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub version: u64,
#[serde(default)]
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<RecordId>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extensions: BTreeMap<String, serde_yaml::Value>,
}
pub mod frontmatter {
use std::collections::BTreeMap;
use super::{DateTime, Error, Record, Result, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Frontmatter {
id: super::RecordId,
title: String,
status: super::RecordStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
assignee: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
labels: Vec<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
#[serde(default)]
version: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_id: Option<super::RecordId>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
extensions: BTreeMap<String, serde_yaml::Value>,
}
pub fn render(issue: &Record) -> Result<String> {
let fm = Frontmatter {
id: issue.id,
title: issue.title.clone(),
status: issue.status,
assignee: issue.assignee.clone(),
labels: issue.labels.clone(),
created_at: issue.created_at,
updated_at: issue.updated_at,
version: issue.version,
parent_id: issue.parent_id,
extensions: issue.extensions.clone(),
};
let yaml = serde_yaml::to_string(&fm)?;
let mut out = String::with_capacity(yaml.len() + issue.body.len() + 16);
out.push_str("---\n");
out.push_str(&yaml);
out.push_str("---\n");
out.push_str(&issue.body);
if !issue.body.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
pub fn parse(text: &str) -> Result<Record> {
let body_start;
let yaml = if let Some(rest) = text.strip_prefix("---\n") {
if let Some(end) = rest.find("\n---\n") {
body_start = end + 5; &rest[..end]
} else if let Some(end) = rest.find("\n---") {
if rest[end + 4..].is_empty() {
body_start = end + 4;
&rest[..end]
} else {
return Err(Error::InvalidRecord(
"frontmatter close fence not followed by newline".into(),
));
}
} else {
return Err(Error::InvalidRecord(
"frontmatter open without close fence".into(),
));
}
} else {
return Err(Error::InvalidRecord(
"missing frontmatter open fence".into(),
));
};
let fm: Frontmatter = serde_yaml::from_str(yaml)?;
let body = rest_after(text, body_start).to_owned();
Ok(Record {
id: fm.id,
title: fm.title,
status: fm.status,
assignee: fm.assignee,
labels: fm.labels,
created_at: fm.created_at,
updated_at: fm.updated_at,
version: fm.version,
body,
parent_id: fm.parent_id,
extensions: fm.extensions,
})
}
pub fn yaml_to_json_value(text: &str) -> Result<serde_json::Value> {
let issue = parse(text)?;
Ok(serde_json::to_value(issue)?)
}
fn rest_after(text: &str, body_start_in_rest: usize) -> &str {
let abs = 4 + body_start_in_rest;
&text[abs.min(text.len())..]
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn sample() -> Record {
let t = Utc.with_ymd_and_hms(2026, 4, 13, 0, 0, 0).unwrap();
Record {
id: RecordId(123),
title: "thing is broken".into(),
status: RecordStatus::InProgress,
assignee: Some("agent-alpha".into()),
labels: vec!["bug".into(), "p1".into()],
created_at: t,
updated_at: t,
version: 3,
body: "Steps to reproduce:\n1. do the thing\n2. observe brokenness\n".into(),
parent_id: None,
extensions: std::collections::BTreeMap::new(),
}
}
#[test]
fn frontmatter_roundtrips() {
let original = sample();
let rendered = frontmatter::render(&original).expect("render");
assert!(rendered.starts_with("---\n"));
let parsed = frontmatter::parse(&rendered).expect("parse");
assert_eq!(parsed.id, original.id);
assert_eq!(parsed.title, original.title);
assert_eq!(parsed.status as u8, original.status as u8);
assert_eq!(parsed.body, original.body);
assert_eq!(parsed.version, original.version);
}
#[test]
fn missing_open_fence_is_rejected() {
let bad = "no frontmatter here\n";
assert!(matches!(
frontmatter::parse(bad),
Err(Error::InvalidRecord(_))
));
}
#[test]
fn parent_id_roundtrips_through_json_when_some() {
let mut iss = sample();
iss.parent_id = Some(RecordId(42));
let json = serde_json::to_string(&iss).unwrap();
assert!(
json.contains("\"parent_id\":42"),
"expected `\"parent_id\":42` in JSON, got: {json}"
);
let back: Record = serde_json::from_str(&json).unwrap();
assert_eq!(back.parent_id, Some(RecordId(42)));
}
#[test]
fn parent_id_omitted_when_none() {
let iss = sample(); let json = serde_json::to_string(&iss).unwrap();
assert!(
!json.contains("parent_id"),
"parent_id should be omitted when None, got: {json}"
);
}
#[test]
fn parent_id_default_on_missing_field() {
let json = r#"{"id":1,"title":"t","status":"open","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}"#;
let iss: Record = serde_json::from_str(json).unwrap();
assert_eq!(iss.parent_id, None);
}
#[test]
fn parent_id_roundtrips_through_frontmatter_when_some() {
let mut iss = sample();
iss.parent_id = Some(RecordId(777));
let rendered = frontmatter::render(&iss).expect("render");
assert!(
rendered.contains("parent_id: 777"),
"expected `parent_id: 777` in YAML, got: {rendered}"
);
let parsed = frontmatter::parse(&rendered).expect("parse");
assert_eq!(parsed.parent_id, Some(RecordId(777)));
}
#[test]
fn parent_id_omitted_from_frontmatter_when_none() {
let iss = sample(); let rendered = frontmatter::render(&iss).expect("render");
assert!(
!rendered.contains("parent_id"),
"parent_id should be omitted from YAML when None, got: {rendered}"
);
}
#[test]
fn frontmatter_renders_parent_id_when_some() {
let mut iss = sample();
iss.parent_id = Some(RecordId(42));
let rendered = frontmatter::render(&iss).expect("render");
assert!(
rendered.contains("parent_id: 42\n"),
"expected exact line `parent_id: 42` in YAML, got: {rendered}"
);
}
#[test]
fn frontmatter_parses_parent_id_when_present() {
let text = "---\n\
id: 1\n\
title: child page\n\
status: open\n\
created_at: 2026-04-14T00:00:00Z\n\
updated_at: 2026-04-14T00:00:00Z\n\
version: 1\n\
parent_id: 42\n\
---\n\
body here.\n";
let iss = frontmatter::parse(text).expect("parse");
assert_eq!(iss.parent_id, Some(RecordId(42)));
assert_eq!(iss.id, RecordId(1));
assert_eq!(iss.title, "child page");
}
#[test]
fn frontmatter_parses_legacy_without_parent_id() {
let text = "---\n\
id: 1\n\
title: Legacy issue\n\
status: open\n\
created_at: 2025-01-01T00:00:00Z\n\
updated_at: 2025-01-01T00:00:00Z\n\
version: 1\n\
---\n\
Body goes here.\n";
let iss = frontmatter::parse(text).expect("legacy frontmatter must parse");
assert_eq!(iss.parent_id, None);
assert_eq!(iss.title, "Legacy issue");
}
#[test]
fn frontmatter_roundtrip_with_parent() {
let mut original = sample();
original.parent_id = Some(RecordId(131_192));
let rendered = frontmatter::render(&original).expect("render");
let parsed = frontmatter::parse(&rendered).expect("parse");
assert_eq!(parsed.id, original.id);
assert_eq!(parsed.title, original.title);
assert_eq!(parsed.status as u8, original.status as u8);
assert_eq!(parsed.assignee, original.assignee);
assert_eq!(parsed.labels, original.labels);
assert_eq!(parsed.created_at, original.created_at);
assert_eq!(parsed.updated_at, original.updated_at);
assert_eq!(parsed.version, original.version);
assert_eq!(parsed.body, original.body);
assert_eq!(parsed.parent_id, Some(RecordId(131_192)));
}
#[test]
fn frontmatter_roundtrip_without_parent() {
let original = sample(); let rendered = frontmatter::render(&original).expect("render");
let parsed = frontmatter::parse(&rendered).expect("parse");
assert_eq!(parsed.id, original.id);
assert_eq!(parsed.title, original.title);
assert_eq!(parsed.status as u8, original.status as u8);
assert_eq!(parsed.assignee, original.assignee);
assert_eq!(parsed.labels, original.labels);
assert_eq!(parsed.created_at, original.created_at);
assert_eq!(parsed.updated_at, original.updated_at);
assert_eq!(parsed.version, original.version);
assert_eq!(parsed.body, original.body);
assert_eq!(parsed.parent_id, None);
}
#[test]
fn extensions_empty_omitted_from_yaml() {
let iss = sample(); let rendered = frontmatter::render(&iss).expect("render");
assert!(
!rendered.contains("extensions"),
"empty extensions must be omitted from YAML, got: {rendered}"
);
}
#[test]
fn extensions_roundtrip() {
let mut iss = sample();
iss.extensions
.insert("foo".into(), serde_yaml::Value::from(42_i64));
iss.extensions
.insert("bar".into(), serde_yaml::Value::from("x"));
let rendered = frontmatter::render(&iss).expect("render");
assert!(
rendered.contains("extensions"),
"non-empty extensions must appear in YAML, got: {rendered}"
);
let parsed = frontmatter::parse(&rendered).expect("parse");
assert_eq!(
parsed.extensions, iss.extensions,
"extensions must round-trip through render/parse"
);
}
#[test]
fn extensions_defaults_to_empty_on_parse() {
let text = "---\n\
id: 1\n\
title: Legacy issue\n\
status: open\n\
created_at: 2025-01-01T00:00:00Z\n\
updated_at: 2025-01-01T00:00:00Z\n\
version: 1\n\
---\n\
Body goes here.\n";
let iss = frontmatter::parse(text).expect("legacy frontmatter must parse");
assert!(
iss.extensions.is_empty(),
"extensions must default to empty on legacy parse, got: {:?}",
iss.extensions
);
}
}