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