1use anyhow::{bail, Context, Result};
20use chrono::Utc;
21use serde::{Deserialize, Serialize};
22use std::fmt;
23use std::path::{Path, PathBuf};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Phase {
31 Context,
33 Questions,
35 Approaches,
37 Design,
39 Document,
41 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#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ClarifyingQuestion {
67 pub question: String,
69 pub rationale: String,
71 pub category: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub answer: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Approach {
81 pub name: String,
83 pub summary: String,
85 pub description: String,
87 pub pros: Vec<String>,
89 pub cons: Vec<String>,
91 pub complexity: Complexity,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub estimated_effort: Option<String>,
96}
97
98#[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#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Component {
120 pub name: String,
122 pub responsibility: String,
124 #[serde(default)]
126 pub interfaces: Vec<String>,
127 #[serde(default)]
129 pub depends_on: Vec<String>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct DesignDocument {
135 pub title: String,
137 pub summary: String,
139 pub problem_statement: String,
141 pub goals: Vec<String>,
143 #[serde(default)]
145 pub non_goals: Vec<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub project_context: Option<String>,
149 #[serde(default)]
151 pub questions: Vec<ClarifyingQuestion>,
152 pub approaches: Vec<Approach>,
154 pub chosen_approach: usize,
156 pub choice_rationale: String,
158 pub architecture: String,
160 pub components: Vec<Component>,
162 pub data_flow: String,
164 #[serde(default)]
166 pub file_plan: Vec<FileEntry>,
167 #[serde(default)]
169 pub open_risks: Vec<String>,
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub author: Option<String>,
173 pub created_at: String,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct FileEntry {
180 pub path: String,
182 pub action: FileAction,
184 pub description: String,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum FileAction {
192 Create,
194 Modify,
196 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#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct BrainstormSession {
213 pub id: String,
215 pub phase: Phase,
217 pub topic: String,
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub project_root: Option<PathBuf>,
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub project_context: Option<String>,
225 #[serde(default)]
227 pub questions: Vec<ClarifyingQuestion>,
228 #[serde(default)]
230 pub approaches: Vec<Approach>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub design: Option<DesignDocument>,
234}
235
236impl BrainstormSession {
239 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 pub fn with_project_root(mut self, root: impl Into<PathBuf>) -> Self {
255 self.project_root = Some(root.into());
256 self
257 }
258
259 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 pub fn set_phase(&mut self, phase: Phase) {
277 self.phase = phase;
278 }
279
280 pub fn set_project_context(&mut self, context: impl Into<String>) {
284 self.project_context = Some(context.into());
285 }
286
287 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 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 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 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 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 pub fn all_questions_answered(&self) -> bool {
394 self.questions.iter().all(|q| q.answer.is_some())
395 }
396
397 pub fn unanswered_questions(&self) -> Vec<&ClarifyingQuestion> {
399 self.questions.iter().filter(|q| q.answer.is_none()).collect()
400 }
401
402 pub fn add_approach(&mut self, approach: Approach) {
406 self.approaches.push(approach);
407 }
408
409 pub fn approach_count(&self) -> usize {
411 self.approaches.len()
412 }
413
414 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 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 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 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 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]; 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 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
547fn render_design_markdown(doc: &DesignDocument) -> String {
551 let mut md = String::with_capacity(4096);
552
553 md.push_str(&format!("# {}\n\n", doc.title));
555
556 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 md.push_str("## Summary\n\n");
565 md.push_str(&doc.summary);
566 md.push_str("\n\n");
567
568 md.push_str("## Problem Statement\n\n");
570 md.push_str(&doc.problem_statement);
571 md.push_str("\n\n");
572
573 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 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 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 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 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 md.push_str("## Choice Rationale\n\n");
648 md.push_str(&doc.choice_rationale);
649 md.push_str("\n\n");
650
651 md.push_str("## Architecture\n\n");
653 md.push_str(&doc.architecture);
654 md.push_str("\n\n");
655
656 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 md.push_str("## Data Flow\n\n");
682 md.push_str(&doc.data_flow);
683 md.push_str("\n\n");
684
685 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 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
711fn 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
732pub 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#[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 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, "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 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 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 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 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 assert!(md.contains("### Beta **(chosen)**"));
1188 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 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}