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