ricecoder_specs/
workflow.rs

1//! Spec-driven development workflow orchestration
2
3use crate::error::SpecError;
4use crate::models::{Spec, Task, TaskStatus};
5use std::collections::{HashMap, HashSet};
6
7/// Orchestrates spec-driven development workflows
8///
9/// Manages the relationship between specs and implementation tasks, enabling
10/// spec-to-task linking, task completion tracking, and acceptance criteria validation.
11#[derive(Debug, Clone)]
12pub struct WorkflowOrchestrator {
13    /// Mapping of task IDs to their linked requirement IDs
14    task_to_requirements: HashMap<String, Vec<String>>,
15    /// Mapping of requirement IDs to their linked task IDs
16    requirement_to_tasks: HashMap<String, Vec<String>>,
17    /// Mapping of task IDs to their completion status
18    task_completion: HashMap<String, TaskStatus>,
19}
20
21impl WorkflowOrchestrator {
22    /// Create a new workflow orchestrator
23    pub fn new() -> Self {
24        WorkflowOrchestrator {
25            task_to_requirements: HashMap::new(),
26            requirement_to_tasks: HashMap::new(),
27            task_completion: HashMap::new(),
28        }
29    }
30
31    /// Link a task to acceptance criteria from requirements
32    ///
33    /// Establishes explicit links between implementation tasks and acceptance criteria
34    /// from the requirements document. This enables traceability and validation.
35    ///
36    /// # Arguments
37    /// * `task_id` - The task identifier
38    /// * `requirement_ids` - IDs of requirements this task addresses
39    ///
40    /// # Returns
41    /// Ok if linking succeeds, Err if task or requirement IDs are invalid
42    pub fn link_task_to_requirements(
43        &mut self,
44        task_id: String,
45        requirement_ids: Vec<String>,
46    ) -> Result<(), SpecError> {
47        if task_id.is_empty() {
48            return Err(SpecError::InvalidFormat(
49                "Task ID cannot be empty".to_string(),
50            ));
51        }
52
53        if requirement_ids.is_empty() {
54            return Err(SpecError::InvalidFormat(
55                "At least one requirement ID must be provided".to_string(),
56            ));
57        }
58
59        // Store task-to-requirements mapping
60        self.task_to_requirements
61            .insert(task_id.clone(), requirement_ids.clone());
62
63        // Store reverse mapping (requirement-to-tasks)
64        for req_id in requirement_ids {
65            self.requirement_to_tasks
66                .entry(req_id)
67                .or_default()
68                .push(task_id.clone());
69        }
70
71        // Initialize task completion status
72        self.task_completion
73            .entry(task_id)
74            .or_insert(TaskStatus::NotStarted);
75
76        Ok(())
77    }
78
79    /// Get all requirements linked to a task
80    ///
81    /// # Arguments
82    /// * `task_id` - The task identifier
83    ///
84    /// # Returns
85    /// A vector of requirement IDs linked to this task
86    pub fn get_task_requirements(&self, task_id: &str) -> Vec<String> {
87        self.task_to_requirements
88            .get(task_id)
89            .cloned()
90            .unwrap_or_default()
91    }
92
93    /// Get all tasks linked to a requirement
94    ///
95    /// # Arguments
96    /// * `requirement_id` - The requirement identifier
97    ///
98    /// # Returns
99    /// A vector of task IDs linked to this requirement
100    pub fn get_requirement_tasks(&self, requirement_id: &str) -> Vec<String> {
101        self.requirement_to_tasks
102            .get(requirement_id)
103            .cloned()
104            .unwrap_or_default()
105    }
106
107    /// Update task completion status
108    ///
109    /// Tracks the progress of implementation tasks through their lifecycle.
110    ///
111    /// # Arguments
112    /// * `task_id` - The task identifier
113    /// * `status` - The new task status
114    ///
115    /// # Returns
116    /// Ok if update succeeds, Err if task is not found
117    pub fn update_task_status(
118        &mut self,
119        task_id: String,
120        status: TaskStatus,
121    ) -> Result<(), SpecError> {
122        if !self.task_completion.contains_key(&task_id) {
123            return Err(SpecError::NotFound(format!("Task not found: {}", task_id)));
124        }
125
126        self.task_completion.insert(task_id, status);
127        Ok(())
128    }
129
130    /// Get task completion status
131    ///
132    /// # Arguments
133    /// * `task_id` - The task identifier
134    ///
135    /// # Returns
136    /// The current status of the task, or NotStarted if not found
137    pub fn get_task_status(&self, task_id: &str) -> TaskStatus {
138        self.task_completion
139            .get(task_id)
140            .copied()
141            .unwrap_or(TaskStatus::NotStarted)
142    }
143
144    /// Validate that all tasks have explicit links to acceptance criteria
145    ///
146    /// Ensures spec-to-task traceability by verifying that every task has
147    /// explicit links to at least one requirement.
148    ///
149    /// # Arguments
150    /// * `spec` - The specification to validate
151    ///
152    /// # Returns
153    /// Ok if all tasks are properly linked, Err with list of unlinked tasks
154    pub fn validate_task_traceability(&self, spec: &Spec) -> Result<(), SpecError> {
155        let mut unlinked_tasks = Vec::new();
156
157        // Collect all task IDs from the spec
158        let all_task_ids = self.collect_all_task_ids(&spec.tasks);
159
160        // Check each task for requirement links
161        for task_id in all_task_ids {
162            if !self.task_to_requirements.contains_key(&task_id) {
163                unlinked_tasks.push(task_id);
164            }
165        }
166
167        if !unlinked_tasks.is_empty() {
168            return Err(SpecError::InvalidFormat(format!(
169                "Tasks without requirement links: {}",
170                unlinked_tasks.join(", ")
171            )));
172        }
173
174        Ok(())
175    }
176
177    /// Validate that all acceptance criteria are addressed by tasks
178    ///
179    /// Ensures that every acceptance criterion from requirements has at least
180    /// one task linked to it.
181    ///
182    /// # Arguments
183    /// * `spec` - The specification to validate
184    ///
185    /// # Returns
186    /// Ok if all acceptance criteria are addressed, Err with list of unaddressed criteria
187    pub fn validate_acceptance_criteria_coverage(&self, spec: &Spec) -> Result<(), SpecError> {
188        let mut unaddressed_criteria = Vec::new();
189
190        // Check each requirement's acceptance criteria
191        for requirement in &spec.requirements {
192            for criterion in &requirement.acceptance_criteria {
193                let criterion_id = format!("{}.{}", requirement.id, criterion.id);
194
195                // Check if any task is linked to this requirement
196                if !self.requirement_to_tasks.contains_key(&requirement.id) {
197                    unaddressed_criteria.push(criterion_id);
198                }
199            }
200        }
201
202        if !unaddressed_criteria.is_empty() {
203            return Err(SpecError::InvalidFormat(format!(
204                "Acceptance criteria without task coverage: {}",
205                unaddressed_criteria.join(", ")
206            )));
207        }
208
209        Ok(())
210    }
211
212    /// Get all tasks that are complete
213    ///
214    /// # Returns
215    /// A vector of task IDs that have been marked as complete
216    pub fn get_completed_tasks(&self) -> Vec<String> {
217        self.task_completion
218            .iter()
219            .filter(|(_, status)| **status == TaskStatus::Complete)
220            .map(|(id, _)| id.clone())
221            .collect()
222    }
223
224    /// Get all tasks that are in progress
225    ///
226    /// # Returns
227    /// A vector of task IDs that are currently in progress
228    pub fn get_in_progress_tasks(&self) -> Vec<String> {
229        self.task_completion
230            .iter()
231            .filter(|(_, status)| **status == TaskStatus::InProgress)
232            .map(|(id, _)| id.clone())
233            .collect()
234    }
235
236    /// Get all tasks that have not been started
237    ///
238    /// # Returns
239    /// A vector of task IDs that have not been started
240    pub fn get_not_started_tasks(&self) -> Vec<String> {
241        self.task_completion
242            .iter()
243            .filter(|(_, status)| **status == TaskStatus::NotStarted)
244            .map(|(id, _)| id.clone())
245            .collect()
246    }
247
248    /// Get overall workflow completion percentage
249    ///
250    /// Calculates the percentage of tasks that have been completed.
251    ///
252    /// # Returns
253    /// A percentage (0-100) of completed tasks, or 0 if no tasks exist
254    pub fn get_completion_percentage(&self) -> f64 {
255        if self.task_completion.is_empty() {
256            return 0.0;
257        }
258
259        let completed = self
260            .task_completion
261            .values()
262            .filter(|status| **status == TaskStatus::Complete)
263            .count();
264
265        (completed as f64 / self.task_completion.len() as f64) * 100.0
266    }
267
268    /// Collect all task IDs from a hierarchical task list
269    #[allow(clippy::only_used_in_recursion)]
270    fn collect_all_task_ids(&self, tasks: &[Task]) -> Vec<String> {
271        let mut ids = Vec::new();
272
273        for task in tasks {
274            ids.push(task.id.clone());
275            ids.extend(self.collect_all_task_ids(&task.subtasks));
276        }
277
278        ids
279    }
280
281    /// Get all linked requirement IDs across all tasks
282    ///
283    /// # Returns
284    /// A set of all requirement IDs that have task links
285    pub fn get_all_linked_requirements(&self) -> HashSet<String> {
286        self.requirement_to_tasks.keys().cloned().collect()
287    }
288
289    /// Get all linked task IDs
290    ///
291    /// # Returns
292    /// A set of all task IDs that have requirement links
293    pub fn get_all_linked_tasks(&self) -> HashSet<String> {
294        self.task_to_requirements.keys().cloned().collect()
295    }
296
297    /// Clear all links and reset the orchestrator
298    pub fn reset(&mut self) {
299        self.task_to_requirements.clear();
300        self.requirement_to_tasks.clear();
301        self.task_completion.clear();
302    }
303}
304
305impl Default for WorkflowOrchestrator {
306    fn default() -> Self {
307        Self::new()
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn test_link_task_to_requirements() {
317        let mut orchestrator = WorkflowOrchestrator::new();
318
319        let result = orchestrator.link_task_to_requirements(
320            "task-1".to_string(),
321            vec!["REQ-1".to_string(), "REQ-2".to_string()],
322        );
323
324        assert!(result.is_ok());
325        assert_eq!(
326            orchestrator.get_task_requirements("task-1"),
327            vec!["REQ-1".to_string(), "REQ-2".to_string()]
328        );
329    }
330
331    #[test]
332    fn test_link_task_empty_task_id() {
333        let mut orchestrator = WorkflowOrchestrator::new();
334
335        let result =
336            orchestrator.link_task_to_requirements("".to_string(), vec!["REQ-1".to_string()]);
337
338        assert!(result.is_err());
339    }
340
341    #[test]
342    fn test_link_task_empty_requirements() {
343        let mut orchestrator = WorkflowOrchestrator::new();
344
345        let result = orchestrator.link_task_to_requirements("task-1".to_string(), vec![]);
346
347        assert!(result.is_err());
348    }
349
350    #[test]
351    fn test_get_requirement_tasks() {
352        let mut orchestrator = WorkflowOrchestrator::new();
353
354        orchestrator
355            .link_task_to_requirements("task-1".to_string(), vec!["REQ-1".to_string()])
356            .unwrap();
357
358        orchestrator
359            .link_task_to_requirements("task-2".to_string(), vec!["REQ-1".to_string()])
360            .unwrap();
361
362        let tasks = orchestrator.get_requirement_tasks("REQ-1");
363        assert_eq!(tasks.len(), 2);
364        assert!(tasks.contains(&"task-1".to_string()));
365        assert!(tasks.contains(&"task-2".to_string()));
366    }
367
368    #[test]
369    fn test_update_task_status() {
370        let mut orchestrator = WorkflowOrchestrator::new();
371
372        orchestrator
373            .link_task_to_requirements("task-1".to_string(), vec!["REQ-1".to_string()])
374            .unwrap();
375
376        let result = orchestrator.update_task_status("task-1".to_string(), TaskStatus::InProgress);
377        assert!(result.is_ok());
378        assert_eq!(
379            orchestrator.get_task_status("task-1"),
380            TaskStatus::InProgress
381        );
382    }
383
384    #[test]
385    fn test_update_task_status_not_found() {
386        let mut orchestrator = WorkflowOrchestrator::new();
387
388        let result =
389            orchestrator.update_task_status("nonexistent".to_string(), TaskStatus::Complete);
390        assert!(result.is_err());
391    }
392
393    #[test]
394    fn test_get_completed_tasks() {
395        let mut orchestrator = WorkflowOrchestrator::new();
396
397        orchestrator
398            .link_task_to_requirements("task-1".to_string(), vec!["REQ-1".to_string()])
399            .unwrap();
400
401        orchestrator
402            .link_task_to_requirements("task-2".to_string(), vec!["REQ-2".to_string()])
403            .unwrap();
404
405        orchestrator
406            .update_task_status("task-1".to_string(), TaskStatus::Complete)
407            .unwrap();
408
409        let completed = orchestrator.get_completed_tasks();
410        assert_eq!(completed.len(), 1);
411        assert!(completed.contains(&"task-1".to_string()));
412    }
413
414    #[test]
415    fn test_get_in_progress_tasks() {
416        let mut orchestrator = WorkflowOrchestrator::new();
417
418        orchestrator
419            .link_task_to_requirements("task-1".to_string(), vec!["REQ-1".to_string()])
420            .unwrap();
421
422        orchestrator
423            .update_task_status("task-1".to_string(), TaskStatus::InProgress)
424            .unwrap();
425
426        let in_progress = orchestrator.get_in_progress_tasks();
427        assert_eq!(in_progress.len(), 1);
428        assert!(in_progress.contains(&"task-1".to_string()));
429    }
430
431    #[test]
432    fn test_get_not_started_tasks() {
433        let mut orchestrator = WorkflowOrchestrator::new();
434
435        orchestrator
436            .link_task_to_requirements("task-1".to_string(), vec!["REQ-1".to_string()])
437            .unwrap();
438
439        let not_started = orchestrator.get_not_started_tasks();
440        assert_eq!(not_started.len(), 1);
441        assert!(not_started.contains(&"task-1".to_string()));
442    }
443
444    #[test]
445    fn test_get_completion_percentage() {
446        let mut orchestrator = WorkflowOrchestrator::new();
447
448        orchestrator
449            .link_task_to_requirements("task-1".to_string(), vec!["REQ-1".to_string()])
450            .unwrap();
451
452        orchestrator
453            .link_task_to_requirements("task-2".to_string(), vec!["REQ-2".to_string()])
454            .unwrap();
455
456        orchestrator
457            .update_task_status("task-1".to_string(), TaskStatus::Complete)
458            .unwrap();
459
460        let percentage = orchestrator.get_completion_percentage();
461        assert_eq!(percentage, 50.0);
462    }
463
464    #[test]
465    fn test_get_completion_percentage_empty() {
466        let orchestrator = WorkflowOrchestrator::new();
467        assert_eq!(orchestrator.get_completion_percentage(), 0.0);
468    }
469
470    #[test]
471    fn test_get_all_linked_requirements() {
472        let mut orchestrator = WorkflowOrchestrator::new();
473
474        orchestrator
475            .link_task_to_requirements(
476                "task-1".to_string(),
477                vec!["REQ-1".to_string(), "REQ-2".to_string()],
478            )
479            .unwrap();
480
481        let requirements = orchestrator.get_all_linked_requirements();
482        assert_eq!(requirements.len(), 2);
483        assert!(requirements.contains("REQ-1"));
484        assert!(requirements.contains("REQ-2"));
485    }
486
487    #[test]
488    fn test_get_all_linked_tasks() {
489        let mut orchestrator = WorkflowOrchestrator::new();
490
491        orchestrator
492            .link_task_to_requirements("task-1".to_string(), vec!["REQ-1".to_string()])
493            .unwrap();
494
495        orchestrator
496            .link_task_to_requirements("task-2".to_string(), vec!["REQ-2".to_string()])
497            .unwrap();
498
499        let tasks = orchestrator.get_all_linked_tasks();
500        assert_eq!(tasks.len(), 2);
501        assert!(tasks.contains("task-1"));
502        assert!(tasks.contains("task-2"));
503    }
504
505    #[test]
506    fn test_reset() {
507        let mut orchestrator = WorkflowOrchestrator::new();
508
509        orchestrator
510            .link_task_to_requirements("task-1".to_string(), vec!["REQ-1".to_string()])
511            .unwrap();
512
513        orchestrator.reset();
514
515        assert_eq!(orchestrator.get_all_linked_tasks().len(), 0);
516        assert_eq!(orchestrator.get_all_linked_requirements().len(), 0);
517    }
518}