use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::schemata::{Artifact, WorkflowSchema};
pub const PROPOSALS_FOLDER: &str = "proposals";
pub const PROPOSAL_NOTE: &str = "proposal";
pub const META_NOTE: &str = "meta";
pub const WORK_NOTE: &str = "work";
pub const META_FORMAT_VERSION: u32 = 1;
#[derive(Debug, Error)]
pub enum ChangeError {
#[error("invalid change id: {0} (expected kebab-case: lowercase alphanumerics and hyphens)")]
InvalidChangeId(String),
#[error("invalid status transition: {0} -> {1}")]
InvalidTransition(ChangeStatus, ChangeStatus),
#[error("meta note parse failure: {0}")]
MetaParse(String),
#[error("meta note serialization failure: {0}")]
Json(#[from] serde_json::Error),
}
pub fn validate_change_id(change_id: &str) -> Result<(), ChangeError> {
let valid = !change_id.is_empty()
&& !change_id.starts_with('-')
&& !change_id.ends_with('-')
&& !change_id.contains("--")
&& change_id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if valid {
Ok(())
} else {
Err(ChangeError::InvalidChangeId(change_id.to_string()))
}
}
pub fn change_folder(change_id: &str) -> String {
format!("{PROPOSALS_FOLDER}/{change_id}")
}
pub fn note_has_authored_content(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line.trim();
let scaffold = trimmed.is_empty()
|| trimmed.starts_with('#')
|| (trimmed.starts_with("<!--") && trimmed.ends_with("-->"));
!scaffold
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ArtifactLayout {
Note(String),
Folder(String),
}
pub fn artifact_layout(artifact: &Artifact) -> ArtifactLayout {
match artifact.generates.split_once('/') {
Some((folder, _)) => ArtifactLayout::Folder(folder.to_string()),
None => {
let stem = artifact
.generates
.strip_suffix(".md")
.unwrap_or(&artifact.generates);
ArtifactLayout::Note(stem.to_string())
}
}
}
pub fn namespace_folders(schema: &WorkflowSchema) -> Vec<String> {
schema
.artifacts
.iter()
.filter_map(|artifact| match artifact_layout(artifact) {
ArtifactLayout::Folder(name) => Some(name),
ArtifactLayout::Note(_) => None,
})
.collect()
}
pub fn namespace_notes(schema: &WorkflowSchema) -> Vec<String> {
schema
.artifacts
.iter()
.filter_map(|artifact| match artifact_layout(artifact) {
ArtifactLayout::Note(name) => Some(name),
ArtifactLayout::Folder(_) => None,
})
.collect()
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangeStatus {
Draft,
Approved,
Implemented,
Archived,
Blocked,
Superseded,
Abandoned,
}
impl ChangeStatus {
pub fn permits_transition(self, next: ChangeStatus) -> bool {
use ChangeStatus::*;
if self == next {
return false;
}
match self {
Draft => matches!(next, Approved | Blocked | Superseded | Abandoned),
Approved => matches!(next, Implemented | Blocked | Superseded | Abandoned),
Implemented => matches!(next, Archived | Blocked | Superseded | Abandoned),
Blocked => matches!(next, Draft | Approved | Implemented | Archived),
Archived | Superseded | Abandoned => false,
}
}
}
impl std::fmt::Display for ChangeStatus {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
ChangeStatus::Draft => "draft",
ChangeStatus::Approved => "approved",
ChangeStatus::Implemented => "implemented",
ChangeStatus::Archived => "archived",
ChangeStatus::Blocked => "blocked",
ChangeStatus::Superseded => "superseded",
ChangeStatus::Abandoned => "abandoned",
};
formatter.write_str(name)
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct RepositoryCommit {
pub status: ChangeStatus,
pub commit: String,
pub recorded_at: Timestamp,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct ChangeMetadata {
pub meta_version: u32,
pub change_id: String,
pub title: Option<String>,
pub status: ChangeStatus,
pub schema: String,
pub notebook: String,
pub created_at: Timestamp,
pub updated_at: Timestamp,
#[serde(default)]
pub repository_commits: Vec<RepositoryCommit>,
}
impl ChangeMetadata {
pub fn new(
change_id: &str,
title: Option<&str>,
schema: &str,
notebook: &str,
) -> Result<Self, ChangeError> {
validate_change_id(change_id)?;
let now = Timestamp::now();
Ok(Self {
meta_version: META_FORMAT_VERSION,
change_id: change_id.to_string(),
title: title.map(String::from),
status: ChangeStatus::Draft,
schema: schema.to_string(),
notebook: notebook.to_string(),
created_at: now,
updated_at: now,
repository_commits: Vec::new(),
})
}
pub fn transition(&mut self, next: ChangeStatus) -> Result<(), ChangeError> {
if !self.status.permits_transition(next) {
return Err(ChangeError::InvalidTransition(self.status, next));
}
self.status = next;
self.updated_at = Timestamp::now();
Ok(())
}
pub fn record_commit(&mut self, commit: &str) {
let now = Timestamp::now();
self.repository_commits.push(RepositoryCommit {
status: self.status,
commit: commit.to_string(),
recorded_at: now,
});
self.updated_at = now;
}
}
pub fn render_meta_note(metadata: &ChangeMetadata) -> Result<String, ChangeError> {
let json = serde_json::to_string_pretty(metadata)?;
Ok(format!("```json\n{json}\n```\n"))
}
pub fn parse_meta_note(content: &str) -> Result<ChangeMetadata, ChangeError> {
let trimmed = content.trim();
if trimmed.starts_with('{') {
return Ok(serde_json::from_str(trimmed)?);
}
let json = extract_fenced_json(content)
.ok_or_else(|| ChangeError::MetaParse("no JSON object found in meta note".to_string()))?;
Ok(serde_json::from_str(json)?)
}
fn extract_fenced_json(content: &str) -> Option<&str> {
let mut fence_body_start: Option<usize> = None;
let mut offset = 0;
for line in content.split_inclusive('\n') {
let trimmed = line.trim();
match fence_body_start {
None => {
if let Some(language) = trimmed.strip_prefix("```")
&& (language.trim().is_empty() || language.trim() == "json")
{
fence_body_start = Some(offset + line.len());
}
}
Some(start) => {
if trimmed == "```" {
return Some(&content[start..offset]);
}
}
}
offset += line.len();
}
None
}