//! 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, /// RFCs produced by this spike (RFC 0038) /// Format: ["0038", "0039"] for local or ["blue-web:0015"] for cross-repo #[serde(default)] pub produces_rfcs: Vec, } /// Outcome of a spike investigation #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SpikeOutcome { NoAction, DecisionMade, RecommendsImplementation, Resolved, } impl SpikeOutcome { pub fn as_str(&self) -> &'static str { match self { SpikeOutcome::NoAction => "no-action", SpikeOutcome::DecisionMade => "decision-made", SpikeOutcome::RecommendsImplementation => "recommends-implementation", SpikeOutcome::Resolved => "resolved", } } } /// 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 parse(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, produces_rfcs: Vec::new(), } } /// 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())); } if !self.produces_rfcs.is_empty() { md.push_str(&format!("| **Produces RFCs** | {} |\n", self.produces_rfcs.join(", "))); } 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() } /// Get current UTC timestamp in ISO 8601 compact format: `YYYY-MM-DDTHHmmZ` pub fn utc_timestamp() -> String { chrono::Utc::now().format("%Y-%m-%dT%H%MZ").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) } /// RFC header format types (RFC 0017) #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HeaderFormat { /// Table format: `| **Status** | Draft |` Table, /// Inline format: `**Status:** Draft` Inline, /// No recognizable header format Missing, } /// Validate RFC header format /// /// Returns the detected header format: /// - Table: canonical format `| **Status** | Draft |` /// - Inline: non-canonical format `**Status:** Draft` /// - Missing: no status header found pub fn validate_rfc_header(content: &str) -> HeaderFormat { let table_pattern = regex::Regex::new(r"\| \*\*Status\*\* \| [^|]+ \|").unwrap(); let inline_pattern = regex::Regex::new(r"\*\*Status:\*\*\s+\S+").unwrap(); if table_pattern.is_match(content) { HeaderFormat::Table } else if inline_pattern.is_match(content) { HeaderFormat::Inline } else { HeaderFormat::Missing } } /// Convert inline header format to table format /// /// Converts patterns like: /// ```text /// **Status:** Draft /// **Created:** 2026-01-25 /// **Author:** Claude /// ``` /// /// To: /// ```text /// | | | /// |---|---| /// | **Status** | Draft | /// | **Created** | 2026-01-25 | /// | **Author** | Claude | /// ``` pub fn convert_inline_to_table_header(content: &str) -> String { // Match inline metadata patterns: **Key:** Value let inline_re = regex::Regex::new(r"\*\*([^:*]+):\*\*\s*(.+)").unwrap(); let mut metadata_lines: Vec<(String, String)> = Vec::new(); let mut other_lines: Vec = Vec::new(); let mut in_header_section = false; let mut header_ended = false; for line in content.lines() { // Skip title line if line.starts_with("# ") { other_lines.push(line.to_string()); in_header_section = true; continue; } // Check for inline metadata if in_header_section && !header_ended { if let Some(caps) = inline_re.captures(line) { let key = caps.get(1).unwrap().as_str().trim(); let value = caps.get(2).unwrap().as_str().trim(); metadata_lines.push((key.to_string(), value.to_string())); continue; } // Empty line in header section is ok if line.trim().is_empty() && !metadata_lines.is_empty() { continue; } // If we had metadata and hit something else, header section ended if !metadata_lines.is_empty() { header_ended = true; } } other_lines.push(line.to_string()); } if metadata_lines.is_empty() { return content.to_string(); } // Reconstruct content with table format let mut result = String::new(); // Find and add title if let Some(title_pos) = other_lines.iter().position(|l| l.starts_with("# ")) { result.push_str(&other_lines[title_pos]); result.push_str("\n\n"); // Add table header result.push_str("| | |\n"); result.push_str("|---|---|\n"); // Add metadata rows for (key, value) in &metadata_lines { result.push_str(&format!("| **{}** | {} |\n", key, value)); } // Add remaining content let remaining = other_lines[title_pos + 1..].join("\n"); let trimmed = remaining.trim_start(); if !trimmed.is_empty() { result.push('\n'); result.push_str(trimmed); } } else { // No title found, return original return content.to_string(); } result } #[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); } #[test] fn test_validate_rfc_header_table_format() { let content = "# RFC 0001: Test\n\n| | |\n|---|---|\n| **Status** | Draft |\n| **Date** | 2026-01-24 |\n"; assert_eq!(validate_rfc_header(content), HeaderFormat::Table); } #[test] fn test_validate_rfc_header_inline_format() { let content = "# RFC 0001: Test\n\n**Status:** Draft\n**Date:** 2026-01-24\n"; assert_eq!(validate_rfc_header(content), HeaderFormat::Inline); } #[test] fn test_validate_rfc_header_missing() { let content = "# RFC 0001: Test\n\nJust some content without status.\n"; assert_eq!(validate_rfc_header(content), HeaderFormat::Missing); } #[test] fn test_convert_inline_to_table_header() { let content = "# RFC 0001: Test\n\n**Status:** Draft\n**Created:** 2026-01-25\n**Author:** Claude\n\n## Problem\n\nSomething is wrong.\n"; let converted = convert_inline_to_table_header(content); assert!(converted.contains("| | |")); assert!(converted.contains("|---|---|")); assert!(converted.contains("| **Status** | Draft |")); assert!(converted.contains("| **Created** | 2026-01-25 |")); assert!(converted.contains("| **Author** | Claude |")); assert!(converted.contains("## Problem")); assert!(converted.contains("Something is wrong.")); assert!(!converted.contains("**Status:**")); } #[test] fn test_convert_inline_to_table_header_no_change() { let content = "# RFC 0001: Test\n\n| | |\n|---|---|\n| **Status** | Draft |\n\n## Problem\n"; let converted = convert_inline_to_table_header(content); // Should not change already-table-formatted content assert_eq!(converted, content); } }