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