1use anyhow::{Context, Result};
6use std::collections::HashMap;
7
8use crate::models::{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 if key == "name" && phase.name != value {
208 phase = Phase::new(value.to_string());
209 }
210 }
211 }
212 Some("nodes") => {
213 let parts = split_by_pipe(trimmed);
215 if parts.len() >= 5 {
216 let id = parts[0].clone();
217 let title = unescape_text(&parts[1]);
218 let status =
219 code_to_status(parts[2].chars().next().unwrap_or('P')).unwrap_or_default();
220 let complexity: u32 = parts[3].parse().unwrap_or(0);
221 let priority = code_to_priority(parts[4].chars().next().unwrap_or('M'))
222 .unwrap_or_default();
223
224 let mut task = Task::new(id.clone(), title, String::new());
225 task.status = status;
226 task.complexity = complexity;
227 task.priority = priority;
228 tasks.insert(id, task);
229 }
230 }
231 Some("edges") => {
232 if let Some((dependent, dependency)) = trimmed.split_once("->") {
234 edges.push((dependent.trim().to_string(), dependency.trim().to_string()));
235 }
236 }
237 Some("parents") => {
238 if let Some((parent, children)) = trimmed.split_once(':') {
240 let child_ids: Vec<String> = children
241 .split(',')
242 .map(|s| s.trim().to_string())
243 .filter(|s| !s.is_empty())
244 .collect();
245 parents.insert(parent.trim().to_string(), child_ids);
246 }
247 }
248 Some("assignments") => {
249 let parts = split_by_pipe(trimmed);
251 if parts.len() >= 2 {
252 let id = parts[0].clone();
253 let assigned = if parts[1].is_empty() {
254 None
255 } else {
256 Some(parts[1].clone())
257 };
258 let locked_by: Option<String> = None;
260 let locked_at: Option<String> = None;
261 assignments.insert(id, (assigned, locked_by, locked_at));
262 }
263 }
264 Some("details") => {
265 flush_detail(
267 ¤t_detail_id,
268 ¤t_detail_field,
269 &mut current_detail_content,
270 &mut details,
271 );
272
273 let parts = split_by_pipe(trimmed);
275 if parts.len() >= 2 {
276 current_detail_id = Some(parts[0].clone());
277 current_detail_field = Some(parts[1].clone());
278 current_detail_content.clear();
279 }
280 }
281 _ => {}
282 }
283 }
284
285 flush_detail(
287 ¤t_detail_id,
288 ¤t_detail_field,
289 &mut current_detail_content,
290 &mut details,
291 );
292
293 for (dependent, dependency) in edges {
295 if let Some(task) = tasks.get_mut(&dependent) {
296 task.dependencies.push(dependency);
297 }
298 }
299
300 for (parent_id, child_ids) in parents {
302 if let Some(parent) = tasks.get_mut(&parent_id) {
303 parent.subtasks = child_ids.clone();
304 }
305 for child_id in child_ids {
306 if let Some(child) = tasks.get_mut(&child_id) {
307 child.parent_id = Some(parent_id.clone());
308 }
309 }
310 }
311
312 for (id, fields) in details {
314 if let Some(task) = tasks.get_mut(&id) {
315 if let Some(desc) = fields.get("description") {
316 task.description = desc.clone();
317 }
318 if let Some(det) = fields.get("details") {
319 task.details = Some(det.clone());
320 }
321 if let Some(ts) = fields.get("test_strategy") {
322 task.test_strategy = Some(ts.clone());
323 }
324 }
325 }
326
327 for (id, (assigned, _locked_by, _locked_at)) in assignments {
329 if let Some(task) = tasks.get_mut(&id) {
330 task.assigned_to = assigned;
331 }
332 }
333
334 phase.tasks = tasks.into_values().collect();
336
337 phase.tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
339
340 Ok(phase)
341}
342
343fn natural_sort_ids(a: &str, b: &str) -> std::cmp::Ordering {
345 let a_parts: Vec<&str> = a.split('.').collect();
346 let b_parts: Vec<&str> = b.split('.').collect();
347
348 for (ap, bp) in a_parts.iter().zip(b_parts.iter()) {
349 match (ap.parse::<u32>(), bp.parse::<u32>()) {
350 (Ok(an), Ok(bn)) => {
351 if an != bn {
352 return an.cmp(&bn);
353 }
354 }
355 _ => {
356 if ap != bp {
357 return ap.cmp(bp);
358 }
359 }
360 }
361 }
362 a_parts.len().cmp(&b_parts.len())
363}
364
365fn flush_detail(
367 id: &Option<String>,
368 field: &Option<String>,
369 content: &mut Vec<String>,
370 details: &mut HashMap<String, HashMap<String, String>>,
371) {
372 if let (Some(id), Some(field)) = (id, field) {
373 let text = content.join("\n");
374 details
375 .entry(id.clone())
376 .or_default()
377 .insert(field.clone(), text);
378 content.clear();
379 }
380}
381
382pub fn serialize_scg(phase: &Phase) -> String {
384 let mut output = String::new();
385
386 output.push_str(&format!("{} {}\n", HEADER_PREFIX, FORMAT_VERSION));
388 output.push_str(&format!("# Phase: {}\n\n", phase.name));
389
390 let now = chrono::Utc::now().to_rfc3339();
392 output.push_str("@meta {\n");
393 output.push_str(&format!(" name {}\n", phase.name));
394 output.push_str(&format!(" updated {}\n", now));
395 output.push_str("}\n\n");
396
397 let mut sorted_tasks = phase.tasks.clone();
399 sorted_tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
400
401 output.push_str("@nodes\n");
403 output.push_str("# id | title | status | complexity | priority\n");
404 for task in &sorted_tasks {
405 output.push_str(&format!(
406 "{} | {} | {} | {} | {}\n",
407 task.id,
408 escape_text(&task.title),
409 status_to_code(&task.status),
410 task.complexity,
411 priority_to_code(&task.priority)
412 ));
413 }
414 output.push('\n');
415
416 let edges: Vec<_> = sorted_tasks
418 .iter()
419 .flat_map(|t| t.dependencies.iter().map(move |dep| (&t.id, dep)))
420 .collect();
421
422 if !edges.is_empty() {
423 output.push_str("@edges\n");
424 output.push_str("# dependent -> dependency\n");
425 for (dependent, dependency) in edges {
426 output.push_str(&format!("{} -> {}\n", dependent, dependency));
427 }
428 output.push('\n');
429 }
430
431 let parents: Vec<_> = sorted_tasks
433 .iter()
434 .filter(|t| !t.subtasks.is_empty())
435 .collect();
436
437 if !parents.is_empty() {
438 output.push_str("@parents\n");
439 output.push_str("# parent: subtasks...\n");
440 for task in parents {
441 output.push_str(&format!("{}: {}\n", task.id, task.subtasks.join(", ")));
442 }
443 output.push('\n');
444 }
445
446 let assignments: Vec<_> = sorted_tasks
448 .iter()
449 .filter(|t| t.assigned_to.is_some())
450 .collect();
451
452 if !assignments.is_empty() {
453 output.push_str("@assignments\n");
454 output.push_str("# id | assigned_to\n");
455 for task in assignments {
456 output.push_str(&format!(
457 "{} | {}\n",
458 task.id,
459 task.assigned_to.as_deref().unwrap_or("")
460 ));
461 }
462 output.push('\n');
463 }
464
465 let tasks_with_details: Vec<_> = sorted_tasks
467 .iter()
468 .filter(|t| !t.description.is_empty() || t.details.is_some() || t.test_strategy.is_some())
469 .collect();
470
471 if !tasks_with_details.is_empty() {
472 output.push_str("@details\n");
473 for task in tasks_with_details {
474 if !task.description.is_empty() {
475 output.push_str(&format!("{} | description |\n", task.id));
476 for line in task.description.lines() {
477 output.push_str(&format!(" {}\n", line));
478 }
479 }
480 if let Some(ref details) = task.details {
481 output.push_str(&format!("{} | details |\n", task.id));
482 for line in details.lines() {
483 output.push_str(&format!(" {}\n", line));
484 }
485 }
486 if let Some(ref test_strategy) = task.test_strategy {
487 output.push_str(&format!("{} | test_strategy |\n", task.id));
488 for line in test_strategy.lines() {
489 output.push_str(&format!(" {}\n", line));
490 }
491 }
492 }
493 }
494
495 output
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn test_status_codes() {
504 assert_eq!(status_to_code(&TaskStatus::Pending), 'P');
505 assert_eq!(status_to_code(&TaskStatus::InProgress), 'I');
506 assert_eq!(status_to_code(&TaskStatus::Done), 'D');
507 assert_eq!(status_to_code(&TaskStatus::Expanded), 'X');
508
509 assert_eq!(code_to_status('P'), Some(TaskStatus::Pending));
510 assert_eq!(code_to_status('X'), Some(TaskStatus::Expanded));
511 assert_eq!(code_to_status('Z'), None);
512 }
513
514 #[test]
515 fn test_priority_codes() {
516 assert_eq!(priority_to_code(&Priority::Critical), 'C');
517 assert_eq!(priority_to_code(&Priority::High), 'H');
518 assert_eq!(priority_to_code(&Priority::Medium), 'M');
519 assert_eq!(priority_to_code(&Priority::Low), 'L');
520
521 assert_eq!(code_to_priority('C'), Some(Priority::Critical));
522 assert_eq!(code_to_priority('H'), Some(Priority::High));
523 assert_eq!(code_to_priority('M'), Some(Priority::Medium));
524 assert_eq!(code_to_priority('L'), Some(Priority::Low));
525 assert_eq!(code_to_priority('Z'), None);
526 }
527
528 #[test]
529 fn test_escape_unescape() {
530 assert_eq!(escape_text("hello|world"), "hello\\|world");
531 assert_eq!(escape_text("line1\nline2"), "line1\\nline2");
532 assert_eq!(unescape_text("hello\\|world"), "hello|world");
533 assert_eq!(unescape_text("line1\\nline2"), "line1\nline2");
534 }
535
536 #[test]
537 fn test_round_trip() {
538 let mut epic = Phase::new("test-epic".to_string());
539
540 let mut task1 = Task::new(
541 "1".to_string(),
542 "First task".to_string(),
543 "Description".to_string(),
544 );
545 task1.complexity = 5;
546 task1.priority = Priority::High;
547 task1.status = TaskStatus::Done;
548
549 let mut task2 = Task::new(
550 "2".to_string(),
551 "Second task".to_string(),
552 "Another desc".to_string(),
553 );
554 task2.dependencies = vec!["1".to_string()];
555 task2.complexity = 3;
556
557 epic.add_task(task1);
558 epic.add_task(task2);
559
560 let scg = serialize_scg(&epic);
561 let parsed = parse_scg(&scg).unwrap();
562
563 assert_eq!(parsed.name, "test-epic");
564 assert_eq!(parsed.tasks.len(), 2);
565
566 let t1 = parsed.get_task("1").unwrap();
567 assert_eq!(t1.title, "First task");
568 assert_eq!(t1.complexity, 5);
569 assert_eq!(t1.status, TaskStatus::Done);
570
571 let t2 = parsed.get_task("2").unwrap();
572 assert_eq!(t2.dependencies, vec!["1".to_string()]);
573 }
574
575 #[test]
576 fn test_parent_child() {
577 let mut epic = Phase::new("parent-test".to_string());
578
579 let mut parent = Task::new(
580 "1".to_string(),
581 "Parent".to_string(),
582 "Parent task".to_string(),
583 );
584 parent.status = TaskStatus::Expanded;
585 parent.subtasks = vec!["1.1".to_string(), "1.2".to_string()];
586
587 let mut child1 = Task::new(
588 "1.1".to_string(),
589 "Child 1".to_string(),
590 "First child".to_string(),
591 );
592 child1.parent_id = Some("1".to_string());
593
594 let mut child2 = Task::new(
595 "1.2".to_string(),
596 "Child 2".to_string(),
597 "Second child".to_string(),
598 );
599 child2.parent_id = Some("1".to_string());
600 child2.dependencies = vec!["1.1".to_string()];
601
602 epic.add_task(parent);
603 epic.add_task(child1);
604 epic.add_task(child2);
605
606 let scg = serialize_scg(&epic);
607 let parsed = parse_scg(&scg).unwrap();
608
609 let p = parsed.get_task("1").unwrap();
610 assert_eq!(p.subtasks, vec!["1.1", "1.2"]);
611
612 let c1 = parsed.get_task("1.1").unwrap();
613 assert_eq!(c1.parent_id, Some("1".to_string()));
614
615 let c2 = parsed.get_task("1.2").unwrap();
616 assert_eq!(c2.parent_id, Some("1".to_string()));
617 assert_eq!(c2.dependencies, vec!["1.1".to_string()]);
618 }
619
620 #[test]
621 fn test_malformed_header() {
622 let result = parse_scg("not a valid scg file");
623 assert!(result.is_err());
624 }
625
626 #[test]
627 fn test_empty_phase() {
628 let content = "# SCUD Graph v1\n# Phase: empty\n\n@nodes\n# id | title | status | complexity | priority\n";
629 let phase = parse_scg(content).unwrap();
630 assert_eq!(phase.name, "empty");
631 assert!(phase.tasks.is_empty());
632 }
633
634 #[test]
635 fn test_special_characters_in_title() {
636 let mut epic = Phase::new("test".to_string());
637 let task = Task::new(
638 "1".to_string(),
639 "Task with | pipe".to_string(),
640 "Desc".to_string(),
641 );
642 epic.add_task(task);
643
644 let scg = serialize_scg(&epic);
645 let parsed = parse_scg(&scg).unwrap();
646
647 assert_eq!(parsed.get_task("1").unwrap().title, "Task with | pipe");
648 }
649
650 #[test]
651 fn test_multiline_description() {
652 let mut epic = Phase::new("test".to_string());
653 let task = Task::new(
654 "1".to_string(),
655 "Task".to_string(),
656 "Line 1\nLine 2\nLine 3".to_string(),
657 );
658 epic.add_task(task);
659
660 let scg = serialize_scg(&epic);
661 let parsed = parse_scg(&scg).unwrap();
662
663 let t = parsed.get_task("1").unwrap();
664 assert_eq!(t.description, "Line 1\nLine 2\nLine 3");
665 }
666
667 #[test]
668 fn test_assignments() {
669 let mut epic = Phase::new("test".to_string());
670 let mut task = Task::new("1".to_string(), "Task".to_string(), "Desc".to_string());
671 task.assigned_to = Some("alice".to_string());
672 epic.add_task(task);
673
674 let scg = serialize_scg(&epic);
675 let parsed = parse_scg(&scg).unwrap();
676
677 let t = parsed.get_task("1").unwrap();
678 assert_eq!(t.assigned_to, Some("alice".to_string()));
679 }
680
681 #[test]
682 fn test_natural_sort_order() {
683 let mut epic = Phase::new("test".to_string());
684
685 for id in ["10", "2", "1", "1.10", "1.2", "1.1"] {
687 let task = Task::new(id.to_string(), format!("Task {}", id), String::new());
688 epic.add_task(task);
689 }
690
691 let scg = serialize_scg(&epic);
692 let parsed = parse_scg(&scg).unwrap();
693
694 let ids: Vec<&str> = parsed.tasks.iter().map(|t| t.id.as_str()).collect();
695 assert_eq!(ids, vec!["1", "1.1", "1.2", "1.10", "2", "10"]);
696 }
697
698 #[test]
699 fn test_all_statuses() {
700 let mut epic = Phase::new("test".to_string());
701
702 let statuses = [
703 ("1", TaskStatus::Pending),
704 ("2", TaskStatus::InProgress),
705 ("3", TaskStatus::Done),
706 ("4", TaskStatus::Review),
707 ("5", TaskStatus::Blocked),
708 ("6", TaskStatus::Deferred),
709 ("7", TaskStatus::Cancelled),
710 ("8", TaskStatus::Expanded),
711 ];
712
713 for (id, status) in &statuses {
714 let mut task = Task::new(id.to_string(), format!("Task {}", id), String::new());
715 task.status = status.clone();
716 epic.add_task(task);
717 }
718
719 let scg = serialize_scg(&epic);
720 let parsed = parse_scg(&scg).unwrap();
721
722 for (id, expected_status) in statuses {
723 let task = parsed.get_task(id).unwrap();
724 assert_eq!(
725 task.status, expected_status,
726 "Status mismatch for task {}",
727 id
728 );
729 }
730 }
731
732 #[test]
733 fn test_all_priorities() {
734 let mut epic = Phase::new("test".to_string());
735
736 let priorities = [
737 ("1", Priority::Critical),
738 ("2", Priority::High),
739 ("3", Priority::Medium),
740 ("4", Priority::Low),
741 ];
742
743 for (id, priority) in &priorities {
744 let mut task = Task::new(id.to_string(), format!("Task {}", id), String::new());
745 task.priority = priority.clone();
746 epic.add_task(task);
747 }
748
749 let scg = serialize_scg(&epic);
750 let parsed = parse_scg(&scg).unwrap();
751
752 for (id, expected_priority) in priorities {
753 let task = parsed.get_task(id).unwrap();
754 assert_eq!(
755 task.priority, expected_priority,
756 "Priority mismatch for task {}",
757 id
758 );
759 }
760 }
761
762 #[test]
763 fn test_details_and_test_strategy() {
764 let mut epic = Phase::new("test".to_string());
765 let mut task = Task::new(
766 "1".to_string(),
767 "Task".to_string(),
768 "Description".to_string(),
769 );
770 task.details = Some("Detailed implementation notes".to_string());
771 task.test_strategy = Some("Unit tests and integration tests".to_string());
772 epic.add_task(task);
773
774 let scg = serialize_scg(&epic);
775 let parsed = parse_scg(&scg).unwrap();
776
777 let t = parsed.get_task("1").unwrap();
778 assert_eq!(t.description, "Description");
779 assert_eq!(t.details, Some("Detailed implementation notes".to_string()));
780 assert_eq!(
781 t.test_strategy,
782 Some("Unit tests and integration tests".to_string())
783 );
784 }
785
786 #[test]
789 fn test_backslash_escape() {
790 let mut epic = Phase::new("test".to_string());
791 let task = Task::new(
792 "1".to_string(),
793 "Task with \\ backslash".to_string(),
794 "Desc".to_string(),
795 );
796 epic.add_task(task);
797
798 let scg = serialize_scg(&epic);
799 let parsed = parse_scg(&scg).unwrap();
800
801 assert_eq!(
802 parsed.get_task("1").unwrap().title,
803 "Task with \\ backslash"
804 );
805 }
806
807 #[test]
808 fn test_multiple_special_chars() {
809 let mut epic = Phase::new("test".to_string());
810 let task = Task::new(
811 "1".to_string(),
812 "Task with | pipe and \\ backslash".to_string(),
813 "Line 1\nLine 2 with | and \\".to_string(),
814 );
815 epic.add_task(task);
816
817 let scg = serialize_scg(&epic);
818 let parsed = parse_scg(&scg).unwrap();
819
820 let t = parsed.get_task("1").unwrap();
821 assert_eq!(t.title, "Task with | pipe and \\ backslash");
822 assert_eq!(t.description, "Line 1\nLine 2 with | and \\");
823 }
824
825 #[test]
826 fn test_unicode_content() {
827 let mut epic = Phase::new("unicode-test".to_string());
828 let task = Task::new(
829 "1".to_string(),
830 "日本語タイトル 🚀 Émojis".to_string(),
831 "描述 with émojis 😀".to_string(),
832 );
833 epic.add_task(task);
834
835 let scg = serialize_scg(&epic);
836 let parsed = parse_scg(&scg).unwrap();
837
838 let t = parsed.get_task("1").unwrap();
839 assert_eq!(t.title, "日本語タイトル 🚀 Émojis");
840 assert_eq!(t.description, "描述 with émojis 😀");
841 }
842
843 #[test]
844 fn test_empty_dependencies() {
845 let mut epic = Phase::new("test".to_string());
846 let task = Task::new("1".to_string(), "Task".to_string(), "Desc".to_string());
847 epic.add_task(task);
848
849 let scg = serialize_scg(&epic);
850 let parsed = parse_scg(&scg).unwrap();
851
852 assert!(parsed.get_task("1").unwrap().dependencies.is_empty());
853 }
854
855 #[test]
856 fn test_multiple_dependencies() {
857 let mut epic = Phase::new("test".to_string());
858
859 let task1 = Task::new("1".to_string(), "Task 1".to_string(), String::new());
860 let task2 = Task::new("2".to_string(), "Task 2".to_string(), String::new());
861 let mut task3 = Task::new("3".to_string(), "Task 3".to_string(), String::new());
862 task3.dependencies = vec!["1".to_string(), "2".to_string()];
863
864 epic.add_task(task1);
865 epic.add_task(task2);
866 epic.add_task(task3);
867
868 let scg = serialize_scg(&epic);
869 let parsed = parse_scg(&scg).unwrap();
870
871 let t3 = parsed.get_task("3").unwrap();
872 assert_eq!(t3.dependencies.len(), 2);
873 assert!(t3.dependencies.contains(&"1".to_string()));
874 assert!(t3.dependencies.contains(&"2".to_string()));
875 }
876
877 #[test]
878 fn test_complexity_boundary_values() {
879 let mut epic = Phase::new("test".to_string());
880
881 let complexities: Vec<u32> = vec![0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
883 for (i, c) in complexities.iter().enumerate() {
884 let mut task = Task::new(
885 format!("{}", i + 1),
886 format!("Task {}", i + 1),
887 String::new(),
888 );
889 task.complexity = *c;
890 epic.add_task(task);
891 }
892
893 let scg = serialize_scg(&epic);
894 let parsed = parse_scg(&scg).unwrap();
895
896 for (i, expected) in complexities.iter().enumerate() {
897 let task = parsed.get_task(&format!("{}", i + 1)).unwrap();
898 assert_eq!(
899 task.complexity,
900 *expected,
901 "Complexity mismatch for task {}",
902 i + 1
903 );
904 }
905 }
906
907 #[test]
908 fn test_long_description() {
909 let mut epic = Phase::new("test".to_string());
910 let long_desc = "A".repeat(5000); let task = Task::new("1".to_string(), "Task".to_string(), long_desc.clone());
912 epic.add_task(task);
913
914 let scg = serialize_scg(&epic);
915 let parsed = parse_scg(&scg).unwrap();
916
917 assert_eq!(parsed.get_task("1").unwrap().description, long_desc);
918 }
919
920 #[test]
921 fn test_empty_description() {
922 let mut epic = Phase::new("test".to_string());
923 let task = Task::new("1".to_string(), "Task".to_string(), String::new());
924 epic.add_task(task);
925
926 let scg = serialize_scg(&epic);
927 let parsed = parse_scg(&scg).unwrap();
928
929 assert_eq!(parsed.get_task("1").unwrap().description, "");
930 }
931
932 #[test]
933 fn test_whitespace_handling() {
934 let mut epic = Phase::new("test".to_string());
936 let task = Task::new(
937 "1".to_string(),
938 " Task with spaces ".to_string(),
939 "Desc".to_string(),
940 );
941 epic.add_task(task);
942
943 let scg = serialize_scg(&epic);
944 let parsed = parse_scg(&scg).unwrap();
945
946 let t = parsed.get_task("1").unwrap();
949 assert_eq!(t.title, "Task with spaces");
950 }
951
952 #[test]
953 fn test_nested_subtasks() {
954 let mut epic = Phase::new("test".to_string());
955
956 let mut parent = Task::new("1".to_string(), "Parent".to_string(), String::new());
958 parent.status = TaskStatus::Expanded;
959 parent.subtasks = vec!["1.1".to_string(), "1.2".to_string()];
960
961 let mut child1 = Task::new("1.1".to_string(), "Child 1".to_string(), String::new());
962 child1.parent_id = Some("1".to_string());
963
964 let mut child2 = Task::new("1.2".to_string(), "Child 2".to_string(), String::new());
965 child2.parent_id = Some("1".to_string());
966 child2.status = TaskStatus::Expanded;
967 child2.subtasks = vec!["1.2.1".to_string()];
968
969 let mut grandchild =
970 Task::new("1.2.1".to_string(), "Grandchild".to_string(), String::new());
971 grandchild.parent_id = Some("1.2".to_string());
972
973 epic.add_task(parent);
974 epic.add_task(child1);
975 epic.add_task(child2);
976 epic.add_task(grandchild);
977
978 let scg = serialize_scg(&epic);
979 let parsed = parse_scg(&scg).unwrap();
980
981 assert_eq!(parsed.tasks.len(), 4);
982
983 let gc = parsed.get_task("1.2.1").unwrap();
984 assert_eq!(gc.parent_id, Some("1.2".to_string()));
985
986 let c2 = parsed.get_task("1.2").unwrap();
987 assert!(c2.subtasks.contains(&"1.2.1".to_string()));
988 }
989
990 #[test]
991 fn test_section_comment_lines_ignored() {
992 let content = r#"# SCUD Graph v1
994# Epic: test
995
996@meta {
997 name test
998 # this is a comment
999 updated 2025-01-01T00:00:00Z
1000}
1001
1002@nodes
1003# id | title | status | complexity | priority
1004# another comment
10051 | Task | P | 0 | M
1006"#;
1007 let epic = parse_scg(content).unwrap();
1008 assert_eq!(epic.tasks.len(), 1);
1009 assert_eq!(epic.get_task("1").unwrap().title, "Task");
1010 }
1011}