use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use strum::AsRefStr;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RfcSpec {
pub rfc_id: String,
pub title: String,
pub version: String,
pub status: RfcStatus,
pub phase: RfcPhase,
pub owners: Vec<String>,
pub created: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<String>,
pub sections: Vec<SectionSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changelog: Vec<ChangelogEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectionSpec {
pub title: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub clauses: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClauseSpec {
pub clause_id: String,
pub title: String,
pub kind: ClauseKind,
#[serde(default)]
pub status: ClauseStatus,
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RfcWire {
pub govctl: RfcMeta,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sections: Vec<SectionSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changelog: Vec<ChangelogEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RfcMeta {
#[allow(dead_code)]
#[serde(default, skip_serializing)]
pub schema: u32,
pub id: String,
pub title: String,
pub version: String,
pub status: RfcStatus,
pub phase: RfcPhase,
pub owners: Vec<String>,
pub created: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
impl From<RfcSpec> for RfcWire {
fn from(s: RfcSpec) -> Self {
Self {
govctl: RfcMeta {
schema: 1,
id: s.rfc_id,
title: s.title,
version: s.version,
status: s.status,
phase: s.phase,
owners: s.owners,
created: s.created,
updated: s.updated,
supersedes: s.supersedes,
refs: s.refs,
signature: s.signature,
},
sections: s.sections,
changelog: s.changelog,
}
}
}
impl From<RfcWire> for RfcSpec {
fn from(w: RfcWire) -> Self {
Self {
rfc_id: w.govctl.id,
title: w.govctl.title,
version: w.govctl.version,
status: w.govctl.status,
phase: w.govctl.phase,
owners: w.govctl.owners,
created: w.govctl.created,
updated: w.govctl.updated,
supersedes: w.govctl.supersedes,
refs: w.govctl.refs,
sections: w.sections,
changelog: w.changelog,
signature: w.govctl.signature,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClauseWire {
pub govctl: ClauseMeta,
pub content: ClauseContent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClauseMeta {
#[allow(dead_code)]
#[serde(default, skip_serializing)]
pub schema: u32,
pub id: String,
pub title: String,
pub kind: ClauseKind,
#[serde(default)]
pub status: ClauseStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClauseContent {
pub text: String,
}
impl From<ClauseSpec> for ClauseWire {
fn from(s: ClauseSpec) -> Self {
Self {
govctl: ClauseMeta {
schema: 1,
id: s.clause_id,
title: s.title,
kind: s.kind,
status: s.status,
anchors: s.anchors,
superseded_by: s.superseded_by,
since: s.since,
},
content: ClauseContent { text: s.text },
}
}
}
impl From<ClauseWire> for ClauseSpec {
fn from(w: ClauseWire) -> Self {
Self {
clause_id: w.govctl.id,
title: w.govctl.title,
kind: w.govctl.kind,
status: w.govctl.status,
text: w.content.text,
anchors: w.govctl.anchors,
superseded_by: w.govctl.superseded_by,
since: w.govctl.since,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogEntry {
pub version: String,
pub date: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub added: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changed: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deprecated: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub removed: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fixed: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub security: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsRefStr, ValueEnum)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum RfcStatus {
Draft,
Normative,
Deprecated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsRefStr, ValueEnum)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum RfcPhase {
Spec,
Impl,
Test,
Stable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, AsRefStr, ValueEnum)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ClauseKind {
Normative,
Informative,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, AsRefStr)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ClauseStatus {
#[default]
Active,
Deprecated,
Superseded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdrMeta {
#[allow(dead_code)]
#[serde(default, skip_serializing)]
pub schema: u32,
pub id: String,
pub title: String,
pub status: AdrStatus,
pub date: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, AsRefStr)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum AlternativeStatus {
#[default]
Considered,
Rejected,
Accepted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alternative {
pub text: String,
#[serde(default)]
pub status: AlternativeStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pros: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cons: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rejection_reason: Option<String>,
}
impl Alternative {
#[cfg(test)]
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
status: AlternativeStatus::Considered,
pros: vec![],
cons: vec![],
rejection_reason: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AdrContent {
#[serde(default)]
pub context: String,
#[serde(default)]
pub decision: String,
#[serde(default)]
pub consequences: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub alternatives: Vec<Alternative>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdrSpec {
pub govctl: AdrMeta,
pub content: AdrContent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsRefStr, ValueEnum)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum AdrStatus {
Proposed,
Accepted,
Rejected,
Superseded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkItemMeta {
#[allow(dead_code)]
#[serde(default, skip_serializing)]
pub schema: u32,
pub id: String,
pub title: String,
pub status: WorkItemStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkItemVerification {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_guards: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub waivers: Vec<GuardWaiver>,
}
impl WorkItemVerification {
pub fn is_empty(&self) -> bool {
self.required_guards.is_empty() && self.waivers.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuardWaiver {
pub guard: String,
pub reason: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, AsRefStr)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ChecklistStatus {
#[default]
Pending,
Done,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChecklistItem {
pub text: String,
#[serde(default)]
pub status: ChecklistStatus,
#[serde(default)]
pub category: ChangelogCategory,
}
impl ChecklistItem {
#[allow(dead_code)] pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
status: ChecklistStatus::Pending,
category: ChangelogCategory::default(),
}
}
pub fn with_category(text: impl Into<String>, category: ChangelogCategory) -> Self {
Self {
text: text.into(),
status: ChecklistStatus::Pending,
category,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalEntry {
pub date: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkItemContent {
#[serde(default)]
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub journal: Vec<JournalEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acceptance_criteria: Vec<ChecklistItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkItemSpec {
pub govctl: WorkItemMeta,
pub content: WorkItemContent,
#[serde(default, skip_serializing_if = "WorkItemVerification::is_empty")]
pub verification: WorkItemVerification,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsRefStr, ValueEnum)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum WorkItemStatus {
Queue,
Active,
Done,
Cancelled,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, AsRefStr, ValueEnum,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ChangelogCategory {
#[default]
Added,
Changed,
Deprecated,
Removed,
Fixed,
Security,
Chore,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuardMeta {
#[allow(dead_code)]
#[serde(default, skip_serializing)]
pub schema: u32,
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuardCheck {
pub command: String,
#[serde(default = "default_guard_timeout_secs")]
pub timeout_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
}
fn default_guard_timeout_secs() -> u64 {
300
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuardSpec {
pub govctl: GuardMeta,
pub check: GuardCheck,
}
impl ChangelogCategory {
pub const VALID_PREFIXES: &'static [&'static str] = &[
"add",
"fix",
"change",
"remove",
"deprecate",
"security",
"chore",
];
pub fn from_prefix(prefix: &str) -> Option<Self> {
match prefix.to_lowercase().as_str() {
"add" | "added" | "feat" | "feature" => Some(Self::Added),
"changed" | "change" | "refactor" | "perf" => Some(Self::Changed),
"deprecated" | "deprecate" => Some(Self::Deprecated),
"removed" | "remove" => Some(Self::Removed),
"fix" | "fixed" => Some(Self::Fixed),
"security" | "sec" => Some(Self::Security),
"chore" | "internal" | "test" | "tests" | "doc" | "docs" | "ci" | "build" => {
Some(Self::Chore)
}
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleasesMeta {
#[allow(dead_code)]
#[serde(default, skip_serializing)]
pub schema: u32,
}
fn default_schema_version() -> u32 {
1
}
impl Default for ReleasesMeta {
fn default() -> Self {
Self {
schema: default_schema_version(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Release {
pub version: String,
pub date: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReleasesFile {
#[serde(default)]
pub govctl: ReleasesMeta,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub releases: Vec<Release>,
}
#[derive(Debug, Clone)]
pub struct RfcIndex {
pub rfc: RfcSpec,
pub clauses: Vec<ClauseEntry>,
pub path: std::path::PathBuf,
}
#[derive(Debug, Clone)]
pub struct ClauseEntry {
pub spec: ClauseSpec,
pub path: std::path::PathBuf,
}
#[derive(Debug, Clone)]
pub struct AdrEntry {
pub spec: AdrSpec,
pub path: std::path::PathBuf,
}
impl AdrEntry {
pub fn meta(&self) -> &AdrMeta {
&self.spec.govctl
}
}
#[derive(Debug, Clone)]
pub struct WorkItemEntry {
pub spec: WorkItemSpec,
pub path: std::path::PathBuf,
}
impl WorkItemEntry {
pub fn meta(&self) -> &WorkItemMeta {
&self.spec.govctl
}
}
#[derive(Debug, Clone)]
pub struct GuardEntry {
pub spec: GuardSpec,
pub path: std::path::PathBuf,
}
impl GuardEntry {
pub fn meta(&self) -> &GuardMeta {
&self.spec.govctl
}
}
#[derive(Debug, Clone, Default)]
pub struct ProjectIndex {
pub rfcs: Vec<RfcIndex>,
pub adrs: Vec<AdrEntry>,
pub work_items: Vec<WorkItemEntry>,
}
impl ProjectIndex {
pub fn iter_clauses(&self) -> impl Iterator<Item = (&RfcIndex, &ClauseEntry)> {
self.rfcs
.iter()
.flat_map(|rfc| rfc.clauses.iter().map(move |c| (rfc, c)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_checklist_item_new() {
let item = ChecklistItem::new("Test criterion");
assert_eq!(item.text, "Test criterion");
assert_eq!(item.status, ChecklistStatus::Pending);
}
#[test]
fn test_checklist_item_new_from_string() {
let item = ChecklistItem::new(String::from("From String"));
assert_eq!(item.text, "From String");
assert_eq!(item.status, ChecklistStatus::Pending);
}
#[test]
fn test_alternative_new() {
let alt = Alternative::new("Use Redis for caching");
assert_eq!(alt.text, "Use Redis for caching");
assert_eq!(alt.status, AlternativeStatus::Considered);
}
#[test]
fn test_alternative_new_from_string() {
let alt = Alternative::new(String::from("Use PostgreSQL"));
assert_eq!(alt.text, "Use PostgreSQL");
assert_eq!(alt.status, AlternativeStatus::Considered);
}
#[test]
fn test_checklist_status_default() {
assert_eq!(ChecklistStatus::default(), ChecklistStatus::Pending);
}
#[test]
fn test_alternative_status_default() {
assert_eq!(AlternativeStatus::default(), AlternativeStatus::Considered);
}
#[test]
fn test_clause_status_default() {
assert_eq!(ClauseStatus::default(), ClauseStatus::Active);
}
#[test]
fn test_rfc_status_as_ref() {
assert_eq!(RfcStatus::Draft.as_ref(), "draft");
assert_eq!(RfcStatus::Normative.as_ref(), "normative");
assert_eq!(RfcStatus::Deprecated.as_ref(), "deprecated");
}
#[test]
fn test_rfc_phase_as_ref() {
assert_eq!(RfcPhase::Spec.as_ref(), "spec");
assert_eq!(RfcPhase::Impl.as_ref(), "impl");
assert_eq!(RfcPhase::Test.as_ref(), "test");
assert_eq!(RfcPhase::Stable.as_ref(), "stable");
}
#[test]
fn test_work_item_status_as_ref() {
assert_eq!(WorkItemStatus::Queue.as_ref(), "queue");
assert_eq!(WorkItemStatus::Active.as_ref(), "active");
assert_eq!(WorkItemStatus::Done.as_ref(), "done");
assert_eq!(WorkItemStatus::Cancelled.as_ref(), "cancelled");
}
#[test]
fn test_adr_status_as_ref() {
assert_eq!(AdrStatus::Proposed.as_ref(), "proposed");
assert_eq!(AdrStatus::Accepted.as_ref(), "accepted");
assert_eq!(AdrStatus::Superseded.as_ref(), "superseded");
}
#[test]
fn test_checklist_status_as_ref() {
assert_eq!(ChecklistStatus::Pending.as_ref(), "pending");
assert_eq!(ChecklistStatus::Done.as_ref(), "done");
assert_eq!(ChecklistStatus::Cancelled.as_ref(), "cancelled");
}
#[test]
fn test_alternative_status_as_ref() {
assert_eq!(AlternativeStatus::Considered.as_ref(), "considered");
assert_eq!(AlternativeStatus::Rejected.as_ref(), "rejected");
assert_eq!(AlternativeStatus::Accepted.as_ref(), "accepted");
}
#[test]
fn test_adr_entry_meta_accessor() {
let entry = AdrEntry {
spec: AdrSpec {
govctl: AdrMeta {
schema: 1,
id: "ADR-0001".to_string(),
title: "Test ADR".to_string(),
status: AdrStatus::Proposed,
date: "2026-01-17".to_string(),
superseded_by: None,
refs: vec![],
},
content: AdrContent::default(),
},
path: std::path::PathBuf::from("test.toml"),
};
assert_eq!(entry.meta().id, "ADR-0001");
assert_eq!(entry.meta().title, "Test ADR");
}
#[test]
fn test_work_item_entry_meta_accessor() {
let entry = WorkItemEntry {
spec: WorkItemSpec {
govctl: WorkItemMeta {
schema: 1,
id: "WI-2026-01-17-001".to_string(),
title: "Test Work Item".to_string(),
status: WorkItemStatus::Queue,
created: Some("2026-01-17".to_string()),
started: None,
completed: None,
refs: vec![],
},
content: WorkItemContent::default(),
verification: WorkItemVerification::default(),
},
path: std::path::PathBuf::from("test.toml"),
};
assert_eq!(entry.meta().id, "WI-2026-01-17-001");
assert_eq!(entry.meta().status, WorkItemStatus::Queue);
}
}