Skip to main content

pe_tasks/
dependency.rs

1//! # Task dependency DAG — blocks/requires/related with cycle detection.
2//!
3//! Tasks can depend on other tasks. Dependencies form a directed acyclic graph (DAG).
4//! Cycle detection prevents deadlocks where A blocks B blocks A.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7
8use chrono::{DateTime, Utc};
9use pe_core::PeError;
10use serde::{Deserialize, Serialize};
11
12use crate::task::{Task, TaskId, TaskStatus};
13
14/// A dependency edge: `task_id` depends on `depends_on_id`.
15///
16/// # Example
17///
18/// ```
19/// use pe_tasks::dependency::{TaskDependency, DependencyType};
20///
21/// let dep = TaskDependency::new("deploy", "test", DependencyType::Blocks);
22/// assert_eq!(dep.dependency_type, DependencyType::Blocks);
23/// ```
24#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct TaskDependency {
26    /// The task that is blocked/waiting.
27    pub task_id: TaskId,
28    /// The task it depends on (must complete first for Blocks).
29    pub depends_on_id: TaskId,
30    /// Nature of the dependency.
31    pub dependency_type: DependencyType,
32    /// When this dependency was created.
33    pub created_at: DateTime<Utc>,
34}
35
36impl TaskDependency {
37    /// Create a new dependency.
38    #[must_use]
39    pub fn new(
40        task_id: impl Into<TaskId>,
41        depends_on_id: impl Into<TaskId>,
42        dependency_type: DependencyType,
43    ) -> Self {
44        Self {
45            task_id: task_id.into(),
46            depends_on_id: depends_on_id.into(),
47            dependency_type,
48            created_at: Utc::now(),
49        }
50    }
51}
52
53/// The nature of a dependency relationship.
54#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
55#[non_exhaustive]
56pub enum DependencyType {
57    /// Hard dependency: `depends_on` must complete before task can start.
58    Blocks,
59    /// Soft dependency: `depends_on` should complete, but task can proceed.
60    Requires,
61    /// Informational link: no execution constraint, just a relationship.
62    Related,
63}
64
65/// Check whether adding `(task_id → depends_on_id)` would create a cycle.
66///
67/// Uses BFS upstream traversal: starting from `depends_on_id`, follow
68/// all outgoing dependencies. If we reach `task_id`, there's a cycle.
69///
70/// # Arguments
71///
72/// * `task_id` — the task that would be blocked
73/// * `depends_on_id` — the task it would depend on
74/// * `existing_deps` — all current dependencies in the system
75///
76/// Returns `true` if adding this edge would create a cycle.
77#[must_use]
78pub fn would_create_cycle(
79    task_id: &str,
80    depends_on_id: &str,
81    existing_deps: &[TaskDependency],
82) -> bool {
83    if task_id == depends_on_id {
84        return true; // self-loop
85    }
86
87    // We want to add: task_id depends_on depends_on_id
88    // Cycle exists if depends_on_id already (transitively) depends on task_id.
89    // Build "depends on" adjacency: task → what it depends on
90    let mut depends_on: HashMap<&str, Vec<&str>> = HashMap::new();
91    for dep in existing_deps {
92        depends_on
93            .entry(dep.task_id.as_str())
94            .or_default()
95            .push(dep.depends_on_id.as_str());
96    }
97
98    // BFS from depends_on_id, following "depends on" edges.
99    // If we reach task_id, then depends_on_id transitively depends on task_id,
100    // so adding task_id → depends_on_id closes the loop.
101    let mut visited = HashSet::new();
102    let mut queue = VecDeque::new();
103    queue.push_back(depends_on_id);
104    visited.insert(depends_on_id);
105
106    while let Some(current) = queue.pop_front() {
107        if let Some(parents) = depends_on.get(current) {
108            for &parent in parents {
109                if parent == task_id {
110                    return true; // found cycle
111                }
112                if visited.insert(parent) {
113                    queue.push_back(parent);
114                }
115            }
116        }
117    }
118
119    false
120}
121
122/// Check which tasks in `task_ids` are "ready" — no incomplete Blocks dependencies.
123///
124/// A task is ready if all tasks it depends on (with `DependencyType::Blocks`)
125/// have status `Completed`.
126pub(crate) fn validate_parent_link(candidate: &Task, tasks: &[Task]) -> Result<(), PeError> {
127    let Some(parent_id) = candidate.parent_task_id.as_deref() else {
128        return Ok(());
129    };
130
131    if parent_id == candidate.id {
132        return Err(PeError::InvalidUpdate {
133            details: format!("Task {} cannot be its own parent", candidate.id),
134        });
135    }
136
137    active_task(parent_id, tasks).ok_or_else(|| PeError::NodeNotFound {
138        node: parent_id.to_string(),
139    })?;
140
141    let mut seen = HashSet::new();
142    let mut current_id = Some(parent_id);
143    while let Some(task_id) = current_id {
144        if task_id == candidate.id {
145            return Err(PeError::InvalidUpdate {
146                details: format!(
147                    "Parent link {} -> {} would create a task hierarchy cycle",
148                    candidate.id, parent_id
149                ),
150            });
151        }
152        if !seen.insert(task_id.to_string()) {
153            return Err(PeError::InvalidUpdate {
154                details: format!(
155                    "Parent chain for task {} already contains a cycle",
156                    candidate.id
157                ),
158            });
159        }
160        current_id = active_task(task_id, tasks).and_then(|task| task.parent_task_id.as_deref());
161    }
162
163    Ok(())
164}
165
166pub(crate) fn validate_dependency_endpoints(
167    dep: &TaskDependency,
168    tasks: &[Task],
169) -> Result<(), PeError> {
170    active_task(&dep.task_id, tasks).ok_or_else(|| PeError::NodeNotFound {
171        node: dep.task_id.clone(),
172    })?;
173    active_task(&dep.depends_on_id, tasks).ok_or_else(|| PeError::NodeNotFound {
174        node: dep.depends_on_id.clone(),
175    })?;
176    Ok(())
177}
178
179pub(crate) fn validate_dependency_unique(
180    dep: &TaskDependency,
181    existing_deps: &[TaskDependency],
182) -> Result<(), PeError> {
183    if existing_deps.iter().any(|existing| {
184        existing.task_id == dep.task_id && existing.depends_on_id == dep.depends_on_id
185    }) {
186        return Err(PeError::InvalidUpdate {
187            details: format!(
188                "Dependency {} -> {} already exists",
189                dep.task_id, dep.depends_on_id
190            ),
191        });
192    }
193    Ok(())
194}
195
196fn active_task<'a>(task_id: &str, tasks: &'a [Task]) -> Option<&'a Task> {
197    tasks
198        .iter()
199        .find(|task| task.id == task_id && task.deleted_at.is_none())
200}
201
202#[must_use]
203pub fn find_ready_tasks(
204    task_ids: &[TaskId],
205    deps: &[TaskDependency],
206    task_statuses: &HashMap<TaskId, TaskStatus>,
207) -> Vec<TaskId> {
208    task_ids
209        .iter()
210        .filter(|tid| {
211            let blockers: Vec<&TaskDependency> = deps
212                .iter()
213                .filter(|d| d.task_id == **tid && d.dependency_type == DependencyType::Blocks)
214                .collect();
215
216            blockers.iter().all(|b| {
217                task_statuses
218                    .get(&b.depends_on_id)
219                    .is_some_and(|s| *s == TaskStatus::Completed)
220            })
221        })
222        .cloned()
223        .collect()
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_no_cycle_simple() {
232        let deps = vec![TaskDependency::new("B", "A", DependencyType::Blocks)];
233        // Adding C→B: A←B←C — no cycle
234        assert!(!would_create_cycle("C", "B", &deps));
235    }
236
237    #[test]
238    fn test_self_loop_detected() {
239        assert!(would_create_cycle("A", "A", &[]));
240    }
241
242    #[test]
243    fn test_direct_cycle_detected() {
244        let deps = vec![TaskDependency::new("B", "A", DependencyType::Blocks)];
245        // Adding A→B when B→A exists: cycle!
246        assert!(would_create_cycle("A", "B", &deps));
247    }
248
249    #[test]
250    fn test_transitive_cycle_detected() {
251        let deps = vec![
252            TaskDependency::new("B", "A", DependencyType::Blocks),
253            TaskDependency::new("C", "B", DependencyType::Blocks),
254        ];
255        // Adding A→C when C→B→A exists: cycle!
256        assert!(would_create_cycle("A", "C", &deps));
257    }
258
259    #[test]
260    fn test_no_cycle_parallel() {
261        let deps = vec![
262            TaskDependency::new("C", "A", DependencyType::Blocks),
263            TaskDependency::new("C", "B", DependencyType::Blocks),
264        ];
265        // A and B are independent — adding B→A is fine
266        assert!(!would_create_cycle("B", "A", &deps));
267    }
268
269    #[test]
270    fn test_ready_tasks() {
271        let deps = vec![
272            TaskDependency::new("B", "A", DependencyType::Blocks),
273            TaskDependency::new("C", "B", DependencyType::Blocks),
274        ];
275        let mut statuses = HashMap::new();
276        statuses.insert("A".into(), TaskStatus::Completed);
277        statuses.insert("B".into(), TaskStatus::Pending);
278        statuses.insert("C".into(), TaskStatus::Pending);
279
280        let ready = find_ready_tasks(&["A".into(), "B".into(), "C".into()], &deps, &statuses);
281        // A is completed (always ready), B is ready (A is done), C is NOT ready (B not done)
282        assert!(ready.contains(&"A".to_string()));
283        assert!(ready.contains(&"B".to_string()));
284        assert!(!ready.contains(&"C".to_string()));
285    }
286
287    #[test]
288    fn test_related_deps_dont_block() {
289        let deps = vec![TaskDependency::new("B", "A", DependencyType::Related)];
290        let mut statuses = HashMap::new();
291        statuses.insert("A".into(), TaskStatus::Pending); // A not done
292        statuses.insert("B".into(), TaskStatus::Pending);
293
294        let ready = find_ready_tasks(&["B".into()], &deps, &statuses);
295        // B is ready because Related deps don't block
296        assert!(ready.contains(&"B".to_string()));
297    }
298}