scud/formats/
scg.rs

1//! SCUD Graph (.scg) format parser and serializer
2//!
3//! A token-efficient, graph-native format for task storage.
4
5use 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
13/// Status code mapping
14fn 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
60/// Escape special characters in text fields
61fn escape_text(text: &str) -> String {
62    text.replace('\\', "\\\\")
63        .replace('|', "\\|")
64        .replace('\n', "\\n")
65}
66
67/// Unescape special characters
68fn 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
91/// Split a line by pipe character, respecting escaped pipes
92fn 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            // Check for escaped pipe or backslash
100            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
119/// Parse SCG format into Phase
120pub fn parse_scg(content: &str) -> Result<Phase> {
121    let mut lines = content.lines().peekable();
122
123    // Parse header
124    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:")) // backwards compatibility
137        .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: (assigned_to, locked_by, locked_at)
146    type AssignmentInfo = (Option<String>, Option<String>, Option<String>);
147    let mut assignments: HashMap<String, AssignmentInfo> = HashMap::new();
148
149    // Track current section
150    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        // Skip empty lines
159        if trimmed.is_empty() {
160            continue;
161        }
162
163        // Check for section headers
164        if trimmed.starts_with('@') {
165            // Flush any pending detail
166            flush_detail(
167                &current_detail_id,
168                &current_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        // Handle continuation lines in details
188        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        // Skip meta closing brace and comment lines
197        if trimmed == "}" || trimmed.starts_with('#') {
198            continue;
199        }
200
201        match current_section {
202            Some("meta") => {
203                // Parse "key value" pairs
204                if let Some((key, value)) = trimmed.split_once(char::is_whitespace) {
205                    let value = value.trim();
206                    // Meta fields are informational, phase name is already set
207                    if key == "name" && phase.name != value {
208                        phase = Phase::new(value.to_string());
209                    }
210                }
211            }
212            Some("nodes") => {
213                // Parse "id | title | status | complexity | priority"
214                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                // Parse "dependent -> dependency"
233                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                // Parse "parent: child1, child2, ..."
239                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                // Parse "id | assigned_to | locked_by | locked_at"
250                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 previous detail if starting new one
273                flush_detail(
274                    &current_detail_id,
275                    &current_detail_field,
276                    &mut current_detail_content,
277                    &mut details,
278                );
279
280                // Parse "id | field |"
281                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 any remaining detail
293    flush_detail(
294        &current_detail_id,
295        &current_detail_field,
296        &mut current_detail_content,
297        &mut details,
298    );
299
300    // Apply edges (dependencies)
301    for (dependent, dependency) in edges {
302        if let Some(task) = tasks.get_mut(&dependent) {
303            task.dependencies.push(dependency);
304        }
305    }
306
307    // Apply parent-child relationships
308    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    // Apply details
320    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    // Apply assignments
335    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    // Add all tasks to phase
344    phase.tasks = tasks.into_values().collect();
345
346    // Sort tasks by ID for consistent ordering
347    phase.tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
348
349    Ok(phase)
350}
351
352/// Natural sort for task IDs: "1" < "2" < "10", "1.1" < "1.2" < "1.10"
353fn 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
374/// Helper to flush current detail
375fn 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
391/// Serialize Phase to SCG format
392pub fn serialize_scg(phase: &Phase) -> String {
393    let mut output = String::new();
394
395    // Header
396    output.push_str(&format!("{} {}\n", HEADER_PREFIX, FORMAT_VERSION));
397    output.push_str(&format!("# Phase: {}\n\n", phase.name));
398
399    // Meta section
400    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    // Sort tasks for consistent output
407    let mut sorted_tasks = phase.tasks.clone();
408    sorted_tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
409
410    // Nodes section
411    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    // Edges section (dependencies)
426    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    // Parents section
441    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    // Assignments section
456    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    // Details section
477    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        // Add tasks in random order
701        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    // Additional edge case tests
802
803    #[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        // Test all Fibonacci complexity values
897        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); // Max description length
926        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        // Ensure whitespace in titles is preserved
950        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        // After round-trip, leading/trailing spaces in title should be trimmed
962        // (this is expected behavior from split/trim)
963        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        // Create hierarchy: 1 -> 1.1, 1.2 -> 1.2.1
972        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        // Manually create SCG with extra comment lines - they should be ignored
1008        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}