1use anyhow::{bail, Context, Result};
19use chrono::Utc;
20use serde::{Deserialize, Serialize};
21use std::fmt;
22use std::fs;
23use std::path::{Path, PathBuf};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum PlannerPhase {
31 Gather,
33 Decompose,
35 Batch,
37 Review,
39 Document,
41 Done,
43}
44
45impl Default for PlannerPhase {
46 fn default() -> Self {
47 PlannerPhase::Gather
48 }
49}
50
51impl fmt::Display for PlannerPhase {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 match self {
54 PlannerPhase::Gather => write!(f, "Gather"),
55 PlannerPhase::Decompose => write!(f, "Decompose"),
56 PlannerPhase::Batch => write!(f, "Batch"),
57 PlannerPhase::Review => write!(f, "Review"),
58 PlannerPhase::Document => write!(f, "Document"),
59 PlannerPhase::Done => write!(f, "Done"),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PlanTask {
69 pub id: String,
71 pub title: String,
73 pub description: String,
75 pub touches_files: Vec<String>,
77 pub depends_on: Vec<String>,
79 pub acceptance_criteria: Vec<String>,
81 pub verification: String,
83 pub complexity: TaskComplexity,
85 pub tdd: bool,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub batch: Option<usize>,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum TaskComplexity {
96 Trivial,
98 Simple,
100 Moderate,
102 Complex,
104 Large,
106}
107
108impl fmt::Display for TaskComplexity {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 match self {
111 TaskComplexity::Trivial => write!(f, "trivial"),
112 TaskComplexity::Simple => write!(f, "simple"),
113 TaskComplexity::Moderate => write!(f, "moderate"),
114 TaskComplexity::Complex => write!(f, "complex"),
115 TaskComplexity::Large => write!(f, "large"),
116 }
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct TaskBatch {
123 pub index: usize,
125 pub tasks: Vec<String>,
127 pub has_conflicts: bool,
129 pub strategy: BatchStrategy,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum BatchStrategy {
137 Parallel,
139 Sequential,
141 Chain,
143}
144
145impl fmt::Display for BatchStrategy {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 match self {
148 BatchStrategy::Parallel => write!(f, "parallel"),
149 BatchStrategy::Sequential => write!(f, "sequential"),
150 BatchStrategy::Chain => write!(f, "chain"),
151 }
152 }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct PlanRisk {
160 pub description: String,
162 pub likelihood: RiskLikelihood,
164 pub impact: RiskImpact,
166 pub mitigation: String,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum RiskLikelihood {
174 Low,
175 Medium,
176 High,
177}
178
179impl fmt::Display for RiskLikelihood {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 match self {
182 RiskLikelihood::Low => write!(f, "low"),
183 RiskLikelihood::Medium => write!(f, "medium"),
184 RiskLikelihood::High => write!(f, "high"),
185 }
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum RiskImpact {
193 Low,
194 Medium,
195 High,
196}
197
198impl fmt::Display for RiskImpact {
199 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200 match self {
201 RiskImpact::Low => write!(f, "low"),
202 RiskImpact::Medium => write!(f, "medium"),
203 RiskImpact::High => write!(f, "high"),
204 }
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct PlanContext {
213 pub project_root: String,
215 pub key_files: Vec<(String, String)>,
217 pub conventions: Vec<String>,
219 pub dependencies: Vec<String>,
221 pub summary: String,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct PlanDocument {
228 pub title: String,
230 pub created_at: String,
232 pub version: u32,
234 pub objective: String,
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub context: Option<PlanContext>,
239 pub tasks: Vec<PlanTask>,
241 pub batches: Vec<TaskBatch>,
243 pub risks: Vec<PlanRisk>,
245 pub assumptions: Vec<String>,
247 pub out_of_scope: Vec<String>,
249 pub open_questions: Vec<String>,
251}
252
253impl PlanDocument {
254 pub fn render_markdown(&self) -> String {
256 let mut md = String::with_capacity(4096);
257
258 md.push_str(&format!("# {}\n\n", self.title));
259 md.push_str(&format!("> Created: {} | Version: {}\n\n", self.created_at, self.version));
260
261 md.push_str("## Objective\n\n");
263 md.push_str(&self.objective);
264 md.push_str("\n\n");
265
266 if let Some(ref ctx) = self.context {
268 md.push_str("## Context\n\n");
269 md.push_str(&ctx.summary);
270 md.push_str("\n\n");
271
272 if !ctx.conventions.is_empty() {
273 md.push_str("**Conventions:**\n");
274 for conv in &ctx.conventions {
275 md.push_str(&format!("- {}\n", conv));
276 }
277 md.push('\n');
278 }
279
280 if !ctx.dependencies.is_empty() {
281 md.push_str("**Relevant Dependencies:**\n");
282 for dep in &ctx.dependencies {
283 md.push_str(&format!("- {}\n", dep));
284 }
285 md.push('\n');
286 }
287 }
288
289 if !self.tasks.is_empty() {
291 md.push_str("## Tasks\n\n");
292 for task in &self.tasks {
293 let batch_label = task
294 .batch
295 .map(|b| format!(" [Batch {}]", b))
296 .unwrap_or_default();
297 md.push_str(&format!(
298 "### {}{}: {}\n\n",
299 task.id, batch_label, task.title
300 ));
301 md.push_str(&task.description);
302 md.push_str("\n\n");
303
304 if !task.touches_files.is_empty() {
305 md.push_str("**Files:**\n");
306 for file in &task.touches_files {
307 md.push_str(&format!("- `{}`\n", file));
308 }
309 md.push('\n');
310 }
311
312 if !task.depends_on.is_empty() {
313 md.push_str(&format!("**Depends on:** {}\n\n", task.depends_on.join(", ")));
314 }
315
316 if !task.acceptance_criteria.is_empty() {
317 md.push_str("**Acceptance Criteria:**\n");
318 for (i, criterion) in task.acceptance_criteria.iter().enumerate() {
319 md.push_str(&format!("{}. [ ] {}\n", i + 1, criterion));
320 }
321 md.push('\n');
322 }
323
324 md.push_str(&format!(
325 "**Verification:** {} | **Complexity:** {}{}{}\n\n",
326 task.verification,
327 task.complexity,
328 if task.tdd { " | **TDD**" } else { "" },
329 "",
330 ));
331 }
332 }
333
334 if !self.batches.is_empty() {
336 md.push_str("## Execution Batches\n\n");
337 for batch in &self.batches {
338 md.push_str(&format!(
339 "### Batch {} ({}){}\n\n",
340 batch.index,
341 batch.strategy,
342 if batch.has_conflicts {
343 " ⚠️ has file conflicts"
344 } else {
345 ""
346 },
347 ));
348 for task_id in &batch.tasks {
349 md.push_str(&format!("- {}\n", task_id));
350 }
351 md.push('\n');
352 }
353 }
354
355 if !self.risks.is_empty() {
357 md.push_str("## Risks\n\n");
358 md.push_str("| Risk | Likelihood | Impact | Mitigation |\n");
359 md.push_str("|------|-----------|--------|------------|\n");
360 for risk in &self.risks {
361 md.push_str(&format!(
362 "| {} | {} | {} | {} |\n",
363 risk.description, risk.likelihood, risk.impact, risk.mitigation
364 ));
365 }
366 md.push('\n');
367 }
368
369 if !self.assumptions.is_empty() {
371 md.push_str("## Assumptions\n\n");
372 for assumption in &self.assumptions {
373 md.push_str(&format!("- {}\n", assumption));
374 }
375 md.push('\n');
376 }
377
378 if !self.out_of_scope.is_empty() {
380 md.push_str("## Out of Scope\n\n");
381 for item in &self.out_of_scope {
382 md.push_str(&format!("- {}\n", item));
383 }
384 md.push('\n');
385 }
386
387 if !self.open_questions.is_empty() {
389 md.push_str("## Open Questions\n\n");
390 for question in &self.open_questions {
391 md.push_str(&format!("- [ ] {}\n", question));
392 }
393 md.push('\n');
394 }
395
396 md
397 }
398
399 pub fn write_to_file(&self, dir: &Path) -> Result<PathBuf> {
401 fs::create_dir_all(dir)
402 .with_context(|| format!("Failed to create directory: {}", dir.display()))?;
403
404 let slug = slugify(&self.title);
405 let date = &self.created_at[..10];
406 let filename = format!("{}-{}.md", date, slug);
407 let path = dir.join(&filename);
408
409 let content = self.render_markdown();
410 fs::write(&path, &content)
411 .with_context(|| format!("Failed to write plan to {}", path.display()))?;
412
413 Ok(path)
414 }
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct PlannerSession {
422 pub phase: PlannerPhase,
424 pub title: String,
426 #[serde(skip_serializing_if = "Option::is_none")]
428 pub project_root: Option<PathBuf>,
429 #[serde(skip_serializing_if = "Option::is_none")]
431 pub context: Option<PlanContext>,
432 pub tasks: Vec<PlanTask>,
434 pub batches: Vec<TaskBatch>,
436 pub risks: Vec<PlanRisk>,
438 pub assumptions: Vec<String>,
440 pub out_of_scope: Vec<String>,
442 pub open_questions: Vec<String>,
444 #[serde(skip_serializing_if = "Option::is_none")]
446 pub plan: Option<PlanDocument>,
447}
448
449impl PlannerSession {
450 pub fn new(title: impl Into<String>) -> Self {
452 Self {
453 phase: PlannerPhase::Gather,
454 title: title.into(),
455 project_root: None,
456 context: None,
457 tasks: Vec::new(),
458 batches: Vec::new(),
459 risks: Vec::new(),
460 assumptions: Vec::new(),
461 out_of_scope: Vec::new(),
462 open_questions: Vec::new(),
463 plan: None,
464 }
465 }
466
467 pub fn with_project_root(mut self, root: impl Into<PathBuf>) -> Self {
469 self.project_root = Some(root.into());
470 self
471 }
472
473 pub fn advance(&mut self) -> Result<()> {
475 let next = match self.phase {
476 PlannerPhase::Gather => PlannerPhase::Decompose,
477 PlannerPhase::Decompose => PlannerPhase::Batch,
478 PlannerPhase::Batch => PlannerPhase::Review,
479 PlannerPhase::Review => PlannerPhase::Document,
480 PlannerPhase::Document => PlannerPhase::Done,
481 PlannerPhase::Done => bail!("Cannot advance past Done"),
482 };
483 self.phase = next;
484 Ok(())
485 }
486
487 pub fn set_phase(&mut self, phase: PlannerPhase) {
489 self.phase = phase;
490 }
491
492 pub fn set_context(&mut self, ctx: PlanContext) {
494 self.context = Some(ctx);
495 }
496
497 pub fn add_task(&mut self, task: PlanTask) {
499 self.tasks.push(task);
500 }
501
502 pub fn get_task(&self, id: &str) -> Option<&PlanTask> {
504 self.tasks.iter().find(|t| t.id == id)
505 }
506
507 pub fn get_task_mut(&mut self, id: &str) -> Option<&mut PlanTask> {
509 self.tasks.iter_mut().find(|t| t.id == id)
510 }
511
512 pub fn task_count(&self) -> usize {
514 self.tasks.len()
515 }
516
517 pub fn add_risk(&mut self, risk: PlanRisk) {
519 self.risks.push(risk);
520 }
521
522 pub fn add_assumption(&mut self, assumption: impl Into<String>) {
524 self.assumptions.push(assumption.into());
525 }
526
527 pub fn add_out_of_scope(&mut self, item: impl Into<String>) {
529 self.out_of_scope.push(item.into());
530 }
531
532 pub fn add_open_question(&mut self, question: impl Into<String>) {
534 self.open_questions.push(question.into());
535 }
536
537 pub fn build_batches(&mut self) -> Result<()> {
543 if self.tasks.is_empty() {
544 bail!("No tasks to batch");
545 }
546
547 let mut batches: Vec<TaskBatch> = Vec::new();
548 let mut assigned: std::collections::HashSet<String> = std::collections::HashSet::new();
549
550 loop {
552 let mut ready: Vec<String> = Vec::new();
553
554 for task in &self.tasks {
555 if assigned.contains(&task.id) {
556 continue;
557 }
558 let deps_met = task
560 .depends_on
561 .iter()
562 .all(|dep| assigned.contains(dep));
563 if deps_met {
564 ready.push(task.id.clone());
565 }
566 }
567
568 if ready.is_empty() {
569 break;
570 }
571
572 let batch_index = batches.len();
573
574 let mut file_owners: std::collections::HashMap<String, String> =
576 std::collections::HashMap::new();
577 let mut has_conflicts = false;
578
579 for task_id in &ready {
580 if let Some(task) = self.get_task(task_id) {
581 for file in &task.touches_files {
582 if let Some(existing) = file_owners.get(file) {
583 tracing::debug!(
584 "File conflict: {} touched by {} and {}",
585 file,
586 existing,
587 task_id
588 );
589 has_conflicts = true;
590 } else {
591 file_owners.insert(file.clone(), task_id.clone());
592 }
593 }
594 }
595 }
596
597 let strategy = if has_conflicts {
598 BatchStrategy::Sequential
599 } else if batch_index == 0 {
600 BatchStrategy::Parallel
601 } else {
602 BatchStrategy::Parallel
603 };
604
605 for task_id in &ready {
607 assigned.insert(task_id.clone());
608 if let Some(task) = self.get_task_mut(task_id) {
609 task.batch = Some(batch_index);
610 }
611 }
612
613 batches.push(TaskBatch {
614 index: batch_index,
615 tasks: ready,
616 has_conflicts,
617 strategy,
618 });
619 }
620
621 let unassigned: Vec<&str> = self
623 .tasks
624 .iter()
625 .filter(|t| !assigned.contains(&t.id))
626 .map(|t| t.id.as_str())
627 .collect();
628
629 if !unassigned.is_empty() {
630 bail!(
631 "Circular dependency detected — these tasks could not be batched: {}",
632 unassigned.join(", ")
633 );
634 }
635
636 self.batches = batches;
637 Ok(())
638 }
639
640 pub fn validate(&self) -> Vec<ValidationIssue> {
642 let mut issues = Vec::new();
643 let task_ids: std::collections::HashSet<&str> =
644 self.tasks.iter().map(|t| t.id.as_str()).collect();
645
646 for task in &self.tasks {
647 for dep in &task.depends_on {
649 if !task_ids.contains(dep.as_str()) {
650 issues.push(ValidationIssue {
651 severity: ValidationSeverity::Error,
652 task_id: Some(task.id.clone()),
653 message: format!(
654 "Task {} depends on non-existent task '{}'",
655 task.id, dep
656 ),
657 });
658 }
659 }
660
661 if task.depends_on.contains(&task.id) {
663 issues.push(ValidationIssue {
664 severity: ValidationSeverity::Error,
665 task_id: Some(task.id.clone()),
666 message: format!("Task {} depends on itself", task.id),
667 });
668 }
669
670 if task.acceptance_criteria.is_empty() {
672 issues.push(ValidationIssue {
673 severity: ValidationSeverity::Warning,
674 task_id: Some(task.id.clone()),
675 message: format!("Task {} has no acceptance criteria", task.id),
676 });
677 }
678
679 if task.verification.is_empty() {
681 issues.push(ValidationIssue {
682 severity: ValidationSeverity::Warning,
683 task_id: Some(task.id.clone()),
684 message: format!("Task {} has no verification method", task.id),
685 });
686 }
687
688 if task.touches_files.is_empty() {
690 issues.push(ValidationIssue {
691 severity: ValidationSeverity::Warning,
692 task_id: Some(task.id.clone()),
693 message: format!("Task {} has no files specified", task.id),
694 });
695 }
696 }
697
698 issues
699 }
700
701 pub fn finalize(&mut self) -> Result<()> {
703 let doc = PlanDocument {
704 title: self.title.clone(),
705 created_at: Utc::now().to_rfc3339(),
706 version: 1,
707 objective: self.title.clone(),
708 context: self.context.clone(),
709 tasks: self.tasks.clone(),
710 batches: self.batches.clone(),
711 risks: self.risks.clone(),
712 assumptions: self.assumptions.clone(),
713 out_of_scope: self.out_of_scope.clone(),
714 open_questions: self.open_questions.clone(),
715 };
716 self.plan = Some(doc);
717 Ok(())
718 }
719
720 pub fn write_plan(&self, explicit_path: Option<&Path>) -> Result<PathBuf> {
722 let doc = self
723 .plan
724 .as_ref()
725 .context("Plan has not been finalized — call finalize() first")?;
726
727 if let Some(path) = explicit_path {
728 if let Some(parent) = path.parent() {
729 fs::create_dir_all(parent)
730 .with_context(|| format!("Failed to create {}", parent.display()))?;
731 }
732 let content = doc.render_markdown();
733 fs::write(path, &content)
734 .with_context(|| format!("Failed to write plan to {}", path.display()))?;
735 Ok(path.to_path_buf())
736 } else {
737 let root = self
738 .project_root
739 .as_deref()
740 .context("No project root set and no explicit path provided")?;
741 let plan_dir = root.join("docs").join("plan");
742 doc.write_to_file(&plan_dir)
743 }
744 }
745}
746
747#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
751#[serde(rename_all = "snake_case")]
752pub enum ValidationSeverity {
753 Error,
755 Warning,
757}
758
759impl fmt::Display for ValidationSeverity {
760 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
761 match self {
762 ValidationSeverity::Error => write!(f, "error"),
763 ValidationSeverity::Warning => write!(f, "warning"),
764 }
765 }
766}
767
768#[derive(Debug, Clone, Serialize, Deserialize)]
770pub struct ValidationIssue {
771 pub severity: ValidationSeverity,
773 pub task_id: Option<String>,
775 pub message: String,
777}
778
779pub struct PlannerSkill;
783
784impl PlannerSkill {
785 pub fn new() -> Self {
787 Self
788 }
789
790 pub fn skill_prompt() -> String {
792 r#"# Planner Skill
793
794You are running the **planner** skill. Your job is to produce a structured
795implementation plan from requirements or a design document.
796
797## Workflow
798
799### Phase 1: Gather Context
800
8011. Read the project structure (directory tree, key config files).
8022. Read any existing specs, designs, or requirements documents.
8033. Identify conventions (coding style, testing patterns, module layout).
8044. Summarize your understanding and confirm with the user.
805
806### Phase 2: Decompose into Tasks
807
8081. Break the objective into vertical slices — each delivers a working, testable increment.
8092. For each task, define:
810 - **ID** (T1, T2, ...)
811 - **Title** — one-line description
812 - **Description** — detailed approach and key changes
813 - **Files** — exact files to create or modify
814 - **Depends on** — task IDs this depends on
815 - **Acceptance criteria** — concrete, testable conditions
816 - **Verification** — how to confirm it works (test command, build, manual check)
817 - **Complexity** — trivial / simple / moderate / complex / large
818 - **TDD** — whether to write tests first
819
8203. Rules:
821 - Every task must have acceptance criteria and a verification method.
822 - No task should exceed ~5 files.
823 - No vague tasks — each must have a clear approach.
824 - Mark logic tasks (parsers, algorithms, data transforms) for TDD.
825
826### Phase 3: Build Batches
827
8281. Group tasks into execution batches based on dependencies:
829 - Batch 1: tasks with no dependencies (max parallelism)
830 - Batch N: tasks whose dependencies are all in earlier batches
8312. Detect file conflicts between tasks in the same batch.
8323. Mark conflicting batches as sequential; non-conflicting as parallel.
8334. Present the batch plan for review.
834
835### Phase 4: Review
836
8371. Validate the plan:
838 - All dependencies exist
839 - No circular dependencies
840 - Every task has acceptance criteria and verification
841 - Every requirement is covered by at least one task
8422. Identify risks and mitigations
8433. List assumptions and open questions
8444. Iterate until the user approves
845
846### Phase 5: Document
847
8481. Write the plan to `docs/plan/YYYY-MM-DD-<slug>.md`.
8492. Confirm the file was written.
8503. The plan is now ready for hand-off to implementation.
851
852## Rules
853
854- Simplicity first: fewer tasks is better than more.
855- Every task must be independently verifiable.
856- Dependencies must form a DAG (no cycles).
857- If a task is too complex to describe in a paragraph, split it.
858- Prefer vertical slices over horizontal layers.
859"#
860 .to_string()
861 }
862}
863
864impl Default for PlannerSkill {
865 fn default() -> Self {
866 Self::new()
867 }
868}
869
870impl fmt::Debug for PlannerSkill {
871 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
872 f.debug_struct("PlannerSkill").finish()
873 }
874}
875
876fn slugify(s: &str) -> String {
880 s.to_lowercase()
881 .chars()
882 .map(|c| {
883 if c.is_ascii_alphanumeric() {
884 c
885 } else if c == ' ' || c == '_' || c == '-' {
886 '-'
887 } else {
888 '\0'
889 }
890 })
891 .filter(|c| *c != '\0')
892 .collect::<String>()
893 .trim_matches('-')
894 .to_string()
895}
896
897#[cfg(test)]
900mod tests {
901 use super::*;
902 use std::fs;
903
904 fn sample_task(id: &str, title: &str, depends_on: Vec<&str>) -> PlanTask {
905 PlanTask {
906 id: id.to_string(),
907 title: title.to_string(),
908 description: format!("Implement {}", title),
909 touches_files: vec![format!("src/{}.rs", id.to_lowercase())],
910 depends_on: depends_on.into_iter().map(|s| s.to_string()).collect(),
911 acceptance_criteria: vec![format!("{} works correctly", title)],
912 verification: format!("cargo test {}", id.to_lowercase()),
913 complexity: TaskComplexity::Moderate,
914 tdd: false,
915 batch: None,
916 }
917 }
918
919 #[test]
920 fn test_session_new() {
921 let session = PlannerSession::new("Build auth system");
922 assert_eq!(session.phase, PlannerPhase::Gather);
923 assert_eq!(session.title, "Build auth system");
924 assert!(session.tasks.is_empty());
925 assert!(session.batches.is_empty());
926 }
927
928 #[test]
929 fn test_phase_advance() {
930 let mut session = PlannerSession::new("test");
931 assert_eq!(session.phase, PlannerPhase::Gather);
932
933 session.advance().unwrap();
934 assert_eq!(session.phase, PlannerPhase::Decompose);
935
936 session.advance().unwrap();
937 assert_eq!(session.phase, PlannerPhase::Batch);
938
939 session.advance().unwrap();
940 assert_eq!(session.phase, PlannerPhase::Review);
941
942 session.advance().unwrap();
943 assert_eq!(session.phase, PlannerPhase::Document);
944
945 session.advance().unwrap();
946 assert_eq!(session.phase, PlannerPhase::Done);
947
948 assert!(session.advance().is_err());
949 }
950
951 #[test]
952 fn test_set_phase() {
953 let mut session = PlannerSession::new("test");
954 session.set_phase(PlannerPhase::Review);
955 assert_eq!(session.phase, PlannerPhase::Review);
956 }
957
958 #[test]
959 fn test_add_and_get_tasks() {
960 let mut session = PlannerSession::new("test");
961 session.add_task(sample_task("T1", "Core module", vec![]));
962 session.add_task(sample_task("T2", "API layer", vec!["T1"]));
963
964 assert_eq!(session.task_count(), 2);
965 assert_eq!(session.get_task("T1").unwrap().title, "Core module");
966 assert_eq!(
967 session.get_task("T2").unwrap().depends_on,
968 vec!["T1".to_string()]
969 );
970 assert!(session.get_task("T99").is_none());
971 }
972
973 #[test]
974 fn test_risks_and_assumptions() {
975 let mut session = PlannerSession::new("test");
976 session.add_risk(PlanRisk {
977 description: "Third-party API may change".to_string(),
978 likelihood: RiskLikelihood::Medium,
979 impact: RiskImpact::High,
980 mitigation: "Abstract behind interface".to_string(),
981 });
982 session.add_assumption("Node.js >= 18");
983 session.add_out_of_scope("Mobile app");
984 session.add_open_question("Which DB to use?");
985
986 assert_eq!(session.risks.len(), 1);
987 assert_eq!(session.assumptions, vec!["Node.js >= 18"]);
988 assert_eq!(session.out_of_scope, vec!["Mobile app"]);
989 assert_eq!(session.open_questions, vec!["Which DB to use?"]);
990 }
991
992 #[test]
993 fn test_build_batches_linear() {
994 let mut session = PlannerSession::new("test");
995 session.add_task(sample_task("T1", "Base", vec![]));
996 session.add_task(sample_task("T2", "Mid", vec!["T1"]));
997 session.add_task(sample_task("T3", "Top", vec!["T2"]));
998
999 session.build_batches().unwrap();
1000
1001 assert_eq!(session.batches.len(), 3);
1002 assert_eq!(session.batches[0].tasks, vec!["T1"]);
1003 assert_eq!(session.batches[1].tasks, vec!["T2"]);
1004 assert_eq!(session.batches[2].tasks, vec!["T3"]);
1005 }
1006
1007 #[test]
1008 fn test_build_batches_parallel() {
1009 let mut session = PlannerSession::new("test");
1010 session.add_task(sample_task("T1", "A", vec![]));
1011 session.add_task(sample_task("T2", "B", vec![]));
1012 session.add_task(sample_task("T3", "C", vec!["T1", "T2"]));
1013
1014 session.build_batches().unwrap();
1015
1016 assert_eq!(session.batches.len(), 2);
1017 assert_eq!(session.batches[0].tasks.len(), 2);
1019 assert!(session.batches[0].tasks.contains(&"T1".to_string()));
1020 assert!(session.batches[0].tasks.contains(&"T2".to_string()));
1021 assert_eq!(session.batches[0].strategy, BatchStrategy::Parallel);
1022 assert_eq!(session.batches[1].tasks, vec!["T3"]);
1024 }
1025
1026 #[test]
1027 fn test_build_batches_file_conflicts() {
1028 let mut session = PlannerSession::new("test");
1029 let mut t1 = sample_task("T1", "A", vec![]);
1030 let mut t2 = sample_task("T2", "B", vec![]);
1031 t1.touches_files = vec!["src/lib.rs".to_string()];
1033 t2.touches_files = vec!["src/lib.rs".to_string()];
1034
1035 session.add_task(t1);
1036 session.add_task(t2);
1037
1038 session.build_batches().unwrap();
1039
1040 assert_eq!(session.batches.len(), 1);
1041 assert!(session.batches[0].has_conflicts);
1042 assert_eq!(session.batches[0].strategy, BatchStrategy::Sequential);
1043 }
1044
1045 #[test]
1046 fn test_build_batches_circular_dependency() {
1047 let mut session = PlannerSession::new("test");
1048 session.add_task(sample_task("T1", "A", vec!["T2"]));
1049 session.add_task(sample_task("T2", "B", vec!["T1"]));
1050
1051 let result = session.build_batches();
1052 assert!(result.is_err());
1053 assert!(result.unwrap_err().to_string().contains("Circular"));
1054 }
1055
1056 #[test]
1057 fn test_build_batches_empty_tasks() {
1058 let mut session = PlannerSession::new("test");
1059 assert!(session.build_batches().is_err());
1060 }
1061
1062 #[test]
1063 fn test_validate_clean() {
1064 let mut session = PlannerSession::new("test");
1065 session.add_task(sample_task("T1", "Good task", vec![]));
1066
1067 let issues = session.validate();
1068 assert!(issues.is_empty());
1069 }
1070
1071 #[test]
1072 fn test_validate_missing_dependency() {
1073 let mut session = PlannerSession::new("test");
1074 session.add_task(sample_task("T1", "Task", vec!["NONEXISTENT"]));
1075
1076 let issues = session.validate();
1077 assert!(issues.iter().any(|i| i.severity == ValidationSeverity::Error
1078 && i.message.contains("non-existent")));
1079 }
1080
1081 #[test]
1082 fn test_validate_self_dependency() {
1083 let mut session = PlannerSession::new("test");
1084 session.add_task(sample_task("T1", "Task", vec!["T1"]));
1085
1086 let issues = session.validate();
1087 assert!(issues.iter().any(|i| i.message.contains("depends on itself")));
1088 }
1089
1090 #[test]
1091 fn test_validate_warnings() {
1092 let mut session = PlannerSession::new("test");
1093 session.add_task(PlanTask {
1094 id: "T1".to_string(),
1095 title: "Vague".to_string(),
1096 description: "Do something".to_string(),
1097 touches_files: vec![],
1098 depends_on: vec![],
1099 acceptance_criteria: vec![],
1100 verification: String::new(),
1101 complexity: TaskComplexity::Simple,
1102 tdd: false,
1103 batch: None,
1104 });
1105
1106 let issues = session.validate();
1107 assert!(issues.iter().any(|i| i.message.contains("no acceptance criteria")));
1108 assert!(issues.iter().any(|i| i.message.contains("no verification method")));
1109 assert!(issues.iter().any(|i| i.message.contains("no files specified")));
1110 }
1111
1112 #[test]
1113 fn test_finalize_and_write() {
1114 let tmp = tempfile::tempdir().unwrap();
1115 let mut session = PlannerSession::new("Auth System Plan")
1116 .with_project_root(tmp.path());
1117 session.add_task(sample_task("T1", "Core auth", vec![]));
1118 session.build_batches().unwrap();
1119 session.finalize().unwrap();
1120
1121 let path = session.write_plan(None).unwrap();
1122 assert!(path.exists());
1123 assert!(path.to_string_lossy().contains("docs/plan"));
1124
1125 let content = fs::read_to_string(&path).unwrap();
1126 assert!(content.contains("# Auth System Plan"));
1127 assert!(content.contains("## Tasks"));
1128 assert!(content.contains("T1"));
1129 }
1130
1131 #[test]
1132 fn test_write_plan_explicit_path() {
1133 let tmp = tempfile::tempdir().unwrap();
1134 let mut session = PlannerSession::new("test");
1135 session.add_task(sample_task("T1", "Task", vec![]));
1136 session.build_batches().unwrap();
1137 session.finalize().unwrap();
1138
1139 let explicit = tmp.path().join("custom-plan.md");
1140 let path = session.write_plan(Some(&explicit)).unwrap();
1141 assert_eq!(path, explicit);
1142 assert!(path.exists());
1143 }
1144
1145 #[test]
1146 fn test_write_plan_not_finalized() {
1147 let session = PlannerSession::new("test");
1148 assert!(session.write_plan(None).is_err());
1149 }
1150
1151 #[test]
1152 fn test_render_markdown() {
1153 let mut session = PlannerSession::new("Test Plan");
1154 session.add_assumption("Rust stable");
1155 session.add_out_of_scope("Benchmarking");
1156 session.add_open_question("DB choice?");
1157 session.add_risk(PlanRisk {
1158 description: "API unstable".to_string(),
1159 likelihood: RiskLikelihood::Medium,
1160 impact: RiskImpact::High,
1161 mitigation: "Pin version".to_string(),
1162 });
1163 session.add_task(PlanTask {
1164 id: "T1".to_string(),
1165 title: "Setup project".to_string(),
1166 description: "Initialize the project structure".to_string(),
1167 touches_files: vec!["Cargo.toml".to_string(), "src/lib.rs".to_string()],
1168 depends_on: vec![],
1169 acceptance_criteria: vec!["Project compiles".to_string()],
1170 verification: "cargo build".to_string(),
1171 complexity: TaskComplexity::Trivial,
1172 tdd: false,
1173 batch: Some(0),
1174 });
1175 session.finalize().unwrap();
1176
1177 let md = session.plan.unwrap().render_markdown();
1178 assert!(md.contains("# Test Plan"));
1179 assert!(md.contains("## Objective"));
1180 assert!(md.contains("## Tasks"));
1181 assert!(md.contains("### T1 [Batch 0]: Setup project"));
1182 assert!(md.contains("`Cargo.toml`"));
1183 assert!(md.contains("## Risks"));
1184 assert!(md.contains("API unstable"));
1185 assert!(md.contains("## Assumptions"));
1186 assert!(md.contains("Rust stable"));
1187 assert!(md.contains("## Out of Scope"));
1188 assert!(md.contains("Benchmarking"));
1189 assert!(md.contains("## Open Questions"));
1190 assert!(md.contains("DB choice?"));
1191 }
1192
1193 #[test]
1194 fn test_session_serialization_roundtrip() {
1195 let mut session = PlannerSession::new("Test");
1196 session.add_task(sample_task("T1", "Task", vec![]));
1197 session.set_phase(PlannerPhase::Batch);
1198
1199 let json = serde_json::to_string(&session).unwrap();
1200 let parsed: PlannerSession = serde_json::from_str(&json).unwrap();
1201 assert_eq!(parsed.title, "Test");
1202 assert_eq!(parsed.phase, PlannerPhase::Batch);
1203 assert_eq!(parsed.tasks.len(), 1);
1204 }
1205
1206 #[test]
1207 fn test_task_complexity_display() {
1208 assert_eq!(format!("{}", TaskComplexity::Trivial), "trivial");
1209 assert_eq!(format!("{}", TaskComplexity::Simple), "simple");
1210 assert_eq!(format!("{}", TaskComplexity::Moderate), "moderate");
1211 assert_eq!(format!("{}", TaskComplexity::Complex), "complex");
1212 assert_eq!(format!("{}", TaskComplexity::Large), "large");
1213 }
1214
1215 #[test]
1216 fn test_batch_strategy_display() {
1217 assert_eq!(format!("{}", BatchStrategy::Parallel), "parallel");
1218 assert_eq!(format!("{}", BatchStrategy::Sequential), "sequential");
1219 assert_eq!(format!("{}", BatchStrategy::Chain), "chain");
1220 }
1221
1222 #[test]
1223 fn test_phase_display() {
1224 assert_eq!(format!("{}", PlannerPhase::Gather), "Gather");
1225 assert_eq!(format!("{}", PlannerPhase::Decompose), "Decompose");
1226 assert_eq!(format!("{}", PlannerPhase::Batch), "Batch");
1227 assert_eq!(format!("{}", PlannerPhase::Review), "Review");
1228 assert_eq!(format!("{}", PlannerPhase::Document), "Document");
1229 assert_eq!(format!("{}", PlannerPhase::Done), "Done");
1230 }
1231
1232 #[test]
1233 fn test_risk_likelihood_display() {
1234 assert_eq!(format!("{}", RiskLikelihood::Low), "low");
1235 assert_eq!(format!("{}", RiskLikelihood::Medium), "medium");
1236 assert_eq!(format!("{}", RiskLikelihood::High), "high");
1237 }
1238
1239 #[test]
1240 fn test_risk_impact_display() {
1241 assert_eq!(format!("{}", RiskImpact::Low), "low");
1242 assert_eq!(format!("{}", RiskImpact::Medium), "medium");
1243 assert_eq!(format!("{}", RiskImpact::High), "high");
1244 }
1245
1246 #[test]
1247 fn test_skill_prompt_not_empty() {
1248 let prompt = PlannerSkill::skill_prompt();
1249 assert!(prompt.contains("Planner Skill"));
1250 assert!(prompt.contains("Phase 1: Gather"));
1251 assert!(prompt.contains("Phase 5: Document"));
1252 }
1253
1254 #[test]
1255 fn test_slugify() {
1256 assert_eq!(slugify("Build Auth System"), "build-auth-system");
1257 assert_eq!(slugify("hello_world"), "hello-world");
1258 assert_eq!(slugify(" spaces "), "spaces");
1259 }
1260
1261 #[test]
1262 fn test_validation_issue_severity_display() {
1263 assert_eq!(format!("{}", ValidationSeverity::Error), "error");
1264 assert_eq!(format!("{}", ValidationSeverity::Warning), "warning");
1265 }
1266}