//! Document types for Blue //! //! RFCs, ADRs, Spikes, and other document structures with markdown generation. use serde::{Deserialize, Serialize}; /// Document status #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Status { Draft, Accepted, InProgress, Implemented, Superseded, } impl Status { pub fn as_str(&self) -> &'static str { match self { Status::Draft => "draft", Status::Accepted => "accepted", Status::InProgress => "in-progress", Status::Implemented => "implemented", Status::Superseded => "superseded", } } } /// An RFC (Request for Comments) - a design document #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Rfc { pub title: String, pub status: Status, pub date: Option, pub source_spike: Option, pub source_prd: Option, pub problem: Option, pub proposal: Option, pub goals: Vec, pub non_goals: Vec, pub plan: Vec, } /// A task within an RFC plan #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Task { pub description: String, pub completed: bool, } /// A Spike - a time-boxed investigation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Spike { pub title: String, pub status: String, pub date: String, pub time_box: Option, pub question: String, pub outcome: Option, pub findings: Option, pub recommendation: Option, } /// Outcome of a spike investigation #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SpikeOutcome { NoAction, DecisionMade, RecommendsImplementation, } impl SpikeOutcome { pub fn as_str(&self) -> &'static str { match self { SpikeOutcome::NoAction => "no-action", SpikeOutcome::DecisionMade => "decision-made", SpikeOutcome::RecommendsImplementation => "recommends-implementation", } } } /// A Decision Note - lightweight choice documentation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Decision { pub title: String, pub date: String, pub decision: String, pub rationale: Option, pub alternatives: Vec, } /// An ADR (Architecture Decision Record) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Adr { pub title: String, pub status: String, pub date: String, pub source_rfc: Option, pub context: String, pub decision: String, pub consequences: Vec, } /// An Audit document - formal findings report #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Audit { pub title: String, pub status: String, pub date: String, pub audit_type: AuditType, pub scope: String, pub summary: Option, pub findings: Vec, pub recommendations: Vec, } /// Types of audits #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AuditType { Repository, Security, RfcVerification, AdrAdherence, Custom, } impl AuditType { pub fn as_str(&self) -> &'static str { match self { AuditType::Repository => "repository", AuditType::Security => "security", AuditType::RfcVerification => "rfc-verification", AuditType::AdrAdherence => "adr-adherence", AuditType::Custom => "custom", } } pub fn from_str(s: &str) -> Option { match s.to_lowercase().as_str() { "repository" => Some(AuditType::Repository), "security" => Some(AuditType::Security), "rfc-verification" => Some(AuditType::RfcVerification), "adr-adherence" => Some(AuditType::AdrAdherence), "custom" => Some(AuditType::Custom), _ => None, } } } /// A finding within an audit #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuditFinding { pub category: String, pub title: String, pub description: String, pub severity: AuditSeverity, } /// Severity of an audit finding #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AuditSeverity { Error, Warning, Info, } impl AuditSeverity { pub fn as_str(&self) -> &'static str { match self { AuditSeverity::Error => "error", AuditSeverity::Warning => "warning", AuditSeverity::Info => "info", } } } impl Rfc { /// Create a new RFC in draft status pub fn new(title: impl Into) -> Self { Self { title: title.into(), status: Status::Draft, date: Some(today()), source_spike: None, source_prd: None, problem: None, proposal: None, goals: Vec::new(), non_goals: Vec::new(), plan: Vec::new(), } } /// Calculate completion percentage of the plan pub fn progress(&self) -> f64 { if self.plan.is_empty() { return 0.0; } let completed = self.plan.iter().filter(|t| t.completed).count(); (completed as f64 / self.plan.len() as f64) * 100.0 } /// Generate markdown content pub fn to_markdown(&self, number: u32) -> String { let mut md = String::new(); // Title md.push_str(&format!( "# RFC {:04}: {}\n\n", number, to_title_case(&self.title) )); // Metadata table md.push_str("| | |\n|---|---|\n"); md.push_str(&format!( "| **Status** | {} |\n", to_title_case(self.status.as_str()) )); if let Some(ref date) = self.date { md.push_str(&format!("| **Date** | {} |\n", date)); } if let Some(ref spike) = self.source_spike { md.push_str(&format!("| **Source Spike** | {} |\n", spike)); } if let Some(ref prd) = self.source_prd { md.push_str(&format!("| **Source PRD** | {} |\n", prd)); } md.push_str("\n---\n\n"); // Summary (problem) if let Some(ref problem) = self.problem { md.push_str("## Summary\n\n"); md.push_str(problem); md.push_str("\n\n"); } // Proposal if let Some(ref proposal) = self.proposal { md.push_str("## Proposal\n\n"); md.push_str(proposal); md.push_str("\n\n"); } // Goals if !self.goals.is_empty() { md.push_str("## Goals\n\n"); for goal in &self.goals { md.push_str(&format!("- {}\n", goal)); } md.push('\n'); } // Non-Goals if !self.non_goals.is_empty() { md.push_str("## Non-Goals\n\n"); for ng in &self.non_goals { md.push_str(&format!("- {}\n", ng)); } md.push('\n'); } // Test Plan (empty checkboxes) md.push_str("## Test Plan\n\n"); md.push_str("- [ ] TBD\n\n"); // Blue's signature md.push_str("---\n\n"); md.push_str("*\"Right then. Let's get to it.\"*\n\n"); md.push_str("— Blue\n"); md } } impl Spike { /// Create a new spike pub fn new(title: impl Into, question: impl Into) -> Self { Self { title: title.into(), status: "in-progress".to_string(), date: today(), time_box: None, question: question.into(), outcome: None, findings: None, recommendation: None, } } /// Generate markdown content pub fn to_markdown(&self) -> String { let mut md = String::new(); md.push_str(&format!("# Spike: {}\n\n", to_title_case(&self.title))); md.push_str("| | |\n|---|---|\n"); md.push_str(&format!( "| **Status** | {} |\n", to_title_case(&self.status) )); md.push_str(&format!("| **Date** | {} |\n", self.date)); if let Some(ref tb) = self.time_box { md.push_str(&format!("| **Time Box** | {} |\n", tb)); } if let Some(ref outcome) = self.outcome { md.push_str(&format!("| **Outcome** | {} |\n", outcome.as_str())); } md.push_str("\n---\n\n"); md.push_str("## Question\n\n"); md.push_str(&self.question); md.push_str("\n\n"); if let Some(ref findings) = self.findings { md.push_str("## Findings\n\n"); md.push_str(findings); md.push_str("\n\n"); } if let Some(ref rec) = self.recommendation { md.push_str("## Recommendation\n\n"); md.push_str(rec); md.push_str("\n\n"); } md.push_str("---\n\n"); md.push_str("*Investigation notes by Blue*\n"); md } } impl Adr { /// Create a new ADR pub fn new(title: impl Into) -> Self { Self { title: title.into(), status: "accepted".to_string(), date: today(), source_rfc: None, context: String::new(), decision: String::new(), consequences: Vec::new(), } } /// Generate markdown content pub fn to_markdown(&self, number: u32) -> String { let mut md = String::new(); md.push_str(&format!( "# ADR {:04}: {}\n\n", number, to_title_case(&self.title) )); md.push_str("| | |\n|---|---|\n"); md.push_str(&format!( "| **Status** | {} |\n", to_title_case(&self.status) )); md.push_str(&format!("| **Date** | {} |\n", self.date)); if let Some(ref rfc) = self.source_rfc { md.push_str(&format!("| **RFC** | {} |\n", rfc)); } md.push_str("\n---\n\n"); md.push_str("## Context\n\n"); md.push_str(&self.context); md.push_str("\n\n"); md.push_str("## Decision\n\n"); md.push_str(&self.decision); md.push_str("\n\n"); if !self.consequences.is_empty() { md.push_str("## Consequences\n\n"); for c in &self.consequences { md.push_str(&format!("- {}\n", c)); } md.push('\n'); } md.push_str("---\n\n"); md.push_str("*Recorded by Blue*\n"); md } } impl Decision { /// Create a new Decision pub fn new(title: impl Into, decision: impl Into) -> Self { Self { title: title.into(), date: today(), decision: decision.into(), rationale: None, alternatives: Vec::new(), } } /// Generate markdown content pub fn to_markdown(&self) -> String { let mut md = String::new(); md.push_str(&format!("# Decision: {}\n\n", to_title_case(&self.title))); md.push_str(&format!("**Date:** {}\n\n", self.date)); md.push_str("## Decision\n\n"); md.push_str(&self.decision); md.push_str("\n\n"); if let Some(ref rationale) = self.rationale { md.push_str("## Rationale\n\n"); md.push_str(rationale); md.push_str("\n\n"); } if !self.alternatives.is_empty() { md.push_str("## Alternatives Considered\n\n"); for alt in &self.alternatives { md.push_str(&format!("- {}\n", alt)); } md.push('\n'); } md.push_str("---\n\n"); md.push_str("*Noted by Blue*\n"); md } } impl Audit { /// Create a new Audit pub fn new(title: impl Into, audit_type: AuditType, scope: impl Into) -> Self { Self { title: title.into(), status: "in-progress".to_string(), date: today(), audit_type, scope: scope.into(), summary: None, findings: Vec::new(), recommendations: Vec::new(), } } /// Generate markdown content pub fn to_markdown(&self) -> String { let mut md = String::new(); md.push_str(&format!("# Audit: {}\n\n", to_title_case(&self.title))); md.push_str("| | |\n|---|---|\n"); md.push_str(&format!( "| **Status** | {} |\n", to_title_case(&self.status) )); md.push_str(&format!("| **Date** | {} |\n", self.date)); md.push_str(&format!( "| **Type** | {} |\n", to_title_case(self.audit_type.as_str()) )); md.push_str(&format!("| **Scope** | {} |\n", self.scope)); md.push_str("\n---\n\n"); if let Some(ref summary) = self.summary { md.push_str("## Executive Summary\n\n"); md.push_str(summary); md.push_str("\n\n"); } if !self.findings.is_empty() { md.push_str("## Findings\n\n"); for finding in &self.findings { md.push_str(&format!( "### {} ({})\n\n", finding.title, finding.severity.as_str() )); md.push_str(&format!("**Category:** {}\n\n", finding.category)); md.push_str(&finding.description); md.push_str("\n\n"); } } if !self.recommendations.is_empty() { md.push_str("## Recommendations\n\n"); for rec in &self.recommendations { md.push_str(&format!("- {}\n", rec)); } md.push('\n'); } md.push_str("---\n\n"); md.push_str("*Audited by Blue*\n"); md } } /// Get current date in YYYY-MM-DD format fn today() -> String { chrono::Utc::now().format("%Y-%m-%d").to_string() } /// Convert kebab-case to Title Case fn to_title_case(s: &str) -> String { s.split('-') .map(|word| { let mut chars = word.chars(); match chars.next() { None => String::new(), Some(first) => first.to_uppercase().chain(chars).collect(), } }) .collect::>() .join(" ") } /// Update status in a markdown file /// /// Handles common status patterns: /// - `| **Status** | Draft |` (table format) /// - `**Status:** Draft` (inline format) /// /// Returns Ok(true) if status was updated, Ok(false) if no match found. pub fn update_markdown_status( file_path: &std::path::Path, new_status: &str, ) -> Result { use std::fs; if !file_path.exists() { return Ok(false); } let content = fs::read_to_string(file_path)?; let display_status = to_title_case(new_status); // Try table format: | **Status** | | let table_pattern = regex::Regex::new(r"\| \*\*Status\*\* \| [^|]+ \|").unwrap(); let mut updated = table_pattern .replace(&content, format!("| **Status** | {} |", display_status).as_str()) .to_string(); // Also try inline format: **Status:** let inline_pattern = regex::Regex::new(r"\*\*Status:\*\* \S+").unwrap(); updated = inline_pattern .replace(&updated, format!("**Status:** {}", display_status).as_str()) .to_string(); let changed = updated != content; if changed { fs::write(file_path, updated)?; } Ok(changed) } #[cfg(test)] mod tests { use super::*; #[test] fn test_rfc_to_markdown() { let mut rfc = Rfc::new("my-feature"); rfc.problem = Some("Things are slow".to_string()); rfc.goals = vec!["Make it fast".to_string()]; let md = rfc.to_markdown(1); assert!(md.contains("# RFC 0001: My Feature")); assert!(md.contains("Things are slow")); assert!(md.contains("Make it fast")); assert!(md.contains("— Blue")); } #[test] fn test_title_case() { assert_eq!(to_title_case("my-feature"), "My Feature"); assert_eq!(to_title_case("in-progress"), "In Progress"); } #[test] fn test_spike_to_markdown() { let spike = Spike::new("test-investigation", "What should we do?"); let md = spike.to_markdown(); assert!(md.contains("# Spike: Test Investigation")); assert!(md.contains("What should we do?")); } #[test] fn test_update_markdown_status_table_format() { use std::fs; let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("test.md"); let content = "# RFC\n\n| | |\n|---|---|\n| **Status** | Draft |\n| **Date** | 2026-01-24 |\n"; fs::write(&file, content).unwrap(); let changed = update_markdown_status(&file, "implemented").unwrap(); assert!(changed); let updated = fs::read_to_string(&file).unwrap(); assert!(updated.contains("| **Status** | Implemented |")); assert!(!updated.contains("Draft")); } #[test] fn test_update_markdown_status_no_file() { let path = std::path::Path::new("/nonexistent/file.md"); let changed = update_markdown_status(path, "implemented").unwrap(); assert!(!changed); } #[test] fn test_update_markdown_status_no_status_field() { use std::fs; let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("test.md"); let content = "# Just a document\n\nNo status here.\n"; fs::write(&file, content).unwrap(); let changed = update_markdown_status(&file, "implemented").unwrap(); assert!(!changed); } }