Skip to main content

ralph/queue/
validation.rs

1//! Queue validation submodule.
2//!
3//! This module contains the validation entry points (`validate_queue`,
4//! `validate_queue_set`) plus internal helpers used to enforce queue invariants:
5//! ID formatting/prefix/width, required task fields, RFC3339 timestamps, and
6//! dependency correctness (existence + acyclic graph).
7//!
8//! The parent `queue` module re-exports the public entry points so external
9//! callers can continue using `crate::queue::validate_queue` and friends.
10//!
11//! Responsibilities:
12//! - Validate queue file format, task fields, and dependencies.
13//! - Detect hard errors (blocking) and warnings (non-blocking) for dependencies.
14//!
15//! Not handled here:
16//! - Queue persistence or modification (see `crate::queue`).
17//! - Config file validation (see `crate::config`).
18//!
19//! Invariants/assumptions:
20//! - Task IDs are unique (within and across queue/done files, except for rejected tasks).
21//! - Dependencies must form a DAG (directed acyclic graph).
22//! - Warnings are collected but do not block queue operations.
23
24use crate::contracts::{QueueFile, Task, TaskStatus};
25use crate::timeutil;
26use anyhow::{Context, Result, anyhow, bail};
27use std::collections::{HashMap, HashSet};
28use time::UtcOffset;
29
30/// Represents a validation issue that doesn't prevent queue operation but should be reported.
31#[derive(Debug, Clone)]
32pub struct ValidationWarning {
33    pub task_id: String,
34    pub message: String,
35}
36
37impl ValidationWarning {
38    /// Log this warning using the standard logging framework.
39    pub fn log(&self) {
40        log::warn!("[{}] {}", self.task_id, self.message);
41    }
42}
43
44/// Log all validation warnings.
45pub fn log_warnings(warnings: &[ValidationWarning]) {
46    for warning in warnings {
47        warning.log();
48    }
49}
50
51/// Result of dependency validation containing both errors and warnings.
52#[derive(Debug, Default)]
53pub struct DependencyValidationResult {
54    pub warnings: Vec<ValidationWarning>,
55}
56
57pub fn validate_queue(queue: &QueueFile, id_prefix: &str, id_width: usize) -> Result<()> {
58    if queue.version != 1 {
59        bail!(
60            "Unsupported queue.jsonc version: {}. Ralph requires version 1. Update the 'version' field in .ralph/queue.jsonc.",
61            queue.version
62        );
63    }
64    if id_width == 0 {
65        bail!(
66            "Invalid id_width: width must be greater than 0. Set a valid width (e.g., 4) in .ralph/config.jsonc or via --id-width."
67        );
68    }
69
70    let expected_prefix = super::normalize_prefix(id_prefix);
71    if expected_prefix.is_empty() {
72        bail!(
73            "Empty id_prefix: prefix is required. Set a non-empty prefix (e.g., 'RQ') in .ralph/config.jsonc or via --id-prefix."
74        );
75    }
76
77    let mut seen = HashSet::new();
78    for (idx, task) in queue.tasks.iter().enumerate() {
79        validate_task_required_fields(idx, task)?;
80        validate_task_agent_fields(idx, task)?;
81        validate_task_id(idx, &task.id, &expected_prefix, id_width)?;
82
83        if task.status == TaskStatus::Rejected {
84            continue;
85        }
86
87        let key = task.id.trim().to_string();
88        if !seen.insert(key.clone()) {
89            bail!(
90                "Duplicate task ID detected: {}. Ensure each task in .ralph/queue.jsonc has a unique ID.",
91                key
92            );
93        }
94    }
95
96    Ok(())
97}
98
99fn validate_task_agent_fields(index: usize, task: &Task) -> Result<()> {
100    if let Some(agent) = task.agent.as_ref() {
101        if let Some(iterations) = agent.iterations
102            && iterations == 0
103        {
104            bail!(
105                "Invalid agent.iterations: task {} (index {}) must specify iterations >= 1.",
106                task.id,
107                index
108            );
109        }
110
111        if let Some(phases) = agent.phases
112            && !(1..=3).contains(&phases)
113        {
114            bail!(
115                "Invalid agent.phases: task {} (index {}) must specify phases in [1, 2, 3].",
116                task.id,
117                index
118            );
119        }
120    }
121    Ok(())
122}
123
124/// Validates a queue set (active + optional done file).
125///
126/// Returns a vector of warnings if validation succeeds. Warnings are non-blocking issues
127/// that should be reported to the user (e.g., dependencies on rejected tasks).
128pub fn validate_queue_set(
129    active: &QueueFile,
130    done: Option<&QueueFile>,
131    id_prefix: &str,
132    id_width: usize,
133    max_dependency_depth: u8,
134) -> Result<Vec<ValidationWarning>> {
135    validate_queue(active, id_prefix, id_width)?;
136    if let Some(done) = done {
137        validate_queue(done, id_prefix, id_width)?;
138        validate_done_terminal_status(done)?;
139
140        let active_ids: HashSet<&str> = active
141            .tasks
142            .iter()
143            .filter(|t| t.status != TaskStatus::Rejected)
144            .map(|t| t.id.trim())
145            .collect();
146        for task in &done.tasks {
147            if task.status == TaskStatus::Rejected {
148                continue;
149            }
150            let id = task.id.trim();
151            if active_ids.contains(id) {
152                bail!(
153                    "Duplicate task ID detected across queue and done: {}. Ensure task IDs are unique across .ralph/queue.jsonc and .ralph/done.jsonc.",
154                    id
155                );
156            }
157        }
158    }
159
160    // Validate dependencies
161    let result = validate_dependencies(active, done, max_dependency_depth)?;
162
163    Ok(result.warnings)
164}
165
166fn validate_done_terminal_status(done: &QueueFile) -> Result<()> {
167    for task in &done.tasks {
168        if !matches!(task.status, TaskStatus::Done | TaskStatus::Rejected) {
169            bail!(
170                "Invalid done.jsonc status: task {} has status '{:?}'. .ralph/done.jsonc must contain only done/rejected tasks. Move the task back to .ralph/queue.jsonc or update its status before archiving.",
171                task.id,
172                task.status
173            );
174        }
175    }
176
177    Ok(())
178}
179
180fn validate_task_required_fields(index: usize, task: &Task) -> Result<()> {
181    if task.id.trim().is_empty() {
182        bail!(
183            "Missing task ID: task at index {} is missing an 'id' field. Add a valid ID (e.g., 'RQ-0001') to the task.",
184            index
185        );
186    }
187    if task.title.trim().is_empty() {
188        bail!(
189            "Missing task title: task {} (index {}) is missing a 'title' field. Add a descriptive title (e.g., 'Fix login bug').",
190            task.id,
191            index
192        );
193    }
194    ensure_list_valid("tags", index, &task.id, &task.tags)?;
195    ensure_list_valid("scope", index, &task.id, &task.scope)?;
196    ensure_list_valid("evidence", index, &task.id, &task.evidence)?;
197    ensure_list_valid("plan", index, &task.id, &task.plan)?;
198
199    // request is optional, so no ensure_field_present check needed.
200
201    // Validate custom field keys
202    for (key_idx, (key, _value)) in task.custom_fields.iter().enumerate() {
203        let key_trimmed = key.trim();
204        if key_trimmed.is_empty() {
205            bail!(
206                "Empty custom field key: task {} (index {}) has an empty key at custom_fields[{}]. Remove the empty key or provide a valid key.",
207                task.id,
208                index,
209                key_idx
210            );
211        }
212        if key_trimmed.chars().any(|c| c.is_whitespace()) {
213            bail!(
214                "Invalid custom field key: task {} (index {}) has a key with whitespace at custom_fields[{}]: '{}'. Custom field keys must not contain whitespace.",
215                task.id,
216                index,
217                key_idx,
218                key_trimmed
219            );
220        }
221    }
222
223    if let Some(ts) = task.created_at.as_deref() {
224        validate_rfc3339("created_at", index, &task.id, ts)?;
225    } else {
226        bail!(
227            "Missing created_at: task {} (index {}) is missing the 'created_at' timestamp. Add a valid RFC3339 timestamp (e.g., '2026-01-19T05:23:13.000000000Z').",
228            task.id,
229            index
230        );
231    }
232
233    if let Some(ts) = task.updated_at.as_deref() {
234        validate_rfc3339("updated_at", index, &task.id, ts)?;
235    } else {
236        bail!(
237            "Missing updated_at: task {} (index {}) is missing the 'updated_at' timestamp. Add a valid RFC3339 timestamp (e.g., '2026-01-19T05:23:13.000000000Z').",
238            task.id,
239            index
240        );
241    }
242
243    if let Some(ts) = task.completed_at.as_deref() {
244        validate_rfc3339("completed_at", index, &task.id, ts)?;
245    } else if task.status == TaskStatus::Done || task.status == TaskStatus::Rejected {
246        bail!(
247            "Missing completed_at: task {} (index {}) is in status '{:?}' but missing 'completed_at'. Add a valid RFC3339 timestamp.",
248            task.id,
249            index,
250            task.status
251        );
252    }
253
254    Ok(())
255}
256
257fn validate_rfc3339(label: &str, index: usize, id: &str, value: &str) -> Result<()> {
258    let trimmed = value.trim();
259    if trimmed.is_empty() {
260        bail!(
261            "Missing {}: task {} (index {}) requires a non-empty '{}' field. Add a valid RFC3339 UTC timestamp (e.g., '2026-01-19T05:23:13.000000000Z').",
262            label,
263            id,
264            index,
265            label
266        );
267    }
268    let dt = timeutil::parse_rfc3339(trimmed).with_context(|| {
269        format!(
270            "task[{}] {} must be a valid RFC3339 UTC timestamp (got: {}, id={}). Example: '2026-01-19T05:23:13.000000000Z'.",
271            index, label, trimmed, id
272        )
273    })?;
274    if dt.offset() != UtcOffset::UTC {
275        bail!(
276            "task[{}] {} must be a valid RFC3339 UTC timestamp (got: {}, id={}). Example: '2026-01-19T05:23:13.000000000Z'.",
277            index,
278            label,
279            trimmed,
280            id
281        );
282    }
283    Ok(())
284}
285
286fn ensure_list_valid(label: &str, index: usize, id: &str, values: &[String]) -> Result<()> {
287    for (i, value) in values.iter().enumerate() {
288        if value.trim().is_empty() {
289            bail!(
290                "Empty {} item: task {} (index {}) contains an empty string at {}[{}]. Remove the empty item or add content.",
291                label,
292                id,
293                index,
294                label,
295                i
296            );
297        }
298    }
299    Ok(())
300}
301
302pub(crate) fn validate_task_id(
303    index: usize,
304    raw_id: &str,
305    expected_prefix: &str,
306    id_width: usize,
307) -> Result<u32> {
308    let trimmed = raw_id.trim();
309    let (prefix_raw, num_raw) = trimmed.split_once('-').ok_or_else(|| {
310        anyhow!(
311            "Invalid task ID format: task at index {} has ID '{}' which is missing a '-'. Task IDs must follow the 'PREFIX-NUMBER' format (e.g., '{}-0001').",
312            index,
313            trimmed,
314            expected_prefix
315        )
316    })?;
317
318    let prefix = prefix_raw.trim().to_uppercase();
319    if prefix != expected_prefix {
320        bail!(
321            "Mismatched task ID prefix: task at index {} has prefix '{}' but expected '{}'. Update the task ID to '{}' or change the prefix in .ralph/config.jsonc.",
322            index,
323            prefix,
324            expected_prefix,
325            super::format_id(expected_prefix, 1, id_width)
326        );
327    }
328
329    let num = num_raw.trim();
330    if num.len() != id_width {
331        bail!(
332            "Invalid task ID width: task at index {} has a numeric suffix of length {} but expected {}. Pad the numeric part with leading zeros (e.g., '{}').",
333            index,
334            num.len(),
335            id_width,
336            super::format_id(expected_prefix, num.parse().unwrap_or(1), id_width)
337        );
338    }
339    if !num.chars().all(|c| c.is_ascii_digit()) {
340        bail!(
341            "Invalid task ID: task at index {} has non-digit characters in its numeric suffix '{}'. Ensure the ID suffix contains only digits (e.g., '0001').",
342            index,
343            num
344        );
345    }
346
347    let value: u32 = num.parse().with_context(|| {
348        format!(
349            "task[{}] id numeric suffix must parse as integer (got: {})",
350            index, num
351        )
352    })?;
353    Ok(value)
354}
355
356fn validate_dependencies(
357    active: &QueueFile,
358    done: Option<&QueueFile>,
359    max_dependency_depth: u8,
360) -> Result<DependencyValidationResult> {
361    let mut result = DependencyValidationResult::default();
362
363    // Build a map of all tasks for quick lookup
364    let mut all_tasks: HashMap<&str, &Task> = HashMap::new();
365    for task in &active.tasks {
366        all_tasks.insert(task.id.trim(), task);
367    }
368    if let Some(done_file) = done {
369        for task in &done_file.tasks {
370            all_tasks.insert(task.id.trim(), task);
371        }
372    }
373
374    let all_task_ids: HashSet<&str> = all_tasks.keys().copied().collect();
375
376    // Build adjacency list for cycle detection and depth calculation
377    let mut graph: HashMap<&str, Vec<&str>> = HashMap::new();
378
379    // Collect all tasks to check (both active and done)
380    let mut all_tasks_iter: Vec<&Task> = active.tasks.iter().collect();
381    if let Some(done_file) = done {
382        all_tasks_iter.extend(&done_file.tasks);
383    }
384
385    for task in &all_tasks_iter {
386        let task_id = task.id.trim();
387        for dep_id in &task.depends_on {
388            let dep_id = dep_id.trim();
389            if dep_id.is_empty() {
390                continue;
391            }
392
393            // Check for self-reference (hard error)
394            if dep_id == task_id {
395                bail!(
396                    "Self-dependency detected: task {} depends on itself. Remove the self-reference from the depends_on field.",
397                    task_id
398                );
399            }
400
401            // Check that dependency exists (hard error)
402            if !all_task_ids.contains(dep_id) {
403                bail!(
404                    "Invalid dependency: task {} depends on non-existent task {}. Ensure the dependency task ID exists in .ralph/queue.jsonc or .ralph/done.jsonc.",
405                    task_id,
406                    dep_id
407                );
408            }
409
410            // Check if dependency is rejected (warning)
411            if let Some(dep_task) = all_tasks.get(dep_id)
412                && dep_task.status == TaskStatus::Rejected
413            {
414                result.warnings.push(ValidationWarning {
415                        task_id: task_id.to_string(),
416                        message: format!(
417                            "Task {} depends on rejected task {}. This dependency will never be satisfied.",
418                            task_id, dep_id
419                        ),
420                    });
421            }
422
423            // Build graph for cycle detection
424            graph.entry(task_id).or_default().push(dep_id);
425        }
426    }
427
428    // Detect cycles using DFS (hard error)
429    let mut visited = std::collections::HashSet::new();
430    let mut rec_stack = std::collections::HashSet::new();
431
432    for node in graph.keys() {
433        if has_cycle(node, &graph, &mut visited, &mut rec_stack) {
434            bail!(
435                "Circular dependency detected involving task {}. Task dependencies must form a DAG (no cycles). Review the depends_on fields to break the cycle.",
436                node
437            );
438        }
439    }
440
441    // Check dependency depth for each task (warning)
442    let mut depth_cache: HashMap<String, usize> = HashMap::new();
443    for task in &active.tasks {
444        let task_id = task.id.trim();
445        let depth = calculate_dependency_depth(task_id, &graph, &mut depth_cache);
446        if depth > max_dependency_depth as usize {
447            result.warnings.push(ValidationWarning {
448                task_id: task_id.to_string(),
449                message: format!(
450                    "Task {} has a dependency chain depth of {}, which exceeds the configured maximum of {}. This may indicate overly complex dependencies.",
451                    task_id, depth, max_dependency_depth
452                ),
453            });
454        }
455    }
456
457    // Check for blocked dependency chains (warning)
458    // A task is "blocked" if all paths from it lead to tasks that will never complete
459    // (i.e., all terminal nodes in its dependency tree are not 'done')
460    let mut blocked_cache: HashMap<String, bool> = HashMap::new();
461    let mut visiting = HashSet::new();
462
463    for task in &active.tasks {
464        let task_id = task.id.trim();
465        if is_task_blocked(
466            task_id,
467            &all_tasks,
468            &graph,
469            &mut visiting,
470            &mut blocked_cache,
471        ) {
472            // Find which dependencies are causing the block
473            let blocking_deps =
474                find_blocking_dependencies(task_id, &all_tasks, &graph, &blocked_cache);
475            if !blocking_deps.is_empty() {
476                result.warnings.push(ValidationWarning {
477                    task_id: task_id.to_string(),
478                    message: format!(
479                        "Task {} is blocked: all dependency paths lead to incomplete or rejected tasks. Blocking dependencies: {}.",
480                        task_id,
481                        blocking_deps.join(", ")
482                    ),
483                });
484            }
485        }
486    }
487
488    // Validate new relationship fields (blocks, relates_to, duplicates)
489    validate_relationships(&all_tasks_iter, &all_task_ids, &all_tasks, &mut result)?;
490
491    // Validate parent_id relationships (cycles are hard errors, other issues are warnings)
492    validate_parent_ids(&all_tasks_iter, &all_task_ids, &mut result)?;
493
494    Ok(result)
495}
496
497/// Calculate the maximum dependency chain depth for a task.
498/// Returns 0 for tasks with no dependencies.
499fn calculate_dependency_depth(
500    task_id: &str,
501    graph: &HashMap<&str, Vec<&str>>,
502    cache: &mut HashMap<String, usize>,
503) -> usize {
504    if let Some(&depth) = cache.get(task_id) {
505        return depth;
506    }
507
508    let depth = if let Some(deps) = graph.get(task_id) {
509        if deps.is_empty() {
510            0
511        } else {
512            1 + deps
513                .iter()
514                .map(|dep| calculate_dependency_depth(dep, graph, cache))
515                .max()
516                .unwrap_or(0)
517        }
518    } else {
519        0
520    };
521
522    cache.insert(task_id.to_string(), depth);
523    depth
524}
525
526/// Determine if a task is blocked (all dependency paths lead to incomplete/rejected tasks).
527/// A task is NOT blocked if any path leads to a 'done' task.
528fn is_task_blocked(
529    task_id: &str,
530    all_tasks: &HashMap<&str, &Task>,
531    graph: &HashMap<&str, Vec<&str>>,
532    visiting: &mut HashSet<String>,
533    memo: &mut HashMap<String, bool>,
534) -> bool {
535    // Check memoized result
536    if let Some(&blocked) = memo.get(task_id) {
537        return blocked;
538    }
539
540    // Cycle detection - if we're visiting this node, we're in a cycle
541    // Cycles are already caught as errors, so treat as blocked to avoid infinite recursion
542    if !visiting.insert(task_id.to_string()) {
543        return true;
544    }
545
546    let deps = match graph.get(task_id) {
547        Some(d) if !d.is_empty() => d,
548        _ => {
549            // No dependencies - task is blocked if it's not done
550            visiting.remove(task_id);
551            let is_blocked = match all_tasks.get(task_id) {
552                Some(task) => task.status != TaskStatus::Done,
553                None => true,
554            };
555            memo.insert(task_id.to_string(), is_blocked);
556            return is_blocked;
557        }
558    };
559
560    // Task is blocked if ALL dependencies are blocked
561    let all_blocked = deps
562        .iter()
563        .all(|dep_id| is_task_blocked(dep_id, all_tasks, graph, visiting, memo));
564
565    visiting.remove(task_id);
566    memo.insert(task_id.to_string(), all_blocked);
567    all_blocked
568}
569
570/// Find the specific dependencies that are causing a task to be blocked.
571fn find_blocking_dependencies(
572    task_id: &str,
573    all_tasks: &HashMap<&str, &Task>,
574    graph: &HashMap<&str, Vec<&str>>,
575    blocked_cache: &HashMap<String, bool>,
576) -> Vec<String> {
577    let mut blocking = Vec::new();
578
579    if let Some(deps) = graph.get(task_id) {
580        for dep_id in deps.iter() {
581            // A dependency is blocking if:
582            // 1. It's marked as blocked in the cache, OR
583            // 2. It's a terminal node (no deps) that's not done
584            let is_blocking = match blocked_cache.get(*dep_id) {
585                Some(true) => true,
586                Some(false) => false,
587                None => {
588                    // Not in cache - check if it's a terminal non-done task
589                    let is_terminal = match graph.get(*dep_id) {
590                        None => true,
591                        Some(deps) => deps.is_empty(),
592                    };
593                    if is_terminal {
594                        match all_tasks.get(*dep_id) {
595                            Some(task) => task.status != TaskStatus::Done,
596                            None => true,
597                        }
598                    } else {
599                        false
600                    }
601                }
602            };
603
604            if is_blocking {
605                blocking.push(dep_id.to_string());
606            }
607        }
608    }
609
610    blocking
611}
612
613/// Validate task relationships (blocks, relates_to, duplicates).
614/// Checks that all relationship targets exist and that blocks relationships don't form cycles.
615fn validate_relationships(
616    tasks: &[&Task],
617    all_task_ids: &HashSet<&str>,
618    all_tasks: &HashMap<&str, &Task>,
619    result: &mut DependencyValidationResult,
620) -> Result<()> {
621    // Build adjacency list for blocks cycle detection
622    let mut blocks_graph: HashMap<&str, Vec<&str>> = HashMap::new();
623
624    for task in tasks {
625        let task_id = task.id.trim();
626
627        // Validate blocks relationships
628        for blocked_id in &task.blocks {
629            let blocked_id = blocked_id.trim();
630            if blocked_id.is_empty() {
631                continue;
632            }
633
634            // Check for self-reference (hard error)
635            if blocked_id == task_id {
636                bail!(
637                    "Self-blocking detected: task {} blocks itself. Remove the self-reference from the blocks field.",
638                    task_id
639                );
640            }
641
642            // Check that blocked task exists (hard error)
643            if !all_task_ids.contains(blocked_id) {
644                bail!(
645                    "Invalid blocks relationship: task {} blocks non-existent task {}. Ensure the blocked task ID exists in .ralph/queue.jsonc or .ralph/done.jsonc.",
646                    task_id,
647                    blocked_id
648                );
649            }
650
651            // Build graph for cycle detection
652            blocks_graph.entry(task_id).or_default().push(blocked_id);
653        }
654
655        // Validate relates_to relationships
656        for related_id in &task.relates_to {
657            let related_id = related_id.trim();
658            if related_id.is_empty() {
659                continue;
660            }
661
662            // Check for self-reference (hard error)
663            if related_id == task_id {
664                bail!(
665                    "Self-reference in relates_to: task {} relates to itself. Remove the self-reference from the relates_to field.",
666                    task_id
667                );
668            }
669
670            // Check that related task exists (hard error)
671            if !all_task_ids.contains(related_id) {
672                bail!(
673                    "Invalid relates_to relationship: task {} relates to non-existent task {}. Ensure the related task ID exists in .ralph/queue.jsonc or .ralph/done.jsonc.",
674                    task_id,
675                    related_id
676                );
677            }
678        }
679
680        // Validate duplicates relationship
681        if let Some(duplicates_id) = &task.duplicates {
682            let duplicates_id = duplicates_id.trim();
683
684            // Check for self-reference (hard error)
685            if duplicates_id == task_id {
686                bail!(
687                    "Self-duplication detected: task {} duplicates itself. Remove the self-reference from the duplicates field.",
688                    task_id
689                );
690            }
691
692            // Check that duplicated task exists (hard error)
693            if !all_task_ids.contains(duplicates_id) {
694                bail!(
695                    "Invalid duplicates relationship: task {} duplicates non-existent task {}. Ensure the duplicated task ID exists in .ralph/queue.jsonc or .ralph/done.jsonc.",
696                    task_id,
697                    duplicates_id
698                );
699            }
700
701            // Warn if duplicating a done/rejected task
702            if let Some(dupe_task) = all_tasks.get(duplicates_id)
703                && matches!(dupe_task.status, TaskStatus::Done | TaskStatus::Rejected)
704            {
705                result.warnings.push(ValidationWarning {
706                        task_id: task_id.to_string(),
707                        message: format!(
708                            "Task {} duplicates {} which is already {}. Consider if this duplicate is still needed.",
709                            task_id,
710                            duplicates_id,
711                            if dupe_task.status == TaskStatus::Done { "done" } else { "rejected" }
712                        ),
713                    });
714            }
715        }
716    }
717
718    // Detect cycles in blocks relationships using DFS (hard error)
719    let mut visited = std::collections::HashSet::new();
720    let mut rec_stack = std::collections::HashSet::new();
721
722    for node in blocks_graph.keys() {
723        if has_cycle(node, &blocks_graph, &mut visited, &mut rec_stack) {
724            bail!(
725                "Circular blocking detected involving task {}. Task blocking relationships must form a DAG (no cycles). Review the blocks fields to break the cycle.",
726                node
727            );
728        }
729    }
730
731    Ok(())
732}
733
734/// Validate parent_id relationships.
735/// Checks for missing parents and self-parenting (warnings), and cycles (hard error).
736fn validate_parent_ids(
737    tasks: &[&Task],
738    all_task_ids: &HashSet<&str>,
739    result: &mut DependencyValidationResult,
740) -> Result<()> {
741    use crate::queue::hierarchy::detect_parent_cycles;
742
743    for task in tasks {
744        let task_id = task.id.trim();
745        if task_id.is_empty() {
746            continue;
747        }
748
749        if let Some(parent_id) = task.parent_id.as_deref() {
750            let parent_id_trimmed = parent_id.trim();
751
752            // Skip empty/whitespace parent_id (treated as unset)
753            if parent_id_trimmed.is_empty() {
754                continue;
755            }
756
757            // Check for self-parenting (warning)
758            if parent_id_trimmed == task_id {
759                result.warnings.push(ValidationWarning {
760                    task_id: task_id.to_string(),
761                    message: format!(
762                        "Task {} references itself as its own parent. Remove the parent_id or set it to a valid parent task.",
763                        task_id
764                    ),
765                });
766                continue;
767            }
768
769            // Check for missing parent (warning, not error - orphaned task)
770            if !all_task_ids.contains(parent_id_trimmed) {
771                result.warnings.push(ValidationWarning {
772                    task_id: task_id.to_string(),
773                    message: format!(
774                        "Task {} references parent {} which does not exist in the queue or done archive.",
775                        task_id, parent_id_trimmed
776                    ),
777                });
778            }
779        }
780    }
781
782    // Detect cycles (hard error, matching depends_on cycle behavior)
783    // Filter out self-cycles (length 1 cycles are A -> A, handled as warnings above)
784    let cycles: Vec<_> = detect_parent_cycles(tasks)
785        .into_iter()
786        .filter(|cycle| cycle.len() > 1)
787        .collect();
788    if let Some(cycle) = cycles.first() {
789        let cycle_str = cycle.join(" -> ");
790        bail!(
791            "Circular parent chain detected: {}. Task parent_id relationships must form a DAG (no cycles). Break the cycle by changing one of the parent_id references.",
792            cycle_str
793        );
794    }
795
796    Ok(())
797}
798
799fn has_cycle(
800    node: &str,
801    graph: &HashMap<&str, Vec<&str>>,
802    visited: &mut std::collections::HashSet<String>,
803    rec_stack: &mut std::collections::HashSet<String>,
804) -> bool {
805    let node_key = node.to_string();
806    visited.insert(node_key.clone());
807    rec_stack.insert(node_key.clone());
808
809    if let Some(neighbors) = graph.get(node) {
810        for neighbor in neighbors.iter() {
811            if !visited.contains(*neighbor) {
812                if has_cycle(neighbor, graph, visited, rec_stack) {
813                    return true;
814                }
815            } else if rec_stack.contains(*neighbor) {
816                return true;
817            }
818        }
819    }
820
821    rec_stack.remove(&node_key);
822    false
823}
824
825#[cfg(test)]
826mod tests;