Skip to main content

fd_core/
emitter.rs

1//! Emitter: SceneGraph → FD text format.
2//!
3//! Produces minimal, token-efficient output that round-trips through the parser.
4
5use crate::id::NodeId;
6use crate::model::*;
7use petgraph::graph::NodeIndex;
8use std::fmt::Write;
9
10/// Emit a `SceneGraph` as an FD text document.
11#[must_use]
12pub fn emit_document(graph: &SceneGraph) -> String {
13    let mut out = String::with_capacity(1024);
14
15    // Count sections to decide if separators add value
16    let has_imports = !graph.imports.is_empty();
17    let has_styles = !graph.styles.is_empty();
18    let children = graph.children(graph.root);
19    let has_constraints = graph.graph.node_indices().any(|idx| {
20        graph.graph[idx]
21            .constraints
22            .iter()
23            .any(|c| !matches!(c, Constraint::Position { .. }))
24    });
25    let has_edges = !graph.edges.is_empty();
26    let section_count =
27        has_imports as u8 + has_styles as u8 + has_constraints as u8 + has_edges as u8;
28    let use_separators = section_count >= 2;
29
30    // Emit imports
31    for import in &graph.imports {
32        let _ = writeln!(out, "import \"{}\" as {}", import.path, import.namespace);
33    }
34    if has_imports {
35        out.push('\n');
36    }
37
38    // Emit style definitions
39    if use_separators && has_styles {
40        out.push_str("# ─── Styles ───\n\n");
41    }
42    let mut styles: Vec<_> = graph.styles.iter().collect();
43    styles.sort_by_key(|(id, _)| id.as_str().to_string());
44    for (name, style) in &styles {
45        emit_style_block(&mut out, name, style, 0);
46        out.push('\n');
47    }
48
49    // Emit edge_defaults block (if present)
50    if let Some(ref defaults) = graph.edge_defaults {
51        emit_edge_defaults_block(&mut out, defaults);
52        out.push('\n');
53    }
54
55    // Emit root's children (node tree)
56    if use_separators && !children.is_empty() {
57        out.push_str("# ─── Layout ───\n\n");
58    }
59    for child_idx in &children {
60        emit_node(&mut out, graph, *child_idx, 0);
61        out.push('\n');
62    }
63
64    // Emit top-level constraints (skip Position — emitted inline as x:/y:)
65    if use_separators && has_constraints {
66        out.push_str("# ─── Constraints ───\n\n");
67    }
68    for idx in graph.graph.node_indices() {
69        let node = &graph.graph[idx];
70        for constraint in &node.constraints {
71            if matches!(constraint, Constraint::Position { .. }) {
72                continue; // emitted inline inside node block
73            }
74            emit_constraint(&mut out, &node.id, constraint);
75        }
76    }
77
78    // Emit edges
79    if use_separators && has_edges {
80        if has_constraints {
81            out.push('\n');
82        }
83        out.push_str("# ─── Flows ───\n\n");
84    }
85    for edge in &graph.edges {
86        emit_edge(&mut out, edge, graph, graph.edge_defaults.as_ref());
87    }
88
89    out
90}
91
92fn indent(out: &mut String, depth: usize) {
93    for _ in 0..depth {
94        out.push_str("  ");
95    }
96}
97
98fn emit_style_block(out: &mut String, name: &NodeId, style: &Properties, depth: usize) {
99    indent(out, depth);
100    writeln!(out, "style {} {{", name.as_str()).unwrap();
101
102    if let Some(ref fill) = style.fill {
103        emit_paint_prop(out, "fill", fill, depth + 1);
104    }
105    if let Some(ref font) = style.font {
106        emit_font_prop(out, font, depth + 1);
107    }
108    if let Some(radius) = style.corner_radius {
109        indent(out, depth + 1);
110        writeln!(out, "corner: {}", format_num(radius)).unwrap();
111    }
112    if let Some(opacity) = style.opacity {
113        indent(out, depth + 1);
114        writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
115    }
116    if let Some(ref shadow) = style.shadow {
117        indent(out, depth + 1);
118        writeln!(
119            out,
120            "shadow: ({},{},{},{})",
121            format_num(shadow.offset_x),
122            format_num(shadow.offset_y),
123            format_num(shadow.blur),
124            shadow.color.to_hex()
125        )
126        .unwrap();
127    }
128    // Text alignment
129    if style.text_align.is_some() || style.text_valign.is_some() {
130        let h = match style.text_align {
131            Some(TextAlign::Left) => "left",
132            Some(TextAlign::Right) => "right",
133            _ => "center",
134        };
135        let v = match style.text_valign {
136            Some(TextVAlign::Top) => "top",
137            Some(TextVAlign::Bottom) => "bottom",
138            _ => "middle",
139        };
140        indent(out, depth + 1);
141        writeln!(out, "align: {h} {v}").unwrap();
142    }
143
144    indent(out, depth);
145    out.push_str("}\n");
146}
147
148fn emit_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, depth: usize) {
149    let node = &graph.graph[idx];
150
151    // Skip empty container nodes (childless Group/Frame) on format.
152    // Shapes (Rect/Ellipse/Text) are always meaningful on their own.
153    // Preserve containers that still carry annotations, styles, animations,
154    // or non-default inline styles (fill, stroke, etc.).
155    if matches!(node.kind, NodeKind::Group | NodeKind::Frame { .. })
156        && graph.children(idx).is_empty()
157        && node.annotations.is_empty()
158        && node.use_styles.is_empty()
159        && node.animations.is_empty()
160        && !has_inline_styles(&node.props)
161        && !matches!(&node.kind, NodeKind::Image { .. })
162    {
163        return;
164    }
165
166    // Emit preserved `# comment` lines before the node declaration
167    for comment in &node.comments {
168        indent(out, depth);
169        writeln!(out, "# {comment}").unwrap();
170    }
171
172    // Auto-generate an [auto] doc-comment for AI comprehension.
173    // These are regenerated each emit and skipped by the parser.
174    let auto_comment = generate_auto_comment(node, graph, idx);
175    if let Some(comment) = auto_comment {
176        indent(out, depth);
177        writeln!(out, "# [auto] {comment}").unwrap();
178    }
179
180    indent(out, depth);
181
182    // Node kind keyword + optional @id + optional inline text
183    match &node.kind {
184        NodeKind::Root => return,
185        NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
186        NodeKind::Group => write!(out, "group @{}", node.id.as_str()).unwrap(),
187        NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
188        NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
189        NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
190        NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
191        NodeKind::Image { .. } => write!(out, "image @{}", node.id.as_str()).unwrap(),
192        NodeKind::Text { content, .. } => {
193            write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
194        }
195    }
196
197    out.push_str(" {\n");
198
199    // Annotations (spec block)
200    emit_annotations(out, &node.annotations, depth + 1);
201
202    // Children — emitted right after spec so the structural skeleton
203    // is visible first. Visual styling comes at the tail for clean folding.
204    let children = graph.children(idx);
205    for child_idx in &children {
206        emit_node(out, graph, *child_idx, depth + 1);
207    }
208
209    // Group is purely organizational — no layout mode emission
210
211    // Layout mode (for frames)
212    if let NodeKind::Frame { layout, .. } = &node.kind {
213        match layout {
214            LayoutMode::Free { pad } => {
215                if *pad > 0.0 {
216                    indent(out, depth + 1);
217                    writeln!(out, "padding: {}", format_num(*pad)).unwrap();
218                }
219            }
220            LayoutMode::Column { gap, pad } => {
221                indent(out, depth + 1);
222                writeln!(
223                    out,
224                    "layout: column gap={} pad={}",
225                    format_num(*gap),
226                    format_num(*pad)
227                )
228                .unwrap();
229            }
230            LayoutMode::Row { gap, pad } => {
231                indent(out, depth + 1);
232                writeln!(
233                    out,
234                    "layout: row gap={} pad={}",
235                    format_num(*gap),
236                    format_num(*pad)
237                )
238                .unwrap();
239            }
240            LayoutMode::Grid { cols, gap, pad } => {
241                indent(out, depth + 1);
242                writeln!(
243                    out,
244                    "layout: grid cols={cols} gap={} pad={}",
245                    format_num(*gap),
246                    format_num(*pad)
247                )
248                .unwrap();
249            }
250        }
251    }
252
253    // Dimensions
254    match &node.kind {
255        NodeKind::Rect { width, height } => {
256            indent(out, depth + 1);
257            writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
258        }
259        NodeKind::Frame { width, height, .. } => {
260            indent(out, depth + 1);
261            writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
262        }
263        NodeKind::Ellipse { rx, ry } => {
264            indent(out, depth + 1);
265            writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
266        }
267        NodeKind::Text {
268            max_width: Some(w), ..
269        } => {
270            indent(out, depth + 1);
271            writeln!(out, "w: {}", format_num(*w)).unwrap();
272        }
273        NodeKind::Image { width, height, .. } => {
274            indent(out, depth + 1);
275            writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
276        }
277        _ => {}
278    }
279
280    // Path d: commands
281    if let NodeKind::Path { commands } = &node.kind
282        && !commands.is_empty()
283    {
284        indent(out, depth + 1);
285        write!(out, "d:").unwrap();
286        for cmd in commands {
287            match cmd {
288                PathCmd::MoveTo(x, y) => {
289                    write!(out, " M {} {}", format_num(*x), format_num(*y)).unwrap()
290                }
291                PathCmd::LineTo(x, y) => {
292                    write!(out, " L {} {}", format_num(*x), format_num(*y)).unwrap()
293                }
294                PathCmd::QuadTo(cx, cy, ex, ey) => write!(
295                    out,
296                    " Q {} {} {} {}",
297                    format_num(*cx),
298                    format_num(*cy),
299                    format_num(*ex),
300                    format_num(*ey)
301                )
302                .unwrap(),
303                PathCmd::CubicTo(c1x, c1y, c2x, c2y, ex, ey) => write!(
304                    out,
305                    " C {} {} {} {} {} {}",
306                    format_num(*c1x),
307                    format_num(*c1y),
308                    format_num(*c2x),
309                    format_num(*c2y),
310                    format_num(*ex),
311                    format_num(*ey)
312                )
313                .unwrap(),
314                PathCmd::Close => write!(out, " Z").unwrap(),
315            }
316        }
317        writeln!(out).unwrap();
318    }
319
320    // Image source and fit
321    if let NodeKind::Image { source, fit, .. } = &node.kind {
322        match source {
323            ImageSource::File(path) => {
324                indent(out, depth + 1);
325                writeln!(out, "src: \"{path}\"").unwrap();
326            }
327        }
328        if *fit != ImageFit::Cover {
329            indent(out, depth + 1);
330            let fit_str = match fit {
331                ImageFit::Cover => "cover",
332                ImageFit::Contain => "contain",
333                ImageFit::Fill => "fill",
334                ImageFit::None => "none",
335            };
336            writeln!(out, "fit: {fit_str}").unwrap();
337        }
338    }
339
340    // Clip property (for frames only)
341    if let NodeKind::Frame { clip: true, .. } = &node.kind {
342        indent(out, depth + 1);
343        writeln!(out, "clip: true").unwrap();
344    }
345
346    // Style references
347    for style_ref in &node.use_styles {
348        indent(out, depth + 1);
349        writeln!(out, "use: {}", style_ref.as_str()).unwrap();
350    }
351
352    // Inline style properties
353    if let Some(ref fill) = node.props.fill {
354        emit_paint_prop(out, "fill", fill, depth + 1);
355    }
356    if let Some(ref stroke) = node.props.stroke {
357        indent(out, depth + 1);
358        match &stroke.paint {
359            Paint::Solid(c) => {
360                writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap()
361            }
362            _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
363        }
364    }
365    if let Some(radius) = node.props.corner_radius {
366        indent(out, depth + 1);
367        writeln!(out, "corner: {}", format_num(radius)).unwrap();
368    }
369    if let Some(ref font) = node.props.font {
370        emit_font_prop(out, font, depth + 1);
371    }
372    if let Some(opacity) = node.props.opacity {
373        indent(out, depth + 1);
374        writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
375    }
376    if let Some(ref shadow) = node.props.shadow {
377        indent(out, depth + 1);
378        writeln!(
379            out,
380            "shadow: ({},{},{},{})",
381            format_num(shadow.offset_x),
382            format_num(shadow.offset_y),
383            format_num(shadow.blur),
384            shadow.color.to_hex()
385        )
386        .unwrap();
387    }
388
389    // Text alignment
390    if node.props.text_align.is_some() || node.props.text_valign.is_some() {
391        let h = match node.props.text_align {
392            Some(TextAlign::Left) => "left",
393            Some(TextAlign::Right) => "right",
394            _ => "center",
395        };
396        let v = match node.props.text_valign {
397            Some(TextVAlign::Top) => "top",
398            Some(TextVAlign::Bottom) => "bottom",
399            _ => "middle",
400        };
401        indent(out, depth + 1);
402        writeln!(out, "align: {h} {v}").unwrap();
403    }
404
405    // Child placement within parent
406    if let Some((h, v)) = node.place {
407        indent(out, depth + 1);
408        let place_str = match (h, v) {
409            (HPlace::Center, VPlace::Middle) => "center".to_string(),
410            (HPlace::Left, VPlace::Top) => "top-left".to_string(),
411            (HPlace::Center, VPlace::Top) => "top".to_string(),
412            (HPlace::Right, VPlace::Top) => "top-right".to_string(),
413            (HPlace::Left, VPlace::Middle) => "left middle".to_string(),
414            (HPlace::Right, VPlace::Middle) => "right middle".to_string(),
415            (HPlace::Left, VPlace::Bottom) => "bottom-left".to_string(),
416            (HPlace::Center, VPlace::Bottom) => "bottom".to_string(),
417            (HPlace::Right, VPlace::Bottom) => "bottom-right".to_string(),
418        };
419        writeln!(out, "place: {place_str}").unwrap();
420    }
421
422    // Inline position (x: / y:) — emitted here for token efficiency
423    for constraint in &node.constraints {
424        if let Constraint::Position { x, y } = constraint {
425            if *x != 0.0 {
426                indent(out, depth + 1);
427                writeln!(out, "x: {}", format_num(*x)).unwrap();
428            }
429            if *y != 0.0 {
430                indent(out, depth + 1);
431                writeln!(out, "y: {}", format_num(*y)).unwrap();
432            }
433        }
434    }
435
436    // Animations (when blocks)
437    for anim in &node.animations {
438        emit_anim(out, anim, depth + 1);
439    }
440
441    indent(out, depth);
442    out.push_str("}\n");
443}
444
445fn emit_annotations(out: &mut String, annotations: &[Annotation], depth: usize) {
446    if annotations.is_empty() {
447        return;
448    }
449
450    // Single description → inline shorthand: `spec "desc"`
451    if annotations.len() == 1
452        && let Annotation::Description(s) = &annotations[0]
453    {
454        indent(out, depth);
455        writeln!(out, "spec \"{s}\"").unwrap();
456        return;
457    }
458
459    // Multiple annotations → block form: `spec { ... }`
460    indent(out, depth);
461    out.push_str("spec {\n");
462    for ann in annotations {
463        indent(out, depth + 1);
464        match ann {
465            Annotation::Description(s) => writeln!(out, "\"{s}\"").unwrap(),
466            Annotation::Accept(s) => writeln!(out, "accept: \"{s}\"").unwrap(),
467            Annotation::Status(s) => writeln!(out, "status: {s}").unwrap(),
468            Annotation::Priority(s) => writeln!(out, "priority: {s}").unwrap(),
469            Annotation::Tag(s) => writeln!(out, "tag: {s}").unwrap(),
470        }
471    }
472    indent(out, depth);
473    out.push_str("}\n");
474}
475
476fn emit_paint_prop(out: &mut String, name: &str, paint: &Paint, depth: usize) {
477    indent(out, depth);
478    match paint {
479        Paint::Solid(c) => {
480            let hex = c.to_hex();
481            let hint = color_hint(&hex);
482            if hint.is_empty() {
483                writeln!(out, "{name}: {hex}").unwrap();
484            } else {
485                writeln!(out, "{name}: {hex}  # {hint}").unwrap();
486            }
487        }
488        Paint::LinearGradient { angle, stops } => {
489            write!(out, "{name}: linear({}deg", format_num(*angle)).unwrap();
490            for stop in stops {
491                write!(out, ", {} {}", stop.color.to_hex(), format_num(stop.offset)).unwrap();
492            }
493            writeln!(out, ")").unwrap();
494        }
495        Paint::RadialGradient { stops } => {
496            write!(out, "{name}: radial(").unwrap();
497            for (i, stop) in stops.iter().enumerate() {
498                if i > 0 {
499                    write!(out, ", ").unwrap();
500                }
501                write!(out, "{} {}", stop.color.to_hex(), format_num(stop.offset)).unwrap();
502            }
503            writeln!(out, ")").unwrap();
504        }
505    }
506}
507
508fn emit_font_prop(out: &mut String, font: &FontSpec, depth: usize) {
509    indent(out, depth);
510    let weight_str = weight_number_to_name(font.weight);
511    writeln!(
512        out,
513        "font: \"{}\" {} {}",
514        font.family,
515        weight_str,
516        format_num(font.size)
517    )
518    .unwrap();
519}
520
521/// Map numeric font weight to human-readable name.
522fn weight_number_to_name(weight: u16) -> &'static str {
523    match weight {
524        100 => "thin",
525        200 => "extralight",
526        300 => "light",
527        400 => "regular",
528        500 => "medium",
529        600 => "semibold",
530        700 => "bold",
531        800 => "extrabold",
532        900 => "black",
533        _ => "400", // fallback
534    }
535}
536
537/// Classify a hex color into a human-readable hue name.
538fn color_hint(hex: &str) -> &'static str {
539    let hex = hex.trim_start_matches('#');
540    let bytes = hex.as_bytes();
541    let Some((r, g, b)) = (match bytes.len() {
542        3 | 4 => {
543            let r = crate::model::hex_val(bytes[0]).unwrap_or(0) * 17;
544            let g = crate::model::hex_val(bytes[1]).unwrap_or(0) * 17;
545            let b = crate::model::hex_val(bytes[2]).unwrap_or(0) * 17;
546            Some((r, g, b))
547        }
548        6 | 8 => {
549            let r = (crate::model::hex_val(bytes[0]).unwrap_or(0) << 4)
550                | crate::model::hex_val(bytes[1]).unwrap_or(0);
551            let g = (crate::model::hex_val(bytes[2]).unwrap_or(0) << 4)
552                | crate::model::hex_val(bytes[3]).unwrap_or(0);
553            let b = (crate::model::hex_val(bytes[4]).unwrap_or(0) << 4)
554                | crate::model::hex_val(bytes[5]).unwrap_or(0);
555            Some((r, g, b))
556        }
557        _ => None,
558    }) else {
559        return "";
560    };
561
562    // Achromatic check
563    let max = r.max(g).max(b);
564    let min = r.min(g).min(b);
565    let diff = max - min;
566    if diff < 15 {
567        return match max {
568            0..=30 => "black",
569            31..=200 => "gray",
570            _ => "white",
571        };
572    }
573
574    // Hue classification
575    let rf = r as f32;
576    let gf = g as f32;
577    let bf = b as f32;
578    let hue = if max == r {
579        60.0 * (((gf - bf) / diff as f32) % 6.0)
580    } else if max == g {
581        60.0 * (((bf - rf) / diff as f32) + 2.0)
582    } else {
583        60.0 * (((rf - gf) / diff as f32) + 4.0)
584    };
585    let hue = if hue < 0.0 { hue + 360.0 } else { hue };
586
587    match hue as u16 {
588        0..=14 | 346..=360 => "red",
589        15..=39 => "orange",
590        40..=64 => "yellow",
591        65..=79 => "lime",
592        80..=159 => "green",
593        160..=179 => "teal",
594        180..=199 => "cyan",
595        200..=259 => "blue",
596        260..=279 => "purple",
597        280..=319 => "pink",
598        320..=345 => "rose",
599        _ => "",
600    }
601}
602
603fn emit_anim(out: &mut String, anim: &AnimKeyframe, depth: usize) {
604    indent(out, depth);
605    let trigger = match &anim.trigger {
606        AnimTrigger::Hover => "hover",
607        AnimTrigger::Press => "press",
608        AnimTrigger::Enter => "enter",
609        AnimTrigger::Custom(s) => s.as_str(),
610    };
611    writeln!(out, "when :{trigger} {{").unwrap();
612
613    if let Some(ref fill) = anim.properties.fill {
614        emit_paint_prop(out, "fill", fill, depth + 1);
615    }
616    if let Some(opacity) = anim.properties.opacity {
617        indent(out, depth + 1);
618        writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
619    }
620    if let Some(scale) = anim.properties.scale {
621        indent(out, depth + 1);
622        writeln!(out, "scale: {}", format_num(scale)).unwrap();
623    }
624    if let Some(rotate) = anim.properties.rotate {
625        indent(out, depth + 1);
626        writeln!(out, "rotate: {}", format_num(rotate)).unwrap();
627    }
628
629    let ease_name = match &anim.easing {
630        Easing::Linear => "linear",
631        Easing::EaseIn => "ease_in",
632        Easing::EaseOut => "ease_out",
633        Easing::EaseInOut => "ease_in_out",
634        Easing::Spring => "spring",
635        Easing::CubicBezier(_, _, _, _) => "cubic",
636    };
637    indent(out, depth + 1);
638    writeln!(out, "ease: {ease_name} {}ms", anim.duration_ms).unwrap();
639
640    indent(out, depth);
641    out.push_str("}\n");
642}
643
644fn emit_constraint(out: &mut String, node_id: &NodeId, constraint: &Constraint) {
645    match constraint {
646        Constraint::CenterIn(target) => {
647            writeln!(
648                out,
649                "@{} -> center_in: {}",
650                node_id.as_str(),
651                target.as_str()
652            )
653            .unwrap();
654        }
655        Constraint::Offset { from, dx, dy } => {
656            writeln!(
657                out,
658                "@{} -> offset: @{} {}, {}",
659                node_id.as_str(),
660                from.as_str(),
661                format_num(*dx),
662                format_num(*dy)
663            )
664            .unwrap();
665        }
666        Constraint::FillParent { pad } => {
667            writeln!(
668                out,
669                "@{} -> fill_parent: {}",
670                node_id.as_str(),
671                format_num(*pad)
672            )
673            .unwrap();
674        }
675        Constraint::Position { .. } => {
676            // Emitted inline as x: / y: inside node block — skip here
677        }
678    }
679}
680
681fn emit_edge_defaults_block(out: &mut String, defaults: &EdgeDefaults) {
682    out.push_str("edge_defaults {\n");
683
684    if let Some(ref stroke) = defaults.props.stroke {
685        match &stroke.paint {
686            Paint::Solid(c) => {
687                writeln!(out, "  stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
688            }
689            _ => {
690                writeln!(out, "  stroke: #000 {}", format_num(stroke.width)).unwrap();
691            }
692        }
693    }
694    if let Some(opacity) = defaults.props.opacity {
695        writeln!(out, "  opacity: {}", format_num(opacity)).unwrap();
696    }
697    if let Some(arrow) = defaults.arrow
698        && arrow != ArrowKind::None
699    {
700        let name = match arrow {
701            ArrowKind::None => "none",
702            ArrowKind::Start => "start",
703            ArrowKind::End => "end",
704            ArrowKind::Both => "both",
705        };
706        writeln!(out, "  arrow: {name}").unwrap();
707    }
708    if let Some(curve) = defaults.curve
709        && curve != CurveKind::Straight
710    {
711        let name = match curve {
712            CurveKind::Straight => "straight",
713            CurveKind::Smooth => "smooth",
714            CurveKind::Step => "step",
715        };
716        writeln!(out, "  curve: {name}").unwrap();
717    }
718
719    out.push_str("}\n");
720}
721
722fn emit_edge(out: &mut String, edge: &Edge, graph: &SceneGraph, defaults: Option<&EdgeDefaults>) {
723    writeln!(out, "edge @{} {{", edge.id.as_str()).unwrap();
724
725    // Annotations
726    emit_annotations(out, &edge.annotations, 1);
727
728    // Nested text child
729    if let Some(text_id) = edge.text_child
730        && let Some(node) = graph.get_by_id(text_id)
731        && let NodeKind::Text { content, .. } = &node.kind
732    {
733        writeln!(out, "  text @{} \"{}\" {{}}", text_id.as_str(), content).unwrap();
734    }
735
736    // from / to
737    match &edge.from {
738        EdgeAnchor::Node(id) => writeln!(out, "  from: @{}", id.as_str()).unwrap(),
739        EdgeAnchor::Point(x, y) => {
740            writeln!(out, "  from: {} {}", format_num(*x), format_num(*y)).unwrap()
741        }
742    }
743    match &edge.to {
744        EdgeAnchor::Node(id) => writeln!(out, "  to: @{}", id.as_str()).unwrap(),
745        EdgeAnchor::Point(x, y) => {
746            writeln!(out, "  to: {} {}", format_num(*x), format_num(*y)).unwrap()
747        }
748    }
749
750    // Style references
751    for style_ref in &edge.use_styles {
752        writeln!(out, "  use: {}", style_ref.as_str()).unwrap();
753    }
754
755    // Stroke — skip if matches edge_defaults
756    let stroke_matches_default = defaults
757        .and_then(|d| d.props.stroke.as_ref())
758        .is_some_and(|ds| {
759            edge.props
760                .stroke
761                .as_ref()
762                .is_some_and(|es| stroke_eq(es, ds))
763        });
764    if !stroke_matches_default && let Some(ref stroke) = edge.props.stroke {
765        match &stroke.paint {
766            Paint::Solid(c) => {
767                writeln!(out, "  stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
768            }
769            _ => {
770                writeln!(out, "  stroke: #000 {}", format_num(stroke.width)).unwrap();
771            }
772        }
773    }
774
775    // Opacity — skip if matches edge_defaults
776    let opacity_matches_default = defaults.and_then(|d| d.props.opacity).is_some_and(|do_| {
777        edge.props
778            .opacity
779            .is_some_and(|eo| (eo - do_).abs() < 0.001)
780    });
781    if !opacity_matches_default && let Some(opacity) = edge.props.opacity {
782        writeln!(out, "  opacity: {}", format_num(opacity)).unwrap();
783    }
784
785    // Arrow — skip if matches edge_defaults
786    let arrow_matches_default = defaults
787        .and_then(|d| d.arrow)
788        .is_some_and(|da| edge.arrow == da);
789    if !arrow_matches_default && edge.arrow != ArrowKind::None {
790        let name = match edge.arrow {
791            ArrowKind::None => "none",
792            ArrowKind::Start => "start",
793            ArrowKind::End => "end",
794            ArrowKind::Both => "both",
795        };
796        writeln!(out, "  arrow: {name}").unwrap();
797    }
798
799    // Curve — skip if matches edge_defaults
800    let curve_matches_default = defaults
801        .and_then(|d| d.curve)
802        .is_some_and(|dc| edge.curve == dc);
803    if !curve_matches_default && edge.curve != CurveKind::Straight {
804        let name = match edge.curve {
805            CurveKind::Straight => "straight",
806            CurveKind::Smooth => "smooth",
807            CurveKind::Step => "step",
808        };
809        writeln!(out, "  curve: {name}").unwrap();
810    }
811
812    // Flow animation
813    if let Some(ref flow) = edge.flow {
814        let kind = match flow.kind {
815            FlowKind::Pulse => "pulse",
816            FlowKind::Dash => "dash",
817        };
818        writeln!(out, "  flow: {} {}ms", kind, flow.duration_ms).unwrap();
819    }
820
821    // Label offset (dragged position)
822    if let Some((ox, oy)) = edge.label_offset {
823        writeln!(out, "  label_offset: {} {}", format_num(ox), format_num(oy)).unwrap();
824    }
825
826    // Trigger animations
827    for anim in &edge.animations {
828        emit_anim(out, anim, 1);
829    }
830
831    out.push_str("}\n");
832}
833
834/// Generate an auto-comment for AI comprehension based on node heuristics.
835///
836/// Returns `None` if the node doesn't benefit from extra context.
837fn generate_auto_comment(node: &SceneNode, graph: &SceneGraph, idx: NodeIndex) -> Option<String> {
838    match &node.kind {
839        NodeKind::Root => None,
840        // Text nodes are self-documenting via inline "content" — skip auto-comment
841        NodeKind::Text { .. } => None,
842        NodeKind::Group => {
843            let count = graph.children(idx).len();
844            if count > 0 {
845                Some(format!("container ({count} children)"))
846            } else {
847                None
848            }
849        }
850        NodeKind::Frame { layout, .. } => {
851            let count = graph.children(idx).len();
852            let layout_str = match layout {
853                LayoutMode::Free { .. } => "free",
854                LayoutMode::Column { .. } => "column",
855                LayoutMode::Row { .. } => "row",
856                LayoutMode::Grid { .. } => "grid",
857            };
858            Some(format!("{layout_str} container ({count} children)"))
859        }
860        _ => {
861            // For shapes: mention use: style if present
862            if let Some(first_style) = node.use_styles.first() {
863                Some(format!("styled: {}", first_style.as_str()))
864            } else {
865                // Check if connected via edges
866                let edge_target_ids: Vec<NodeId> = graph
867                    .edges
868                    .iter()
869                    .filter(|e| e.from.node_id() == Some(node.id))
870                    .filter_map(|e| e.to.node_id())
871                    .collect();
872                if !edge_target_ids.is_empty() {
873                    let names: Vec<&str> = edge_target_ids.iter().map(|id| id.as_str()).collect();
874                    Some(format!("connects to {}", names.join(", ")))
875                } else {
876                    None
877                }
878            }
879        }
880    }
881}
882
883/// Compare two stroke values for equality (using f32 approximate comparison).
884fn stroke_eq(a: &Stroke, b: &Stroke) -> bool {
885    (a.width - b.width).abs() < 0.001 && a.paint == b.paint
886}
887
888// ─── Read Modes (filtered emit for AI agents) ────────────────────────────
889
890/// What an AI agent wants to read from the document.
891///
892/// Each mode selectively emits only the properties relevant to a specific
893/// concern, saving 50-80% tokens while preserving structural understanding.
894#[derive(Debug, Clone, Copy, PartialEq, Eq)]
895pub enum ReadMode {
896    /// Full file — no filtering (identical to `emit_document`).
897    Full,
898    /// Node types, `@id`s, parent-child nesting only.
899    Structure,
900    /// Structure + dimensions (`w:`/`h:`) + `layout:` directives + constraints.
901    Layout,
902    /// Structure + styles + `fill:`/`stroke:`/`font:`/`corner:`/`use:` refs.
903    Design,
904    /// Structure + `spec {}` blocks + annotations.
905    Spec,
906    /// Layout + Design + When combined — the full visual story.
907    Visual,
908    /// Structure + `when :trigger { ... }` animation blocks only.
909    When,
910    /// Structure + `edge @id { ... }` blocks.
911    Edges,
912    /// Only shows changes since a previous snapshot.
913    /// Requires calling `snapshot_graph` first to create a baseline.
914    Diff,
915}
916
917/// Emit a `SceneGraph` filtered to show only the properties relevant to `mode`.
918///
919/// - `Full`: identical to `emit_document`.
920/// - `Structure`: node kind + `@id` + children. No styles, dims, anims, specs.
921/// - `Layout`: structure + `w:`/`h:` + `layout:` + constraints (`->`).
922/// - `Design`: structure + styles + `fill:`/`stroke:`/`font:`/`corner:`/`use:`.
923/// - `Spec`: structure + `spec {}` blocks.
924/// - `Visual`: layout + design + when combined.
925/// - `When`: structure + `when :trigger { ... }` blocks.
926/// - `Edges`: structure + `edge @id { ... }` blocks.
927#[must_use]
928pub fn emit_filtered(graph: &SceneGraph, mode: ReadMode) -> String {
929    if mode == ReadMode::Full {
930        return emit_document(graph);
931    }
932    if mode == ReadMode::Diff {
933        // Diff mode requires a snapshot — use emit_diff() directly
934        return String::from("# Use emit_diff(graph, &snapshot) for Diff mode\n");
935    }
936
937    let mut out = String::with_capacity(1024);
938
939    let children = graph.children(graph.root);
940    let include_styles = matches!(mode, ReadMode::Design | ReadMode::Visual);
941    let include_constraints = matches!(mode, ReadMode::Layout | ReadMode::Visual);
942    let include_edges = matches!(mode, ReadMode::Edges | ReadMode::Visual);
943
944    // Styles (Design and Visual modes)
945    if include_styles && !graph.styles.is_empty() {
946        let mut styles: Vec<_> = graph.styles.iter().collect();
947        styles.sort_by_key(|(id, _)| id.as_str().to_string());
948        for (name, style) in &styles {
949            emit_style_block(&mut out, name, style, 0);
950            out.push('\n');
951        }
952    }
953
954    // Node tree (always emitted, but with per-mode filtering)
955    for child_idx in &children {
956        emit_node_filtered(&mut out, graph, *child_idx, 0, mode);
957        out.push('\n');
958    }
959
960    // Constraints (Layout and Visual modes)
961    if include_constraints {
962        for idx in graph.graph.node_indices() {
963            let node = &graph.graph[idx];
964            for constraint in &node.constraints {
965                if matches!(constraint, Constraint::Position { .. }) {
966                    continue;
967                }
968                emit_constraint(&mut out, &node.id, constraint);
969            }
970        }
971    }
972
973    // Edges (Edges and Visual modes)
974    if include_edges {
975        for edge in &graph.edges {
976            emit_edge(&mut out, edge, graph, graph.edge_defaults.as_ref());
977            out.push('\n');
978        }
979    }
980
981    out
982}
983
984/// Emit a single node with mode-based property filtering.
985fn emit_node_filtered(
986    out: &mut String,
987    graph: &SceneGraph,
988    idx: NodeIndex,
989    depth: usize,
990    mode: ReadMode,
991) {
992    let node = &graph.graph[idx];
993
994    if matches!(node.kind, NodeKind::Root) {
995        return;
996    }
997
998    indent(out, depth);
999
1000    // Node kind + @id (always emitted)
1001    match &node.kind {
1002        NodeKind::Root => return,
1003        NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
1004        NodeKind::Group => write!(out, "group @{}", node.id.as_str()).unwrap(),
1005        NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
1006        NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
1007        NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
1008        NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
1009        NodeKind::Image { .. } => write!(out, "image @{}", node.id.as_str()).unwrap(),
1010        NodeKind::Text { content, .. } => {
1011            write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
1012        }
1013    }
1014
1015    out.push_str(" {\n");
1016
1017    // Spec annotations (Spec mode only)
1018    if mode == ReadMode::Spec {
1019        emit_annotations(out, &node.annotations, depth + 1);
1020    }
1021
1022    // Children (always recurse)
1023    let children = graph.children(idx);
1024    for child_idx in &children {
1025        emit_node_filtered(out, graph, *child_idx, depth + 1, mode);
1026    }
1027
1028    // Layout directives (Layout and Visual modes)
1029    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1030        emit_layout_mode_filtered(out, &node.kind, depth + 1);
1031    }
1032
1033    // Dimensions (Layout and Visual modes)
1034    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1035        emit_dimensions_filtered(out, &node.kind, depth + 1);
1036    }
1037
1038    // Style properties (Design and Visual modes)
1039    if matches!(mode, ReadMode::Design | ReadMode::Visual) {
1040        for style_ref in &node.use_styles {
1041            indent(out, depth + 1);
1042            writeln!(out, "use: {}", style_ref.as_str()).unwrap();
1043        }
1044        if let Some(ref fill) = node.props.fill {
1045            emit_paint_prop(out, "fill", fill, depth + 1);
1046        }
1047        if let Some(ref stroke) = node.props.stroke {
1048            indent(out, depth + 1);
1049            match &stroke.paint {
1050                Paint::Solid(c) => {
1051                    writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
1052                }
1053                _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
1054            }
1055        }
1056        if let Some(radius) = node.props.corner_radius {
1057            indent(out, depth + 1);
1058            writeln!(out, "corner: {}", format_num(radius)).unwrap();
1059        }
1060        if let Some(ref font) = node.props.font {
1061            emit_font_prop(out, font, depth + 1);
1062        }
1063        if let Some(opacity) = node.props.opacity {
1064            indent(out, depth + 1);
1065            writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
1066        }
1067    }
1068
1069    // Inline position (Layout and Visual modes)
1070    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1071        for constraint in &node.constraints {
1072            if let Constraint::Position { x, y } = constraint {
1073                if *x != 0.0 {
1074                    indent(out, depth + 1);
1075                    writeln!(out, "x: {}", format_num(*x)).unwrap();
1076                }
1077                if *y != 0.0 {
1078                    indent(out, depth + 1);
1079                    writeln!(out, "y: {}", format_num(*y)).unwrap();
1080                }
1081            }
1082        }
1083    }
1084
1085    // Animations / when blocks (When and Visual modes)
1086    if matches!(mode, ReadMode::When | ReadMode::Visual) {
1087        for anim in &node.animations {
1088            emit_anim(out, anim, depth + 1);
1089        }
1090    }
1091
1092    indent(out, depth);
1093    out.push_str("}\n");
1094}
1095
1096/// Emit layout mode directive for groups and frames (filtered path).
1097fn emit_layout_mode_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
1098    let layout = match kind {
1099        NodeKind::Frame { layout, .. } => layout,
1100        _ => return, // Group is always Free — no layout emission
1101    };
1102    match layout {
1103        LayoutMode::Free { pad } => {
1104            if *pad > 0.0 {
1105                indent(out, depth);
1106                writeln!(out, "padding: {}", format_num(*pad)).unwrap();
1107            }
1108        }
1109        LayoutMode::Column { gap, pad } => {
1110            indent(out, depth);
1111            writeln!(
1112                out,
1113                "layout: column gap={} pad={}",
1114                format_num(*gap),
1115                format_num(*pad)
1116            )
1117            .unwrap();
1118        }
1119        LayoutMode::Row { gap, pad } => {
1120            indent(out, depth);
1121            writeln!(
1122                out,
1123                "layout: row gap={} pad={}",
1124                format_num(*gap),
1125                format_num(*pad)
1126            )
1127            .unwrap();
1128        }
1129        LayoutMode::Grid { cols, gap, pad } => {
1130            indent(out, depth);
1131            writeln!(
1132                out,
1133                "layout: grid cols={cols} gap={} pad={}",
1134                format_num(*gap),
1135                format_num(*pad)
1136            )
1137            .unwrap();
1138        }
1139    }
1140}
1141
1142/// Emit dimension properties (w/h) for sized nodes (filtered path).
1143fn emit_dimensions_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
1144    match kind {
1145        NodeKind::Rect { width, height } | NodeKind::Frame { width, height, .. } => {
1146            indent(out, depth);
1147            writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
1148        }
1149        NodeKind::Ellipse { rx, ry } => {
1150            indent(out, depth);
1151            writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
1152        }
1153        NodeKind::Image { width, height, .. } => {
1154            indent(out, depth);
1155            writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
1156        }
1157        _ => {}
1158    }
1159}
1160
1161// ─── Spec Markdown Export ─────────────────────────────────────────────────
1162
1163/// Emit a `SceneGraph` as a markdown spec document.
1164///
1165/// Extracts only `@id` names, `spec { ... }` annotations, hierarchy, and edges —
1166/// all visual properties (fill, stroke, dimensions, animations) are omitted.
1167/// Intended for PM-facing spec reports.
1168#[must_use]
1169pub fn emit_spec_markdown(graph: &SceneGraph, title: &str) -> String {
1170    let mut out = String::with_capacity(512);
1171    writeln!(out, "# Spec: {title}\n").unwrap();
1172
1173    // Emit root's children
1174    let children = graph.children(graph.root);
1175    for child_idx in &children {
1176        emit_spec_node(&mut out, graph, *child_idx, 2);
1177    }
1178
1179    // Emit edges as flow descriptions
1180    if !graph.edges.is_empty() {
1181        out.push_str("\n---\n\n## Flows\n\n");
1182        for edge in &graph.edges {
1183            let from_str = match &edge.from {
1184                EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
1185                EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
1186            };
1187            let to_str = match &edge.to {
1188                EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
1189                EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
1190            };
1191            write!(out, "- **{}** → **{}**", from_str, to_str).unwrap();
1192            if let Some(text_id) = edge.text_child
1193                && let Some(node) = graph.get_by_id(text_id)
1194                && let NodeKind::Text { content, .. } = &node.kind
1195            {
1196                write!(out, " — {content}").unwrap();
1197            }
1198            out.push('\n');
1199            emit_spec_annotations(&mut out, &edge.annotations, "  ");
1200        }
1201    }
1202
1203    out
1204}
1205
1206fn emit_spec_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, heading_level: usize) {
1207    let node = &graph.graph[idx];
1208
1209    // Skip nodes with no annotations and no annotated children
1210    let has_annotations = !node.annotations.is_empty();
1211    let children = graph.children(idx);
1212    let has_annotated_children = children
1213        .iter()
1214        .any(|c| has_annotations_recursive(graph, *c));
1215
1216    if !has_annotations && !has_annotated_children {
1217        return;
1218    }
1219
1220    // Heading: ## @node_id (kind)
1221    let hashes = "#".repeat(heading_level.min(6));
1222    let kind_label = match &node.kind {
1223        NodeKind::Root => return,
1224        NodeKind::Generic => "spec",
1225        NodeKind::Group => "group",
1226        NodeKind::Frame { .. } => "frame",
1227        NodeKind::Rect { .. } => "rect",
1228        NodeKind::Ellipse { .. } => "ellipse",
1229        NodeKind::Path { .. } => "path",
1230        NodeKind::Image { .. } => "image",
1231        NodeKind::Text { .. } => "text",
1232    };
1233    writeln!(out, "{hashes} @{} `{kind_label}`\n", node.id.as_str()).unwrap();
1234
1235    // Annotation details
1236    emit_spec_annotations(out, &node.annotations, "");
1237
1238    // Children (recurse with deeper heading level)
1239    for child_idx in &children {
1240        emit_spec_node(out, graph, *child_idx, heading_level + 1);
1241    }
1242}
1243
1244fn has_annotations_recursive(graph: &SceneGraph, idx: NodeIndex) -> bool {
1245    let node = &graph.graph[idx];
1246    if !node.annotations.is_empty() {
1247        return true;
1248    }
1249    graph
1250        .children(idx)
1251        .iter()
1252        .any(|c| has_annotations_recursive(graph, *c))
1253}
1254
1255fn emit_spec_annotations(out: &mut String, annotations: &[Annotation], prefix: &str) {
1256    for ann in annotations {
1257        match ann {
1258            Annotation::Description(s) => writeln!(out, "{prefix}> {s}").unwrap(),
1259            Annotation::Accept(s) => writeln!(out, "{prefix}- [ ] {s}").unwrap(),
1260            Annotation::Status(s) => writeln!(out, "{prefix}- **Status:** {s}").unwrap(),
1261            Annotation::Priority(s) => writeln!(out, "{prefix}- **Priority:** {s}").unwrap(),
1262            Annotation::Tag(s) => writeln!(out, "{prefix}- **Tag:** {s}").unwrap(),
1263        }
1264    }
1265    if !annotations.is_empty() {
1266        out.push('\n');
1267    }
1268}
1269
1270/// Check if a `Style` has any non-default properties set.
1271fn has_inline_styles(style: &Properties) -> bool {
1272    style.fill.is_some()
1273        || style.stroke.is_some()
1274        || style.font.is_some()
1275        || style.corner_radius.is_some()
1276        || style.opacity.is_some()
1277        || style.shadow.is_some()
1278        || style.text_align.is_some()
1279        || style.text_valign.is_some()
1280        || style.scale.is_some()
1281}
1282
1283/// Format a float without trailing zeros for compact output.
1284pub(crate) fn format_num(n: f32) -> String {
1285    if n == n.floor() {
1286        format!("{}", n as i32)
1287    } else {
1288        format!("{n:.1}")
1289            .trim_end_matches('0')
1290            .trim_end_matches('.')
1291            .to_string()
1292    }
1293}
1294
1295// ─── Snapshot / Diff ─────────────────────────────────────────────────────
1296
1297use std::collections::hash_map::DefaultHasher;
1298use std::hash::{Hash, Hasher};
1299
1300/// Create a hash-based snapshot of the current graph state.
1301///
1302/// Each node and edge is independently emitted to a string and hashed.
1303/// This avoids implementing `Hash` on f32-containing types while staying
1304/// memory-efficient (only stores u64 per element instead of full copies).
1305#[must_use]
1306pub fn snapshot_graph(graph: &SceneGraph) -> GraphSnapshot {
1307    let mut snapshot = GraphSnapshot::default();
1308
1309    // Hash each node
1310    for idx in graph.graph.node_indices() {
1311        let node = &graph.graph[idx];
1312        if matches!(node.kind, NodeKind::Root) {
1313            continue;
1314        }
1315        let mut buf = String::new();
1316        emit_node(&mut buf, graph, idx, 0);
1317        let mut hasher = DefaultHasher::new();
1318        buf.hash(&mut hasher);
1319        snapshot.node_hashes.insert(node.id, hasher.finish());
1320    }
1321
1322    // Hash each edge
1323    for edge in &graph.edges {
1324        let mut buf = String::new();
1325        emit_edge(&mut buf, edge, graph, graph.edge_defaults.as_ref());
1326        let mut hasher = DefaultHasher::new();
1327        buf.hash(&mut hasher);
1328        snapshot.edge_hashes.insert(edge.id, hasher.finish());
1329    }
1330
1331    snapshot
1332}
1333
1334/// Emit the diff between the current graph and a previous snapshot.
1335///
1336/// Output format:
1337/// - `+ node/edge` block: added (not in snapshot)
1338/// - `~ node/edge` block: modified (hash differs)
1339/// - `- @id`: removed (in snapshot but not in current graph)
1340#[must_use]
1341pub fn emit_diff(graph: &SceneGraph, prev: &GraphSnapshot) -> String {
1342    let mut out = String::with_capacity(512);
1343
1344    // Check nodes: added or modified
1345    for idx in graph.graph.node_indices() {
1346        let node = &graph.graph[idx];
1347        if matches!(node.kind, NodeKind::Root) {
1348            continue;
1349        }
1350
1351        let mut buf = String::new();
1352        emit_node(&mut buf, graph, idx, 0);
1353        let mut hasher = DefaultHasher::new();
1354        buf.hash(&mut hasher);
1355        let current_hash = hasher.finish();
1356
1357        match prev.node_hashes.get(&node.id) {
1358            None => {
1359                // Added
1360                out.push_str("+ ");
1361                out.push_str(&buf);
1362                out.push('\n');
1363            }
1364            Some(&prev_hash) if prev_hash != current_hash => {
1365                // Modified
1366                out.push_str("~ ");
1367                out.push_str(&buf);
1368                out.push('\n');
1369            }
1370            _ => {} // Unchanged
1371        }
1372    }
1373
1374    // Check edges: added or modified
1375    for edge in &graph.edges {
1376        let mut buf = String::new();
1377        emit_edge(&mut buf, edge, graph, graph.edge_defaults.as_ref());
1378        let mut hasher = DefaultHasher::new();
1379        buf.hash(&mut hasher);
1380        let current_hash = hasher.finish();
1381
1382        match prev.edge_hashes.get(&edge.id) {
1383            None => {
1384                out.push_str("+ ");
1385                out.push_str(&buf);
1386                out.push('\n');
1387            }
1388            Some(&prev_hash) if prev_hash != current_hash => {
1389                out.push_str("~ ");
1390                out.push_str(&buf);
1391                out.push('\n');
1392            }
1393            _ => {}
1394        }
1395    }
1396
1397    // Check for removed nodes
1398    for id in prev.node_hashes.keys() {
1399        if graph.get_by_id(*id).is_none() {
1400            writeln!(out, "- @{}", id.as_str()).unwrap();
1401        }
1402    }
1403
1404    // Check for removed edges
1405    let current_edge_ids: std::collections::HashSet<NodeId> =
1406        graph.edges.iter().map(|e| e.id).collect();
1407    for id in prev.edge_hashes.keys() {
1408        if !current_edge_ids.contains(id) {
1409            writeln!(out, "- edge @{}", id.as_str()).unwrap();
1410        }
1411    }
1412
1413    if out.is_empty() {
1414        out.push_str("# No changes\n");
1415    }
1416
1417    out
1418}
1419
1420#[cfg(test)]
1421#[path = "emitter_tests.rs"]
1422mod tests;