Skip to main content

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// --- Pipeline extension types ---
14
15/// Result of parsing an SCG file, including optional pipeline metadata.
16#[derive(Debug)]
17pub struct ScgParseResult {
18    pub phase: Phase,
19    pub pipeline: Option<ScgPipeline>,
20}
21
22/// Pipeline-specific data parsed from an SCG file in `mode pipeline`.
23#[derive(Debug, Clone, Default)]
24pub struct ScgPipeline {
25    pub goal: Option<String>,
26    pub model_stylesheet: Option<String>,
27    pub node_attrs: HashMap<String, PipelineNodeAttrs>,
28    pub edge_attrs: Vec<ScgEdgeAttrs>,
29}
30
31/// Per-node pipeline attributes from the `@pipeline` section.
32#[derive(Debug, Clone, Default)]
33pub struct PipelineNodeAttrs {
34    pub handler_type: String,
35    pub max_retries: u32,
36    pub retry_target: Option<String>,
37    pub goal_gate: bool,
38    pub timeout: Option<String>,
39}
40
41/// Extended edge attributes for pipeline mode.
42#[derive(Debug, Clone)]
43pub struct ScgEdgeAttrs {
44    pub from: String,
45    pub to: String,
46    pub label: String,
47    pub condition: String,
48    pub weight: i32,
49}
50
51/// Status code mapping
52fn status_to_code(status: &TaskStatus) -> char {
53    match status {
54        TaskStatus::Pending => 'P',
55        TaskStatus::InProgress => 'I',
56        TaskStatus::Done => 'D',
57        TaskStatus::Review => 'R',
58        TaskStatus::Blocked => 'B',
59        TaskStatus::Deferred => 'F',
60        TaskStatus::Cancelled => 'C',
61        TaskStatus::Expanded => 'X',
62        TaskStatus::Failed => '!',
63    }
64}
65
66fn code_to_status(code: char) -> Option<TaskStatus> {
67    match code {
68        'P' => Some(TaskStatus::Pending),
69        'I' => Some(TaskStatus::InProgress),
70        'D' => Some(TaskStatus::Done),
71        'R' => Some(TaskStatus::Review),
72        'B' => Some(TaskStatus::Blocked),
73        'F' => Some(TaskStatus::Deferred),
74        'C' => Some(TaskStatus::Cancelled),
75        'X' => Some(TaskStatus::Expanded),
76        '!' => Some(TaskStatus::Failed),
77        _ => None,
78    }
79}
80
81fn priority_to_code(priority: &Priority) -> char {
82    match priority {
83        Priority::Critical => 'C',
84        Priority::High => 'H',
85        Priority::Medium => 'M',
86        Priority::Low => 'L',
87    }
88}
89
90fn code_to_priority(code: char) -> Option<Priority> {
91    match code {
92        'C' => Some(Priority::Critical),
93        'H' => Some(Priority::High),
94        'M' => Some(Priority::Medium),
95        'L' => Some(Priority::Low),
96        _ => None,
97    }
98}
99
100/// Escape special characters in text fields
101fn escape_text(text: &str) -> String {
102    text.replace('\\', "\\\\")
103        .replace('|', "\\|")
104        .replace('\n', "\\n")
105}
106
107/// Unescape special characters
108fn unescape_text(text: &str) -> String {
109    let mut result = String::with_capacity(text.len());
110    let mut chars = text.chars().peekable();
111
112    while let Some(c) = chars.next() {
113        if c == '\\' {
114            match chars.next() {
115                Some('\\') => result.push('\\'),
116                Some('|') => result.push('|'),
117                Some('n') => result.push('\n'),
118                Some(other) => {
119                    result.push('\\');
120                    result.push(other);
121                }
122                None => result.push('\\'),
123            }
124        } else {
125            result.push(c);
126        }
127    }
128    result
129}
130
131/// Split a line by pipe character, respecting escaped pipes
132fn split_by_pipe(line: &str) -> Vec<String> {
133    let mut parts = Vec::new();
134    let mut current = String::new();
135    let mut chars = line.chars().peekable();
136
137    while let Some(c) = chars.next() {
138        if c == '\\' {
139            // Check for escaped pipe or backslash
140            if let Some(&next) = chars.peek() {
141                if next == '|' || next == '\\' {
142                    current.push(c);
143                    current.push(chars.next().unwrap());
144                    continue;
145                }
146            }
147            current.push(c);
148        } else if c == '|' {
149            parts.push(current.trim().to_string());
150            current = String::new();
151        } else {
152            current.push(c);
153        }
154    }
155    parts.push(current.trim().to_string());
156    parts
157}
158
159/// Parse SCG format into Phase (backwards-compatible wrapper).
160pub fn parse_scg(content: &str) -> Result<Phase> {
161    parse_scg_result(content).map(|r| r.phase)
162}
163
164/// Parse SCG format into ScgParseResult, capturing pipeline metadata if present.
165pub fn parse_scg_result(content: &str) -> Result<ScgParseResult> {
166    let mut lines = content.lines().peekable();
167
168    // Parse header
169    let first_line = lines.next().context("Empty file")?;
170    if !first_line.starts_with(HEADER_PREFIX) {
171        anyhow::bail!(
172            "Invalid SCG header: expected '{}', got '{}'",
173            HEADER_PREFIX,
174            first_line
175        );
176    }
177
178    let phase_line = lines.next().context("Missing phase tag line")?;
179    let phase_tag = phase_line
180        .strip_prefix("# Phase:")
181        .or_else(|| phase_line.strip_prefix("# Epic:")) // backwards compatibility
182        .map(|s| s.trim())
183        .context("Invalid phase line format")?;
184
185    let mut phase = Phase::new(phase_tag.to_string());
186    let mut tasks: HashMap<String, Task> = HashMap::new();
187    let mut edges: Vec<(String, String)> = Vec::new();
188    let mut parents: HashMap<String, Vec<String>> = HashMap::new();
189    let mut details: HashMap<String, HashMap<String, String>> = HashMap::new();
190    // Type: (assigned_to, locked_by, locked_at)
191    type AssignmentInfo = (Option<String>, Option<String>, Option<String>);
192    let mut assignments: HashMap<String, AssignmentInfo> = HashMap::new();
193    let mut agent_types: HashMap<String, String> = HashMap::new();
194
195    // Pipeline-specific accumulators
196    let mut is_pipeline = false;
197    let mut pipeline_goal: Option<String> = None;
198    let mut pipeline_stylesheet: Option<String> = None;
199    let mut pipeline_node_attrs: HashMap<String, PipelineNodeAttrs> = HashMap::new();
200    let mut pipeline_edge_attrs: Vec<ScgEdgeAttrs> = Vec::new();
201
202    // Track current section
203    let mut current_section: Option<&str> = None;
204    let mut current_detail_id: Option<String> = None;
205    let mut current_detail_field: Option<String> = None;
206    let mut current_detail_content: Vec<String> = Vec::new();
207
208    for line in lines {
209        let trimmed = line.trim();
210
211        // Skip empty lines
212        if trimmed.is_empty() {
213            continue;
214        }
215
216        // Check for section headers
217        if trimmed.starts_with('@') {
218            // Flush any pending detail
219            flush_detail(
220                &current_detail_id,
221                &current_detail_field,
222                &mut current_detail_content,
223                &mut details,
224            );
225            current_detail_id = None;
226            current_detail_field = None;
227
228            current_section = Some(match trimmed {
229                "@meta {" | "@meta" => "meta",
230                "@nodes" => "nodes",
231                "@edges" => "edges",
232                "@parents" => "parents",
233                "@assignments" => "assignments",
234                "@agents" => "agents",
235                "@details" => "details",
236                "@pipeline" => "pipeline",
237                _ => continue,
238            });
239            continue;
240        }
241
242        // Handle continuation lines in details
243        if current_section == Some("details")
244            && line.starts_with("  ")
245            && current_detail_id.is_some()
246        {
247            current_detail_content.push(line[2..].to_string());
248            continue;
249        }
250
251        // Skip meta closing brace and comment lines
252        if trimmed == "}" || trimmed.starts_with('#') {
253            continue;
254        }
255
256        match current_section {
257            Some("meta") => {
258                // Parse "key value" pairs
259                if let Some((key, value)) = trimmed.split_once(char::is_whitespace) {
260                    let value = value.trim();
261                    match key {
262                        "name" => {
263                            if phase.name != value {
264                                phase = Phase::new(value.to_string());
265                            }
266                        }
267                        "id_format" => {
268                            phase.id_format = IdFormat::parse(value);
269                        }
270                        "mode" => {
271                            if value == "pipeline" {
272                                is_pipeline = true;
273                            }
274                        }
275                        "goal" => {
276                            pipeline_goal = Some(value.to_string());
277                        }
278                        "model_stylesheet" => {
279                            // Capture everything after "model_stylesheet "
280                            pipeline_stylesheet = Some(value.to_string());
281                        }
282                        _ => {
283                            // Ignore other meta fields (e.g., "updated")
284                        }
285                    }
286                }
287            }
288            Some("nodes") => {
289                // Parse "id | title | status | complexity | priority"
290                let parts = split_by_pipe(trimmed);
291                if parts.len() >= 5 {
292                    let id = parts[0].clone();
293                    let title = unescape_text(&parts[1]);
294                    let status =
295                        code_to_status(parts[2].chars().next().unwrap_or('P')).unwrap_or_default();
296                    let complexity: u32 = parts[3].parse().unwrap_or(0);
297                    let priority = code_to_priority(parts[4].chars().next().unwrap_or('M'))
298                        .unwrap_or_default();
299
300                    let mut task = Task::new(id.clone(), title, String::new());
301                    task.status = status;
302                    task.complexity = complexity;
303                    task.priority = priority;
304                    tasks.insert(id, task);
305                }
306            }
307            Some("edges") => {
308                if is_pipeline {
309                    // Pipeline mode: "from -> to [| label | condition | weight]"
310                    if let Some((from_part, rest)) = trimmed.split_once("->") {
311                        let from = from_part.trim().to_string();
312                        // The rest may contain pipe-delimited fields
313                        let parts = split_by_pipe(rest);
314                        let to = parts[0].trim().to_string();
315                        let label = parts.get(1).map(|s| s.trim().to_string()).unwrap_or_default();
316                        let condition = parts.get(2).map(|s| s.trim().to_string()).unwrap_or_default();
317                        let weight: i32 = parts.get(3).and_then(|s| s.trim().parse().ok()).unwrap_or(0);
318                        pipeline_edge_attrs.push(ScgEdgeAttrs {
319                            from,
320                            to,
321                            label,
322                            condition,
323                            weight,
324                        });
325                    }
326                } else {
327                    // Standard mode: "dependent -> dependency"
328                    if let Some((dependent, dependency)) = trimmed.split_once("->") {
329                        edges.push((dependent.trim().to_string(), dependency.trim().to_string()));
330                    }
331                }
332            }
333            Some("pipeline") => {
334                // Parse "id | handler_type | max_retries | retry_target | goal_gate | timeout"
335                let parts = split_by_pipe(trimmed);
336                if parts.len() >= 2 {
337                    let id = parts[0].clone();
338                    let handler_type = parts[1].clone();
339                    let max_retries: u32 = parts.get(2).and_then(|s| {
340                        let s = s.trim();
341                        if s.is_empty() { None } else { s.parse().ok() }
342                    }).unwrap_or(0);
343                    let retry_target = parts.get(3).and_then(|s| {
344                        let s = s.trim();
345                        if s.is_empty() { None } else { Some(s.to_string()) }
346                    });
347                    let goal_gate = parts.get(4).map(|s| {
348                        let s = s.trim();
349                        s == "true"
350                    }).unwrap_or(false);
351                    let timeout = parts.get(5).and_then(|s| {
352                        let s = s.trim();
353                        if s.is_empty() { None } else { Some(s.to_string()) }
354                    });
355                    pipeline_node_attrs.insert(id, PipelineNodeAttrs {
356                        handler_type,
357                        max_retries,
358                        retry_target,
359                        goal_gate,
360                        timeout,
361                    });
362                }
363            }
364            Some("parents") => {
365                // Parse "parent: child1, child2, ..."
366                if let Some((parent, children)) = trimmed.split_once(':') {
367                    let child_ids: Vec<String> = children
368                        .split(',')
369                        .map(|s| s.trim().to_string())
370                        .filter(|s| !s.is_empty())
371                        .collect();
372                    parents.insert(parent.trim().to_string(), child_ids);
373                }
374            }
375            Some("assignments") => {
376                // Parse "id | assigned_to" (new format) or "id | assigned_to | locked_by | locked_at" (legacy)
377                let parts = split_by_pipe(trimmed);
378                if parts.len() >= 2 {
379                    let id = parts[0].clone();
380                    let assigned = if parts[1].is_empty() {
381                        None
382                    } else {
383                        Some(parts[1].clone())
384                    };
385                    // Legacy fields (locked_by, locked_at) are ignored if present
386                    let locked_by: Option<String> = None;
387                    let locked_at: Option<String> = None;
388                    assignments.insert(id, (assigned, locked_by, locked_at));
389                }
390            }
391            Some("agents") => {
392                // Parse "id | agent_type"
393                let parts = split_by_pipe(trimmed);
394                if parts.len() >= 2 && !parts[1].is_empty() {
395                    agent_types.insert(parts[0].clone(), parts[1].clone());
396                }
397            }
398            Some("details") => {
399                // Flush previous detail if starting new one
400                flush_detail(
401                    &current_detail_id,
402                    &current_detail_field,
403                    &mut current_detail_content,
404                    &mut details,
405                );
406
407                // Parse "id | field |"
408                let parts = split_by_pipe(trimmed);
409                if parts.len() >= 2 {
410                    current_detail_id = Some(parts[0].clone());
411                    current_detail_field = Some(parts[1].clone());
412                    current_detail_content.clear();
413                }
414            }
415            _ => {}
416        }
417    }
418
419    // Flush any remaining detail
420    flush_detail(
421        &current_detail_id,
422        &current_detail_field,
423        &mut current_detail_content,
424        &mut details,
425    );
426
427    // Apply edges (dependencies) — only in non-pipeline mode
428    if !is_pipeline {
429        for (dependent, dependency) in edges {
430            if let Some(task) = tasks.get_mut(&dependent) {
431                task.dependencies.push(dependency);
432            }
433        }
434    }
435
436    // Apply parent-child relationships
437    for (parent_id, child_ids) in parents {
438        if let Some(parent) = tasks.get_mut(&parent_id) {
439            parent.subtasks = child_ids.clone();
440        }
441        for child_id in child_ids {
442            if let Some(child) = tasks.get_mut(&child_id) {
443                child.parent_id = Some(parent_id.clone());
444            }
445        }
446    }
447
448    // Apply details
449    for (id, fields) in details {
450        if let Some(task) = tasks.get_mut(&id) {
451            if let Some(desc) = fields.get("description") {
452                task.description = desc.clone();
453            }
454            if let Some(det) = fields.get("details") {
455                task.details = Some(det.clone());
456            }
457            if let Some(ts) = fields.get("test_strategy") {
458                task.test_strategy = Some(ts.clone());
459            }
460        }
461    }
462
463    // Apply assignments (informational only, no locking)
464    for (id, (assigned, _locked_by, _locked_at)) in assignments {
465        if let Some(task) = tasks.get_mut(&id) {
466            task.assigned_to = assigned;
467        }
468    }
469
470    // Apply agent types
471    for (id, agent_type) in agent_types {
472        if let Some(task) = tasks.get_mut(&id) {
473            task.agent_type = Some(agent_type);
474        }
475    }
476
477    // Add all tasks to phase
478    phase.tasks = tasks.into_values().collect();
479
480    // Sort tasks by ID for consistent ordering
481    phase.tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
482
483    // Build pipeline if in pipeline mode
484    let pipeline = if is_pipeline {
485        Some(ScgPipeline {
486            goal: pipeline_goal,
487            model_stylesheet: pipeline_stylesheet,
488            node_attrs: pipeline_node_attrs,
489            edge_attrs: pipeline_edge_attrs,
490        })
491    } else {
492        None
493    };
494
495    Ok(ScgParseResult { phase, pipeline })
496}
497
498/// Natural sort for task IDs with UUID fallback
499/// Numeric IDs: "1" < "2" < "10", "1.1" < "1.2" < "1.10"
500/// UUIDs: Lexicographic comparison
501pub fn natural_sort_ids(a: &str, b: &str) -> std::cmp::Ordering {
502    // Check if both look like numeric IDs (contain only digits and dots)
503    let a_is_numeric = a.chars().all(|c| c.is_ascii_digit() || c == '.');
504    let b_is_numeric = b.chars().all(|c| c.is_ascii_digit() || c == '.');
505
506    if a_is_numeric && b_is_numeric {
507        // Existing numeric sort logic
508        let a_parts: Vec<&str> = a.split('.').collect();
509        let b_parts: Vec<&str> = b.split('.').collect();
510
511        for (ap, bp) in a_parts.iter().zip(b_parts.iter()) {
512            match (ap.parse::<u32>(), bp.parse::<u32>()) {
513                (Ok(an), Ok(bn)) => {
514                    if an != bn {
515                        return an.cmp(&bn);
516                    }
517                }
518                _ => {
519                    if ap != bp {
520                        return ap.cmp(bp);
521                    }
522                }
523            }
524        }
525        a_parts.len().cmp(&b_parts.len())
526    } else {
527        // UUID or mixed: fall back to lexicographic
528        a.cmp(b)
529    }
530}
531
532/// Helper to flush current detail
533fn flush_detail(
534    id: &Option<String>,
535    field: &Option<String>,
536    content: &mut Vec<String>,
537    details: &mut HashMap<String, HashMap<String, String>>,
538) {
539    if let (Some(id), Some(field)) = (id, field) {
540        let text = content.join("\n");
541        details
542            .entry(id.clone())
543            .or_default()
544            .insert(field.clone(), text);
545        content.clear();
546    }
547}
548
549/// Serialize Phase to SCG format
550pub fn serialize_scg(phase: &Phase) -> String {
551    let mut output = String::new();
552
553    // Header
554    output.push_str(&format!("{} {}\n", HEADER_PREFIX, FORMAT_VERSION));
555    output.push_str(&format!("# Phase: {}\n\n", phase.name));
556
557    // Meta section
558    let now = chrono::Utc::now().to_rfc3339();
559    output.push_str("@meta {\n");
560    output.push_str(&format!("  name {}\n", phase.name));
561    output.push_str(&format!("  id_format {}\n", phase.id_format.as_str()));
562    output.push_str(&format!("  updated {}\n", now));
563    output.push_str("}\n\n");
564
565    // Sort tasks for consistent output
566    let mut sorted_tasks = phase.tasks.clone();
567    sorted_tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
568
569    // Nodes section
570    output.push_str("@nodes\n");
571    output.push_str("# id | title | status | complexity | priority\n");
572    for task in &sorted_tasks {
573        output.push_str(&format!(
574            "{} | {} | {} | {} | {}\n",
575            task.id,
576            escape_text(&task.title),
577            status_to_code(&task.status),
578            task.complexity,
579            priority_to_code(&task.priority)
580        ));
581    }
582    output.push('\n');
583
584    // Edges section (dependencies)
585    let edges: Vec<_> = sorted_tasks
586        .iter()
587        .flat_map(|t| t.dependencies.iter().map(move |dep| (&t.id, dep)))
588        .collect();
589
590    if !edges.is_empty() {
591        output.push_str("@edges\n");
592        output.push_str("# dependent -> dependency\n");
593        for (dependent, dependency) in edges {
594            output.push_str(&format!("{} -> {}\n", dependent, dependency));
595        }
596        output.push('\n');
597    }
598
599    // Parents section
600    let parents: Vec<_> = sorted_tasks
601        .iter()
602        .filter(|t| !t.subtasks.is_empty())
603        .collect();
604
605    if !parents.is_empty() {
606        output.push_str("@parents\n");
607        output.push_str("# parent: subtasks...\n");
608        for task in parents {
609            output.push_str(&format!("{}: {}\n", task.id, task.subtasks.join(", ")));
610        }
611        output.push('\n');
612    }
613
614    // Assignments section (informational only, no locking)
615    let assignments: Vec<_> = sorted_tasks
616        .iter()
617        .filter(|t| t.assigned_to.is_some())
618        .collect();
619
620    if !assignments.is_empty() {
621        output.push_str("@assignments\n");
622        output.push_str("# id | assigned_to\n");
623        for task in assignments {
624            output.push_str(&format!(
625                "{} | {}\n",
626                task.id,
627                task.assigned_to.as_deref().unwrap_or("")
628            ));
629        }
630        output.push('\n');
631    }
632
633    // Agents section
634    let agents: Vec<_> = sorted_tasks
635        .iter()
636        .filter(|t| t.agent_type.is_some())
637        .collect();
638
639    if !agents.is_empty() {
640        output.push_str("@agents\n");
641        output.push_str("# id | agent_type\n");
642        for task in agents {
643            output.push_str(&format!(
644                "{} | {}\n",
645                task.id,
646                task.agent_type.as_deref().unwrap_or("")
647            ));
648        }
649        output.push('\n');
650    }
651
652    // Details section
653    let tasks_with_details: Vec<_> = sorted_tasks
654        .iter()
655        .filter(|t| !t.description.is_empty() || t.details.is_some() || t.test_strategy.is_some())
656        .collect();
657
658    if !tasks_with_details.is_empty() {
659        output.push_str("@details\n");
660        for task in tasks_with_details {
661            if !task.description.is_empty() {
662                output.push_str(&format!("{} | description |\n", task.id));
663                for line in task.description.lines() {
664                    output.push_str(&format!("  {}\n", line));
665                }
666            }
667            if let Some(ref details) = task.details {
668                output.push_str(&format!("{} | details |\n", task.id));
669                for line in details.lines() {
670                    output.push_str(&format!("  {}\n", line));
671                }
672            }
673            if let Some(ref test_strategy) = task.test_strategy {
674                output.push_str(&format!("{} | test_strategy |\n", task.id));
675                for line in test_strategy.lines() {
676                    output.push_str(&format!("  {}\n", line));
677                }
678            }
679        }
680    }
681
682    output
683}
684
685/// Serialize a pipeline-mode SCG from a ScgParseResult.
686pub fn serialize_scg_pipeline(result: &ScgParseResult) -> String {
687    let phase = &result.phase;
688    let pipeline = result.pipeline.as_ref().expect("serialize_scg_pipeline requires pipeline data");
689
690    let mut output = String::new();
691
692    // Header
693    output.push_str(&format!("{} {}\n", HEADER_PREFIX, FORMAT_VERSION));
694    output.push_str(&format!("# Phase: {}\n\n", phase.name));
695
696    // Meta section with pipeline fields
697    output.push_str("@meta {\n");
698    output.push_str(&format!("  name {}\n", phase.name));
699    output.push_str("  mode pipeline\n");
700    if let Some(ref goal) = pipeline.goal {
701        output.push_str(&format!("  goal {}\n", goal));
702    }
703    if let Some(ref stylesheet) = pipeline.model_stylesheet {
704        output.push_str(&format!("  model_stylesheet {}\n", stylesheet));
705    }
706    output.push_str("}\n\n");
707
708    // Sort tasks for consistent output
709    let mut sorted_tasks = phase.tasks.clone();
710    sorted_tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
711
712    // Nodes section
713    output.push_str("@nodes\n");
714    output.push_str("# id | title | status | complexity | priority\n");
715    for task in &sorted_tasks {
716        output.push_str(&format!(
717            "{} | {} | {} | {} | {}\n",
718            task.id,
719            escape_text(&task.title),
720            status_to_code(&task.status),
721            task.complexity,
722            priority_to_code(&task.priority)
723        ));
724    }
725    output.push('\n');
726
727    // Edges section (pipeline style: from -> to | label | condition | weight)
728    if !pipeline.edge_attrs.is_empty() {
729        output.push_str("@edges\n");
730        output.push_str("# from -> to [| label | condition | weight]\n");
731        for edge in &pipeline.edge_attrs {
732            let has_extras = !edge.label.is_empty() || !edge.condition.is_empty() || edge.weight != 0;
733            if has_extras {
734                output.push_str(&format!(
735                    "{} -> {} | {} | {} | {}\n",
736                    edge.from, edge.to, edge.label, edge.condition, edge.weight
737                ));
738            } else {
739                output.push_str(&format!("{} -> {}\n", edge.from, edge.to));
740            }
741        }
742        output.push('\n');
743    }
744
745    // Pipeline section
746    if !pipeline.node_attrs.is_empty() {
747        output.push_str("@pipeline\n");
748        output.push_str("# id | handler_type | max_retries | retry_target | goal_gate | timeout\n");
749
750        // Sort by task order
751        let task_ids: Vec<&str> = sorted_tasks.iter().map(|t| t.id.as_str()).collect();
752        let mut sorted_attrs: Vec<_> = pipeline.node_attrs.iter().collect();
753        sorted_attrs.sort_by(|(a, _), (b, _)| {
754            let a_pos = task_ids.iter().position(|id| id == a).unwrap_or(usize::MAX);
755            let b_pos = task_ids.iter().position(|id| id == b).unwrap_or(usize::MAX);
756            a_pos.cmp(&b_pos)
757        });
758
759        for (id, attrs) in sorted_attrs {
760            let retry_target = attrs.retry_target.as_deref().unwrap_or("");
761            let goal_gate = if attrs.goal_gate { "true" } else { "" };
762            let timeout = attrs.timeout.as_deref().unwrap_or("");
763
764            // Trim trailing empty fields
765            if !timeout.is_empty() {
766                output.push_str(&format!(
767                    "{} | {} | {} | {} | {} | {}\n",
768                    id, attrs.handler_type, attrs.max_retries, retry_target, goal_gate, timeout
769                ));
770            } else if !goal_gate.is_empty() {
771                output.push_str(&format!(
772                    "{} | {} | {} | {} | {}\n",
773                    id, attrs.handler_type, attrs.max_retries, retry_target, goal_gate
774                ));
775            } else if !retry_target.is_empty() {
776                output.push_str(&format!(
777                    "{} | {} | {} | {}\n",
778                    id, attrs.handler_type, attrs.max_retries, retry_target
779                ));
780            } else if attrs.max_retries > 0 {
781                output.push_str(&format!(
782                    "{} | {} | {}\n",
783                    id, attrs.handler_type, attrs.max_retries
784                ));
785            } else {
786                output.push_str(&format!("{} | {}\n", id, attrs.handler_type));
787            }
788        }
789        output.push('\n');
790    }
791
792    // Details section (same as standard SCG)
793    let tasks_with_details: Vec<_> = sorted_tasks
794        .iter()
795        .filter(|t| !t.description.is_empty() || t.details.is_some() || t.test_strategy.is_some())
796        .collect();
797
798    if !tasks_with_details.is_empty() {
799        output.push_str("@details\n");
800        for task in tasks_with_details {
801            if !task.description.is_empty() {
802                output.push_str(&format!("{} | description |\n", task.id));
803                for line in task.description.lines() {
804                    output.push_str(&format!("  {}\n", line));
805                }
806            }
807            if let Some(ref details) = task.details {
808                output.push_str(&format!("{} | details |\n", task.id));
809                for line in details.lines() {
810                    output.push_str(&format!("  {}\n", line));
811                }
812            }
813            if let Some(ref test_strategy) = task.test_strategy {
814                output.push_str(&format!("{} | test_strategy |\n", task.id));
815                for line in test_strategy.lines() {
816                    output.push_str(&format!("  {}\n", line));
817                }
818            }
819        }
820    }
821
822    output
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    #[test]
830    fn test_status_codes() {
831        assert_eq!(status_to_code(&TaskStatus::Pending), 'P');
832        assert_eq!(status_to_code(&TaskStatus::InProgress), 'I');
833        assert_eq!(status_to_code(&TaskStatus::Done), 'D');
834        assert_eq!(status_to_code(&TaskStatus::Expanded), 'X');
835        assert_eq!(status_to_code(&TaskStatus::Failed), '!');
836
837        assert_eq!(code_to_status('P'), Some(TaskStatus::Pending));
838        assert_eq!(code_to_status('X'), Some(TaskStatus::Expanded));
839        assert_eq!(code_to_status('!'), Some(TaskStatus::Failed));
840        assert_eq!(code_to_status('Z'), None);
841    }
842
843    #[test]
844    fn test_priority_codes() {
845        assert_eq!(priority_to_code(&Priority::Critical), 'C');
846        assert_eq!(priority_to_code(&Priority::High), 'H');
847        assert_eq!(priority_to_code(&Priority::Medium), 'M');
848        assert_eq!(priority_to_code(&Priority::Low), 'L');
849
850        assert_eq!(code_to_priority('C'), Some(Priority::Critical));
851        assert_eq!(code_to_priority('H'), Some(Priority::High));
852        assert_eq!(code_to_priority('M'), Some(Priority::Medium));
853        assert_eq!(code_to_priority('L'), Some(Priority::Low));
854        assert_eq!(code_to_priority('Z'), None);
855    }
856
857    #[test]
858    fn test_escape_unescape() {
859        assert_eq!(escape_text("hello|world"), "hello\\|world");
860        assert_eq!(escape_text("line1\nline2"), "line1\\nline2");
861        assert_eq!(unescape_text("hello\\|world"), "hello|world");
862        assert_eq!(unescape_text("line1\\nline2"), "line1\nline2");
863    }
864
865    #[test]
866    fn test_id_format_round_trip() {
867        use crate::models::IdFormat;
868
869        // Test UUID format persists through SCG round-trip
870        let mut phase = Phase::new("uuid-phase".to_string());
871        phase.id_format = IdFormat::Uuid;
872
873        let task = Task::new(
874            "a1b2c3d4e5f6789012345678901234ab".to_string(),
875            "UUID Task".to_string(),
876            "Description".to_string(),
877        );
878        phase.add_task(task);
879
880        let scg = serialize_scg(&phase);
881        let parsed = parse_scg(&scg).unwrap();
882
883        assert_eq!(parsed.id_format, IdFormat::Uuid);
884        assert_eq!(parsed.name, "uuid-phase");
885    }
886
887    #[test]
888    fn test_id_format_default_sequential() {
889        // Test that phases without id_format default to Sequential
890        let content = r#"# SCUD Graph v1
891# Phase: old-phase
892
893@meta {
894  name old-phase
895  updated 2025-01-01T00:00:00Z
896}
897
898@nodes
899# id | title | status | complexity | priority
9001 | Task | P | 0 | M
901"#;
902        let phase = parse_scg(content).unwrap();
903        assert_eq!(phase.id_format, IdFormat::Sequential);
904    }
905
906    #[test]
907    fn test_round_trip() {
908        let mut epic = Phase::new("test-epic".to_string());
909
910        let mut task1 = Task::new(
911            "1".to_string(),
912            "First task".to_string(),
913            "Description".to_string(),
914        );
915        task1.complexity = 5;
916        task1.priority = Priority::High;
917        task1.status = TaskStatus::Done;
918
919        let mut task2 = Task::new(
920            "2".to_string(),
921            "Second task".to_string(),
922            "Another desc".to_string(),
923        );
924        task2.dependencies = vec!["1".to_string()];
925        task2.complexity = 3;
926
927        epic.add_task(task1);
928        epic.add_task(task2);
929
930        let scg = serialize_scg(&epic);
931        let parsed = parse_scg(&scg).unwrap();
932
933        assert_eq!(parsed.name, "test-epic");
934        assert_eq!(parsed.tasks.len(), 2);
935
936        let t1 = parsed.get_task("1").unwrap();
937        assert_eq!(t1.title, "First task");
938        assert_eq!(t1.complexity, 5);
939        assert_eq!(t1.status, TaskStatus::Done);
940
941        let t2 = parsed.get_task("2").unwrap();
942        assert_eq!(t2.dependencies, vec!["1".to_string()]);
943    }
944
945    #[test]
946    fn test_parent_child() {
947        let mut epic = Phase::new("parent-test".to_string());
948
949        let mut parent = Task::new(
950            "1".to_string(),
951            "Parent".to_string(),
952            "Parent task".to_string(),
953        );
954        parent.status = TaskStatus::Expanded;
955        parent.subtasks = vec!["1.1".to_string(), "1.2".to_string()];
956
957        let mut child1 = Task::new(
958            "1.1".to_string(),
959            "Child 1".to_string(),
960            "First child".to_string(),
961        );
962        child1.parent_id = Some("1".to_string());
963
964        let mut child2 = Task::new(
965            "1.2".to_string(),
966            "Child 2".to_string(),
967            "Second child".to_string(),
968        );
969        child2.parent_id = Some("1".to_string());
970        child2.dependencies = vec!["1.1".to_string()];
971
972        epic.add_task(parent);
973        epic.add_task(child1);
974        epic.add_task(child2);
975
976        let scg = serialize_scg(&epic);
977        let parsed = parse_scg(&scg).unwrap();
978
979        let p = parsed.get_task("1").unwrap();
980        assert_eq!(p.subtasks, vec!["1.1", "1.2"]);
981
982        let c1 = parsed.get_task("1.1").unwrap();
983        assert_eq!(c1.parent_id, Some("1".to_string()));
984
985        let c2 = parsed.get_task("1.2").unwrap();
986        assert_eq!(c2.parent_id, Some("1".to_string()));
987        assert_eq!(c2.dependencies, vec!["1.1".to_string()]);
988    }
989
990    #[test]
991    fn test_malformed_header() {
992        let result = parse_scg("not a valid scg file");
993        assert!(result.is_err());
994    }
995
996    #[test]
997    fn test_empty_phase() {
998        let content = "# SCUD Graph v1\n# Phase: empty\n\n@nodes\n# id | title | status | complexity | priority\n";
999        let phase = parse_scg(content).unwrap();
1000        assert_eq!(phase.name, "empty");
1001        assert!(phase.tasks.is_empty());
1002    }
1003
1004    #[test]
1005    fn test_special_characters_in_title() {
1006        let mut epic = Phase::new("test".to_string());
1007        let task = Task::new(
1008            "1".to_string(),
1009            "Task with | pipe".to_string(),
1010            "Desc".to_string(),
1011        );
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().title, "Task with | pipe");
1018    }
1019
1020    #[test]
1021    fn test_multiline_description() {
1022        let mut epic = Phase::new("test".to_string());
1023        let task = Task::new(
1024            "1".to_string(),
1025            "Task".to_string(),
1026            "Line 1\nLine 2\nLine 3".to_string(),
1027        );
1028        epic.add_task(task);
1029
1030        let scg = serialize_scg(&epic);
1031        let parsed = parse_scg(&scg).unwrap();
1032
1033        let t = parsed.get_task("1").unwrap();
1034        assert_eq!(t.description, "Line 1\nLine 2\nLine 3");
1035    }
1036
1037    #[test]
1038    fn test_assignments() {
1039        let mut epic = Phase::new("test".to_string());
1040        let mut task = Task::new("1".to_string(), "Task".to_string(), "Desc".to_string());
1041        task.assigned_to = Some("alice".to_string());
1042        epic.add_task(task);
1043
1044        let scg = serialize_scg(&epic);
1045        let parsed = parse_scg(&scg).unwrap();
1046
1047        let t = parsed.get_task("1").unwrap();
1048        assert_eq!(t.assigned_to, Some("alice".to_string()));
1049    }
1050
1051    #[test]
1052    fn test_agent_types() {
1053        let mut epic = Phase::new("test".to_string());
1054        let mut task1 = Task::new(
1055            "1".to_string(),
1056            "Review Task".to_string(),
1057            "Desc".to_string(),
1058        );
1059        task1.agent_type = Some("reviewer".to_string());
1060
1061        let mut task2 = Task::new(
1062            "2".to_string(),
1063            "Build Task".to_string(),
1064            "Desc".to_string(),
1065        );
1066        task2.agent_type = Some("builder".to_string());
1067
1068        let task3 = Task::new("3".to_string(), "No Agent".to_string(), "Desc".to_string());
1069        // task3 has no agent_type
1070
1071        epic.add_task(task1);
1072        epic.add_task(task2);
1073        epic.add_task(task3);
1074
1075        let scg = serialize_scg(&epic);
1076
1077        // Verify @agents section is present
1078        assert!(scg.contains("@agents"));
1079        assert!(scg.contains("1 | reviewer"));
1080        assert!(scg.contains("2 | builder"));
1081
1082        let parsed = parse_scg(&scg).unwrap();
1083
1084        let t1 = parsed.get_task("1").unwrap();
1085        assert_eq!(t1.agent_type, Some("reviewer".to_string()));
1086
1087        let t2 = parsed.get_task("2").unwrap();
1088        assert_eq!(t2.agent_type, Some("builder".to_string()));
1089
1090        let t3 = parsed.get_task("3").unwrap();
1091        assert_eq!(t3.agent_type, None);
1092    }
1093
1094    #[test]
1095    fn test_natural_sort_order() {
1096        let mut epic = Phase::new("test".to_string());
1097
1098        // Add tasks in random order
1099        for id in ["10", "2", "1", "1.10", "1.2", "1.1"] {
1100            let task = Task::new(id.to_string(), format!("Task {}", id), String::new());
1101            epic.add_task(task);
1102        }
1103
1104        let scg = serialize_scg(&epic);
1105        let parsed = parse_scg(&scg).unwrap();
1106
1107        let ids: Vec<&str> = parsed.tasks.iter().map(|t| t.id.as_str()).collect();
1108        assert_eq!(ids, vec!["1", "1.1", "1.2", "1.10", "2", "10"]);
1109    }
1110
1111    #[test]
1112    fn test_natural_sort_uuids() {
1113        // Test UUID sorting falls back to lexicographic
1114        use super::natural_sort_ids;
1115
1116        // UUIDs should sort lexicographically
1117        let uuid_a = "a1b2c3d4e5f6789012345678901234ab";
1118        let uuid_b = "b1b2c3d4e5f6789012345678901234ab";
1119        let uuid_c = "c1b2c3d4e5f6789012345678901234ab";
1120
1121        assert_eq!(natural_sort_ids(uuid_a, uuid_b), std::cmp::Ordering::Less);
1122        assert_eq!(natural_sort_ids(uuid_b, uuid_c), std::cmp::Ordering::Less);
1123        assert_eq!(natural_sort_ids(uuid_a, uuid_a), std::cmp::Ordering::Equal);
1124
1125        // Mixed UUIDs should also sort lexicographically
1126        let mut ids = vec![uuid_c, uuid_a, uuid_b];
1127        ids.sort_by(|a, b| natural_sort_ids(a, b));
1128        assert_eq!(ids, vec![uuid_a, uuid_b, uuid_c]);
1129    }
1130
1131    #[test]
1132    fn test_natural_sort_mixed_numeric_uuid() {
1133        // When mixing numeric and UUID IDs, fall back to lexicographic
1134        use super::natural_sort_ids;
1135
1136        let numeric = "123";
1137        let uuid = "a1b2c3d4e5f6789012345678901234ab";
1138
1139        // "123" < "a1b2..." lexicographically
1140        assert_eq!(natural_sort_ids(numeric, uuid), std::cmp::Ordering::Less);
1141    }
1142
1143    #[test]
1144    fn test_all_statuses() {
1145        let mut epic = Phase::new("test".to_string());
1146
1147        let statuses = [
1148            ("1", TaskStatus::Pending),
1149            ("2", TaskStatus::InProgress),
1150            ("3", TaskStatus::Done),
1151            ("4", TaskStatus::Review),
1152            ("5", TaskStatus::Blocked),
1153            ("6", TaskStatus::Deferred),
1154            ("7", TaskStatus::Cancelled),
1155            ("8", TaskStatus::Expanded),
1156            ("9", TaskStatus::Failed),
1157        ];
1158
1159        for (id, status) in &statuses {
1160            let mut task = Task::new(id.to_string(), format!("Task {}", id), String::new());
1161            task.status = status.clone();
1162            epic.add_task(task);
1163        }
1164
1165        let scg = serialize_scg(&epic);
1166        let parsed = parse_scg(&scg).unwrap();
1167
1168        for (id, expected_status) in statuses {
1169            let task = parsed.get_task(id).unwrap();
1170            assert_eq!(
1171                task.status, expected_status,
1172                "Status mismatch for task {}",
1173                id
1174            );
1175        }
1176    }
1177
1178    #[test]
1179    fn test_all_priorities() {
1180        let mut epic = Phase::new("test".to_string());
1181
1182        let priorities = [
1183            ("1", Priority::Critical),
1184            ("2", Priority::High),
1185            ("3", Priority::Medium),
1186            ("4", Priority::Low),
1187        ];
1188
1189        for (id, priority) in &priorities {
1190            let mut task = Task::new(id.to_string(), format!("Task {}", id), String::new());
1191            task.priority = priority.clone();
1192            epic.add_task(task);
1193        }
1194
1195        let scg = serialize_scg(&epic);
1196        let parsed = parse_scg(&scg).unwrap();
1197
1198        for (id, expected_priority) in priorities {
1199            let task = parsed.get_task(id).unwrap();
1200            assert_eq!(
1201                task.priority, expected_priority,
1202                "Priority mismatch for task {}",
1203                id
1204            );
1205        }
1206    }
1207
1208    #[test]
1209    fn test_details_and_test_strategy() {
1210        let mut epic = Phase::new("test".to_string());
1211        let mut task = Task::new(
1212            "1".to_string(),
1213            "Task".to_string(),
1214            "Description".to_string(),
1215        );
1216        task.details = Some("Detailed implementation notes".to_string());
1217        task.test_strategy = Some("Unit tests and integration tests".to_string());
1218        epic.add_task(task);
1219
1220        let scg = serialize_scg(&epic);
1221        let parsed = parse_scg(&scg).unwrap();
1222
1223        let t = parsed.get_task("1").unwrap();
1224        assert_eq!(t.description, "Description");
1225        assert_eq!(t.details, Some("Detailed implementation notes".to_string()));
1226        assert_eq!(
1227            t.test_strategy,
1228            Some("Unit tests and integration tests".to_string())
1229        );
1230    }
1231
1232    // Additional edge case tests
1233
1234    #[test]
1235    fn test_backslash_escape() {
1236        let mut epic = Phase::new("test".to_string());
1237        let task = Task::new(
1238            "1".to_string(),
1239            "Task with \\ backslash".to_string(),
1240            "Desc".to_string(),
1241        );
1242        epic.add_task(task);
1243
1244        let scg = serialize_scg(&epic);
1245        let parsed = parse_scg(&scg).unwrap();
1246
1247        assert_eq!(
1248            parsed.get_task("1").unwrap().title,
1249            "Task with \\ backslash"
1250        );
1251    }
1252
1253    #[test]
1254    fn test_multiple_special_chars() {
1255        let mut epic = Phase::new("test".to_string());
1256        let task = Task::new(
1257            "1".to_string(),
1258            "Task with | pipe and \\ backslash".to_string(),
1259            "Line 1\nLine 2 with | and \\".to_string(),
1260        );
1261        epic.add_task(task);
1262
1263        let scg = serialize_scg(&epic);
1264        let parsed = parse_scg(&scg).unwrap();
1265
1266        let t = parsed.get_task("1").unwrap();
1267        assert_eq!(t.title, "Task with | pipe and \\ backslash");
1268        assert_eq!(t.description, "Line 1\nLine 2 with | and \\");
1269    }
1270
1271    #[test]
1272    fn test_unicode_content() {
1273        let mut epic = Phase::new("unicode-test".to_string());
1274        let task = Task::new(
1275            "1".to_string(),
1276            "日本語タイトル 🚀 Émojis".to_string(),
1277            "描述 with émojis 😀".to_string(),
1278        );
1279        epic.add_task(task);
1280
1281        let scg = serialize_scg(&epic);
1282        let parsed = parse_scg(&scg).unwrap();
1283
1284        let t = parsed.get_task("1").unwrap();
1285        assert_eq!(t.title, "日本語タイトル 🚀 Émojis");
1286        assert_eq!(t.description, "描述 with émojis 😀");
1287    }
1288
1289    #[test]
1290    fn test_empty_dependencies() {
1291        let mut epic = Phase::new("test".to_string());
1292        let task = Task::new("1".to_string(), "Task".to_string(), "Desc".to_string());
1293        epic.add_task(task);
1294
1295        let scg = serialize_scg(&epic);
1296        let parsed = parse_scg(&scg).unwrap();
1297
1298        assert!(parsed.get_task("1").unwrap().dependencies.is_empty());
1299    }
1300
1301    #[test]
1302    fn test_multiple_dependencies() {
1303        let mut epic = Phase::new("test".to_string());
1304
1305        let task1 = Task::new("1".to_string(), "Task 1".to_string(), String::new());
1306        let task2 = Task::new("2".to_string(), "Task 2".to_string(), String::new());
1307        let mut task3 = Task::new("3".to_string(), "Task 3".to_string(), String::new());
1308        task3.dependencies = vec!["1".to_string(), "2".to_string()];
1309
1310        epic.add_task(task1);
1311        epic.add_task(task2);
1312        epic.add_task(task3);
1313
1314        let scg = serialize_scg(&epic);
1315        let parsed = parse_scg(&scg).unwrap();
1316
1317        let t3 = parsed.get_task("3").unwrap();
1318        assert_eq!(t3.dependencies.len(), 2);
1319        assert!(t3.dependencies.contains(&"1".to_string()));
1320        assert!(t3.dependencies.contains(&"2".to_string()));
1321    }
1322
1323    #[test]
1324    fn test_complexity_boundary_values() {
1325        let mut epic = Phase::new("test".to_string());
1326
1327        // Test all Fibonacci complexity values
1328        let complexities: Vec<u32> = vec![0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
1329        for (i, c) in complexities.iter().enumerate() {
1330            let mut task = Task::new(
1331                format!("{}", i + 1),
1332                format!("Task {}", i + 1),
1333                String::new(),
1334            );
1335            task.complexity = *c;
1336            epic.add_task(task);
1337        }
1338
1339        let scg = serialize_scg(&epic);
1340        let parsed = parse_scg(&scg).unwrap();
1341
1342        for (i, expected) in complexities.iter().enumerate() {
1343            let task = parsed.get_task(&format!("{}", i + 1)).unwrap();
1344            assert_eq!(
1345                task.complexity,
1346                *expected,
1347                "Complexity mismatch for task {}",
1348                i + 1
1349            );
1350        }
1351    }
1352
1353    #[test]
1354    fn test_long_description() {
1355        let mut epic = Phase::new("test".to_string());
1356        let long_desc = "A".repeat(5000); // Max description length
1357        let task = Task::new("1".to_string(), "Task".to_string(), long_desc.clone());
1358        epic.add_task(task);
1359
1360        let scg = serialize_scg(&epic);
1361        let parsed = parse_scg(&scg).unwrap();
1362
1363        assert_eq!(parsed.get_task("1").unwrap().description, long_desc);
1364    }
1365
1366    #[test]
1367    fn test_empty_description() {
1368        let mut epic = Phase::new("test".to_string());
1369        let task = Task::new("1".to_string(), "Task".to_string(), String::new());
1370        epic.add_task(task);
1371
1372        let scg = serialize_scg(&epic);
1373        let parsed = parse_scg(&scg).unwrap();
1374
1375        assert_eq!(parsed.get_task("1").unwrap().description, "");
1376    }
1377
1378    #[test]
1379    fn test_whitespace_handling() {
1380        // Ensure whitespace in titles is preserved
1381        let mut epic = Phase::new("test".to_string());
1382        let task = Task::new(
1383            "1".to_string(),
1384            "  Task with   spaces  ".to_string(),
1385            "Desc".to_string(),
1386        );
1387        epic.add_task(task);
1388
1389        let scg = serialize_scg(&epic);
1390        let parsed = parse_scg(&scg).unwrap();
1391
1392        // After round-trip, leading/trailing spaces in title should be trimmed
1393        // (this is expected behavior from split/trim)
1394        let t = parsed.get_task("1").unwrap();
1395        assert_eq!(t.title, "Task with   spaces");
1396    }
1397
1398    #[test]
1399    fn test_nested_subtasks() {
1400        let mut epic = Phase::new("test".to_string());
1401
1402        // Create hierarchy: 1 -> 1.1, 1.2 -> 1.2.1
1403        let mut parent = Task::new("1".to_string(), "Parent".to_string(), String::new());
1404        parent.status = TaskStatus::Expanded;
1405        parent.subtasks = vec!["1.1".to_string(), "1.2".to_string()];
1406
1407        let mut child1 = Task::new("1.1".to_string(), "Child 1".to_string(), String::new());
1408        child1.parent_id = Some("1".to_string());
1409
1410        let mut child2 = Task::new("1.2".to_string(), "Child 2".to_string(), String::new());
1411        child2.parent_id = Some("1".to_string());
1412        child2.status = TaskStatus::Expanded;
1413        child2.subtasks = vec!["1.2.1".to_string()];
1414
1415        let mut grandchild =
1416            Task::new("1.2.1".to_string(), "Grandchild".to_string(), String::new());
1417        grandchild.parent_id = Some("1.2".to_string());
1418
1419        epic.add_task(parent);
1420        epic.add_task(child1);
1421        epic.add_task(child2);
1422        epic.add_task(grandchild);
1423
1424        let scg = serialize_scg(&epic);
1425        let parsed = parse_scg(&scg).unwrap();
1426
1427        assert_eq!(parsed.tasks.len(), 4);
1428
1429        let gc = parsed.get_task("1.2.1").unwrap();
1430        assert_eq!(gc.parent_id, Some("1.2".to_string()));
1431
1432        let c2 = parsed.get_task("1.2").unwrap();
1433        assert!(c2.subtasks.contains(&"1.2.1".to_string()));
1434    }
1435
1436    #[test]
1437    fn test_section_comment_lines_ignored() {
1438        // Manually create SCG with extra comment lines - they should be ignored
1439        let content = r#"# SCUD Graph v1
1440# Epic: test
1441
1442@meta {
1443  name test
1444  # this is a comment
1445  updated 2025-01-01T00:00:00Z
1446}
1447
1448@nodes
1449# id | title | status | complexity | priority
1450# another comment
14511 | Task | P | 0 | M
1452"#;
1453        let epic = parse_scg(content).unwrap();
1454        assert_eq!(epic.tasks.len(), 1);
1455        assert_eq!(epic.get_task("1").unwrap().title, "Task");
1456    }
1457}