1use crate::contracts::{QueueFile, Task, TaskStatus};
25use crate::timeutil;
26use anyhow::{Context, Result, anyhow, bail};
27use std::collections::{HashMap, HashSet};
28use time::UtcOffset;
29
30#[derive(Debug, Clone)]
32pub struct ValidationWarning {
33 pub task_id: String,
34 pub message: String,
35}
36
37impl ValidationWarning {
38 pub fn log(&self) {
40 log::warn!("[{}] {}", self.task_id, self.message);
41 }
42}
43
44pub fn log_warnings(warnings: &[ValidationWarning]) {
46 for warning in warnings {
47 warning.log();
48 }
49}
50
51#[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
124pub 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 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 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 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 let mut graph: HashMap<&str, Vec<&str>> = HashMap::new();
378
379 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 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 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 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 graph.entry(task_id).or_default().push(dep_id);
425 }
426 }
427
428 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 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 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 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_relationships(&all_tasks_iter, &all_task_ids, &all_tasks, &mut result)?;
490
491 validate_parent_ids(&all_tasks_iter, &all_task_ids, &mut result)?;
493
494 Ok(result)
495}
496
497fn 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
526fn 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 if let Some(&blocked) = memo.get(task_id) {
537 return blocked;
538 }
539
540 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 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 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
570fn 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 let is_blocking = match blocked_cache.get(*dep_id) {
585 Some(true) => true,
586 Some(false) => false,
587 None => {
588 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
613fn validate_relationships(
616 tasks: &[&Task],
617 all_task_ids: &HashSet<&str>,
618 all_tasks: &HashMap<&str, &Task>,
619 result: &mut DependencyValidationResult,
620) -> Result<()> {
621 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 for blocked_id in &task.blocks {
629 let blocked_id = blocked_id.trim();
630 if blocked_id.is_empty() {
631 continue;
632 }
633
634 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 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 blocks_graph.entry(task_id).or_default().push(blocked_id);
653 }
654
655 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 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 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 if let Some(duplicates_id) = &task.duplicates {
682 let duplicates_id = duplicates_id.trim();
683
684 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 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 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 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
734fn 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 if parent_id_trimmed.is_empty() {
754 continue;
755 }
756
757 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 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 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;