blue/crates/blue-core/src/documents.rs
Eric Garcia 3e157d76a6 feat(core): Complete Phase 1 - Foundation porting from coherence-mcp
Port core functionality from coherence-mcp to blue-core:

- store.rs: SQLite persistence with schema v1, WAL mode, FTS5 search
  - documents, document_links, tasks, worktrees, metadata tables
  - Blue's voice in all error messages

- documents.rs: Enhanced with markdown generation
  - Rfc, Spike, Adr, Decision types with to_markdown() methods
  - Blue's signature at the end of generated docs

- state.rs: ProjectState aggregation
  - active_items(), ready_items(), stalled_items(), draft_items()
  - generate_hint() for contextual recommendations
  - status_summary() for complete overview

- repo.rs: Git detection and worktree operations
  - detect_blue() finds .blue/ directory structure
  - WorktreeInfo with rfc_title() extraction
  - create_worktree(), remove_worktree(), is_branch_merged()

- workflow.rs: Status transitions
  - RfcStatus, SpikeOutcome, SpikeStatus, PrdStatus enums
  - Transition validation with helpful error messages

MCP server updated with 9 tools:
- blue_status, blue_next, blue_rfc_create, blue_rfc_get
- blue_rfc_update_status, blue_rfc_plan, blue_rfc_validate
- blue_rfc_task_complete, blue_search

14 unit tests passing.

RFC 0002 tracks remaining phases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 00:43:25 -05:00

414 lines
11 KiB
Rust

//! 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<String>,
pub source_spike: Option<String>,
pub source_prd: Option<String>,
pub problem: Option<String>,
pub proposal: Option<String>,
pub goals: Vec<String>,
pub non_goals: Vec<String>,
pub plan: Vec<Task>,
}
/// 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<String>,
pub question: String,
pub outcome: Option<SpikeOutcome>,
pub findings: Option<String>,
pub recommendation: Option<String>,
}
/// 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<String>,
pub alternatives: Vec<String>,
}
/// 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<String>,
pub context: String,
pub decision: String,
pub consequences: Vec<String>,
}
impl Rfc {
/// Create a new RFC in draft status
pub fn new(title: impl Into<String>) -> 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<String>, question: impl Into<String>) -> 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<String>) -> 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<String>, decision: impl Into<String>) -> 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
}
}
/// 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::<Vec<_>>()
.join(" ")
}
#[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?"));
}
}