Skip to main content

molten_workflow/
engine.rs

1//! This module provides the functions which implement the core workflow engine logic.
2
3use crate::error::WorkflowError;
4use molten_core::document::Document;
5use molten_core::workflow::{WorkflowDefinition, WorkflowGraph};
6
7/// Attempts to transition a document from its current phase to a specified target phase
8/// according to the rules defined in the provided workflow.
9///
10/// If the transition is valid, the `document.current_phase` field is updated in place
11/// to the `target_phase_id`.
12///
13/// This function performs several checks:
14/// 1. Verifies that the document's `workflow_id` matches the provided `workflow.id()`.
15/// 2. Ensures the `target_phase_id` actually exists within the `workflow` definition.
16/// 3. Handles initial transitions for new documents (those with an empty `current_phase`),
17///    only allowing them to transition to the workflow's designated "Start" phase.
18/// 4. Validates that a direct transition path exists from the document's `current_phase`
19///    to the `target_phase_id` within the `workflow` graph.
20///
21/// # Arguments
22/// * `doc` - A mutable reference to the `Document` to be transitioned.
23/// * `workflow` - The `WorkflowDefinition` that defines the valid phases and transitions
24///   for this document.
25/// * `target_phase_id` - The `id` of the phase to which the document should transition.
26///
27/// # Returns
28/// * `Ok(())` if the transition was successful and the document's phase was updated.
29/// * `Err(WorkflowError)` if any of the validation checks fail (e.g., workflow mismatch,
30///   unknown phase, invalid transition, or no current phase on a non-new document).
31pub fn transition(
32    doc: &mut Document,
33    workflow: &WorkflowDefinition,
34    target_phase_id: &str,
35) -> Result<(), WorkflowError> {
36    // 1. Sanity Check: Does the document belong to this workflow?
37    if doc.workflow_id != workflow.id() {
38        return Err(WorkflowError::WorkflowMismatch {
39            doc_wf: doc.workflow_id.clone(),
40            provided_wf: workflow.id().to_string(),
41        });
42    }
43
44    // 2. Validate Target Phase Existence
45    if workflow.get_phase(target_phase_id).is_none() {
46        return Err(WorkflowError::UnknownPhase(target_phase_id.to_string()));
47    }
48
49    // 3. Handle "New" Documents (Empty Phase)
50    // If the document has no phase, we only allow transitioning to the "Start" phase.
51    if doc.current_phase.is_empty() {
52        if let Some(start_phase) = workflow.get_start_phase() {
53            if start_phase.id == target_phase_id {
54                doc.current_phase = target_phase_id.to_string();
55                return Ok(());
56            } else {
57                return Err(WorkflowError::InvalidTransition {
58                    current: "WAITING_TO_START".to_string(),
59                    target: target_phase_id.to_string(),
60                });
61            }
62        } else {
63            // Should verify workflow has start phase, but for runtime safety:
64            return Err(WorkflowError::UnknownPhase(
65                "No start phase defined".to_string(),
66            ));
67        }
68    }
69
70    // 4. Validate the Edge (The Transition Rule)
71    // We delegate this check to the WorkflowGraph trait we defined in Core.
72    if !workflow.can_transition(&doc.current_phase, target_phase_id) {
73        return Err(WorkflowError::InvalidTransition {
74            current: doc.current_phase.clone(),
75            target: target_phase_id.to_string(),
76        });
77    }
78
79    // 5. Apply the Change
80    doc.current_phase = target_phase_id.to_string();
81
82    // Note: In a real system, you might trigger "Side Effects" here
83    // (e.g., sending emails), but that belongs in `molten-service`.
84
85    Ok(())
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use molten_core::workflow::{Phase, PhaseType, Transition, WorkflowBuilder};
92
93    fn create_simple_workflow() -> WorkflowDefinition {
94        WorkflowBuilder::new("wf_ticket", "Ticket Workflow")
95            .add_phase(Phase::new("draft", "Draft", PhaseType::Start))
96            .add_phase(Phase::new("review", "Review", PhaseType::Normal))
97            .add_phase(Phase::new("closed", "Closed", PhaseType::End))
98            // Define paths: Draft -> Review -> Closed
99            .add_transition(Transition::new("submit", "draft", "review"))
100            .add_transition(Transition::new("approve", "review", "closed"))
101            // Also allow "Reject": Review -> Draft
102            .add_transition(Transition::new("reject", "review", "draft"))
103            .build()
104            .unwrap()
105    }
106
107    #[test]
108    fn test_valid_transitions() {
109        let wf = create_simple_workflow();
110        let mut doc = Document::new("doc1", "form_ticket", "wf_ticket");
111
112        // 1. Initialize (Empty -> Start)
113        assert!(transition(&mut doc, &wf, "draft").is_ok());
114        assert_eq!(doc.current_phase, "draft");
115
116        // 2. Draft -> Review
117        assert!(transition(&mut doc, &wf, "review").is_ok());
118        assert_eq!(doc.current_phase, "review");
119
120        // 3. Review -> Closed
121        assert!(transition(&mut doc, &wf, "closed").is_ok());
122        assert_eq!(doc.current_phase, "closed");
123    }
124
125    #[test]
126    fn test_invalid_jump() {
127        let wf = create_simple_workflow();
128        let mut doc = Document::new("doc1", "doc_ticket", "wf_ticket");
129
130        // Initialize
131        let _ = transition(&mut doc, &wf, "draft");
132
133        // Try to skip Review (Draft -> Closed)
134        let res = transition(&mut doc, &wf, "closed");
135        assert!(res.is_err());
136        assert!(matches!(
137            res.unwrap_err(),
138            WorkflowError::InvalidTransition { .. }
139        ));
140
141        // Ensure state didn't change
142        assert_eq!(doc.current_phase, "draft");
143    }
144
145    #[test]
146    fn test_workflow_mismatch() {
147        let wf = create_simple_workflow(); // ID: wf_ticket
148        let mut doc = Document::new("doc1", "doc_ticket", "other_workflow_id");
149
150        let res = transition(&mut doc, &wf, "draft");
151        assert!(matches!(
152            res.unwrap_err(),
153            WorkflowError::WorkflowMismatch { .. }
154        ));
155    }
156}