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
13fn status_to_code(status: &TaskStatus) -> char {
15 match status {
16 TaskStatus::Pending => 'P',
17 TaskStatus::InProgress => 'I',
18 TaskStatus::Done => 'D',
19 TaskStatus::Review => 'R',
20 TaskStatus::Blocked => 'B',
21 TaskStatus::Deferred => 'F',
22 TaskStatus::Cancelled => 'C',
23 TaskStatus::Expanded => 'X',
24 TaskStatus::Failed => '!',
25 }
26}
27
28fn code_to_status(code: char) -> Option<TaskStatus> {
29 match code {
30 'P' => Some(TaskStatus::Pending),
31 'I' => Some(TaskStatus::InProgress),
32 'D' => Some(TaskStatus::Done),
33 'R' => Some(TaskStatus::Review),
34 'B' => Some(TaskStatus::Blocked),
35 'F' => Some(TaskStatus::Deferred),
36 'C' => Some(TaskStatus::Cancelled),
37 'X' => Some(TaskStatus::Expanded),
38 '!' => Some(TaskStatus::Failed),
39 _ => None,
40 }
41}
42
43fn priority_to_code(priority: &Priority) -> char {
44 match priority {
45 Priority::Critical => 'C',
46 Priority::High => 'H',
47 Priority::Medium => 'M',
48 Priority::Low => 'L',
49 }
50}
51
52fn code_to_priority(code: char) -> Option<Priority> {
53 match code {
54 'C' => Some(Priority::Critical),
55 'H' => Some(Priority::High),
56 'M' => Some(Priority::Medium),
57 'L' => Some(Priority::Low),
58 _ => None,
59 }
60}
61
62fn escape_text(text: &str) -> String {
64 text.replace('\\', "\\\\")
65 .replace('|', "\\|")
66 .replace('\n', "\\n")
67}
68
69fn unescape_text(text: &str) -> String {
71 let mut result = String::with_capacity(text.len());
72 let mut chars = text.chars().peekable();
73
74 while let Some(c) = chars.next() {
75 if c == '\\' {
76 match chars.next() {
77 Some('\\') => result.push('\\'),
78 Some('|') => result.push('|'),
79 Some('n') => result.push('\n'),
80 Some(other) => {
81 result.push('\\');
82 result.push(other);
83 }
84 None => result.push('\\'),
85 }
86 } else {
87 result.push(c);
88 }
89 }
90 result
91}
92
93fn split_by_pipe(line: &str) -> Vec<String> {
95 let mut parts = Vec::new();
96 let mut current = String::new();
97 let mut chars = line.chars().peekable();
98
99 while let Some(c) = chars.next() {
100 if c == '\\' {
101 if let Some(&next) = chars.peek() {
103 if next == '|' || next == '\\' {
104 current.push(c);
105 current.push(chars.next().unwrap());
106 continue;
107 }
108 }
109 current.push(c);
110 } else if c == '|' {
111 parts.push(current.trim().to_string());
112 current = String::new();
113 } else {
114 current.push(c);
115 }
116 }
117 parts.push(current.trim().to_string());
118 parts
119}
120
121pub fn parse_scg(content: &str) -> Result<Phase> {
123 let mut lines = content.lines().peekable();
124
125 let first_line = lines.next().context("Empty file")?;
127 if !first_line.starts_with(HEADER_PREFIX) {
128 anyhow::bail!(
129 "Invalid SCG header: expected '{}', got '{}'",
130 HEADER_PREFIX,
131 first_line
132 );
133 }
134
135 let phase_line = lines.next().context("Missing phase tag line")?;
136 let phase_tag = phase_line
137 .strip_prefix("# Phase:")
138 .or_else(|| phase_line.strip_prefix("# Epic:")) .map(|s| s.trim())
140 .context("Invalid phase line format")?;
141
142 let mut phase = Phase::new(phase_tag.to_string());
143 let mut tasks: HashMap<String, Task> = HashMap::new();
144 let mut edges: Vec<(String, String)> = Vec::new();
145 let mut parents: HashMap<String, Vec<String>> = HashMap::new();
146 let mut details: HashMap<String, HashMap<String, String>> = HashMap::new();
147 type AssignmentInfo = (Option<String>, Option<String>, Option<String>);
149 let mut assignments: HashMap<String, AssignmentInfo> = HashMap::new();
150 let mut agent_types: HashMap<String, String> = HashMap::new();
151
152 let mut current_section: Option<&str> = None;
154 let mut current_detail_id: Option<String> = None;
155 let mut current_detail_field: Option<String> = None;
156 let mut current_detail_content: Vec<String> = Vec::new();
157
158 for line in lines {
159 let trimmed = line.trim();
160
161 if trimmed.is_empty() {
163 continue;
164 }
165
166 if trimmed.starts_with('@') {
168 flush_detail(
170 ¤t_detail_id,
171 ¤t_detail_field,
172 &mut current_detail_content,
173 &mut details,
174 );
175 current_detail_id = None;
176 current_detail_field = None;
177
178 current_section = Some(match trimmed {
179 "@meta {" | "@meta" => "meta",
180 "@nodes" => "nodes",
181 "@edges" => "edges",
182 "@parents" => "parents",
183 "@assignments" => "assignments",
184 "@agents" => "agents",
185 "@details" => "details",
186 _ => continue,
187 });
188 continue;
189 }
190
191 if current_section == Some("details")
193 && line.starts_with(" ")
194 && current_detail_id.is_some()
195 {
196 current_detail_content.push(line[2..].to_string());
197 continue;
198 }
199
200 if trimmed == "}" || trimmed.starts_with('#') {
202 continue;
203 }
204
205 match current_section {
206 Some("meta") => {
207 if let Some((key, value)) = trimmed.split_once(char::is_whitespace) {
209 let value = value.trim();
210 match key {
211 "name" => {
212 if phase.name != value {
213 phase = Phase::new(value.to_string());
214 }
215 }
216 "id_format" => {
217 phase.id_format = IdFormat::parse(value);
218 }
219 _ => {
220 }
222 }
223 }
224 }
225 Some("nodes") => {
226 let parts = split_by_pipe(trimmed);
228 if parts.len() >= 5 {
229 let id = parts[0].clone();
230 let title = unescape_text(&parts[1]);
231 let status =
232 code_to_status(parts[2].chars().next().unwrap_or('P')).unwrap_or_default();
233 let complexity: u32 = parts[3].parse().unwrap_or(0);
234 let priority = code_to_priority(parts[4].chars().next().unwrap_or('M'))
235 .unwrap_or_default();
236
237 let mut task = Task::new(id.clone(), title, String::new());
238 task.status = status;
239 task.complexity = complexity;
240 task.priority = priority;
241 tasks.insert(id, task);
242 }
243 }
244 Some("edges") => {
245 if let Some((dependent, dependency)) = trimmed.split_once("->") {
247 edges.push((dependent.trim().to_string(), dependency.trim().to_string()));
248 }
249 }
250 Some("parents") => {
251 if let Some((parent, children)) = trimmed.split_once(':') {
253 let child_ids: Vec<String> = children
254 .split(',')
255 .map(|s| s.trim().to_string())
256 .filter(|s| !s.is_empty())
257 .collect();
258 parents.insert(parent.trim().to_string(), child_ids);
259 }
260 }
261 Some("assignments") => {
262 let parts = split_by_pipe(trimmed);
264 if parts.len() >= 2 {
265 let id = parts[0].clone();
266 let assigned = if parts[1].is_empty() {
267 None
268 } else {
269 Some(parts[1].clone())
270 };
271 let locked_by: Option<String> = None;
273 let locked_at: Option<String> = None;
274 assignments.insert(id, (assigned, locked_by, locked_at));
275 }
276 }
277 Some("agents") => {
278 let parts = split_by_pipe(trimmed);
280 if parts.len() >= 2 && !parts[1].is_empty() {
281 agent_types.insert(parts[0].clone(), parts[1].clone());
282 }
283 }
284 Some("details") => {
285 flush_detail(
287 ¤t_detail_id,
288 ¤t_detail_field,
289 &mut current_detail_content,
290 &mut details,
291 );
292
293 let parts = split_by_pipe(trimmed);
295 if parts.len() >= 2 {
296 current_detail_id = Some(parts[0].clone());
297 current_detail_field = Some(parts[1].clone());
298 current_detail_content.clear();
299 }
300 }
301 _ => {}
302 }
303 }
304
305 flush_detail(
307 ¤t_detail_id,
308 ¤t_detail_field,
309 &mut current_detail_content,
310 &mut details,
311 );
312
313 for (dependent, dependency) in edges {
315 if let Some(task) = tasks.get_mut(&dependent) {
316 task.dependencies.push(dependency);
317 }
318 }
319
320 for (parent_id, child_ids) in parents {
322 if let Some(parent) = tasks.get_mut(&parent_id) {
323 parent.subtasks = child_ids.clone();
324 }
325 for child_id in child_ids {
326 if let Some(child) = tasks.get_mut(&child_id) {
327 child.parent_id = Some(parent_id.clone());
328 }
329 }
330 }
331
332 for (id, fields) in details {
334 if let Some(task) = tasks.get_mut(&id) {
335 if let Some(desc) = fields.get("description") {
336 task.description = desc.clone();
337 }
338 if let Some(det) = fields.get("details") {
339 task.details = Some(det.clone());
340 }
341 if let Some(ts) = fields.get("test_strategy") {
342 task.test_strategy = Some(ts.clone());
343 }
344 }
345 }
346
347 for (id, (assigned, _locked_by, _locked_at)) in assignments {
349 if let Some(task) = tasks.get_mut(&id) {
350 task.assigned_to = assigned;
351 }
352 }
353
354 for (id, agent_type) in agent_types {
356 if let Some(task) = tasks.get_mut(&id) {
357 task.agent_type = Some(agent_type);
358 }
359 }
360
361 phase.tasks = tasks.into_values().collect();
363
364 phase.tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
366
367 Ok(phase)
368}
369
370pub fn natural_sort_ids(a: &str, b: &str) -> std::cmp::Ordering {
374 let a_is_numeric = a.chars().all(|c| c.is_ascii_digit() || c == '.');
376 let b_is_numeric = b.chars().all(|c| c.is_ascii_digit() || c == '.');
377
378 if a_is_numeric && b_is_numeric {
379 let a_parts: Vec<&str> = a.split('.').collect();
381 let b_parts: Vec<&str> = b.split('.').collect();
382
383 for (ap, bp) in a_parts.iter().zip(b_parts.iter()) {
384 match (ap.parse::<u32>(), bp.parse::<u32>()) {
385 (Ok(an), Ok(bn)) => {
386 if an != bn {
387 return an.cmp(&bn);
388 }
389 }
390 _ => {
391 if ap != bp {
392 return ap.cmp(bp);
393 }
394 }
395 }
396 }
397 a_parts.len().cmp(&b_parts.len())
398 } else {
399 a.cmp(b)
401 }
402}
403
404fn flush_detail(
406 id: &Option<String>,
407 field: &Option<String>,
408 content: &mut Vec<String>,
409 details: &mut HashMap<String, HashMap<String, String>>,
410) {
411 if let (Some(id), Some(field)) = (id, field) {
412 let text = content.join("\n");
413 details
414 .entry(id.clone())
415 .or_default()
416 .insert(field.clone(), text);
417 content.clear();
418 }
419}
420
421pub fn serialize_scg(phase: &Phase) -> String {
423 let mut output = String::new();
424
425 output.push_str(&format!("{} {}\n", HEADER_PREFIX, FORMAT_VERSION));
427 output.push_str(&format!("# Phase: {}\n\n", phase.name));
428
429 let now = chrono::Utc::now().to_rfc3339();
431 output.push_str("@meta {\n");
432 output.push_str(&format!(" name {}\n", phase.name));
433 output.push_str(&format!(" id_format {}\n", phase.id_format.as_str()));
434 output.push_str(&format!(" updated {}\n", now));
435 output.push_str("}\n\n");
436
437 let mut sorted_tasks = phase.tasks.clone();
439 sorted_tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
440
441 output.push_str("@nodes\n");
443 output.push_str("# id | title | status | complexity | priority\n");
444 for task in &sorted_tasks {
445 output.push_str(&format!(
446 "{} | {} | {} | {} | {}\n",
447 task.id,
448 escape_text(&task.title),
449 status_to_code(&task.status),
450 task.complexity,
451 priority_to_code(&task.priority)
452 ));
453 }
454 output.push('\n');
455
456 let edges: Vec<_> = sorted_tasks
458 .iter()
459 .flat_map(|t| t.dependencies.iter().map(move |dep| (&t.id, dep)))
460 .collect();
461
462 if !edges.is_empty() {
463 output.push_str("@edges\n");
464 output.push_str("# dependent -> dependency\n");
465 for (dependent, dependency) in edges {
466 output.push_str(&format!("{} -> {}\n", dependent, dependency));
467 }
468 output.push('\n');
469 }
470
471 let parents: Vec<_> = sorted_tasks
473 .iter()
474 .filter(|t| !t.subtasks.is_empty())
475 .collect();
476
477 if !parents.is_empty() {
478 output.push_str("@parents\n");
479 output.push_str("# parent: subtasks...\n");
480 for task in parents {
481 output.push_str(&format!("{}: {}\n", task.id, task.subtasks.join(", ")));
482 }
483 output.push('\n');
484 }
485
486 let assignments: Vec<_> = sorted_tasks
488 .iter()
489 .filter(|t| t.assigned_to.is_some())
490 .collect();
491
492 if !assignments.is_empty() {
493 output.push_str("@assignments\n");
494 output.push_str("# id | assigned_to\n");
495 for task in assignments {
496 output.push_str(&format!(
497 "{} | {}\n",
498 task.id,
499 task.assigned_to.as_deref().unwrap_or("")
500 ));
501 }
502 output.push('\n');
503 }
504
505 let agents: Vec<_> = sorted_tasks
507 .iter()
508 .filter(|t| t.agent_type.is_some())
509 .collect();
510
511 if !agents.is_empty() {
512 output.push_str("@agents\n");
513 output.push_str("# id | agent_type\n");
514 for task in agents {
515 output.push_str(&format!(
516 "{} | {}\n",
517 task.id,
518 task.agent_type.as_deref().unwrap_or("")
519 ));
520 }
521 output.push('\n');
522 }
523
524 let tasks_with_details: Vec<_> = sorted_tasks
526 .iter()
527 .filter(|t| !t.description.is_empty() || t.details.is_some() || t.test_strategy.is_some())
528 .collect();
529
530 if !tasks_with_details.is_empty() {
531 output.push_str("@details\n");
532 for task in tasks_with_details {
533 if !task.description.is_empty() {
534 output.push_str(&format!("{} | description |\n", task.id));
535 for line in task.description.lines() {
536 output.push_str(&format!(" {}\n", line));
537 }
538 }
539 if let Some(ref details) = task.details {
540 output.push_str(&format!("{} | details |\n", task.id));
541 for line in details.lines() {
542 output.push_str(&format!(" {}\n", line));
543 }
544 }
545 if let Some(ref test_strategy) = task.test_strategy {
546 output.push_str(&format!("{} | test_strategy |\n", task.id));
547 for line in test_strategy.lines() {
548 output.push_str(&format!(" {}\n", line));
549 }
550 }
551 }
552 }
553
554 output
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn test_status_codes() {
563 assert_eq!(status_to_code(&TaskStatus::Pending), 'P');
564 assert_eq!(status_to_code(&TaskStatus::InProgress), 'I');
565 assert_eq!(status_to_code(&TaskStatus::Done), 'D');
566 assert_eq!(status_to_code(&TaskStatus::Expanded), 'X');
567 assert_eq!(status_to_code(&TaskStatus::Failed), '!');
568
569 assert_eq!(code_to_status('P'), Some(TaskStatus::Pending));
570 assert_eq!(code_to_status('X'), Some(TaskStatus::Expanded));
571 assert_eq!(code_to_status('!'), Some(TaskStatus::Failed));
572 assert_eq!(code_to_status('Z'), None);
573 }
574
575 #[test]
576 fn test_priority_codes() {
577 assert_eq!(priority_to_code(&Priority::Critical), 'C');
578 assert_eq!(priority_to_code(&Priority::High), 'H');
579 assert_eq!(priority_to_code(&Priority::Medium), 'M');
580 assert_eq!(priority_to_code(&Priority::Low), 'L');
581
582 assert_eq!(code_to_priority('C'), Some(Priority::Critical));
583 assert_eq!(code_to_priority('H'), Some(Priority::High));
584 assert_eq!(code_to_priority('M'), Some(Priority::Medium));
585 assert_eq!(code_to_priority('L'), Some(Priority::Low));
586 assert_eq!(code_to_priority('Z'), None);
587 }
588
589 #[test]
590 fn test_escape_unescape() {
591 assert_eq!(escape_text("hello|world"), "hello\\|world");
592 assert_eq!(escape_text("line1\nline2"), "line1\\nline2");
593 assert_eq!(unescape_text("hello\\|world"), "hello|world");
594 assert_eq!(unescape_text("line1\\nline2"), "line1\nline2");
595 }
596
597 #[test]
598 fn test_id_format_round_trip() {
599 use crate::models::IdFormat;
600
601 let mut phase = Phase::new("uuid-phase".to_string());
603 phase.id_format = IdFormat::Uuid;
604
605 let task = Task::new(
606 "a1b2c3d4e5f6789012345678901234ab".to_string(),
607 "UUID Task".to_string(),
608 "Description".to_string(),
609 );
610 phase.add_task(task);
611
612 let scg = serialize_scg(&phase);
613 let parsed = parse_scg(&scg).unwrap();
614
615 assert_eq!(parsed.id_format, IdFormat::Uuid);
616 assert_eq!(parsed.name, "uuid-phase");
617 }
618
619 #[test]
620 fn test_id_format_default_sequential() {
621 let content = r#"# SCUD Graph v1
623# Phase: old-phase
624
625@meta {
626 name old-phase
627 updated 2025-01-01T00:00:00Z
628}
629
630@nodes
631# id | title | status | complexity | priority
6321 | Task | P | 0 | M
633"#;
634 let phase = parse_scg(content).unwrap();
635 assert_eq!(phase.id_format, IdFormat::Sequential);
636 }
637
638 #[test]
639 fn test_round_trip() {
640 let mut epic = Phase::new("test-epic".to_string());
641
642 let mut task1 = Task::new(
643 "1".to_string(),
644 "First task".to_string(),
645 "Description".to_string(),
646 );
647 task1.complexity = 5;
648 task1.priority = Priority::High;
649 task1.status = TaskStatus::Done;
650
651 let mut task2 = Task::new(
652 "2".to_string(),
653 "Second task".to_string(),
654 "Another desc".to_string(),
655 );
656 task2.dependencies = vec!["1".to_string()];
657 task2.complexity = 3;
658
659 epic.add_task(task1);
660 epic.add_task(task2);
661
662 let scg = serialize_scg(&epic);
663 let parsed = parse_scg(&scg).unwrap();
664
665 assert_eq!(parsed.name, "test-epic");
666 assert_eq!(parsed.tasks.len(), 2);
667
668 let t1 = parsed.get_task("1").unwrap();
669 assert_eq!(t1.title, "First task");
670 assert_eq!(t1.complexity, 5);
671 assert_eq!(t1.status, TaskStatus::Done);
672
673 let t2 = parsed.get_task("2").unwrap();
674 assert_eq!(t2.dependencies, vec!["1".to_string()]);
675 }
676
677 #[test]
678 fn test_parent_child() {
679 let mut epic = Phase::new("parent-test".to_string());
680
681 let mut parent = Task::new(
682 "1".to_string(),
683 "Parent".to_string(),
684 "Parent task".to_string(),
685 );
686 parent.status = TaskStatus::Expanded;
687 parent.subtasks = vec!["1.1".to_string(), "1.2".to_string()];
688
689 let mut child1 = Task::new(
690 "1.1".to_string(),
691 "Child 1".to_string(),
692 "First child".to_string(),
693 );
694 child1.parent_id = Some("1".to_string());
695
696 let mut child2 = Task::new(
697 "1.2".to_string(),
698 "Child 2".to_string(),
699 "Second child".to_string(),
700 );
701 child2.parent_id = Some("1".to_string());
702 child2.dependencies = vec!["1.1".to_string()];
703
704 epic.add_task(parent);
705 epic.add_task(child1);
706 epic.add_task(child2);
707
708 let scg = serialize_scg(&epic);
709 let parsed = parse_scg(&scg).unwrap();
710
711 let p = parsed.get_task("1").unwrap();
712 assert_eq!(p.subtasks, vec!["1.1", "1.2"]);
713
714 let c1 = parsed.get_task("1.1").unwrap();
715 assert_eq!(c1.parent_id, Some("1".to_string()));
716
717 let c2 = parsed.get_task("1.2").unwrap();
718 assert_eq!(c2.parent_id, Some("1".to_string()));
719 assert_eq!(c2.dependencies, vec!["1.1".to_string()]);
720 }
721
722 #[test]
723 fn test_malformed_header() {
724 let result = parse_scg("not a valid scg file");
725 assert!(result.is_err());
726 }
727
728 #[test]
729 fn test_empty_phase() {
730 let content = "# SCUD Graph v1\n# Phase: empty\n\n@nodes\n# id | title | status | complexity | priority\n";
731 let phase = parse_scg(content).unwrap();
732 assert_eq!(phase.name, "empty");
733 assert!(phase.tasks.is_empty());
734 }
735
736 #[test]
737 fn test_special_characters_in_title() {
738 let mut epic = Phase::new("test".to_string());
739 let task = Task::new(
740 "1".to_string(),
741 "Task with | pipe".to_string(),
742 "Desc".to_string(),
743 );
744 epic.add_task(task);
745
746 let scg = serialize_scg(&epic);
747 let parsed = parse_scg(&scg).unwrap();
748
749 assert_eq!(parsed.get_task("1").unwrap().title, "Task with | pipe");
750 }
751
752 #[test]
753 fn test_multiline_description() {
754 let mut epic = Phase::new("test".to_string());
755 let task = Task::new(
756 "1".to_string(),
757 "Task".to_string(),
758 "Line 1\nLine 2\nLine 3".to_string(),
759 );
760 epic.add_task(task);
761
762 let scg = serialize_scg(&epic);
763 let parsed = parse_scg(&scg).unwrap();
764
765 let t = parsed.get_task("1").unwrap();
766 assert_eq!(t.description, "Line 1\nLine 2\nLine 3");
767 }
768
769 #[test]
770 fn test_assignments() {
771 let mut epic = Phase::new("test".to_string());
772 let mut task = Task::new("1".to_string(), "Task".to_string(), "Desc".to_string());
773 task.assigned_to = Some("alice".to_string());
774 epic.add_task(task);
775
776 let scg = serialize_scg(&epic);
777 let parsed = parse_scg(&scg).unwrap();
778
779 let t = parsed.get_task("1").unwrap();
780 assert_eq!(t.assigned_to, Some("alice".to_string()));
781 }
782
783 #[test]
784 fn test_agent_types() {
785 let mut epic = Phase::new("test".to_string());
786 let mut task1 = Task::new(
787 "1".to_string(),
788 "Review Task".to_string(),
789 "Desc".to_string(),
790 );
791 task1.agent_type = Some("reviewer".to_string());
792
793 let mut task2 = Task::new(
794 "2".to_string(),
795 "Build Task".to_string(),
796 "Desc".to_string(),
797 );
798 task2.agent_type = Some("builder".to_string());
799
800 let task3 = Task::new("3".to_string(), "No Agent".to_string(), "Desc".to_string());
801 epic.add_task(task1);
804 epic.add_task(task2);
805 epic.add_task(task3);
806
807 let scg = serialize_scg(&epic);
808
809 assert!(scg.contains("@agents"));
811 assert!(scg.contains("1 | reviewer"));
812 assert!(scg.contains("2 | builder"));
813
814 let parsed = parse_scg(&scg).unwrap();
815
816 let t1 = parsed.get_task("1").unwrap();
817 assert_eq!(t1.agent_type, Some("reviewer".to_string()));
818
819 let t2 = parsed.get_task("2").unwrap();
820 assert_eq!(t2.agent_type, Some("builder".to_string()));
821
822 let t3 = parsed.get_task("3").unwrap();
823 assert_eq!(t3.agent_type, None);
824 }
825
826 #[test]
827 fn test_natural_sort_order() {
828 let mut epic = Phase::new("test".to_string());
829
830 for id in ["10", "2", "1", "1.10", "1.2", "1.1"] {
832 let task = Task::new(id.to_string(), format!("Task {}", id), String::new());
833 epic.add_task(task);
834 }
835
836 let scg = serialize_scg(&epic);
837 let parsed = parse_scg(&scg).unwrap();
838
839 let ids: Vec<&str> = parsed.tasks.iter().map(|t| t.id.as_str()).collect();
840 assert_eq!(ids, vec!["1", "1.1", "1.2", "1.10", "2", "10"]);
841 }
842
843 #[test]
844 fn test_natural_sort_uuids() {
845 use super::natural_sort_ids;
847
848 let uuid_a = "a1b2c3d4e5f6789012345678901234ab";
850 let uuid_b = "b1b2c3d4e5f6789012345678901234ab";
851 let uuid_c = "c1b2c3d4e5f6789012345678901234ab";
852
853 assert_eq!(natural_sort_ids(uuid_a, uuid_b), std::cmp::Ordering::Less);
854 assert_eq!(natural_sort_ids(uuid_b, uuid_c), std::cmp::Ordering::Less);
855 assert_eq!(natural_sort_ids(uuid_a, uuid_a), std::cmp::Ordering::Equal);
856
857 let mut ids = vec![uuid_c, uuid_a, uuid_b];
859 ids.sort_by(|a, b| natural_sort_ids(a, b));
860 assert_eq!(ids, vec![uuid_a, uuid_b, uuid_c]);
861 }
862
863 #[test]
864 fn test_natural_sort_mixed_numeric_uuid() {
865 use super::natural_sort_ids;
867
868 let numeric = "123";
869 let uuid = "a1b2c3d4e5f6789012345678901234ab";
870
871 assert_eq!(natural_sort_ids(numeric, uuid), std::cmp::Ordering::Less);
873 }
874
875 #[test]
876 fn test_all_statuses() {
877 let mut epic = Phase::new("test".to_string());
878
879 let statuses = [
880 ("1", TaskStatus::Pending),
881 ("2", TaskStatus::InProgress),
882 ("3", TaskStatus::Done),
883 ("4", TaskStatus::Review),
884 ("5", TaskStatus::Blocked),
885 ("6", TaskStatus::Deferred),
886 ("7", TaskStatus::Cancelled),
887 ("8", TaskStatus::Expanded),
888 ("9", TaskStatus::Failed),
889 ];
890
891 for (id, status) in &statuses {
892 let mut task = Task::new(id.to_string(), format!("Task {}", id), String::new());
893 task.status = status.clone();
894 epic.add_task(task);
895 }
896
897 let scg = serialize_scg(&epic);
898 let parsed = parse_scg(&scg).unwrap();
899
900 for (id, expected_status) in statuses {
901 let task = parsed.get_task(id).unwrap();
902 assert_eq!(
903 task.status, expected_status,
904 "Status mismatch for task {}",
905 id
906 );
907 }
908 }
909
910 #[test]
911 fn test_all_priorities() {
912 let mut epic = Phase::new("test".to_string());
913
914 let priorities = [
915 ("1", Priority::Critical),
916 ("2", Priority::High),
917 ("3", Priority::Medium),
918 ("4", Priority::Low),
919 ];
920
921 for (id, priority) in &priorities {
922 let mut task = Task::new(id.to_string(), format!("Task {}", id), String::new());
923 task.priority = priority.clone();
924 epic.add_task(task);
925 }
926
927 let scg = serialize_scg(&epic);
928 let parsed = parse_scg(&scg).unwrap();
929
930 for (id, expected_priority) in priorities {
931 let task = parsed.get_task(id).unwrap();
932 assert_eq!(
933 task.priority, expected_priority,
934 "Priority mismatch for task {}",
935 id
936 );
937 }
938 }
939
940 #[test]
941 fn test_details_and_test_strategy() {
942 let mut epic = Phase::new("test".to_string());
943 let mut task = Task::new(
944 "1".to_string(),
945 "Task".to_string(),
946 "Description".to_string(),
947 );
948 task.details = Some("Detailed implementation notes".to_string());
949 task.test_strategy = Some("Unit tests and integration tests".to_string());
950 epic.add_task(task);
951
952 let scg = serialize_scg(&epic);
953 let parsed = parse_scg(&scg).unwrap();
954
955 let t = parsed.get_task("1").unwrap();
956 assert_eq!(t.description, "Description");
957 assert_eq!(t.details, Some("Detailed implementation notes".to_string()));
958 assert_eq!(
959 t.test_strategy,
960 Some("Unit tests and integration tests".to_string())
961 );
962 }
963
964 #[test]
967 fn test_backslash_escape() {
968 let mut epic = Phase::new("test".to_string());
969 let task = Task::new(
970 "1".to_string(),
971 "Task with \\ backslash".to_string(),
972 "Desc".to_string(),
973 );
974 epic.add_task(task);
975
976 let scg = serialize_scg(&epic);
977 let parsed = parse_scg(&scg).unwrap();
978
979 assert_eq!(
980 parsed.get_task("1").unwrap().title,
981 "Task with \\ backslash"
982 );
983 }
984
985 #[test]
986 fn test_multiple_special_chars() {
987 let mut epic = Phase::new("test".to_string());
988 let task = Task::new(
989 "1".to_string(),
990 "Task with | pipe and \\ backslash".to_string(),
991 "Line 1\nLine 2 with | and \\".to_string(),
992 );
993 epic.add_task(task);
994
995 let scg = serialize_scg(&epic);
996 let parsed = parse_scg(&scg).unwrap();
997
998 let t = parsed.get_task("1").unwrap();
999 assert_eq!(t.title, "Task with | pipe and \\ backslash");
1000 assert_eq!(t.description, "Line 1\nLine 2 with | and \\");
1001 }
1002
1003 #[test]
1004 fn test_unicode_content() {
1005 let mut epic = Phase::new("unicode-test".to_string());
1006 let task = Task::new(
1007 "1".to_string(),
1008 "日本語タイトル 🚀 Émojis".to_string(),
1009 "描述 with émojis 😀".to_string(),
1010 );
1011 epic.add_task(task);
1012
1013 let scg = serialize_scg(&epic);
1014 let parsed = parse_scg(&scg).unwrap();
1015
1016 let t = parsed.get_task("1").unwrap();
1017 assert_eq!(t.title, "日本語タイトル 🚀 Émojis");
1018 assert_eq!(t.description, "描述 with émojis 😀");
1019 }
1020
1021 #[test]
1022 fn test_empty_dependencies() {
1023 let mut epic = Phase::new("test".to_string());
1024 let task = Task::new("1".to_string(), "Task".to_string(), "Desc".to_string());
1025 epic.add_task(task);
1026
1027 let scg = serialize_scg(&epic);
1028 let parsed = parse_scg(&scg).unwrap();
1029
1030 assert!(parsed.get_task("1").unwrap().dependencies.is_empty());
1031 }
1032
1033 #[test]
1034 fn test_multiple_dependencies() {
1035 let mut epic = Phase::new("test".to_string());
1036
1037 let task1 = Task::new("1".to_string(), "Task 1".to_string(), String::new());
1038 let task2 = Task::new("2".to_string(), "Task 2".to_string(), String::new());
1039 let mut task3 = Task::new("3".to_string(), "Task 3".to_string(), String::new());
1040 task3.dependencies = vec!["1".to_string(), "2".to_string()];
1041
1042 epic.add_task(task1);
1043 epic.add_task(task2);
1044 epic.add_task(task3);
1045
1046 let scg = serialize_scg(&epic);
1047 let parsed = parse_scg(&scg).unwrap();
1048
1049 let t3 = parsed.get_task("3").unwrap();
1050 assert_eq!(t3.dependencies.len(), 2);
1051 assert!(t3.dependencies.contains(&"1".to_string()));
1052 assert!(t3.dependencies.contains(&"2".to_string()));
1053 }
1054
1055 #[test]
1056 fn test_complexity_boundary_values() {
1057 let mut epic = Phase::new("test".to_string());
1058
1059 let complexities: Vec<u32> = vec![0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
1061 for (i, c) in complexities.iter().enumerate() {
1062 let mut task = Task::new(
1063 format!("{}", i + 1),
1064 format!("Task {}", i + 1),
1065 String::new(),
1066 );
1067 task.complexity = *c;
1068 epic.add_task(task);
1069 }
1070
1071 let scg = serialize_scg(&epic);
1072 let parsed = parse_scg(&scg).unwrap();
1073
1074 for (i, expected) in complexities.iter().enumerate() {
1075 let task = parsed.get_task(&format!("{}", i + 1)).unwrap();
1076 assert_eq!(
1077 task.complexity,
1078 *expected,
1079 "Complexity mismatch for task {}",
1080 i + 1
1081 );
1082 }
1083 }
1084
1085 #[test]
1086 fn test_long_description() {
1087 let mut epic = Phase::new("test".to_string());
1088 let long_desc = "A".repeat(5000); let task = Task::new("1".to_string(), "Task".to_string(), long_desc.clone());
1090 epic.add_task(task);
1091
1092 let scg = serialize_scg(&epic);
1093 let parsed = parse_scg(&scg).unwrap();
1094
1095 assert_eq!(parsed.get_task("1").unwrap().description, long_desc);
1096 }
1097
1098 #[test]
1099 fn test_empty_description() {
1100 let mut epic = Phase::new("test".to_string());
1101 let task = Task::new("1".to_string(), "Task".to_string(), String::new());
1102 epic.add_task(task);
1103
1104 let scg = serialize_scg(&epic);
1105 let parsed = parse_scg(&scg).unwrap();
1106
1107 assert_eq!(parsed.get_task("1").unwrap().description, "");
1108 }
1109
1110 #[test]
1111 fn test_whitespace_handling() {
1112 let mut epic = Phase::new("test".to_string());
1114 let task = Task::new(
1115 "1".to_string(),
1116 " Task with spaces ".to_string(),
1117 "Desc".to_string(),
1118 );
1119 epic.add_task(task);
1120
1121 let scg = serialize_scg(&epic);
1122 let parsed = parse_scg(&scg).unwrap();
1123
1124 let t = parsed.get_task("1").unwrap();
1127 assert_eq!(t.title, "Task with spaces");
1128 }
1129
1130 #[test]
1131 fn test_nested_subtasks() {
1132 let mut epic = Phase::new("test".to_string());
1133
1134 let mut parent = Task::new("1".to_string(), "Parent".to_string(), String::new());
1136 parent.status = TaskStatus::Expanded;
1137 parent.subtasks = vec!["1.1".to_string(), "1.2".to_string()];
1138
1139 let mut child1 = Task::new("1.1".to_string(), "Child 1".to_string(), String::new());
1140 child1.parent_id = Some("1".to_string());
1141
1142 let mut child2 = Task::new("1.2".to_string(), "Child 2".to_string(), String::new());
1143 child2.parent_id = Some("1".to_string());
1144 child2.status = TaskStatus::Expanded;
1145 child2.subtasks = vec!["1.2.1".to_string()];
1146
1147 let mut grandchild =
1148 Task::new("1.2.1".to_string(), "Grandchild".to_string(), String::new());
1149 grandchild.parent_id = Some("1.2".to_string());
1150
1151 epic.add_task(parent);
1152 epic.add_task(child1);
1153 epic.add_task(child2);
1154 epic.add_task(grandchild);
1155
1156 let scg = serialize_scg(&epic);
1157 let parsed = parse_scg(&scg).unwrap();
1158
1159 assert_eq!(parsed.tasks.len(), 4);
1160
1161 let gc = parsed.get_task("1.2.1").unwrap();
1162 assert_eq!(gc.parent_id, Some("1.2".to_string()));
1163
1164 let c2 = parsed.get_task("1.2").unwrap();
1165 assert!(c2.subtasks.contains(&"1.2.1".to_string()));
1166 }
1167
1168 #[test]
1169 fn test_section_comment_lines_ignored() {
1170 let content = r#"# SCUD Graph v1
1172# Epic: test
1173
1174@meta {
1175 name test
1176 # this is a comment
1177 updated 2025-01-01T00:00:00Z
1178}
1179
1180@nodes
1181# id | title | status | complexity | priority
1182# another comment
11831 | Task | P | 0 | M
1184"#;
1185 let epic = parse_scg(content).unwrap();
1186 assert_eq!(epic.tasks.len(), 1);
1187 assert_eq!(epic.get_task("1").unwrap().title, "Task");
1188 }
1189}