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