1use anyhow::{Context, Result};
6use std::collections::HashMap;
7
8use crate::models::{IdFormat, Phase, Priority, Task, TaskStatus};
9
10const FORMAT_VERSION: &str = "v1";
11const HEADER_PREFIX: &str = "# SCUD Graph";
12
13#[derive(Debug)]
17pub struct ScgParseResult {
18 pub phase: Phase,
19 pub pipeline: Option<ScgPipeline>,
20}
21
22#[derive(Debug, Clone, Default)]
24pub struct ScgPipeline {
25 pub goal: Option<String>,
26 pub model_stylesheet: Option<String>,
27 pub node_attrs: HashMap<String, PipelineNodeAttrs>,
28 pub edge_attrs: Vec<ScgEdgeAttrs>,
29}
30
31#[derive(Debug, Clone, Default)]
33pub struct PipelineNodeAttrs {
34 pub handler_type: String,
35 pub max_retries: u32,
36 pub retry_target: Option<String>,
37 pub goal_gate: bool,
38 pub timeout: Option<String>,
39}
40
41#[derive(Debug, Clone)]
43pub struct ScgEdgeAttrs {
44 pub from: String,
45 pub to: String,
46 pub label: String,
47 pub condition: String,
48 pub weight: i32,
49}
50
51fn status_to_code(status: &TaskStatus) -> char {
53 match status {
54 TaskStatus::Pending => 'P',
55 TaskStatus::InProgress => 'I',
56 TaskStatus::Done => 'D',
57 TaskStatus::Review => 'R',
58 TaskStatus::Blocked => 'B',
59 TaskStatus::Deferred => 'F',
60 TaskStatus::Cancelled => 'C',
61 TaskStatus::Expanded => 'X',
62 TaskStatus::Failed => '!',
63 }
64}
65
66fn code_to_status(code: char) -> Option<TaskStatus> {
67 match code {
68 'P' => Some(TaskStatus::Pending),
69 'I' => Some(TaskStatus::InProgress),
70 'D' => Some(TaskStatus::Done),
71 'R' => Some(TaskStatus::Review),
72 'B' => Some(TaskStatus::Blocked),
73 'F' => Some(TaskStatus::Deferred),
74 'C' => Some(TaskStatus::Cancelled),
75 'X' => Some(TaskStatus::Expanded),
76 '!' => Some(TaskStatus::Failed),
77 _ => None,
78 }
79}
80
81fn priority_to_code(priority: &Priority) -> char {
82 match priority {
83 Priority::Critical => 'C',
84 Priority::High => 'H',
85 Priority::Medium => 'M',
86 Priority::Low => 'L',
87 }
88}
89
90fn code_to_priority(code: char) -> Option<Priority> {
91 match code {
92 'C' => Some(Priority::Critical),
93 'H' => Some(Priority::High),
94 'M' => Some(Priority::Medium),
95 'L' => Some(Priority::Low),
96 _ => None,
97 }
98}
99
100fn escape_text(text: &str) -> String {
102 text.replace('\\', "\\\\")
103 .replace('|', "\\|")
104 .replace('\n', "\\n")
105}
106
107fn unescape_text(text: &str) -> String {
109 let mut result = String::with_capacity(text.len());
110 let mut chars = text.chars().peekable();
111
112 while let Some(c) = chars.next() {
113 if c == '\\' {
114 match chars.next() {
115 Some('\\') => result.push('\\'),
116 Some('|') => result.push('|'),
117 Some('n') => result.push('\n'),
118 Some(other) => {
119 result.push('\\');
120 result.push(other);
121 }
122 None => result.push('\\'),
123 }
124 } else {
125 result.push(c);
126 }
127 }
128 result
129}
130
131fn split_by_pipe(line: &str) -> Vec<String> {
133 let mut parts = Vec::new();
134 let mut current = String::new();
135 let mut chars = line.chars().peekable();
136
137 while let Some(c) = chars.next() {
138 if c == '\\' {
139 if let Some(&next) = chars.peek() {
141 if next == '|' || next == '\\' {
142 current.push(c);
143 current.push(chars.next().unwrap());
144 continue;
145 }
146 }
147 current.push(c);
148 } else if c == '|' {
149 parts.push(current.trim().to_string());
150 current = String::new();
151 } else {
152 current.push(c);
153 }
154 }
155 parts.push(current.trim().to_string());
156 parts
157}
158
159pub fn parse_scg(content: &str) -> Result<Phase> {
161 parse_scg_result(content).map(|r| r.phase)
162}
163
164pub fn parse_scg_result(content: &str) -> Result<ScgParseResult> {
166 let mut lines = content.lines().peekable();
167
168 let first_line = lines.next().context("Empty file")?;
170 if !first_line.starts_with(HEADER_PREFIX) {
171 anyhow::bail!(
172 "Invalid SCG header: expected '{}', got '{}'",
173 HEADER_PREFIX,
174 first_line
175 );
176 }
177
178 let phase_line = lines.next().context("Missing phase tag line")?;
179 let phase_tag = phase_line
180 .strip_prefix("# Phase:")
181 .or_else(|| phase_line.strip_prefix("# Epic:")) .map(|s| s.trim())
183 .context("Invalid phase line format")?;
184
185 let mut phase = Phase::new(phase_tag.to_string());
186 let mut tasks: HashMap<String, Task> = HashMap::new();
187 let mut edges: Vec<(String, String)> = Vec::new();
188 let mut parents: HashMap<String, Vec<String>> = HashMap::new();
189 let mut details: HashMap<String, HashMap<String, String>> = HashMap::new();
190 type AssignmentInfo = (Option<String>, Option<String>, Option<String>);
192 let mut assignments: HashMap<String, AssignmentInfo> = HashMap::new();
193 let mut agent_types: HashMap<String, String> = HashMap::new();
194
195 let mut is_pipeline = false;
197 let mut pipeline_goal: Option<String> = None;
198 let mut pipeline_stylesheet: Option<String> = None;
199 let mut pipeline_node_attrs: HashMap<String, PipelineNodeAttrs> = HashMap::new();
200 let mut pipeline_edge_attrs: Vec<ScgEdgeAttrs> = Vec::new();
201
202 let mut current_section: Option<&str> = None;
204 let mut current_detail_id: Option<String> = None;
205 let mut current_detail_field: Option<String> = None;
206 let mut current_detail_content: Vec<String> = Vec::new();
207
208 for line in lines {
209 let trimmed = line.trim();
210
211 if trimmed.is_empty() {
213 continue;
214 }
215
216 if trimmed.starts_with('@') {
218 flush_detail(
220 ¤t_detail_id,
221 ¤t_detail_field,
222 &mut current_detail_content,
223 &mut details,
224 );
225 current_detail_id = None;
226 current_detail_field = None;
227
228 current_section = Some(match trimmed {
229 "@meta {" | "@meta" => "meta",
230 "@nodes" => "nodes",
231 "@edges" => "edges",
232 "@parents" => "parents",
233 "@assignments" => "assignments",
234 "@agents" => "agents",
235 "@details" => "details",
236 "@pipeline" => "pipeline",
237 _ => continue,
238 });
239 continue;
240 }
241
242 if current_section == Some("details")
244 && line.starts_with(" ")
245 && current_detail_id.is_some()
246 {
247 current_detail_content.push(line[2..].to_string());
248 continue;
249 }
250
251 if trimmed == "}" || trimmed.starts_with('#') {
253 continue;
254 }
255
256 match current_section {
257 Some("meta") => {
258 if let Some((key, value)) = trimmed.split_once(char::is_whitespace) {
260 let value = value.trim();
261 match key {
262 "name" => {
263 if phase.name != value {
264 phase = Phase::new(value.to_string());
265 }
266 }
267 "id_format" => {
268 phase.id_format = IdFormat::parse(value);
269 }
270 "mode" => {
271 if value == "pipeline" {
272 is_pipeline = true;
273 }
274 }
275 "goal" => {
276 pipeline_goal = Some(value.to_string());
277 }
278 "model_stylesheet" => {
279 pipeline_stylesheet = Some(value.to_string());
281 }
282 _ => {
283 }
285 }
286 }
287 }
288 Some("nodes") => {
289 let parts = split_by_pipe(trimmed);
291 if parts.len() >= 5 {
292 let id = parts[0].clone();
293 let title = unescape_text(&parts[1]);
294 let status =
295 code_to_status(parts[2].chars().next().unwrap_or('P')).unwrap_or_default();
296 let complexity: u32 = parts[3].parse().unwrap_or(0);
297 let priority = code_to_priority(parts[4].chars().next().unwrap_or('M'))
298 .unwrap_or_default();
299
300 let mut task = Task::new(id.clone(), title, String::new());
301 task.status = status;
302 task.complexity = complexity;
303 task.priority = priority;
304 tasks.insert(id, task);
305 }
306 }
307 Some("edges") => {
308 if is_pipeline {
309 if let Some((from_part, rest)) = trimmed.split_once("->") {
311 let from = from_part.trim().to_string();
312 let parts = split_by_pipe(rest);
314 let to = parts[0].trim().to_string();
315 let label = parts.get(1).map(|s| s.trim().to_string()).unwrap_or_default();
316 let condition = parts.get(2).map(|s| s.trim().to_string()).unwrap_or_default();
317 let weight: i32 = parts.get(3).and_then(|s| s.trim().parse().ok()).unwrap_or(0);
318 pipeline_edge_attrs.push(ScgEdgeAttrs {
319 from,
320 to,
321 label,
322 condition,
323 weight,
324 });
325 }
326 } else {
327 if let Some((dependent, dependency)) = trimmed.split_once("->") {
329 edges.push((dependent.trim().to_string(), dependency.trim().to_string()));
330 }
331 }
332 }
333 Some("pipeline") => {
334 let parts = split_by_pipe(trimmed);
336 if parts.len() >= 2 {
337 let id = parts[0].clone();
338 let handler_type = parts[1].clone();
339 let max_retries: u32 = parts.get(2).and_then(|s| {
340 let s = s.trim();
341 if s.is_empty() { None } else { s.parse().ok() }
342 }).unwrap_or(0);
343 let retry_target = parts.get(3).and_then(|s| {
344 let s = s.trim();
345 if s.is_empty() { None } else { Some(s.to_string()) }
346 });
347 let goal_gate = parts.get(4).map(|s| {
348 let s = s.trim();
349 s == "true"
350 }).unwrap_or(false);
351 let timeout = parts.get(5).and_then(|s| {
352 let s = s.trim();
353 if s.is_empty() { None } else { Some(s.to_string()) }
354 });
355 pipeline_node_attrs.insert(id, PipelineNodeAttrs {
356 handler_type,
357 max_retries,
358 retry_target,
359 goal_gate,
360 timeout,
361 });
362 }
363 }
364 Some("parents") => {
365 if let Some((parent, children)) = trimmed.split_once(':') {
367 let child_ids: Vec<String> = children
368 .split(',')
369 .map(|s| s.trim().to_string())
370 .filter(|s| !s.is_empty())
371 .collect();
372 parents.insert(parent.trim().to_string(), child_ids);
373 }
374 }
375 Some("assignments") => {
376 let parts = split_by_pipe(trimmed);
378 if parts.len() >= 2 {
379 let id = parts[0].clone();
380 let assigned = if parts[1].is_empty() {
381 None
382 } else {
383 Some(parts[1].clone())
384 };
385 let locked_by: Option<String> = None;
387 let locked_at: Option<String> = None;
388 assignments.insert(id, (assigned, locked_by, locked_at));
389 }
390 }
391 Some("agents") => {
392 let parts = split_by_pipe(trimmed);
394 if parts.len() >= 2 && !parts[1].is_empty() {
395 agent_types.insert(parts[0].clone(), parts[1].clone());
396 }
397 }
398 Some("details") => {
399 flush_detail(
401 ¤t_detail_id,
402 ¤t_detail_field,
403 &mut current_detail_content,
404 &mut details,
405 );
406
407 let parts = split_by_pipe(trimmed);
409 if parts.len() >= 2 {
410 current_detail_id = Some(parts[0].clone());
411 current_detail_field = Some(parts[1].clone());
412 current_detail_content.clear();
413 }
414 }
415 _ => {}
416 }
417 }
418
419 flush_detail(
421 ¤t_detail_id,
422 ¤t_detail_field,
423 &mut current_detail_content,
424 &mut details,
425 );
426
427 if !is_pipeline {
429 for (dependent, dependency) in edges {
430 if let Some(task) = tasks.get_mut(&dependent) {
431 task.dependencies.push(dependency);
432 }
433 }
434 }
435
436 for (parent_id, child_ids) in parents {
438 if let Some(parent) = tasks.get_mut(&parent_id) {
439 parent.subtasks = child_ids.clone();
440 }
441 for child_id in child_ids {
442 if let Some(child) = tasks.get_mut(&child_id) {
443 child.parent_id = Some(parent_id.clone());
444 }
445 }
446 }
447
448 for (id, fields) in details {
450 if let Some(task) = tasks.get_mut(&id) {
451 if let Some(desc) = fields.get("description") {
452 task.description = desc.clone();
453 }
454 if let Some(det) = fields.get("details") {
455 task.details = Some(det.clone());
456 }
457 if let Some(ts) = fields.get("test_strategy") {
458 task.test_strategy = Some(ts.clone());
459 }
460 }
461 }
462
463 for (id, (assigned, _locked_by, _locked_at)) in assignments {
465 if let Some(task) = tasks.get_mut(&id) {
466 task.assigned_to = assigned;
467 }
468 }
469
470 for (id, agent_type) in agent_types {
472 if let Some(task) = tasks.get_mut(&id) {
473 task.agent_type = Some(agent_type);
474 }
475 }
476
477 phase.tasks = tasks.into_values().collect();
479
480 phase.tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
482
483 let pipeline = if is_pipeline {
485 Some(ScgPipeline {
486 goal: pipeline_goal,
487 model_stylesheet: pipeline_stylesheet,
488 node_attrs: pipeline_node_attrs,
489 edge_attrs: pipeline_edge_attrs,
490 })
491 } else {
492 None
493 };
494
495 Ok(ScgParseResult { phase, pipeline })
496}
497
498pub fn natural_sort_ids(a: &str, b: &str) -> std::cmp::Ordering {
502 let a_is_numeric = a.chars().all(|c| c.is_ascii_digit() || c == '.');
504 let b_is_numeric = b.chars().all(|c| c.is_ascii_digit() || c == '.');
505
506 if a_is_numeric && b_is_numeric {
507 let a_parts: Vec<&str> = a.split('.').collect();
509 let b_parts: Vec<&str> = b.split('.').collect();
510
511 for (ap, bp) in a_parts.iter().zip(b_parts.iter()) {
512 match (ap.parse::<u32>(), bp.parse::<u32>()) {
513 (Ok(an), Ok(bn)) => {
514 if an != bn {
515 return an.cmp(&bn);
516 }
517 }
518 _ => {
519 if ap != bp {
520 return ap.cmp(bp);
521 }
522 }
523 }
524 }
525 a_parts.len().cmp(&b_parts.len())
526 } else {
527 a.cmp(b)
529 }
530}
531
532fn flush_detail(
534 id: &Option<String>,
535 field: &Option<String>,
536 content: &mut Vec<String>,
537 details: &mut HashMap<String, HashMap<String, String>>,
538) {
539 if let (Some(id), Some(field)) = (id, field) {
540 let text = content.join("\n");
541 details
542 .entry(id.clone())
543 .or_default()
544 .insert(field.clone(), text);
545 content.clear();
546 }
547}
548
549pub fn serialize_scg(phase: &Phase) -> String {
551 let mut output = String::new();
552
553 output.push_str(&format!("{} {}\n", HEADER_PREFIX, FORMAT_VERSION));
555 output.push_str(&format!("# Phase: {}\n\n", phase.name));
556
557 let now = chrono::Utc::now().to_rfc3339();
559 output.push_str("@meta {\n");
560 output.push_str(&format!(" name {}\n", phase.name));
561 output.push_str(&format!(" id_format {}\n", phase.id_format.as_str()));
562 output.push_str(&format!(" updated {}\n", now));
563 output.push_str("}\n\n");
564
565 let mut sorted_tasks = phase.tasks.clone();
567 sorted_tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
568
569 output.push_str("@nodes\n");
571 output.push_str("# id | title | status | complexity | priority\n");
572 for task in &sorted_tasks {
573 output.push_str(&format!(
574 "{} | {} | {} | {} | {}\n",
575 task.id,
576 escape_text(&task.title),
577 status_to_code(&task.status),
578 task.complexity,
579 priority_to_code(&task.priority)
580 ));
581 }
582 output.push('\n');
583
584 let edges: Vec<_> = sorted_tasks
586 .iter()
587 .flat_map(|t| t.dependencies.iter().map(move |dep| (&t.id, dep)))
588 .collect();
589
590 if !edges.is_empty() {
591 output.push_str("@edges\n");
592 output.push_str("# dependent -> dependency\n");
593 for (dependent, dependency) in edges {
594 output.push_str(&format!("{} -> {}\n", dependent, dependency));
595 }
596 output.push('\n');
597 }
598
599 let parents: Vec<_> = sorted_tasks
601 .iter()
602 .filter(|t| !t.subtasks.is_empty())
603 .collect();
604
605 if !parents.is_empty() {
606 output.push_str("@parents\n");
607 output.push_str("# parent: subtasks...\n");
608 for task in parents {
609 output.push_str(&format!("{}: {}\n", task.id, task.subtasks.join(", ")));
610 }
611 output.push('\n');
612 }
613
614 let assignments: Vec<_> = sorted_tasks
616 .iter()
617 .filter(|t| t.assigned_to.is_some())
618 .collect();
619
620 if !assignments.is_empty() {
621 output.push_str("@assignments\n");
622 output.push_str("# id | assigned_to\n");
623 for task in assignments {
624 output.push_str(&format!(
625 "{} | {}\n",
626 task.id,
627 task.assigned_to.as_deref().unwrap_or("")
628 ));
629 }
630 output.push('\n');
631 }
632
633 let agents: Vec<_> = sorted_tasks
635 .iter()
636 .filter(|t| t.agent_type.is_some())
637 .collect();
638
639 if !agents.is_empty() {
640 output.push_str("@agents\n");
641 output.push_str("# id | agent_type\n");
642 for task in agents {
643 output.push_str(&format!(
644 "{} | {}\n",
645 task.id,
646 task.agent_type.as_deref().unwrap_or("")
647 ));
648 }
649 output.push('\n');
650 }
651
652 let tasks_with_details: Vec<_> = sorted_tasks
654 .iter()
655 .filter(|t| !t.description.is_empty() || t.details.is_some() || t.test_strategy.is_some())
656 .collect();
657
658 if !tasks_with_details.is_empty() {
659 output.push_str("@details\n");
660 for task in tasks_with_details {
661 if !task.description.is_empty() {
662 output.push_str(&format!("{} | description |\n", task.id));
663 for line in task.description.lines() {
664 output.push_str(&format!(" {}\n", line));
665 }
666 }
667 if let Some(ref details) = task.details {
668 output.push_str(&format!("{} | details |\n", task.id));
669 for line in details.lines() {
670 output.push_str(&format!(" {}\n", line));
671 }
672 }
673 if let Some(ref test_strategy) = task.test_strategy {
674 output.push_str(&format!("{} | test_strategy |\n", task.id));
675 for line in test_strategy.lines() {
676 output.push_str(&format!(" {}\n", line));
677 }
678 }
679 }
680 }
681
682 output
683}
684
685pub fn serialize_scg_pipeline(result: &ScgParseResult) -> String {
687 let phase = &result.phase;
688 let pipeline = result.pipeline.as_ref().expect("serialize_scg_pipeline requires pipeline data");
689
690 let mut output = String::new();
691
692 output.push_str(&format!("{} {}\n", HEADER_PREFIX, FORMAT_VERSION));
694 output.push_str(&format!("# Phase: {}\n\n", phase.name));
695
696 output.push_str("@meta {\n");
698 output.push_str(&format!(" name {}\n", phase.name));
699 output.push_str(" mode pipeline\n");
700 if let Some(ref goal) = pipeline.goal {
701 output.push_str(&format!(" goal {}\n", goal));
702 }
703 if let Some(ref stylesheet) = pipeline.model_stylesheet {
704 output.push_str(&format!(" model_stylesheet {}\n", stylesheet));
705 }
706 output.push_str("}\n\n");
707
708 let mut sorted_tasks = phase.tasks.clone();
710 sorted_tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
711
712 output.push_str("@nodes\n");
714 output.push_str("# id | title | status | complexity | priority\n");
715 for task in &sorted_tasks {
716 output.push_str(&format!(
717 "{} | {} | {} | {} | {}\n",
718 task.id,
719 escape_text(&task.title),
720 status_to_code(&task.status),
721 task.complexity,
722 priority_to_code(&task.priority)
723 ));
724 }
725 output.push('\n');
726
727 if !pipeline.edge_attrs.is_empty() {
729 output.push_str("@edges\n");
730 output.push_str("# from -> to [| label | condition | weight]\n");
731 for edge in &pipeline.edge_attrs {
732 let has_extras = !edge.label.is_empty() || !edge.condition.is_empty() || edge.weight != 0;
733 if has_extras {
734 output.push_str(&format!(
735 "{} -> {} | {} | {} | {}\n",
736 edge.from, edge.to, edge.label, edge.condition, edge.weight
737 ));
738 } else {
739 output.push_str(&format!("{} -> {}\n", edge.from, edge.to));
740 }
741 }
742 output.push('\n');
743 }
744
745 if !pipeline.node_attrs.is_empty() {
747 output.push_str("@pipeline\n");
748 output.push_str("# id | handler_type | max_retries | retry_target | goal_gate | timeout\n");
749
750 let task_ids: Vec<&str> = sorted_tasks.iter().map(|t| t.id.as_str()).collect();
752 let mut sorted_attrs: Vec<_> = pipeline.node_attrs.iter().collect();
753 sorted_attrs.sort_by(|(a, _), (b, _)| {
754 let a_pos = task_ids.iter().position(|id| id == a).unwrap_or(usize::MAX);
755 let b_pos = task_ids.iter().position(|id| id == b).unwrap_or(usize::MAX);
756 a_pos.cmp(&b_pos)
757 });
758
759 for (id, attrs) in sorted_attrs {
760 let retry_target = attrs.retry_target.as_deref().unwrap_or("");
761 let goal_gate = if attrs.goal_gate { "true" } else { "" };
762 let timeout = attrs.timeout.as_deref().unwrap_or("");
763
764 if !timeout.is_empty() {
766 output.push_str(&format!(
767 "{} | {} | {} | {} | {} | {}\n",
768 id, attrs.handler_type, attrs.max_retries, retry_target, goal_gate, timeout
769 ));
770 } else if !goal_gate.is_empty() {
771 output.push_str(&format!(
772 "{} | {} | {} | {} | {}\n",
773 id, attrs.handler_type, attrs.max_retries, retry_target, goal_gate
774 ));
775 } else if !retry_target.is_empty() {
776 output.push_str(&format!(
777 "{} | {} | {} | {}\n",
778 id, attrs.handler_type, attrs.max_retries, retry_target
779 ));
780 } else if attrs.max_retries > 0 {
781 output.push_str(&format!(
782 "{} | {} | {}\n",
783 id, attrs.handler_type, attrs.max_retries
784 ));
785 } else {
786 output.push_str(&format!("{} | {}\n", id, attrs.handler_type));
787 }
788 }
789 output.push('\n');
790 }
791
792 let tasks_with_details: Vec<_> = sorted_tasks
794 .iter()
795 .filter(|t| !t.description.is_empty() || t.details.is_some() || t.test_strategy.is_some())
796 .collect();
797
798 if !tasks_with_details.is_empty() {
799 output.push_str("@details\n");
800 for task in tasks_with_details {
801 if !task.description.is_empty() {
802 output.push_str(&format!("{} | description |\n", task.id));
803 for line in task.description.lines() {
804 output.push_str(&format!(" {}\n", line));
805 }
806 }
807 if let Some(ref details) = task.details {
808 output.push_str(&format!("{} | details |\n", task.id));
809 for line in details.lines() {
810 output.push_str(&format!(" {}\n", line));
811 }
812 }
813 if let Some(ref test_strategy) = task.test_strategy {
814 output.push_str(&format!("{} | test_strategy |\n", task.id));
815 for line in test_strategy.lines() {
816 output.push_str(&format!(" {}\n", line));
817 }
818 }
819 }
820 }
821
822 output
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828
829 #[test]
830 fn test_status_codes() {
831 assert_eq!(status_to_code(&TaskStatus::Pending), 'P');
832 assert_eq!(status_to_code(&TaskStatus::InProgress), 'I');
833 assert_eq!(status_to_code(&TaskStatus::Done), 'D');
834 assert_eq!(status_to_code(&TaskStatus::Expanded), 'X');
835 assert_eq!(status_to_code(&TaskStatus::Failed), '!');
836
837 assert_eq!(code_to_status('P'), Some(TaskStatus::Pending));
838 assert_eq!(code_to_status('X'), Some(TaskStatus::Expanded));
839 assert_eq!(code_to_status('!'), Some(TaskStatus::Failed));
840 assert_eq!(code_to_status('Z'), None);
841 }
842
843 #[test]
844 fn test_priority_codes() {
845 assert_eq!(priority_to_code(&Priority::Critical), 'C');
846 assert_eq!(priority_to_code(&Priority::High), 'H');
847 assert_eq!(priority_to_code(&Priority::Medium), 'M');
848 assert_eq!(priority_to_code(&Priority::Low), 'L');
849
850 assert_eq!(code_to_priority('C'), Some(Priority::Critical));
851 assert_eq!(code_to_priority('H'), Some(Priority::High));
852 assert_eq!(code_to_priority('M'), Some(Priority::Medium));
853 assert_eq!(code_to_priority('L'), Some(Priority::Low));
854 assert_eq!(code_to_priority('Z'), None);
855 }
856
857 #[test]
858 fn test_escape_unescape() {
859 assert_eq!(escape_text("hello|world"), "hello\\|world");
860 assert_eq!(escape_text("line1\nline2"), "line1\\nline2");
861 assert_eq!(unescape_text("hello\\|world"), "hello|world");
862 assert_eq!(unescape_text("line1\\nline2"), "line1\nline2");
863 }
864
865 #[test]
866 fn test_id_format_round_trip() {
867 use crate::models::IdFormat;
868
869 let mut phase = Phase::new("uuid-phase".to_string());
871 phase.id_format = IdFormat::Uuid;
872
873 let task = Task::new(
874 "a1b2c3d4e5f6789012345678901234ab".to_string(),
875 "UUID Task".to_string(),
876 "Description".to_string(),
877 );
878 phase.add_task(task);
879
880 let scg = serialize_scg(&phase);
881 let parsed = parse_scg(&scg).unwrap();
882
883 assert_eq!(parsed.id_format, IdFormat::Uuid);
884 assert_eq!(parsed.name, "uuid-phase");
885 }
886
887 #[test]
888 fn test_id_format_default_sequential() {
889 let content = r#"# SCUD Graph v1
891# Phase: old-phase
892
893@meta {
894 name old-phase
895 updated 2025-01-01T00:00:00Z
896}
897
898@nodes
899# id | title | status | complexity | priority
9001 | Task | P | 0 | M
901"#;
902 let phase = parse_scg(content).unwrap();
903 assert_eq!(phase.id_format, IdFormat::Sequential);
904 }
905
906 #[test]
907 fn test_round_trip() {
908 let mut epic = Phase::new("test-epic".to_string());
909
910 let mut task1 = Task::new(
911 "1".to_string(),
912 "First task".to_string(),
913 "Description".to_string(),
914 );
915 task1.complexity = 5;
916 task1.priority = Priority::High;
917 task1.status = TaskStatus::Done;
918
919 let mut task2 = Task::new(
920 "2".to_string(),
921 "Second task".to_string(),
922 "Another desc".to_string(),
923 );
924 task2.dependencies = vec!["1".to_string()];
925 task2.complexity = 3;
926
927 epic.add_task(task1);
928 epic.add_task(task2);
929
930 let scg = serialize_scg(&epic);
931 let parsed = parse_scg(&scg).unwrap();
932
933 assert_eq!(parsed.name, "test-epic");
934 assert_eq!(parsed.tasks.len(), 2);
935
936 let t1 = parsed.get_task("1").unwrap();
937 assert_eq!(t1.title, "First task");
938 assert_eq!(t1.complexity, 5);
939 assert_eq!(t1.status, TaskStatus::Done);
940
941 let t2 = parsed.get_task("2").unwrap();
942 assert_eq!(t2.dependencies, vec!["1".to_string()]);
943 }
944
945 #[test]
946 fn test_parent_child() {
947 let mut epic = Phase::new("parent-test".to_string());
948
949 let mut parent = Task::new(
950 "1".to_string(),
951 "Parent".to_string(),
952 "Parent task".to_string(),
953 );
954 parent.status = TaskStatus::Expanded;
955 parent.subtasks = vec!["1.1".to_string(), "1.2".to_string()];
956
957 let mut child1 = Task::new(
958 "1.1".to_string(),
959 "Child 1".to_string(),
960 "First child".to_string(),
961 );
962 child1.parent_id = Some("1".to_string());
963
964 let mut child2 = Task::new(
965 "1.2".to_string(),
966 "Child 2".to_string(),
967 "Second child".to_string(),
968 );
969 child2.parent_id = Some("1".to_string());
970 child2.dependencies = vec!["1.1".to_string()];
971
972 epic.add_task(parent);
973 epic.add_task(child1);
974 epic.add_task(child2);
975
976 let scg = serialize_scg(&epic);
977 let parsed = parse_scg(&scg).unwrap();
978
979 let p = parsed.get_task("1").unwrap();
980 assert_eq!(p.subtasks, vec!["1.1", "1.2"]);
981
982 let c1 = parsed.get_task("1.1").unwrap();
983 assert_eq!(c1.parent_id, Some("1".to_string()));
984
985 let c2 = parsed.get_task("1.2").unwrap();
986 assert_eq!(c2.parent_id, Some("1".to_string()));
987 assert_eq!(c2.dependencies, vec!["1.1".to_string()]);
988 }
989
990 #[test]
991 fn test_malformed_header() {
992 let result = parse_scg("not a valid scg file");
993 assert!(result.is_err());
994 }
995
996 #[test]
997 fn test_empty_phase() {
998 let content = "# SCUD Graph v1\n# Phase: empty\n\n@nodes\n# id | title | status | complexity | priority\n";
999 let phase = parse_scg(content).unwrap();
1000 assert_eq!(phase.name, "empty");
1001 assert!(phase.tasks.is_empty());
1002 }
1003
1004 #[test]
1005 fn test_special_characters_in_title() {
1006 let mut epic = Phase::new("test".to_string());
1007 let task = Task::new(
1008 "1".to_string(),
1009 "Task with | pipe".to_string(),
1010 "Desc".to_string(),
1011 );
1012 epic.add_task(task);
1013
1014 let scg = serialize_scg(&epic);
1015 let parsed = parse_scg(&scg).unwrap();
1016
1017 assert_eq!(parsed.get_task("1").unwrap().title, "Task with | pipe");
1018 }
1019
1020 #[test]
1021 fn test_multiline_description() {
1022 let mut epic = Phase::new("test".to_string());
1023 let task = Task::new(
1024 "1".to_string(),
1025 "Task".to_string(),
1026 "Line 1\nLine 2\nLine 3".to_string(),
1027 );
1028 epic.add_task(task);
1029
1030 let scg = serialize_scg(&epic);
1031 let parsed = parse_scg(&scg).unwrap();
1032
1033 let t = parsed.get_task("1").unwrap();
1034 assert_eq!(t.description, "Line 1\nLine 2\nLine 3");
1035 }
1036
1037 #[test]
1038 fn test_assignments() {
1039 let mut epic = Phase::new("test".to_string());
1040 let mut task = Task::new("1".to_string(), "Task".to_string(), "Desc".to_string());
1041 task.assigned_to = Some("alice".to_string());
1042 epic.add_task(task);
1043
1044 let scg = serialize_scg(&epic);
1045 let parsed = parse_scg(&scg).unwrap();
1046
1047 let t = parsed.get_task("1").unwrap();
1048 assert_eq!(t.assigned_to, Some("alice".to_string()));
1049 }
1050
1051 #[test]
1052 fn test_agent_types() {
1053 let mut epic = Phase::new("test".to_string());
1054 let mut task1 = Task::new(
1055 "1".to_string(),
1056 "Review Task".to_string(),
1057 "Desc".to_string(),
1058 );
1059 task1.agent_type = Some("reviewer".to_string());
1060
1061 let mut task2 = Task::new(
1062 "2".to_string(),
1063 "Build Task".to_string(),
1064 "Desc".to_string(),
1065 );
1066 task2.agent_type = Some("builder".to_string());
1067
1068 let task3 = Task::new("3".to_string(), "No Agent".to_string(), "Desc".to_string());
1069 epic.add_task(task1);
1072 epic.add_task(task2);
1073 epic.add_task(task3);
1074
1075 let scg = serialize_scg(&epic);
1076
1077 assert!(scg.contains("@agents"));
1079 assert!(scg.contains("1 | reviewer"));
1080 assert!(scg.contains("2 | builder"));
1081
1082 let parsed = parse_scg(&scg).unwrap();
1083
1084 let t1 = parsed.get_task("1").unwrap();
1085 assert_eq!(t1.agent_type, Some("reviewer".to_string()));
1086
1087 let t2 = parsed.get_task("2").unwrap();
1088 assert_eq!(t2.agent_type, Some("builder".to_string()));
1089
1090 let t3 = parsed.get_task("3").unwrap();
1091 assert_eq!(t3.agent_type, None);
1092 }
1093
1094 #[test]
1095 fn test_natural_sort_order() {
1096 let mut epic = Phase::new("test".to_string());
1097
1098 for id in ["10", "2", "1", "1.10", "1.2", "1.1"] {
1100 let task = Task::new(id.to_string(), format!("Task {}", id), String::new());
1101 epic.add_task(task);
1102 }
1103
1104 let scg = serialize_scg(&epic);
1105 let parsed = parse_scg(&scg).unwrap();
1106
1107 let ids: Vec<&str> = parsed.tasks.iter().map(|t| t.id.as_str()).collect();
1108 assert_eq!(ids, vec!["1", "1.1", "1.2", "1.10", "2", "10"]);
1109 }
1110
1111 #[test]
1112 fn test_natural_sort_uuids() {
1113 use super::natural_sort_ids;
1115
1116 let uuid_a = "a1b2c3d4e5f6789012345678901234ab";
1118 let uuid_b = "b1b2c3d4e5f6789012345678901234ab";
1119 let uuid_c = "c1b2c3d4e5f6789012345678901234ab";
1120
1121 assert_eq!(natural_sort_ids(uuid_a, uuid_b), std::cmp::Ordering::Less);
1122 assert_eq!(natural_sort_ids(uuid_b, uuid_c), std::cmp::Ordering::Less);
1123 assert_eq!(natural_sort_ids(uuid_a, uuid_a), std::cmp::Ordering::Equal);
1124
1125 let mut ids = vec![uuid_c, uuid_a, uuid_b];
1127 ids.sort_by(|a, b| natural_sort_ids(a, b));
1128 assert_eq!(ids, vec![uuid_a, uuid_b, uuid_c]);
1129 }
1130
1131 #[test]
1132 fn test_natural_sort_mixed_numeric_uuid() {
1133 use super::natural_sort_ids;
1135
1136 let numeric = "123";
1137 let uuid = "a1b2c3d4e5f6789012345678901234ab";
1138
1139 assert_eq!(natural_sort_ids(numeric, uuid), std::cmp::Ordering::Less);
1141 }
1142
1143 #[test]
1144 fn test_all_statuses() {
1145 let mut epic = Phase::new("test".to_string());
1146
1147 let statuses = [
1148 ("1", TaskStatus::Pending),
1149 ("2", TaskStatus::InProgress),
1150 ("3", TaskStatus::Done),
1151 ("4", TaskStatus::Review),
1152 ("5", TaskStatus::Blocked),
1153 ("6", TaskStatus::Deferred),
1154 ("7", TaskStatus::Cancelled),
1155 ("8", TaskStatus::Expanded),
1156 ("9", TaskStatus::Failed),
1157 ];
1158
1159 for (id, status) in &statuses {
1160 let mut task = Task::new(id.to_string(), format!("Task {}", id), String::new());
1161 task.status = status.clone();
1162 epic.add_task(task);
1163 }
1164
1165 let scg = serialize_scg(&epic);
1166 let parsed = parse_scg(&scg).unwrap();
1167
1168 for (id, expected_status) in statuses {
1169 let task = parsed.get_task(id).unwrap();
1170 assert_eq!(
1171 task.status, expected_status,
1172 "Status mismatch for task {}",
1173 id
1174 );
1175 }
1176 }
1177
1178 #[test]
1179 fn test_all_priorities() {
1180 let mut epic = Phase::new("test".to_string());
1181
1182 let priorities = [
1183 ("1", Priority::Critical),
1184 ("2", Priority::High),
1185 ("3", Priority::Medium),
1186 ("4", Priority::Low),
1187 ];
1188
1189 for (id, priority) in &priorities {
1190 let mut task = Task::new(id.to_string(), format!("Task {}", id), String::new());
1191 task.priority = priority.clone();
1192 epic.add_task(task);
1193 }
1194
1195 let scg = serialize_scg(&epic);
1196 let parsed = parse_scg(&scg).unwrap();
1197
1198 for (id, expected_priority) in priorities {
1199 let task = parsed.get_task(id).unwrap();
1200 assert_eq!(
1201 task.priority, expected_priority,
1202 "Priority mismatch for task {}",
1203 id
1204 );
1205 }
1206 }
1207
1208 #[test]
1209 fn test_details_and_test_strategy() {
1210 let mut epic = Phase::new("test".to_string());
1211 let mut task = Task::new(
1212 "1".to_string(),
1213 "Task".to_string(),
1214 "Description".to_string(),
1215 );
1216 task.details = Some("Detailed implementation notes".to_string());
1217 task.test_strategy = Some("Unit tests and integration tests".to_string());
1218 epic.add_task(task);
1219
1220 let scg = serialize_scg(&epic);
1221 let parsed = parse_scg(&scg).unwrap();
1222
1223 let t = parsed.get_task("1").unwrap();
1224 assert_eq!(t.description, "Description");
1225 assert_eq!(t.details, Some("Detailed implementation notes".to_string()));
1226 assert_eq!(
1227 t.test_strategy,
1228 Some("Unit tests and integration tests".to_string())
1229 );
1230 }
1231
1232 #[test]
1235 fn test_backslash_escape() {
1236 let mut epic = Phase::new("test".to_string());
1237 let task = Task::new(
1238 "1".to_string(),
1239 "Task with \\ backslash".to_string(),
1240 "Desc".to_string(),
1241 );
1242 epic.add_task(task);
1243
1244 let scg = serialize_scg(&epic);
1245 let parsed = parse_scg(&scg).unwrap();
1246
1247 assert_eq!(
1248 parsed.get_task("1").unwrap().title,
1249 "Task with \\ backslash"
1250 );
1251 }
1252
1253 #[test]
1254 fn test_multiple_special_chars() {
1255 let mut epic = Phase::new("test".to_string());
1256 let task = Task::new(
1257 "1".to_string(),
1258 "Task with | pipe and \\ backslash".to_string(),
1259 "Line 1\nLine 2 with | and \\".to_string(),
1260 );
1261 epic.add_task(task);
1262
1263 let scg = serialize_scg(&epic);
1264 let parsed = parse_scg(&scg).unwrap();
1265
1266 let t = parsed.get_task("1").unwrap();
1267 assert_eq!(t.title, "Task with | pipe and \\ backslash");
1268 assert_eq!(t.description, "Line 1\nLine 2 with | and \\");
1269 }
1270
1271 #[test]
1272 fn test_unicode_content() {
1273 let mut epic = Phase::new("unicode-test".to_string());
1274 let task = Task::new(
1275 "1".to_string(),
1276 "日本語タイトル 🚀 Émojis".to_string(),
1277 "描述 with émojis 😀".to_string(),
1278 );
1279 epic.add_task(task);
1280
1281 let scg = serialize_scg(&epic);
1282 let parsed = parse_scg(&scg).unwrap();
1283
1284 let t = parsed.get_task("1").unwrap();
1285 assert_eq!(t.title, "日本語タイトル 🚀 Émojis");
1286 assert_eq!(t.description, "描述 with émojis 😀");
1287 }
1288
1289 #[test]
1290 fn test_empty_dependencies() {
1291 let mut epic = Phase::new("test".to_string());
1292 let task = Task::new("1".to_string(), "Task".to_string(), "Desc".to_string());
1293 epic.add_task(task);
1294
1295 let scg = serialize_scg(&epic);
1296 let parsed = parse_scg(&scg).unwrap();
1297
1298 assert!(parsed.get_task("1").unwrap().dependencies.is_empty());
1299 }
1300
1301 #[test]
1302 fn test_multiple_dependencies() {
1303 let mut epic = Phase::new("test".to_string());
1304
1305 let task1 = Task::new("1".to_string(), "Task 1".to_string(), String::new());
1306 let task2 = Task::new("2".to_string(), "Task 2".to_string(), String::new());
1307 let mut task3 = Task::new("3".to_string(), "Task 3".to_string(), String::new());
1308 task3.dependencies = vec!["1".to_string(), "2".to_string()];
1309
1310 epic.add_task(task1);
1311 epic.add_task(task2);
1312 epic.add_task(task3);
1313
1314 let scg = serialize_scg(&epic);
1315 let parsed = parse_scg(&scg).unwrap();
1316
1317 let t3 = parsed.get_task("3").unwrap();
1318 assert_eq!(t3.dependencies.len(), 2);
1319 assert!(t3.dependencies.contains(&"1".to_string()));
1320 assert!(t3.dependencies.contains(&"2".to_string()));
1321 }
1322
1323 #[test]
1324 fn test_complexity_boundary_values() {
1325 let mut epic = Phase::new("test".to_string());
1326
1327 let complexities: Vec<u32> = vec![0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
1329 for (i, c) in complexities.iter().enumerate() {
1330 let mut task = Task::new(
1331 format!("{}", i + 1),
1332 format!("Task {}", i + 1),
1333 String::new(),
1334 );
1335 task.complexity = *c;
1336 epic.add_task(task);
1337 }
1338
1339 let scg = serialize_scg(&epic);
1340 let parsed = parse_scg(&scg).unwrap();
1341
1342 for (i, expected) in complexities.iter().enumerate() {
1343 let task = parsed.get_task(&format!("{}", i + 1)).unwrap();
1344 assert_eq!(
1345 task.complexity,
1346 *expected,
1347 "Complexity mismatch for task {}",
1348 i + 1
1349 );
1350 }
1351 }
1352
1353 #[test]
1354 fn test_long_description() {
1355 let mut epic = Phase::new("test".to_string());
1356 let long_desc = "A".repeat(5000); let task = Task::new("1".to_string(), "Task".to_string(), long_desc.clone());
1358 epic.add_task(task);
1359
1360 let scg = serialize_scg(&epic);
1361 let parsed = parse_scg(&scg).unwrap();
1362
1363 assert_eq!(parsed.get_task("1").unwrap().description, long_desc);
1364 }
1365
1366 #[test]
1367 fn test_empty_description() {
1368 let mut epic = Phase::new("test".to_string());
1369 let task = Task::new("1".to_string(), "Task".to_string(), String::new());
1370 epic.add_task(task);
1371
1372 let scg = serialize_scg(&epic);
1373 let parsed = parse_scg(&scg).unwrap();
1374
1375 assert_eq!(parsed.get_task("1").unwrap().description, "");
1376 }
1377
1378 #[test]
1379 fn test_whitespace_handling() {
1380 let mut epic = Phase::new("test".to_string());
1382 let task = Task::new(
1383 "1".to_string(),
1384 " Task with spaces ".to_string(),
1385 "Desc".to_string(),
1386 );
1387 epic.add_task(task);
1388
1389 let scg = serialize_scg(&epic);
1390 let parsed = parse_scg(&scg).unwrap();
1391
1392 let t = parsed.get_task("1").unwrap();
1395 assert_eq!(t.title, "Task with spaces");
1396 }
1397
1398 #[test]
1399 fn test_nested_subtasks() {
1400 let mut epic = Phase::new("test".to_string());
1401
1402 let mut parent = Task::new("1".to_string(), "Parent".to_string(), String::new());
1404 parent.status = TaskStatus::Expanded;
1405 parent.subtasks = vec!["1.1".to_string(), "1.2".to_string()];
1406
1407 let mut child1 = Task::new("1.1".to_string(), "Child 1".to_string(), String::new());
1408 child1.parent_id = Some("1".to_string());
1409
1410 let mut child2 = Task::new("1.2".to_string(), "Child 2".to_string(), String::new());
1411 child2.parent_id = Some("1".to_string());
1412 child2.status = TaskStatus::Expanded;
1413 child2.subtasks = vec!["1.2.1".to_string()];
1414
1415 let mut grandchild =
1416 Task::new("1.2.1".to_string(), "Grandchild".to_string(), String::new());
1417 grandchild.parent_id = Some("1.2".to_string());
1418
1419 epic.add_task(parent);
1420 epic.add_task(child1);
1421 epic.add_task(child2);
1422 epic.add_task(grandchild);
1423
1424 let scg = serialize_scg(&epic);
1425 let parsed = parse_scg(&scg).unwrap();
1426
1427 assert_eq!(parsed.tasks.len(), 4);
1428
1429 let gc = parsed.get_task("1.2.1").unwrap();
1430 assert_eq!(gc.parent_id, Some("1.2".to_string()));
1431
1432 let c2 = parsed.get_task("1.2").unwrap();
1433 assert!(c2.subtasks.contains(&"1.2.1".to_string()));
1434 }
1435
1436 #[test]
1437 fn test_section_comment_lines_ignored() {
1438 let content = r#"# SCUD Graph v1
1440# Epic: test
1441
1442@meta {
1443 name test
1444 # this is a comment
1445 updated 2025-01-01T00:00:00Z
1446}
1447
1448@nodes
1449# id | title | status | complexity | priority
1450# another comment
14511 | Task | P | 0 | M
1452"#;
1453 let epic = parse_scg(content).unwrap();
1454 assert_eq!(epic.tasks.len(), 1);
1455 assert_eq!(epic.get_task("1").unwrap().title, "Task");
1456 }
1457}