Skip to main content

oxi/skills/
brainstorming.rs

1//! Brainstorming skill for oxi
2//!
3//! An interactive design-exploration workflow that guides a user from a vague
4//! idea to a concrete, documented design. The workflow proceeds through five
5//! phases:
6//!
7//! 1. **Context** — Explore project structure, existing code, and conventions.
8//! 2. **Questions** — Identify ambiguities and ask targeted clarifying questions.
9//! 3. **Approaches** — Propose 2–3 candidate approaches with trade-off analysis.
10//! 4. **Design** — Present a detailed design covering architecture, components,
11//!    and data flow.
12//! 5. **Document** — Write the approved design to a Markdown file.
13//!
14//! The module is designed to be usable both as a library (driven programmatically)
15//! and as a skill whose SKILL.md is appended to the agent system prompt. The
16//! [`BrainstormSession`] struct tracks the full lifecycle and produces a
17//! structured [`DesignDocument`] that can be serialized to disk.
18
19use anyhow::{bail, Context, Result};
20use chrono::Utc;
21use serde::{Deserialize, Serialize};
22use std::fmt;
23use std::path::{Path, PathBuf};
24
25// ── Types ──────────────────────────────────────────────────────────────
26
27/// The phase a brainstorm session is currently in.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Phase {
31    /// Gathering context from the project.
32    Context,
33    /// Asking clarifying questions.
34    Questions,
35    /// Proposing candidate approaches.
36    Approaches,
37    /// Presenting the detailed design.
38    Design,
39    /// Writing the design document to disk.
40    Document,
41    /// Session concluded.
42    Done,
43}
44
45impl Default for Phase {
46    fn default() -> Self {
47        Phase::Context
48    }
49}
50
51impl fmt::Display for Phase {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Phase::Context => write!(f, "Context"),
55            Phase::Questions => write!(f, "Questions"),
56            Phase::Approaches => write!(f, "Approaches"),
57            Phase::Design => write!(f, "Design"),
58            Phase::Document => write!(f, "Document"),
59            Phase::Done => write!(f, "Done"),
60        }
61    }
62}
63
64/// A single clarifying question with its answer.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ClarifyingQuestion {
67    /// The question text.
68    pub question: String,
69    /// The rationale for why this question matters.
70    pub rationale: String,
71    /// Category for grouping (e.g., "scope", "constraints", "users").
72    pub category: String,
73    /// User-provided answer (filled in later).
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub answer: Option<String>,
76}
77
78/// A candidate approach with its trade-offs.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Approach {
81    /// Short name for this approach.
82    pub name: String,
83    /// One-line summary.
84    pub summary: String,
85    /// Detailed description.
86    pub description: String,
87    /// Key advantages.
88    pub pros: Vec<String>,
89    /// Key disadvantages or risks.
90    pub cons: Vec<String>,
91    /// Estimated complexity (low / medium / high).
92    pub complexity: Complexity,
93    /// Rough time estimate (e.g., "2-3 days", "1 week").
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub estimated_effort: Option<String>,
96}
97
98/// Complexity level for an approach.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum Complexity {
102    Low,
103    Medium,
104    High,
105}
106
107impl fmt::Display for Complexity {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        match self {
110            Complexity::Low => write!(f, "low"),
111            Complexity::Medium => write!(f, "medium"),
112            Complexity::High => write!(f, "high"),
113        }
114    }
115}
116
117/// A component within the design.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Component {
120    /// Component name.
121    pub name: String,
122    /// What this component is responsible for.
123    pub responsibility: String,
124    /// Key interfaces / APIs this component exposes.
125    #[serde(default)]
126    pub interfaces: Vec<String>,
127    /// Dependencies on other components.
128    #[serde(default)]
129    pub depends_on: Vec<String>,
130}
131
132/// The final design document.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct DesignDocument {
135    /// Title of the design.
136    pub title: String,
137    /// Brief summary / abstract.
138    pub summary: String,
139    /// Problem statement being solved.
140    pub problem_statement: String,
141    /// Key goals / requirements.
142    pub goals: Vec<String>,
143    /// Non-goals (explicitly out of scope).
144    #[serde(default)]
145    pub non_goals: Vec<String>,
146    /// Context gathered from the project.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub project_context: Option<String>,
149    /// Clarifying questions and answers.
150    #[serde(default)]
151    pub questions: Vec<ClarifyingQuestion>,
152    /// Candidate approaches that were considered.
153    pub approaches: Vec<Approach>,
154    /// Index of the chosen approach (0-based).
155    pub chosen_approach: usize,
156    /// Rationale for the choice.
157    pub choice_rationale: String,
158    /// Architectural overview text.
159    pub architecture: String,
160    /// Components that make up the design.
161    pub components: Vec<Component>,
162    /// Data flow description.
163    pub data_flow: String,
164    /// Files that need to be created or modified.
165    #[serde(default)]
166    pub file_plan: Vec<FileEntry>,
167    /// Open questions / risks remaining.
168    #[serde(default)]
169    pub open_risks: Vec<String>,
170    /// Author metadata.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub author: Option<String>,
173    /// Creation timestamp.
174    pub created_at: String,
175}
176
177/// A file entry in the implementation plan.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct FileEntry {
180    /// File path (relative to project root).
181    pub path: String,
182    /// Action to take.
183    pub action: FileAction,
184    /// Brief description of changes.
185    pub description: String,
186}
187
188/// What to do with a file.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum FileAction {
192    /// Create a new file.
193    Create,
194    /// Modify an existing file.
195    Modify,
196    /// Delete a file.
197    Delete,
198}
199
200impl fmt::Display for FileAction {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        match self {
203            FileAction::Create => write!(f, "create"),
204            FileAction::Modify => write!(f, "modify"),
205            FileAction::Delete => write!(f, "delete"),
206        }
207    }
208}
209
210/// The state of a brainstorming session.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct BrainstormSession {
213    /// Unique session identifier.
214    pub id: String,
215    /// Current phase.
216    pub phase: Phase,
217    /// The original topic / idea from the user.
218    pub topic: String,
219    /// Project root directory (for context gathering).
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub project_root: Option<PathBuf>,
222    /// Context gathered from the project.
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub project_context: Option<String>,
225    /// Clarifying questions identified.
226    #[serde(default)]
227    pub questions: Vec<ClarifyingQuestion>,
228    /// Candidate approaches.
229    #[serde(default)]
230    pub approaches: Vec<Approach>,
231    /// The final design document (set once in the Design phase).
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub design: Option<DesignDocument>,
234}
235
236// ── Session lifecycle ──────────────────────────────────────────────────
237
238impl BrainstormSession {
239    /// Start a new brainstorming session for the given topic.
240    pub fn new(topic: impl Into<String>) -> Self {
241        Self {
242            id: uuid::Uuid::new_v4().to_string(),
243            phase: Phase::Context,
244            topic: topic.into(),
245            project_root: None,
246            project_context: None,
247            questions: Vec::new(),
248            approaches: Vec::new(),
249            design: None,
250        }
251    }
252
253    /// Set the project root for context gathering.
254    pub fn with_project_root(mut self, root: impl Into<PathBuf>) -> Self {
255        self.project_root = Some(root.into());
256        self
257    }
258
259    /// Advance to the next phase.
260    ///
261    /// Enforces ordering: Context → Questions → Approaches → Design → Document → Done.
262    pub fn advance(&mut self) -> Result<()> {
263        let next = match self.phase {
264            Phase::Context => Phase::Questions,
265            Phase::Questions => Phase::Approaches,
266            Phase::Approaches => Phase::Design,
267            Phase::Design => Phase::Document,
268            Phase::Document => Phase::Done,
269            Phase::Done => bail!("Session is already complete"),
270        };
271        self.phase = next;
272        Ok(())
273    }
274
275    /// Jump to a specific phase (for resuming or revisiting).
276    pub fn set_phase(&mut self, phase: Phase) {
277        self.phase = phase;
278    }
279
280    // ── Phase 1: Context ─────────────────────────────────────────────
281
282    /// Set the project context summary.
283    pub fn set_project_context(&mut self, context: impl Into<String>) {
284        self.project_context = Some(context.into());
285    }
286
287    /// Quick project context from files that commonly exist.
288    ///
289    /// Reads up to `max_bytes` bytes from well-known project files
290    /// (README.md, Cargo.toml, package.json, etc.) to build a compact
291    /// summary string.
292    pub fn gather_project_context(&self, max_bytes: usize) -> Result<String> {
293        let root = self
294            .project_root
295            .as_deref()
296            .context("No project root set")?;
297
298        let probe_files = [
299            "README.md",
300            "Cargo.toml",
301            "package.json",
302            "pyproject.toml",
303            "go.mod",
304            "Makefile",
305            "AGENTS.md",
306            "CONTRIBUTING.md",
307            ".oxi/settings.toml",
308        ];
309
310        let mut context_parts: Vec<String> = Vec::new();
311        let mut bytes_used: usize = 0;
312
313        for filename in &probe_files {
314            let path = root.join(filename);
315            if !path.exists() {
316                continue;
317            }
318            let content = std::fs::read_to_string(&path)
319                .with_context(|| format!("Failed to read {}", path.display()))?;
320
321            // Truncate if too long
322            let available = max_bytes.saturating_sub(bytes_used);
323            if available == 0 {
324                break;
325            }
326
327            let truncated = if content.len() > available {
328                &content[..available]
329            } else {
330                &content
331            };
332
333            context_parts.push(format!("## {filename}\n{truncated}"));
334            bytes_used += truncated.len();
335
336            if bytes_used >= max_bytes {
337                break;
338            }
339        }
340
341        // Also list the top-level directory structure (first level only).
342        if let Ok(entries) = std::fs::read_dir(root) {
343            let mut names: Vec<String> = entries
344                .filter_map(|e| e.ok())
345                .map(|e| {
346                    let name = e.file_name().to_string_lossy().to_string();
347                    if e.path().is_dir() {
348                        format!("{name}/")
349                    } else {
350                        name
351                    }
352                })
353                .filter(|n| !n.starts_with('.'))
354                .collect();
355            names.sort();
356
357            if !names.is_empty() {
358                context_parts.push(format!("## Directory Structure\n{}", names.join("\n")));
359            }
360        }
361
362        Ok(context_parts.join("\n\n"))
363    }
364
365    // ── Phase 2: Questions ───────────────────────────────────────────
366
367    /// Add a clarifying question.
368    pub fn add_question(
369        &mut self,
370        question: impl Into<String>,
371        rationale: impl Into<String>,
372        category: impl Into<String>,
373    ) {
374        self.questions.push(ClarifyingQuestion {
375            question: question.into(),
376            rationale: rationale.into(),
377            category: category.into(),
378            answer: None,
379        });
380    }
381
382    /// Answer a question by index.
383    pub fn answer_question(&mut self, index: usize, answer: impl Into<String>) -> Result<()> {
384        let q = self
385            .questions
386            .get_mut(index)
387            .with_context(|| format!("No question at index {}", index))?;
388        q.answer = Some(answer.into());
389        Ok(())
390    }
391
392    /// Check whether all questions have been answered.
393    pub fn all_questions_answered(&self) -> bool {
394        self.questions.iter().all(|q| q.answer.is_some())
395    }
396
397    /// Get unanswered questions.
398    pub fn unanswered_questions(&self) -> Vec<&ClarifyingQuestion> {
399        self.questions.iter().filter(|q| q.answer.is_none()).collect()
400    }
401
402    // ── Phase 3: Approaches ──────────────────────────────────────────
403
404    /// Add a candidate approach.
405    pub fn add_approach(&mut self, approach: Approach) {
406        self.approaches.push(approach);
407    }
408
409    /// Get the number of approaches.
410    pub fn approach_count(&self) -> usize {
411        self.approaches.len()
412    }
413
414    // ── Phase 4: Design ──────────────────────────────────────────────
415
416    /// Finalize the design by choosing an approach and building the full
417    /// [`DesignDocument`].
418    pub fn finalize_design(
419        &mut self,
420        chosen_approach: usize,
421        choice_rationale: impl Into<String>,
422        architecture: impl Into<String>,
423        components: Vec<Component>,
424        data_flow: impl Into<String>,
425        file_plan: Vec<FileEntry>,
426        open_risks: Vec<String>,
427    ) -> Result<()> {
428        if chosen_approach >= self.approaches.len() {
429            bail!(
430                "Invalid approach index {} (only {} approaches defined)",
431                chosen_approach,
432                self.approaches.len()
433            );
434        }
435
436        let goals = self.extract_goals();
437        let non_goals = self.extract_non_goals();
438
439        let doc = DesignDocument {
440            title: self.topic.clone(),
441            summary: self
442                .approaches
443                .get(chosen_approach)
444                .map(|a| a.summary.clone())
445                .unwrap_or_default(),
446            problem_statement: self.topic.clone(),
447            goals,
448            non_goals,
449            project_context: self.project_context.clone(),
450            questions: self.questions.clone(),
451            approaches: self.approaches.clone(),
452            chosen_approach,
453            choice_rationale: choice_rationale.into(),
454            architecture: architecture.into(),
455            components,
456            data_flow: data_flow.into(),
457            file_plan,
458            open_risks,
459            author: None,
460            created_at: Utc::now().to_rfc3339(),
461        };
462
463        self.design = Some(doc);
464        Ok(())
465    }
466
467    /// Extract goals from answered questions.
468    fn extract_goals(&self) -> Vec<String> {
469        self.questions
470            .iter()
471            .filter(|q| {
472                q.category == "goals"
473                    || q.category == "requirements"
474                    || q.category == "scope"
475            })
476            .filter_map(|q| q.answer.as_ref())
477            .cloned()
478            .collect()
479    }
480
481    /// Extract non-goals from answered questions.
482    fn extract_non_goals(&self) -> Vec<String> {
483        self.questions
484            .iter()
485            .filter(|q| q.category == "non-goals" || q.category == "out_of_scope")
486            .filter_map(|q| q.answer.as_ref())
487            .cloned()
488            .collect()
489    }
490
491    // ── Phase 5: Document ────────────────────────────────────────────
492
493    /// Render the design document as Markdown.
494    pub fn render_markdown(&self) -> Result<String> {
495        let doc = self
496            .design
497            .as_ref()
498            .context("Design has not been finalized yet")?;
499
500        Ok(render_design_markdown(doc))
501    }
502
503    /// Write the design document to a Markdown file.
504    ///
505    /// Defaults to `docs/design/YYYY-MM-DD-<slugified-title>.md` under
506    /// the project root, but an explicit path can be provided.
507    pub fn write_document(&self, path: Option<&Path>) -> Result<PathBuf> {
508        let doc = self
509            .design
510            .as_ref()
511            .context("Design has not been finalized yet")?;
512
513        let output_path = match path {
514            Some(p) => p.to_path_buf(),
515            None => {
516                let root = self
517                    .project_root
518                    .as_deref()
519                    .context("No project root set and no explicit path provided")?;
520                let date = &doc.created_at[..10]; // YYYY-MM-DD
521                let slug = slugify(&doc.title);
522                let design_dir = root.join("docs").join("design");
523                std::fs::create_dir_all(&design_dir).with_context(|| {
524                    format!("Failed to create {}", design_dir.display())
525                })?;
526                design_dir.join(format!("{date}-{slug}.md"))
527            }
528        };
529
530        let markdown = render_design_markdown(doc);
531
532        // Ensure parent directory exists.
533        if let Some(parent) = output_path.parent() {
534            std::fs::create_dir_all(parent).with_context(|| {
535                format!("Failed to create {}", parent.display())
536            })?;
537        }
538
539        std::fs::write(&output_path, &markdown).with_context(|| {
540            format!("Failed to write design document to {}", output_path.display())
541        })?;
542
543        Ok(output_path)
544    }
545}
546
547// ── Markdown rendering ─────────────────────────────────────────────────
548
549/// Render a [`DesignDocument`] into a well-structured Markdown string.
550fn render_design_markdown(doc: &DesignDocument) -> String {
551    let mut md = String::with_capacity(4096);
552
553    // Title
554    md.push_str(&format!("# {}\n\n", doc.title));
555
556    // Metadata
557    md.push_str(&format!("> Created: {}\n", doc.created_at));
558    if let Some(ref author) = doc.author {
559        md.push_str(&format!("> Author: {}\n", author));
560    }
561    md.push('\n');
562
563    // Summary
564    md.push_str("## Summary\n\n");
565    md.push_str(&doc.summary);
566    md.push_str("\n\n");
567
568    // Problem statement
569    md.push_str("## Problem Statement\n\n");
570    md.push_str(&doc.problem_statement);
571    md.push_str("\n\n");
572
573    // Goals
574    if !doc.goals.is_empty() {
575        md.push_str("## Goals\n\n");
576        for goal in &doc.goals {
577            md.push_str(&format!("- {}\n", goal));
578        }
579        md.push('\n');
580    }
581
582    // Non-goals
583    if !doc.non_goals.is_empty() {
584        md.push_str("## Non-Goals\n\n");
585        for ng in &doc.non_goals {
586            md.push_str(&format!("- {}\n", ng));
587        }
588        md.push('\n');
589    }
590
591    // Project context
592    if let Some(ref ctx) = doc.project_context {
593        md.push_str("## Project Context\n\n");
594        md.push_str(ctx);
595        md.push_str("\n\n");
596    }
597
598    // Clarifying questions
599    if !doc.questions.is_empty() {
600        md.push_str("## Clarifying Questions\n\n");
601        for (i, q) in doc.questions.iter().enumerate() {
602            md.push_str(&format!("### Q{}: {}\n\n", i + 1, q.question));
603            md.push_str(&format!("**Rationale:** {}\n\n", q.rationale));
604            if let Some(ref answer) = q.answer {
605                md.push_str(&format!("**Answer:** {}\n\n", answer));
606            } else {
607                md.push_str("**Answer:** *(unanswered)*\n\n");
608            }
609        }
610    }
611
612    // Approaches
613    md.push_str("## Approaches Considered\n\n");
614    for (i, approach) in doc.approaches.iter().enumerate() {
615        let chosen_marker = if i == doc.chosen_approach {
616            " **(chosen)**"
617        } else {
618            ""
619        };
620        md.push_str(&format!("### {}{}\n\n", approach.name, chosen_marker));
621        md.push_str(&format!("{}\n\n", approach.summary));
622        md.push_str(&format!("{}\n\n", approach.description));
623
624        md.push_str("**Pros:**\n\n");
625        for pro in &approach.pros {
626            md.push_str(&format!("+ {}\n", pro));
627        }
628        md.push('\n');
629
630        md.push_str("**Cons:**\n\n");
631        for con in &approach.cons {
632            md.push_str(&format!("- {}\n", con));
633        }
634        md.push('\n');
635
636        md.push_str(&format!(
637            "**Complexity:** {}",
638            approach.complexity
639        ));
640        if let Some(ref effort) = approach.estimated_effort {
641            md.push_str(&format!(" | **Effort:** {}", effort));
642        }
643        md.push_str("\n\n");
644    }
645
646    // Choice rationale
647    md.push_str("## Choice Rationale\n\n");
648    md.push_str(&doc.choice_rationale);
649    md.push_str("\n\n");
650
651    // Architecture
652    md.push_str("## Architecture\n\n");
653    md.push_str(&doc.architecture);
654    md.push_str("\n\n");
655
656    // Components
657    if !doc.components.is_empty() {
658        md.push_str("## Components\n\n");
659        for component in &doc.components {
660            md.push_str(&format!("### {}\n\n", component.name));
661            md.push_str(&format!("{}\n\n", component.responsibility));
662
663            if !component.interfaces.is_empty() {
664                md.push_str("**Interfaces:**\n\n");
665                for iface in &component.interfaces {
666                    md.push_str(&format!("- {}\n", iface));
667                }
668                md.push('\n');
669            }
670
671            if !component.depends_on.is_empty() {
672                md.push_str(&format!(
673                    "**Depends on:** {}\n\n",
674                    component.depends_on.join(", ")
675                ));
676            }
677        }
678    }
679
680    // Data flow
681    md.push_str("## Data Flow\n\n");
682    md.push_str(&doc.data_flow);
683    md.push_str("\n\n");
684
685    // File plan
686    if !doc.file_plan.is_empty() {
687        md.push_str("## File Plan\n\n");
688        md.push_str("| Action | Path | Description |\n");
689        md.push_str("|--------|------|-------------|\n");
690        for entry in &doc.file_plan {
691            md.push_str(&format!(
692                "| {} | `{}` | {} |\n",
693                entry.action, entry.path, entry.description
694            ));
695        }
696        md.push('\n');
697    }
698
699    // Open risks
700    if !doc.open_risks.is_empty() {
701        md.push_str("## Open Risks\n\n");
702        for risk in &doc.open_risks {
703            md.push_str(&format!("- {}\n", risk));
704        }
705        md.push('\n');
706    }
707
708    md
709}
710
711// ── Helpers ────────────────────────────────────────────────────────────
712
713/// Convert a title into a filesystem-safe slug.
714fn slugify(s: &str) -> String {
715    s.to_lowercase()
716        .chars()
717        .map(|c| {
718            if c.is_ascii_alphanumeric() {
719                c
720            } else if c == ' ' || c == '_' {
721                '-'
722            } else {
723                '\0'
724            }
725        })
726        .filter(|c| *c != '\0')
727        .collect::<String>()
728        .trim_matches('-')
729        .to_string()
730}
731
732// ── Prompt generation ──────────────────────────────────────────────────
733
734/// Generate a system-prompt fragment that instructs the agent to follow the
735/// brainstorming workflow. This is intended to be appended to the base system
736/// prompt when the brainstorming skill is active.
737pub fn brainstorm_skill_prompt() -> String {
738    let prompt = r#"# Brainstorming Skill
739
740You are running the **brainstorming** skill. Your job is to guide the user
741through a structured design exploration, producing a concrete design document.
742
743## Workflow
744
745Follow these phases strictly. Do not skip ahead.
746
747### Phase 1: Context
748
7491. Ask the user for the project root directory (or infer it from the current working directory).
7502. Read key project files (README, manifest, config) to understand the codebase.
7513. Summarize what you found and confirm understanding with the user.
752
753### Phase 2: Clarifying Questions
754
7551. Identify 3–8 critical ambiguities in the user's idea.
7562. For each, ask a targeted question and explain *why* it matters.
7573. Group questions by category (scope, constraints, users, performance, etc.).
7584. Wait for the user to answer all questions before proceeding.
759
760### Phase 3: Approaches
761
7621. Propose 2–3 distinct approaches to solving the problem.
7632. For each approach, provide:
764   - Name and one-line summary
765   - Detailed description
766   - Pros (list)
767   - Cons / risks (list)
768   - Complexity rating (low / medium / high)
769   - Estimated effort
7703. Present a comparison table summarizing the approaches.
7714. Wait for the user to select an approach.
772
773### Phase 4: Design
774
7751. Present a detailed design for the chosen approach, including:
776   - Architecture overview
777   - Component breakdown with responsibilities, interfaces, and dependencies
778   - Data flow description
779   - File plan (which files to create / modify / delete)
780   - Open risks or unresolved questions
7812. Walk through the design and invite feedback.
7823. Iterate until the user approves.
783
784### Phase 5: Document
785
7861. Write the approved design to `docs/design/YYYY-MM-DD-<slug>.md`.
7872. Confirm the file was written successfully.
7883. Suggest next steps (e.g., hand off to the autonomous-loop skill).
789
790## Rules
791
792- Never propose more than 3 approaches. Force prioritization.
793- Every question must have a clear rationale — do not ask lazy questions.
794- The design must be concrete enough to hand off directly to implementation.
795- Prefer simplicity. If two approaches are equally viable, recommend the simpler one.
796- Document all decisions and their reasoning.
797"#;
798    prompt.to_string()
799}
800
801// ── Tests ──────────────────────────────────────────────────────────────
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806
807    fn sample_approach(name: &str) -> Approach {
808        Approach {
809            name: name.to_string(),
810            summary: format!("{} summary", name),
811            description: format!("{} description", name),
812            pros: vec!["fast".to_string()],
813            cons: vec!["risky".to_string()],
814            complexity: Complexity::Medium,
815            estimated_effort: Some("1 week".to_string()),
816        }
817    }
818
819    fn sample_component(name: &str) -> Component {
820        Component {
821            name: name.to_string(),
822            responsibility: format!("{} does things", name),
823            interfaces: vec![format!("{}.run() -> Result", name)],
824            depends_on: vec![],
825        }
826    }
827
828    #[test]
829    fn test_session_new() {
830        let session = BrainstormSession::new("Build a cache layer");
831        assert_eq!(session.phase, Phase::Context);
832        assert_eq!(session.topic, "Build a cache layer");
833        assert!(session.questions.is_empty());
834        assert!(session.approaches.is_empty());
835        assert!(session.design.is_none());
836    }
837
838    #[test]
839    fn test_phase_advance() {
840        let mut session = BrainstormSession::new("test");
841        assert_eq!(session.phase, Phase::Context);
842
843        session.advance().unwrap();
844        assert_eq!(session.phase, Phase::Questions);
845
846        session.advance().unwrap();
847        assert_eq!(session.phase, Phase::Approaches);
848
849        session.advance().unwrap();
850        assert_eq!(session.phase, Phase::Design);
851
852        session.advance().unwrap();
853        assert_eq!(session.phase, Phase::Document);
854
855        session.advance().unwrap();
856        assert_eq!(session.phase, Phase::Done);
857
858        // Cannot advance past Done
859        assert!(session.advance().is_err());
860    }
861
862    #[test]
863    fn test_set_phase() {
864        let mut session = BrainstormSession::new("test");
865        session.set_phase(Phase::Design);
866        assert_eq!(session.phase, Phase::Design);
867    }
868
869    #[test]
870    fn test_add_and_answer_questions() {
871        let mut session = BrainstormSession::new("test");
872
873        session.add_question("What is the scope?", "Defines boundaries", "scope");
874        session.add_question("Any perf requirements?", "Affects approach", "constraints");
875
876        assert_eq!(session.questions.len(), 2);
877        assert!(!session.all_questions_answered());
878        assert_eq!(session.unanswered_questions().len(), 2);
879
880        session.answer_question(0, "API layer only").unwrap();
881        assert!(!session.all_questions_answered());
882        assert_eq!(session.unanswered_questions().len(), 1);
883
884        session.answer_question(1, "< 50ms p99").unwrap();
885        assert!(session.all_questions_answered());
886        assert!(session.unanswered_questions().is_empty());
887    }
888
889    #[test]
890    fn test_answer_invalid_index() {
891        let mut session = BrainstormSession::new("test");
892        session.add_question("Q1?", "R1", "cat");
893        let result = session.answer_question(5, "answer");
894        assert!(result.is_err());
895    }
896
897    #[test]
898    fn test_add_approaches() {
899        let mut session = BrainstormSession::new("test");
900        session.add_approach(sample_approach("A"));
901        session.add_approach(sample_approach("B"));
902        assert_eq!(session.approach_count(), 2);
903    }
904
905    #[test]
906    fn test_finalize_design_invalid_approach() {
907        let mut session = BrainstormSession::new("test");
908        session.add_approach(sample_approach("A"));
909
910        let result = session.finalize_design(
911            5, // out of bounds
912            "reason",
913            "arch",
914            vec![],
915            "flow",
916            vec![],
917            vec![],
918        );
919        assert!(result.is_err());
920    }
921
922    #[test]
923    fn test_finalize_and_render_design() {
924        let mut session = BrainstormSession::new("Build a cache layer");
925        session.add_question("Scope?", "Defines boundaries", "goals");
926        session.answer_question(0, "API layer only").unwrap();
927        session.add_question("Out of scope?", "What to exclude", "non-goals");
928        session.answer_question(1, "CLI tools").unwrap();
929        session.add_approach(sample_approach("In-memory LRU"));
930        session.add_approach(sample_approach("Redis-backed"));
931
932        session
933            .finalize_design(
934                0,
935                "In-memory is simpler and sufficient for single-process use",
936                "Layered architecture: Cache trait -> LRU implementation -> integration point",
937                vec![
938                    sample_component("CacheStore"),
939                    sample_component("CacheConfig"),
940                ],
941                "Request -> CacheStore.get() -> hit: return / miss: compute -> store -> return",
942                vec![
943                    FileEntry {
944                        path: "src/cache.rs".to_string(),
945                        action: FileAction::Create,
946                        description: "Core cache module".to_string(),
947                    },
948                    FileEntry {
949                        path: "src/lib.rs".to_string(),
950                        action: FileAction::Modify,
951                        description: "Add cache module declaration".to_string(),
952                    },
953                ],
954                vec!["Cache invalidation strategy TBD".to_string()],
955            )
956            .unwrap();
957
958        let doc = session.design.as_ref().unwrap();
959        assert_eq!(doc.title, "Build a cache layer");
960        assert_eq!(doc.chosen_approach, 0);
961        assert_eq!(doc.components.len(), 2);
962        assert_eq!(doc.file_plan.len(), 2);
963        assert_eq!(doc.open_risks.len(), 1);
964
965        // Render markdown
966        let md = session.render_markdown().unwrap();
967        assert!(md.contains("# Build a cache layer"));
968        assert!(md.contains("## Approaches Considered"));
969        assert!(md.contains("In-memory LRU"));
970        assert!(md.contains("## Architecture"));
971        assert!(md.contains("## Components"));
972        assert!(md.contains("## Data Flow"));
973        assert!(md.contains("## File Plan"));
974        assert!(md.contains("| create | `src/cache.rs` |"));
975        assert!(md.contains("## Open Risks"));
976        assert!(md.contains("(chosen)"));
977    }
978
979    #[test]
980    fn test_render_markdown_before_finalize() {
981        let session = BrainstormSession::new("test");
982        assert!(session.render_markdown().is_err());
983    }
984
985    #[test]
986    fn test_write_document_without_design() {
987        let session = BrainstormSession::new("test");
988        assert!(session.write_document(None).is_err());
989    }
990
991    #[test]
992    fn test_write_document_to_file() {
993        let tmp = tempfile::tempdir().unwrap();
994        let mut session = BrainstormSession::new("Test Design");
995        session.project_root = Some(tmp.path().to_path_buf());
996        session.add_approach(sample_approach("Simple"));
997        session
998            .finalize_design(
999                0,
1000                "Simplest option",
1001                "Flat architecture",
1002                vec![],
1003                "N/A",
1004                vec![],
1005                vec![],
1006            )
1007            .unwrap();
1008
1009        let path = session.write_document(None).unwrap();
1010        assert!(path.exists());
1011        assert!(path.to_string_lossy().contains("docs/design"));
1012
1013        // Verify content
1014        let content = std::fs::read_to_string(&path).unwrap();
1015        assert!(content.contains("# Test Design"));
1016        assert!(content.contains("Simple"));
1017    }
1018
1019    #[test]
1020    fn test_write_document_explicit_path() {
1021        let tmp = tempfile::tempdir().unwrap();
1022        let mut session = BrainstormSession::new("Test");
1023        session.add_approach(sample_approach("A"));
1024        session
1025            .finalize_design(0, "r", "a", vec![], "f", vec![], vec![])
1026            .unwrap();
1027
1028        let explicit = tmp.path().join("custom-design.md");
1029        let path = session.write_document(Some(&explicit)).unwrap();
1030        assert_eq!(path, explicit);
1031        assert!(path.exists());
1032    }
1033
1034    #[test]
1035    fn test_gather_project_context() {
1036        let tmp = tempfile::tempdir().unwrap();
1037
1038        // Create some project files
1039        std::fs::write(tmp.path().join("README.md"), "# My Project\nA cool project.").unwrap();
1040        std::fs::write(
1041            tmp.path().join("Cargo.toml"),
1042            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
1043        )
1044        .unwrap();
1045
1046        let session = BrainstormSession::new("test").with_project_root(tmp.path());
1047        let context = session.gather_project_context(10000).unwrap();
1048
1049        assert!(context.contains("README.md"));
1050        assert!(context.contains("My Project"));
1051        assert!(context.contains("Cargo.toml"));
1052        assert!(context.contains("Directory Structure"));
1053    }
1054
1055    #[test]
1056    fn test_gather_project_context_no_root() {
1057        let session = BrainstormSession::new("test");
1058        assert!(session.gather_project_context(10000).is_err());
1059    }
1060
1061    #[test]
1062    fn test_gather_project_context_truncation() {
1063        let tmp = tempfile::tempdir().unwrap();
1064        std::fs::write(
1065            tmp.path().join("README.md"),
1066            "x".repeat(5000),
1067        )
1068        .unwrap();
1069
1070        let session = BrainstormSession::new("test").with_project_root(tmp.path());
1071        let context = session.gather_project_context(100).unwrap();
1072
1073        // Should be truncated to fit within budget
1074        assert!(context.len() < 200);
1075    }
1076
1077    #[test]
1078    fn test_slugify() {
1079        assert_eq!(slugify("Hello World"), "hello-world");
1080        assert_eq!(slugify("Build a Cache Layer!"), "build-a-cache-layer");
1081        assert_eq!(slugify("foo_bar baz"), "foo-bar-baz");
1082        assert_eq!(slugify("  spaces  "), "spaces");
1083        assert_eq!(slugify("API v2.0"), "api-v20");
1084    }
1085
1086    #[test]
1087    fn test_phase_display() {
1088        assert_eq!(format!("{}", Phase::Context), "Context");
1089        assert_eq!(format!("{}", Phase::Questions), "Questions");
1090        assert_eq!(format!("{}", Phase::Approaches), "Approaches");
1091        assert_eq!(format!("{}", Phase::Design), "Design");
1092        assert_eq!(format!("{}", Phase::Document), "Document");
1093        assert_eq!(format!("{}", Phase::Done), "Done");
1094    }
1095
1096    #[test]
1097    fn test_complexity_display() {
1098        assert_eq!(format!("{}", Complexity::Low), "low");
1099        assert_eq!(format!("{}", Complexity::Medium), "medium");
1100        assert_eq!(format!("{}", Complexity::High), "high");
1101    }
1102
1103    #[test]
1104    fn test_file_action_display() {
1105        assert_eq!(format!("{}", FileAction::Create), "create");
1106        assert_eq!(format!("{}", FileAction::Modify), "modify");
1107        assert_eq!(format!("{}", FileAction::Delete), "delete");
1108    }
1109
1110    #[test]
1111    fn test_brainstorm_skill_prompt() {
1112        let prompt = brainstorm_skill_prompt();
1113        assert!(prompt.contains("Brainstorming Skill"));
1114        assert!(prompt.contains("Phase 1: Context"));
1115        assert!(prompt.contains("Phase 2: Clarifying Questions"));
1116        assert!(prompt.contains("Phase 3: Approaches"));
1117        assert!(prompt.contains("Phase 4: Design"));
1118        assert!(prompt.contains("Phase 5: Document"));
1119    }
1120
1121    #[test]
1122    fn test_design_document_serialization_roundtrip() {
1123        let doc = DesignDocument {
1124            title: "Test".to_string(),
1125            summary: "A test design".to_string(),
1126            problem_statement: "Need to test serialization".to_string(),
1127            goals: vec!["Goal 1".to_string()],
1128            non_goals: vec!["Non-goal 1".to_string()],
1129            project_context: Some("Context".to_string()),
1130            questions: vec![ClarifyingQuestion {
1131                question: "Q?".to_string(),
1132                rationale: "R".to_string(),
1133                category: "scope".to_string(),
1134                answer: Some("A".to_string()),
1135            }],
1136            approaches: vec![sample_approach("X")],
1137            chosen_approach: 0,
1138            choice_rationale: "Simplest".to_string(),
1139            architecture: "Flat".to_string(),
1140            components: vec![sample_component("C")],
1141            data_flow: "A -> B".to_string(),
1142            file_plan: vec![FileEntry {
1143                path: "a.rs".to_string(),
1144                action: FileAction::Create,
1145                description: "desc".to_string(),
1146            }],
1147            open_risks: vec!["Risk 1".to_string()],
1148            author: Some("test".to_string()),
1149            created_at: "2025-01-01T00:00:00Z".to_string(),
1150        };
1151
1152        let json = serde_json::to_string_pretty(&doc).unwrap();
1153        let parsed: DesignDocument = serde_json::from_str(&json).unwrap();
1154        assert_eq!(parsed.title, doc.title);
1155        assert_eq!(parsed.goals, doc.goals);
1156        assert_eq!(parsed.components.len(), 1);
1157        assert_eq!(parsed.file_plan.len(), 1);
1158    }
1159
1160    #[test]
1161    fn test_session_serialization_roundtrip() {
1162        let mut session = BrainstormSession::new("Test Session");
1163        session.add_question("Q?", "R", "scope");
1164        session.add_approach(sample_approach("A"));
1165        session.set_phase(Phase::Approaches);
1166
1167        let json = serde_json::to_string(&session).unwrap();
1168        let parsed: BrainstormSession = serde_json::from_str(&json).unwrap();
1169        assert_eq!(parsed.topic, session.topic);
1170        assert_eq!(parsed.phase, Phase::Approaches);
1171        assert_eq!(parsed.questions.len(), 1);
1172        assert_eq!(parsed.approaches.len(), 1);
1173    }
1174
1175    #[test]
1176    fn test_render_markdown_with_chosen_marker() {
1177        let mut session = BrainstormSession::new("test");
1178        session.add_approach(sample_approach("Alpha"));
1179        session.add_approach(sample_approach("Beta"));
1180        session.add_approach(sample_approach("Gamma"));
1181        session
1182            .finalize_design(1, "Picked Beta", "arch", vec![], "flow", vec![], vec![])
1183            .unwrap();
1184
1185        let md = session.render_markdown().unwrap();
1186        // Beta heading SHOULD include the chosen marker
1187        assert!(md.contains("### Beta **(chosen)**"));
1188        // Alpha and Gamma headings should NOT include the chosen marker
1189        assert!(!md.contains("### Alpha **(chosen)**"));
1190        assert!(!md.contains("### Gamma **(chosen)**"));
1191    }
1192
1193    #[test]
1194    fn test_empty_design_render() {
1195        let mut session = BrainstormSession::new("Empty Design");
1196        session.add_approach(sample_approach("Only Option"));
1197        session
1198            .finalize_design(0, "Only one option", "Simple", vec![], "N/A", vec![], vec![])
1199            .unwrap();
1200
1201        let md = session.render_markdown().unwrap();
1202        assert!(md.contains("# Empty Design"));
1203        // Should not crash even with empty components, files, risks
1204        assert!(!md.contains("## Components"));
1205        assert!(!md.contains("## File Plan"));
1206        assert!(!md.contains("## Open Risks"));
1207    }
1208
1209    #[test]
1210    fn test_extract_goals_from_questions() {
1211        let mut session = BrainstormSession::new("test");
1212        session.add_question("Goal?", "Why", "goals");
1213        session.add_question("Req?", "Why", "requirements");
1214        session.add_question("Scope?", "Why", "scope");
1215        session.add_question("OOS?", "Why", "non-goals");
1216        session.answer_question(0, "G1").unwrap();
1217        session.answer_question(1, "R1").unwrap();
1218        session.answer_question(2, "S1").unwrap();
1219        session.answer_question(3, "NG1").unwrap();
1220
1221        let goals = session.extract_goals();
1222        assert_eq!(goals, vec!["G1", "R1", "S1"]);
1223
1224        let non_goals = session.extract_non_goals();
1225        assert_eq!(non_goals, vec!["NG1"]);
1226    }
1227}