use anyhow::{bail, Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Context,
Questions,
Approaches,
Design,
Document,
Done,
}
impl Default for Phase {
fn default() -> Self {
Phase::Context
}
}
impl fmt::Display for Phase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Phase::Context => write!(f, "Context"),
Phase::Questions => write!(f, "Questions"),
Phase::Approaches => write!(f, "Approaches"),
Phase::Design => write!(f, "Design"),
Phase::Document => write!(f, "Document"),
Phase::Done => write!(f, "Done"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClarifyingQuestion {
pub question: String,
pub rationale: String,
pub category: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub answer: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Approach {
pub name: String,
pub summary: String,
pub description: String,
pub pros: Vec<String>,
pub cons: Vec<String>,
pub complexity: Complexity,
#[serde(skip_serializing_if = "Option::is_none")]
pub estimated_effort: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Complexity {
Low,
Medium,
High,
}
impl fmt::Display for Complexity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Complexity::Low => write!(f, "low"),
Complexity::Medium => write!(f, "medium"),
Complexity::High => write!(f, "high"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Component {
pub name: String,
pub responsibility: String,
#[serde(default)]
pub interfaces: Vec<String>,
#[serde(default)]
pub depends_on: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesignDocument {
pub title: String,
pub summary: String,
pub problem_statement: String,
pub goals: Vec<String>,
#[serde(default)]
pub non_goals: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_context: Option<String>,
#[serde(default)]
pub questions: Vec<ClarifyingQuestion>,
pub approaches: Vec<Approach>,
pub chosen_approach: usize,
pub choice_rationale: String,
pub architecture: String,
pub components: Vec<Component>,
pub data_flow: String,
#[serde(default)]
pub file_plan: Vec<FileEntry>,
#[serde(default)]
pub open_risks: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
pub path: String,
pub action: FileAction,
pub description: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileAction {
Create,
Modify,
Delete,
}
impl fmt::Display for FileAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FileAction::Create => write!(f, "create"),
FileAction::Modify => write!(f, "modify"),
FileAction::Delete => write!(f, "delete"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainstormSession {
pub id: String,
pub phase: Phase,
pub topic: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_root: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_context: Option<String>,
#[serde(default)]
pub questions: Vec<ClarifyingQuestion>,
#[serde(default)]
pub approaches: Vec<Approach>,
#[serde(skip_serializing_if = "Option::is_none")]
pub design: Option<DesignDocument>,
}
impl BrainstormSession {
pub fn new(topic: impl Into<String>) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
phase: Phase::Context,
topic: topic.into(),
project_root: None,
project_context: None,
questions: Vec::new(),
approaches: Vec::new(),
design: None,
}
}
pub fn with_project_root(mut self, root: impl Into<PathBuf>) -> Self {
self.project_root = Some(root.into());
self
}
pub fn advance(&mut self) -> Result<()> {
let next = match self.phase {
Phase::Context => Phase::Questions,
Phase::Questions => Phase::Approaches,
Phase::Approaches => Phase::Design,
Phase::Design => Phase::Document,
Phase::Document => Phase::Done,
Phase::Done => bail!("Session is already complete"),
};
self.phase = next;
Ok(())
}
pub fn set_phase(&mut self, phase: Phase) {
self.phase = phase;
}
pub fn set_project_context(&mut self, context: impl Into<String>) {
self.project_context = Some(context.into());
}
pub fn gather_project_context(&self, max_bytes: usize) -> Result<String> {
let root = self
.project_root
.as_deref()
.context("No project root set")?;
let probe_files = [
"README.md",
"Cargo.toml",
"package.json",
"pyproject.toml",
"go.mod",
"Makefile",
"AGENTS.md",
"CONTRIBUTING.md",
".oxi/settings.toml",
];
let mut context_parts: Vec<String> = Vec::new();
let mut bytes_used: usize = 0;
for filename in &probe_files {
let path = root.join(filename);
if !path.exists() {
continue;
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let available = max_bytes.saturating_sub(bytes_used);
if available == 0 {
break;
}
let truncated = if content.len() > available {
&content[..available]
} else {
&content
};
context_parts.push(format!("## {filename}\n{truncated}"));
bytes_used += truncated.len();
if bytes_used >= max_bytes {
break;
}
}
if let Ok(entries) = std::fs::read_dir(root) {
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if e.path().is_dir() {
format!("{name}/")
} else {
name
}
})
.filter(|n| !n.starts_with('.'))
.collect();
names.sort();
if !names.is_empty() {
context_parts.push(format!("## Directory Structure\n{}", names.join("\n")));
}
}
Ok(context_parts.join("\n\n"))
}
pub fn add_question(
&mut self,
question: impl Into<String>,
rationale: impl Into<String>,
category: impl Into<String>,
) {
self.questions.push(ClarifyingQuestion {
question: question.into(),
rationale: rationale.into(),
category: category.into(),
answer: None,
});
}
pub fn answer_question(&mut self, index: usize, answer: impl Into<String>) -> Result<()> {
let q = self
.questions
.get_mut(index)
.with_context(|| format!("No question at index {}", index))?;
q.answer = Some(answer.into());
Ok(())
}
pub fn all_questions_answered(&self) -> bool {
self.questions.iter().all(|q| q.answer.is_some())
}
pub fn unanswered_questions(&self) -> Vec<&ClarifyingQuestion> {
self.questions
.iter()
.filter(|q| q.answer.is_none())
.collect()
}
pub fn add_approach(&mut self, approach: Approach) {
self.approaches.push(approach);
}
pub fn approach_count(&self) -> usize {
self.approaches.len()
}
pub fn finalize_design(
&mut self,
chosen_approach: usize,
choice_rationale: impl Into<String>,
architecture: impl Into<String>,
components: Vec<Component>,
data_flow: impl Into<String>,
file_plan: Vec<FileEntry>,
open_risks: Vec<String>,
) -> Result<()> {
if chosen_approach >= self.approaches.len() {
bail!(
"Invalid approach index {} (only {} approaches defined)",
chosen_approach,
self.approaches.len()
);
}
let goals = self.extract_goals();
let non_goals = self.extract_non_goals();
let doc = DesignDocument {
title: self.topic.clone(),
summary: self
.approaches
.get(chosen_approach)
.map(|a| a.summary.clone())
.unwrap_or_default(),
problem_statement: self.topic.clone(),
goals,
non_goals,
project_context: self.project_context.clone(),
questions: self.questions.clone(),
approaches: self.approaches.clone(),
chosen_approach,
choice_rationale: choice_rationale.into(),
architecture: architecture.into(),
components,
data_flow: data_flow.into(),
file_plan,
open_risks,
author: None,
created_at: Utc::now().to_rfc3339(),
};
self.design = Some(doc);
Ok(())
}
fn extract_goals(&self) -> Vec<String> {
self.questions
.iter()
.filter(|q| {
q.category == "goals" || q.category == "requirements" || q.category == "scope"
})
.filter_map(|q| q.answer.as_ref())
.cloned()
.collect()
}
fn extract_non_goals(&self) -> Vec<String> {
self.questions
.iter()
.filter(|q| q.category == "non-goals" || q.category == "out_of_scope")
.filter_map(|q| q.answer.as_ref())
.cloned()
.collect()
}
pub fn render_markdown(&self) -> Result<String> {
let doc = self
.design
.as_ref()
.context("Design has not been finalized yet")?;
Ok(render_design_markdown(doc))
}
pub fn write_document(&self, path: Option<&Path>) -> Result<PathBuf> {
let doc = self
.design
.as_ref()
.context("Design has not been finalized yet")?;
let output_path = match path {
Some(p) => p.to_path_buf(),
None => {
let root = self
.project_root
.as_deref()
.context("No project root set and no explicit path provided")?;
let date = &doc.created_at[..10]; let slug = slugify(&doc.title);
let design_dir = root.join("docs").join("design");
std::fs::create_dir_all(&design_dir)
.with_context(|| format!("Failed to create {}", design_dir.display()))?;
design_dir.join(format!("{date}-{slug}.md"))
}
};
let markdown = render_design_markdown(doc);
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
std::fs::write(&output_path, &markdown).with_context(|| {
format!(
"Failed to write design document to {}",
output_path.display()
)
})?;
Ok(output_path)
}
}
fn render_design_markdown(doc: &DesignDocument) -> String {
let mut md = String::with_capacity(4096);
md.push_str(&format!("# {}\n\n", doc.title));
md.push_str(&format!("> Created: {}\n", doc.created_at));
if let Some(ref author) = doc.author {
md.push_str(&format!("> Author: {}\n", author));
}
md.push('\n');
md.push_str("## Summary\n\n");
md.push_str(&doc.summary);
md.push_str("\n\n");
md.push_str("## Problem Statement\n\n");
md.push_str(&doc.problem_statement);
md.push_str("\n\n");
if !doc.goals.is_empty() {
md.push_str("## Goals\n\n");
for goal in &doc.goals {
md.push_str(&format!("- {}\n", goal));
}
md.push('\n');
}
if !doc.non_goals.is_empty() {
md.push_str("## Non-Goals\n\n");
for ng in &doc.non_goals {
md.push_str(&format!("- {}\n", ng));
}
md.push('\n');
}
if let Some(ref ctx) = doc.project_context {
md.push_str("## Project Context\n\n");
md.push_str(ctx);
md.push_str("\n\n");
}
if !doc.questions.is_empty() {
md.push_str("## Clarifying Questions\n\n");
for (i, q) in doc.questions.iter().enumerate() {
md.push_str(&format!("### Q{}: {}\n\n", i + 1, q.question));
md.push_str(&format!("**Rationale:** {}\n\n", q.rationale));
if let Some(ref answer) = q.answer {
md.push_str(&format!("**Answer:** {}\n\n", answer));
} else {
md.push_str("**Answer:** *(unanswered)*\n\n");
}
}
}
md.push_str("## Approaches Considered\n\n");
for (i, approach) in doc.approaches.iter().enumerate() {
let chosen_marker = if i == doc.chosen_approach {
" **(chosen)**"
} else {
""
};
md.push_str(&format!("### {}{}\n\n", approach.name, chosen_marker));
md.push_str(&format!("{}\n\n", approach.summary));
md.push_str(&format!("{}\n\n", approach.description));
md.push_str("**Pros:**\n\n");
for pro in &approach.pros {
md.push_str(&format!("+ {}\n", pro));
}
md.push('\n');
md.push_str("**Cons:**\n\n");
for con in &approach.cons {
md.push_str(&format!("- {}\n", con));
}
md.push('\n');
md.push_str(&format!("**Complexity:** {}", approach.complexity));
if let Some(ref effort) = approach.estimated_effort {
md.push_str(&format!(" | **Effort:** {}", effort));
}
md.push_str("\n\n");
}
md.push_str("## Choice Rationale\n\n");
md.push_str(&doc.choice_rationale);
md.push_str("\n\n");
md.push_str("## Architecture\n\n");
md.push_str(&doc.architecture);
md.push_str("\n\n");
if !doc.components.is_empty() {
md.push_str("## Components\n\n");
for component in &doc.components {
md.push_str(&format!("### {}\n\n", component.name));
md.push_str(&format!("{}\n\n", component.responsibility));
if !component.interfaces.is_empty() {
md.push_str("**Interfaces:**\n\n");
for iface in &component.interfaces {
md.push_str(&format!("- {}\n", iface));
}
md.push('\n');
}
if !component.depends_on.is_empty() {
md.push_str(&format!(
"**Depends on:** {}\n\n",
component.depends_on.join(", ")
));
}
}
}
md.push_str("## Data Flow\n\n");
md.push_str(&doc.data_flow);
md.push_str("\n\n");
if !doc.file_plan.is_empty() {
md.push_str("## File Plan\n\n");
md.push_str("| Action | Path | Description |\n");
md.push_str("|--------|------|-------------|\n");
for entry in &doc.file_plan {
md.push_str(&format!(
"| {} | `{}` | {} |\n",
entry.action, entry.path, entry.description
));
}
md.push('\n');
}
if !doc.open_risks.is_empty() {
md.push_str("## Open Risks\n\n");
for risk in &doc.open_risks {
md.push_str(&format!("- {}\n", risk));
}
md.push('\n');
}
md
}
fn slugify(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c
} else if c == ' ' || c == '_' {
'-'
} else {
'\0'
}
})
.filter(|c| *c != '\0')
.collect::<String>()
.trim_matches('-')
.to_string()
}
pub fn brainstorm_skill_prompt() -> String {
let prompt = r#"# Brainstorming Skill
You are running the **brainstorming** skill. Your job is to guide the user
through a structured design exploration, producing a concrete design document.
## Workflow
Follow these phases strictly. Do not skip ahead.
### Phase 1: Context
1. Ask the user for the project root directory (or infer it from the current working directory).
2. Read key project files (README, manifest, config) to understand the codebase.
3. Summarize what you found and confirm understanding with the user.
### Phase 2: Clarifying Questions
1. Identify 3–8 critical ambiguities in the user's idea.
2. For each, ask a targeted question and explain *why* it matters.
3. Group questions by category (scope, constraints, users, performance, etc.).
4. Wait for the user to answer all questions before proceeding.
### Phase 3: Approaches
1. Propose 2–3 distinct approaches to solving the problem.
2. For each approach, provide:
- Name and one-line summary
- Detailed description
- Pros (list)
- Cons / risks (list)
- Complexity rating (low / medium / high)
- Estimated effort
3. Present a comparison table summarizing the approaches.
4. Wait for the user to select an approach.
### Phase 4: Design
1. Present a detailed design for the chosen approach, including:
- Architecture overview
- Component breakdown with responsibilities, interfaces, and dependencies
- Data flow description
- File plan (which files to create / modify / delete)
- Open risks or unresolved questions
2. Walk through the design and invite feedback.
3. Iterate until the user approves.
### Phase 5: Document
1. Write the approved design to `docs/design/YYYY-MM-DD-<slug>.md`.
2. Confirm the file was written successfully.
3. Suggest next steps (e.g., hand off to the autonomous-loop skill).
## Rules
- Never propose more than 3 approaches. Force prioritization.
- Every question must have a clear rationale — do not ask lazy questions.
- The design must be concrete enough to hand off directly to implementation.
- Prefer simplicity. If two approaches are equally viable, recommend the simpler one.
- Document all decisions and their reasoning.
"#;
prompt.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_approach(name: &str) -> Approach {
Approach {
name: name.to_string(),
summary: format!("{} summary", name),
description: format!("{} description", name),
pros: vec!["fast".to_string()],
cons: vec!["risky".to_string()],
complexity: Complexity::Medium,
estimated_effort: Some("1 week".to_string()),
}
}
fn sample_component(name: &str) -> Component {
Component {
name: name.to_string(),
responsibility: format!("{} does things", name),
interfaces: vec![format!("{}.run() -> Result", name)],
depends_on: vec![],
}
}
#[test]
fn test_session_new() {
let session = BrainstormSession::new("Build a cache layer");
assert_eq!(session.phase, Phase::Context);
assert_eq!(session.topic, "Build a cache layer");
assert!(session.questions.is_empty());
assert!(session.approaches.is_empty());
assert!(session.design.is_none());
}
#[test]
fn test_phase_advance() {
let mut session = BrainstormSession::new("test");
assert_eq!(session.phase, Phase::Context);
session.advance().unwrap();
assert_eq!(session.phase, Phase::Questions);
session.advance().unwrap();
assert_eq!(session.phase, Phase::Approaches);
session.advance().unwrap();
assert_eq!(session.phase, Phase::Design);
session.advance().unwrap();
assert_eq!(session.phase, Phase::Document);
session.advance().unwrap();
assert_eq!(session.phase, Phase::Done);
assert!(session.advance().is_err());
}
#[test]
fn test_set_phase() {
let mut session = BrainstormSession::new("test");
session.set_phase(Phase::Design);
assert_eq!(session.phase, Phase::Design);
}
#[test]
fn test_add_and_answer_questions() {
let mut session = BrainstormSession::new("test");
session.add_question("What is the scope?", "Defines boundaries", "scope");
session.add_question("Any perf requirements?", "Affects approach", "constraints");
assert_eq!(session.questions.len(), 2);
assert!(!session.all_questions_answered());
assert_eq!(session.unanswered_questions().len(), 2);
session.answer_question(0, "API layer only").unwrap();
assert!(!session.all_questions_answered());
assert_eq!(session.unanswered_questions().len(), 1);
session.answer_question(1, "< 50ms p99").unwrap();
assert!(session.all_questions_answered());
assert!(session.unanswered_questions().is_empty());
}
#[test]
fn test_answer_invalid_index() {
let mut session = BrainstormSession::new("test");
session.add_question("Q1?", "R1", "cat");
let result = session.answer_question(5, "answer");
assert!(result.is_err());
}
#[test]
fn test_add_approaches() {
let mut session = BrainstormSession::new("test");
session.add_approach(sample_approach("A"));
session.add_approach(sample_approach("B"));
assert_eq!(session.approach_count(), 2);
}
#[test]
fn test_finalize_design_invalid_approach() {
let mut session = BrainstormSession::new("test");
session.add_approach(sample_approach("A"));
let result = session.finalize_design(
5, "reason",
"arch",
vec![],
"flow",
vec![],
vec![],
);
assert!(result.is_err());
}
#[test]
fn test_finalize_and_render_design() {
let mut session = BrainstormSession::new("Build a cache layer");
session.add_question("Scope?", "Defines boundaries", "goals");
session.answer_question(0, "API layer only").unwrap();
session.add_question("Out of scope?", "What to exclude", "non-goals");
session.answer_question(1, "CLI tools").unwrap();
session.add_approach(sample_approach("In-memory LRU"));
session.add_approach(sample_approach("Redis-backed"));
session
.finalize_design(
0,
"In-memory is simpler and sufficient for single-process use",
"Layered architecture: Cache trait -> LRU implementation -> integration point",
vec![
sample_component("CacheStore"),
sample_component("CacheConfig"),
],
"Request -> CacheStore.get() -> hit: return / miss: compute -> store -> return",
vec![
FileEntry {
path: "src/cache.rs".to_string(),
action: FileAction::Create,
description: "Core cache module".to_string(),
},
FileEntry {
path: "src/lib.rs".to_string(),
action: FileAction::Modify,
description: "Add cache module declaration".to_string(),
},
],
vec!["Cache invalidation strategy TBD".to_string()],
)
.unwrap();
let doc = session.design.as_ref().unwrap();
assert_eq!(doc.title, "Build a cache layer");
assert_eq!(doc.chosen_approach, 0);
assert_eq!(doc.components.len(), 2);
assert_eq!(doc.file_plan.len(), 2);
assert_eq!(doc.open_risks.len(), 1);
let md = session.render_markdown().unwrap();
assert!(md.contains("# Build a cache layer"));
assert!(md.contains("## Approaches Considered"));
assert!(md.contains("In-memory LRU"));
assert!(md.contains("## Architecture"));
assert!(md.contains("## Components"));
assert!(md.contains("## Data Flow"));
assert!(md.contains("## File Plan"));
assert!(md.contains("| create | `src/cache.rs` |"));
assert!(md.contains("## Open Risks"));
assert!(md.contains("(chosen)"));
}
#[test]
fn test_render_markdown_before_finalize() {
let session = BrainstormSession::new("test");
assert!(session.render_markdown().is_err());
}
#[test]
fn test_write_document_without_design() {
let session = BrainstormSession::new("test");
assert!(session.write_document(None).is_err());
}
#[test]
fn test_write_document_to_file() {
let tmp = tempfile::tempdir().unwrap();
let mut session = BrainstormSession::new("Test Design");
session.project_root = Some(tmp.path().to_path_buf());
session.add_approach(sample_approach("Simple"));
session
.finalize_design(
0,
"Simplest option",
"Flat architecture",
vec![],
"N/A",
vec![],
vec![],
)
.unwrap();
let path = session.write_document(None).unwrap();
assert!(path.exists());
assert!(path.to_string_lossy().contains("docs/design"));
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("# Test Design"));
assert!(content.contains("Simple"));
}
#[test]
fn test_write_document_explicit_path() {
let tmp = tempfile::tempdir().unwrap();
let mut session = BrainstormSession::new("Test");
session.add_approach(sample_approach("A"));
session
.finalize_design(0, "r", "a", vec![], "f", vec![], vec![])
.unwrap();
let explicit = tmp.path().join("custom-design.md");
let path = session.write_document(Some(&explicit)).unwrap();
assert_eq!(path, explicit);
assert!(path.exists());
}
#[test]
fn test_gather_project_context() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("README.md"),
"# My Project\nA cool project.",
)
.unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let session = BrainstormSession::new("test").with_project_root(tmp.path());
let context = session.gather_project_context(10000).unwrap();
assert!(context.contains("README.md"));
assert!(context.contains("My Project"));
assert!(context.contains("Cargo.toml"));
assert!(context.contains("Directory Structure"));
}
#[test]
fn test_gather_project_context_no_root() {
let session = BrainstormSession::new("test");
assert!(session.gather_project_context(10000).is_err());
}
#[test]
fn test_gather_project_context_truncation() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("README.md"), "x".repeat(5000)).unwrap();
let session = BrainstormSession::new("test").with_project_root(tmp.path());
let context = session.gather_project_context(100).unwrap();
assert!(context.len() < 200);
}
#[test]
fn test_slugify() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("Build a Cache Layer!"), "build-a-cache-layer");
assert_eq!(slugify("foo_bar baz"), "foo-bar-baz");
assert_eq!(slugify(" spaces "), "spaces");
assert_eq!(slugify("API v2.0"), "api-v20");
}
#[test]
fn test_phase_display() {
assert_eq!(format!("{}", Phase::Context), "Context");
assert_eq!(format!("{}", Phase::Questions), "Questions");
assert_eq!(format!("{}", Phase::Approaches), "Approaches");
assert_eq!(format!("{}", Phase::Design), "Design");
assert_eq!(format!("{}", Phase::Document), "Document");
assert_eq!(format!("{}", Phase::Done), "Done");
}
#[test]
fn test_complexity_display() {
assert_eq!(format!("{}", Complexity::Low), "low");
assert_eq!(format!("{}", Complexity::Medium), "medium");
assert_eq!(format!("{}", Complexity::High), "high");
}
#[test]
fn test_file_action_display() {
assert_eq!(format!("{}", FileAction::Create), "create");
assert_eq!(format!("{}", FileAction::Modify), "modify");
assert_eq!(format!("{}", FileAction::Delete), "delete");
}
#[test]
fn test_brainstorm_skill_prompt() {
let prompt = brainstorm_skill_prompt();
assert!(prompt.contains("Brainstorming Skill"));
assert!(prompt.contains("Phase 1: Context"));
assert!(prompt.contains("Phase 2: Clarifying Questions"));
assert!(prompt.contains("Phase 3: Approaches"));
assert!(prompt.contains("Phase 4: Design"));
assert!(prompt.contains("Phase 5: Document"));
}
#[test]
fn test_design_document_serialization_roundtrip() {
let doc = DesignDocument {
title: "Test".to_string(),
summary: "A test design".to_string(),
problem_statement: "Need to test serialization".to_string(),
goals: vec!["Goal 1".to_string()],
non_goals: vec!["Non-goal 1".to_string()],
project_context: Some("Context".to_string()),
questions: vec![ClarifyingQuestion {
question: "Q?".to_string(),
rationale: "R".to_string(),
category: "scope".to_string(),
answer: Some("A".to_string()),
}],
approaches: vec![sample_approach("X")],
chosen_approach: 0,
choice_rationale: "Simplest".to_string(),
architecture: "Flat".to_string(),
components: vec![sample_component("C")],
data_flow: "A -> B".to_string(),
file_plan: vec![FileEntry {
path: "a.rs".to_string(),
action: FileAction::Create,
description: "desc".to_string(),
}],
open_risks: vec!["Risk 1".to_string()],
author: Some("test".to_string()),
created_at: "2025-01-01T00:00:00Z".to_string(),
};
let json = serde_json::to_string_pretty(&doc).unwrap();
let parsed: DesignDocument = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.title, doc.title);
assert_eq!(parsed.goals, doc.goals);
assert_eq!(parsed.components.len(), 1);
assert_eq!(parsed.file_plan.len(), 1);
}
#[test]
fn test_session_serialization_roundtrip() {
let mut session = BrainstormSession::new("Test Session");
session.add_question("Q?", "R", "scope");
session.add_approach(sample_approach("A"));
session.set_phase(Phase::Approaches);
let json = serde_json::to_string(&session).unwrap();
let parsed: BrainstormSession = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.topic, session.topic);
assert_eq!(parsed.phase, Phase::Approaches);
assert_eq!(parsed.questions.len(), 1);
assert_eq!(parsed.approaches.len(), 1);
}
#[test]
fn test_render_markdown_with_chosen_marker() {
let mut session = BrainstormSession::new("test");
session.add_approach(sample_approach("Alpha"));
session.add_approach(sample_approach("Beta"));
session.add_approach(sample_approach("Gamma"));
session
.finalize_design(1, "Picked Beta", "arch", vec![], "flow", vec![], vec![])
.unwrap();
let md = session.render_markdown().unwrap();
assert!(md.contains("### Beta **(chosen)**"));
assert!(!md.contains("### Alpha **(chosen)**"));
assert!(!md.contains("### Gamma **(chosen)**"));
}
#[test]
fn test_empty_design_render() {
let mut session = BrainstormSession::new("Empty Design");
session.add_approach(sample_approach("Only Option"));
session
.finalize_design(
0,
"Only one option",
"Simple",
vec![],
"N/A",
vec![],
vec![],
)
.unwrap();
let md = session.render_markdown().unwrap();
assert!(md.contains("# Empty Design"));
assert!(!md.contains("## Components"));
assert!(!md.contains("## File Plan"));
assert!(!md.contains("## Open Risks"));
}
#[test]
fn test_extract_goals_from_questions() {
let mut session = BrainstormSession::new("test");
session.add_question("Goal?", "Why", "goals");
session.add_question("Req?", "Why", "requirements");
session.add_question("Scope?", "Why", "scope");
session.add_question("OOS?", "Why", "non-goals");
session.answer_question(0, "G1").unwrap();
session.answer_question(1, "R1").unwrap();
session.answer_question(2, "S1").unwrap();
session.answer_question(3, "NG1").unwrap();
let goals = session.extract_goals();
assert_eq!(goals, vec!["G1", "R1", "S1"]);
let non_goals = session.extract_non_goals();
assert_eq!(non_goals, vec!["NG1"]);
}
}