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