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
151    // Track current section
152    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        // Skip empty lines
161        if trimmed.is_empty() {
162            continue;
163        }
164
165        // Check for section headers
166        if trimmed.starts_with('@') {
167            // Flush any pending detail
168            flush_detail(
169                &current_detail_id,
170                &current_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        // Handle continuation lines in details
190        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        // Skip meta closing brace and comment lines
199        if trimmed == "}" || trimmed.starts_with('#') {
200            continue;
201        }
202
203        match current_section {
204            Some("meta") => {
205                // Parse "key value" pairs
206                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                            // Ignore other meta fields (e.g., "updated")
219                        }
220                    }
221                }
222            }
223            Some("nodes") => {
224                // Parse "id | title | status | complexity | priority"
225                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                // Parse "dependent -> dependency"
244                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                // Parse "parent: child1, child2, ..."
250                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                // Parse "id | assigned_to" (new format) or "id | assigned_to | locked_by | locked_at" (legacy)
261                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                    // Legacy fields (locked_by, locked_at) are ignored if present
270                    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 previous detail if starting new one
277                flush_detail(
278                    &current_detail_id,
279                    &current_detail_field,
280                    &mut current_detail_content,
281                    &mut details,
282                );
283
284                // Parse "id | field |"
285                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 any remaining detail
297    flush_detail(
298        &current_detail_id,
299        &current_detail_field,
300        &mut current_detail_content,
301        &mut details,
302    );
303
304    // Apply edges (dependencies)
305    for (dependent, dependency) in edges {
306        if let Some(task) = tasks.get_mut(&dependent) {
307            task.dependencies.push(dependency);
308        }
309    }
310
311    // Apply parent-child relationships
312    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    // Apply details
324    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    // Apply assignments (informational only, no locking)
339    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    // Add all tasks to phase
346    phase.tasks = tasks.into_values().collect();
347
348    // Sort tasks by ID for consistent ordering
349    phase.tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
350
351    Ok(phase)
352}
353
354/// Natural sort for task IDs with UUID fallback
355/// Numeric IDs: "1" < "2" < "10", "1.1" < "1.2" < "1.10"
356/// UUIDs: Lexicographic comparison
357pub fn natural_sort_ids(a: &str, b: &str) -> std::cmp::Ordering {
358    // Check if both look like numeric IDs (contain only digits and dots)
359    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        // Existing numeric sort logic
364        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        // UUID or mixed: fall back to lexicographic
384        a.cmp(b)
385    }
386}
387
388/// Helper to flush current detail
389fn 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
405/// Serialize Phase to SCG format
406pub fn serialize_scg(phase: &Phase) -> String {
407    let mut output = String::new();
408
409    // Header
410    output.push_str(&format!("{} {}\n", HEADER_PREFIX, FORMAT_VERSION));
411    output.push_str(&format!("# Phase: {}\n\n", phase.name));
412
413    // Meta section
414    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    // Sort tasks for consistent output
422    let mut sorted_tasks = phase.tasks.clone();
423    sorted_tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
424
425    // Nodes section
426    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    // Edges section (dependencies)
441    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    // Parents section
456    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    // Assignments section (informational only, no locking)
471    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    // Details section
490    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        // Test UUID format persists through SCG round-trip
567        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        // Test that phases without id_format default to Sequential
587        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        // Add tasks in random order
753        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        // Test UUID sorting falls back to lexicographic
768        use super::natural_sort_ids;
769
770        // UUIDs should sort lexicographically
771        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        // Mixed UUIDs should also sort lexicographically
780        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        // When mixing numeric and UUID IDs, fall back to lexicographic
788        use super::natural_sort_ids;
789
790        let numeric = "123";
791        let uuid = "a1b2c3d4e5f6789012345678901234ab";
792
793        // "123" < "a1b2..." lexicographically
794        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    // Additional edge case tests
887
888    #[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        // Test all Fibonacci complexity values
982        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); // Max description length
1011        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        // Ensure whitespace in titles is preserved
1035        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        // After round-trip, leading/trailing spaces in title should be trimmed
1047        // (this is expected behavior from split/trim)
1048        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        // Create hierarchy: 1 -> 1.1, 1.2 -> 1.2.1
1057        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        // Manually create SCG with extra comment lines - they should be ignored
1093        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}