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.spec.is_none()
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    // Spec block
200    emit_spec(out, &node.spec, 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    // Locked flag (only emit when true — false is default)
423    if node.locked {
424        indent(out, depth + 1);
425        writeln!(out, "locked: true").unwrap();
426    }
427
428    // Inline position (x: / y:) — emitted here for token efficiency
429    for constraint in &node.constraints {
430        if let Constraint::Position { x, y } = constraint {
431            if *x != 0.0 {
432                indent(out, depth + 1);
433                writeln!(out, "x: {}", format_num(*x)).unwrap();
434            }
435            if *y != 0.0 {
436                indent(out, depth + 1);
437                writeln!(out, "y: {}", format_num(*y)).unwrap();
438            }
439        }
440    }
441
442    // Animations (when blocks)
443    for anim in &node.animations {
444        emit_anim(out, anim, depth + 1);
445    }
446
447    indent(out, depth);
448    out.push_str("}\n");
449}
450
451fn emit_spec(out: &mut String, spec: &Option<String>, depth: usize) {
452    let content = match spec {
453        Some(s) if !s.is_empty() => s,
454        _ => return,
455    };
456
457    // Single-line note → inline shorthand: `note "text"`
458    if !content.contains('\n') {
459        indent(out, depth);
460        writeln!(out, "spec \"{content}\"").unwrap();
461        return;
462    }
463
464    // Multiline → block form: `spec { ... }`
465    indent(out, depth);
466    out.push_str("spec {\n");
467    for line in content.lines() {
468        indent(out, depth + 1);
469        out.push_str(line);
470        out.push('\n');
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    if let Some(delay) = anim.delay_ms {
641        indent(out, depth + 1);
642        writeln!(out, "delay: {delay}ms").unwrap();
643    }
644
645    indent(out, depth);
646    out.push_str("}\n");
647}
648
649fn emit_constraint(out: &mut String, node_id: &NodeId, constraint: &Constraint) {
650    match constraint {
651        Constraint::CenterIn(target) => {
652            writeln!(
653                out,
654                "@{} -> center_in: {}",
655                node_id.as_str(),
656                target.as_str()
657            )
658            .unwrap();
659        }
660        Constraint::Offset { from, dx, dy } => {
661            writeln!(
662                out,
663                "@{} -> offset: @{} {}, {}",
664                node_id.as_str(),
665                from.as_str(),
666                format_num(*dx),
667                format_num(*dy)
668            )
669            .unwrap();
670        }
671        Constraint::FillParent { pad } => {
672            writeln!(
673                out,
674                "@{} -> fill_parent: {}",
675                node_id.as_str(),
676                format_num(*pad)
677            )
678            .unwrap();
679        }
680        Constraint::Position { .. } => {
681            // Emitted inline as x: / y: inside node block — skip here
682        }
683    }
684}
685
686fn emit_edge_defaults_block(out: &mut String, defaults: &EdgeDefaults) {
687    out.push_str("edge_defaults {\n");
688
689    if let Some(ref stroke) = defaults.props.stroke {
690        match &stroke.paint {
691            Paint::Solid(c) => {
692                writeln!(out, "  stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
693            }
694            _ => {
695                writeln!(out, "  stroke: #000 {}", format_num(stroke.width)).unwrap();
696            }
697        }
698    }
699    if let Some(opacity) = defaults.props.opacity {
700        writeln!(out, "  opacity: {}", format_num(opacity)).unwrap();
701    }
702    if let Some(arrow) = defaults.arrow
703        && arrow != ArrowKind::None
704    {
705        let name = match arrow {
706            ArrowKind::None => "none",
707            ArrowKind::Start => "start",
708            ArrowKind::End => "end",
709            ArrowKind::Both => "both",
710        };
711        writeln!(out, "  arrow: {name}").unwrap();
712    }
713    if let Some(curve) = defaults.curve
714        && curve != CurveKind::Straight
715    {
716        let name = match curve {
717            CurveKind::Straight => "straight",
718            CurveKind::Smooth => "smooth",
719            CurveKind::Step => "step",
720        };
721        writeln!(out, "  curve: {name}").unwrap();
722    }
723
724    out.push_str("}\n");
725}
726
727fn emit_edge(out: &mut String, edge: &Edge, graph: &SceneGraph, defaults: Option<&EdgeDefaults>) {
728    writeln!(out, "edge @{} {{", edge.id.as_str()).unwrap();
729
730    // Spec
731    emit_spec(out, &edge.spec, 1);
732
733    // Nested text child
734    if let Some(text_id) = edge.text_child
735        && let Some(node) = graph.get_by_id(text_id)
736        && let NodeKind::Text { content, .. } = &node.kind
737    {
738        writeln!(out, "  text @{} \"{}\" {{}}", text_id.as_str(), content).unwrap();
739    }
740
741    // from / to
742    match &edge.from {
743        EdgeAnchor::Node(id) => writeln!(out, "  from: @{}", id.as_str()).unwrap(),
744        EdgeAnchor::Point(x, y) => {
745            writeln!(out, "  from: {} {}", format_num(*x), format_num(*y)).unwrap()
746        }
747    }
748    match &edge.to {
749        EdgeAnchor::Node(id) => writeln!(out, "  to: @{}", id.as_str()).unwrap(),
750        EdgeAnchor::Point(x, y) => {
751            writeln!(out, "  to: {} {}", format_num(*x), format_num(*y)).unwrap()
752        }
753    }
754
755    // Style references
756    for style_ref in &edge.use_styles {
757        writeln!(out, "  use: {}", style_ref.as_str()).unwrap();
758    }
759
760    // Stroke — skip if matches edge_defaults
761    let stroke_matches_default = defaults
762        .and_then(|d| d.props.stroke.as_ref())
763        .is_some_and(|ds| {
764            edge.props
765                .stroke
766                .as_ref()
767                .is_some_and(|es| stroke_eq(es, ds))
768        });
769    if !stroke_matches_default && let Some(ref stroke) = edge.props.stroke {
770        match &stroke.paint {
771            Paint::Solid(c) => {
772                writeln!(out, "  stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
773            }
774            _ => {
775                writeln!(out, "  stroke: #000 {}", format_num(stroke.width)).unwrap();
776            }
777        }
778    }
779
780    // Opacity — skip if matches edge_defaults
781    let opacity_matches_default = defaults.and_then(|d| d.props.opacity).is_some_and(|do_| {
782        edge.props
783            .opacity
784            .is_some_and(|eo| (eo - do_).abs() < 0.001)
785    });
786    if !opacity_matches_default && let Some(opacity) = edge.props.opacity {
787        writeln!(out, "  opacity: {}", format_num(opacity)).unwrap();
788    }
789
790    // Arrow — skip if matches edge_defaults
791    let arrow_matches_default = defaults
792        .and_then(|d| d.arrow)
793        .is_some_and(|da| edge.arrow == da);
794    if !arrow_matches_default && edge.arrow != ArrowKind::None {
795        let name = match edge.arrow {
796            ArrowKind::None => "none",
797            ArrowKind::Start => "start",
798            ArrowKind::End => "end",
799            ArrowKind::Both => "both",
800        };
801        writeln!(out, "  arrow: {name}").unwrap();
802    }
803
804    // Curve — skip if matches edge_defaults
805    let curve_matches_default = defaults
806        .and_then(|d| d.curve)
807        .is_some_and(|dc| edge.curve == dc);
808    if !curve_matches_default && edge.curve != CurveKind::Straight {
809        let name = match edge.curve {
810            CurveKind::Straight => "straight",
811            CurveKind::Smooth => "smooth",
812            CurveKind::Step => "step",
813        };
814        writeln!(out, "  curve: {name}").unwrap();
815    }
816
817    // Flow animation
818    if let Some(ref flow) = edge.flow {
819        let kind = match flow.kind {
820            FlowKind::Pulse => "pulse",
821            FlowKind::Dash => "dash",
822        };
823        writeln!(out, "  flow: {} {}ms", kind, flow.duration_ms).unwrap();
824    }
825
826    // Label offset (dragged position)
827    if let Some((ox, oy)) = edge.label_offset {
828        writeln!(out, "  label_offset: {} {}", format_num(ox), format_num(oy)).unwrap();
829    }
830
831    // Trigger animations
832    for anim in &edge.animations {
833        emit_anim(out, anim, 1);
834    }
835
836    out.push_str("}\n");
837}
838
839/// Emit a single node subtree as standalone FD text.
840///
841/// Public wrapper around `emit_node` for use by `emit_selection_fd`.
842/// Includes the node with all its children, styles, and animations.
843pub fn emit_node_standalone(
844    out: &mut String,
845    graph: &SceneGraph,
846    idx: NodeIndex,
847    _bounds: &std::collections::HashMap<NodeIndex, ResolvedBounds>,
848) {
849    emit_node(out, graph, idx, 0);
850}
851
852/// Emit a single edge as standalone FD text.
853///
854/// Public wrapper around `emit_edge` for use by `emit_selection_fd`.
855pub fn emit_edge_standalone(
856    out: &mut String,
857    edge: &Edge,
858    graph: &SceneGraph,
859    defaults: Option<&EdgeDefaults>,
860) {
861    emit_edge(out, edge, graph, defaults);
862}
863
864/// Generate an auto-comment for AI comprehension based on node heuristics.
865///
866/// Returns `None` if the node doesn't benefit from extra context.
867fn generate_auto_comment(node: &SceneNode, graph: &SceneGraph, idx: NodeIndex) -> Option<String> {
868    match &node.kind {
869        NodeKind::Root => None,
870        // Text nodes are self-documenting via inline "content" — skip auto-comment
871        NodeKind::Text { .. } => None,
872        NodeKind::Group => {
873            let count = graph.children(idx).len();
874            if count > 0 {
875                Some(format!("container ({count} children)"))
876            } else {
877                None
878            }
879        }
880        NodeKind::Frame { layout, .. } => {
881            let count = graph.children(idx).len();
882            let layout_str = match layout {
883                LayoutMode::Free { .. } => "free",
884                LayoutMode::Column { .. } => "column",
885                LayoutMode::Row { .. } => "row",
886                LayoutMode::Grid { .. } => "grid",
887            };
888            Some(format!("{layout_str} container ({count} children)"))
889        }
890        _ => {
891            // For shapes: mention use: style if present
892            if let Some(first_style) = node.use_styles.first() {
893                Some(format!("styled: {}", first_style.as_str()))
894            } else {
895                // Check if connected via edges
896                let edge_target_ids: Vec<NodeId> = graph
897                    .edges
898                    .iter()
899                    .filter(|e| e.from.node_id() == Some(node.id))
900                    .filter_map(|e| e.to.node_id())
901                    .collect();
902                if !edge_target_ids.is_empty() {
903                    let names: Vec<&str> = edge_target_ids.iter().map(|id| id.as_str()).collect();
904                    Some(format!("connects to {}", names.join(", ")))
905                } else {
906                    None
907                }
908            }
909        }
910    }
911}
912
913/// Compare two stroke values for equality (using f32 approximate comparison).
914fn stroke_eq(a: &Stroke, b: &Stroke) -> bool {
915    (a.width - b.width).abs() < 0.001 && a.paint == b.paint
916}
917
918// ─── Read Modes (filtered emit for AI agents) ────────────────────────────
919
920/// What an AI agent wants to read from the document.
921///
922/// Each mode selectively emits only the properties relevant to a specific
923/// concern, saving 50-80% tokens while preserving structural understanding.
924#[derive(Debug, Clone, Copy, PartialEq, Eq)]
925pub enum ReadMode {
926    /// Full file — no filtering (identical to `emit_document`).
927    Full,
928    /// Node types, `@id`s, parent-child nesting only.
929    Structure,
930    /// Structure + dimensions (`w:`/`h:`) + `layout:` directives + constraints.
931    Layout,
932    /// Structure + styles + `fill:`/`stroke:`/`font:`/`corner:`/`use:` refs.
933    Design,
934    /// Structure + `spec {}` blocks + annotations.
935    Spec,
936    /// Backward-compatible alias for `Spec`.
937    Notes,
938    /// Layout + Design + When combined — the full visual story.
939    Visual,
940    /// Structure + `when :trigger { ... }` animation blocks only.
941    When,
942    /// Structure + `edge @id { ... }` blocks.
943    Edges,
944    /// Only shows changes since a previous snapshot.
945    /// Requires calling `snapshot_graph` first to create a baseline.
946    Diff,
947}
948
949/// Emit a `SceneGraph` filtered to show only the properties relevant to `mode`.
950///
951/// - `Full`: identical to `emit_document`.
952/// - `Structure`: node kind + `@id` + children. No styles, dims, anims, specs.
953/// - `Layout`: structure + `w:`/`h:` + `layout:` + constraints (`->`).
954/// - `Design`: structure + styles + `fill:`/`stroke:`/`font:`/`corner:`/`use:`.
955/// - `Spec` (or `Notes`): structure + `spec {}` blocks.
956/// - `Visual`: layout + design + when combined.
957/// - `When`: structure + `when :trigger { ... }` blocks.
958/// - `Edges`: structure + `edge @id { ... }` blocks.
959#[must_use]
960pub fn emit_filtered(graph: &SceneGraph, mode: ReadMode) -> String {
961    if mode == ReadMode::Full {
962        return emit_document(graph);
963    }
964    // Normalize Notes alias to Spec
965    let mode = if mode == ReadMode::Notes {
966        ReadMode::Spec
967    } else {
968        mode
969    };
970    if mode == ReadMode::Diff {
971        // Diff mode requires a snapshot — use emit_diff() directly
972        return String::from("# Use emit_diff(graph, &snapshot) for Diff mode\n");
973    }
974
975    let mut out = String::with_capacity(1024);
976
977    let children = graph.children(graph.root);
978    let include_styles = matches!(mode, ReadMode::Design | ReadMode::Visual);
979    let include_constraints = matches!(mode, ReadMode::Layout | ReadMode::Visual);
980    let include_edges = matches!(mode, ReadMode::Edges | ReadMode::Visual);
981
982    // Styles (Design and Visual modes)
983    if include_styles && !graph.styles.is_empty() {
984        let mut styles: Vec<_> = graph.styles.iter().collect();
985        styles.sort_by_key(|(id, _)| id.as_str().to_string());
986        for (name, style) in &styles {
987            emit_style_block(&mut out, name, style, 0);
988            out.push('\n');
989        }
990    }
991
992    // Node tree (always emitted, but with per-mode filtering)
993    for child_idx in &children {
994        emit_node_filtered(&mut out, graph, *child_idx, 0, mode);
995        out.push('\n');
996    }
997
998    // Constraints (Layout and Visual modes)
999    if include_constraints {
1000        for idx in graph.graph.node_indices() {
1001            let node = &graph.graph[idx];
1002            for constraint in &node.constraints {
1003                if matches!(constraint, Constraint::Position { .. }) {
1004                    continue;
1005                }
1006                emit_constraint(&mut out, &node.id, constraint);
1007            }
1008        }
1009    }
1010
1011    // Edges (Edges and Visual modes)
1012    if include_edges {
1013        for edge in &graph.edges {
1014            emit_edge(&mut out, edge, graph, graph.edge_defaults.as_ref());
1015            out.push('\n');
1016        }
1017    }
1018
1019    out
1020}
1021
1022/// Emit a single node with mode-based property filtering.
1023fn emit_node_filtered(
1024    out: &mut String,
1025    graph: &SceneGraph,
1026    idx: NodeIndex,
1027    depth: usize,
1028    mode: ReadMode,
1029) {
1030    let node = &graph.graph[idx];
1031
1032    if matches!(node.kind, NodeKind::Root) {
1033        return;
1034    }
1035
1036    indent(out, depth);
1037
1038    // Node kind + @id (always emitted)
1039    match &node.kind {
1040        NodeKind::Root => return,
1041        NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
1042        NodeKind::Group => write!(out, "group @{}", node.id.as_str()).unwrap(),
1043        NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
1044        NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
1045        NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
1046        NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
1047        NodeKind::Image { .. } => write!(out, "image @{}", node.id.as_str()).unwrap(),
1048        NodeKind::Text { content, .. } => {
1049            write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
1050        }
1051    }
1052
1053    out.push_str(" {\n");
1054
1055    // Spec (Spec mode only)
1056    if mode == ReadMode::Spec {
1057        emit_spec(out, &node.spec, depth + 1);
1058    }
1059
1060    // Children (always recurse)
1061    let children = graph.children(idx);
1062    for child_idx in &children {
1063        emit_node_filtered(out, graph, *child_idx, depth + 1, mode);
1064    }
1065
1066    // Layout directives (Layout and Visual modes)
1067    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1068        emit_layout_mode_filtered(out, &node.kind, depth + 1);
1069    }
1070
1071    // Dimensions (Layout and Visual modes)
1072    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1073        emit_dimensions_filtered(out, &node.kind, depth + 1);
1074    }
1075
1076    // Style properties (Design and Visual modes)
1077    if matches!(mode, ReadMode::Design | ReadMode::Visual) {
1078        for style_ref in &node.use_styles {
1079            indent(out, depth + 1);
1080            writeln!(out, "use: {}", style_ref.as_str()).unwrap();
1081        }
1082        if let Some(ref fill) = node.props.fill {
1083            emit_paint_prop(out, "fill", fill, depth + 1);
1084        }
1085        if let Some(ref stroke) = node.props.stroke {
1086            indent(out, depth + 1);
1087            match &stroke.paint {
1088                Paint::Solid(c) => {
1089                    writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
1090                }
1091                _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
1092            }
1093        }
1094        if let Some(radius) = node.props.corner_radius {
1095            indent(out, depth + 1);
1096            writeln!(out, "corner: {}", format_num(radius)).unwrap();
1097        }
1098        if let Some(ref font) = node.props.font {
1099            emit_font_prop(out, font, depth + 1);
1100        }
1101        if let Some(opacity) = node.props.opacity {
1102            indent(out, depth + 1);
1103            writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
1104        }
1105    }
1106
1107    // Inline position (Layout and Visual modes)
1108    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1109        for constraint in &node.constraints {
1110            if let Constraint::Position { x, y } = constraint {
1111                if *x != 0.0 {
1112                    indent(out, depth + 1);
1113                    writeln!(out, "x: {}", format_num(*x)).unwrap();
1114                }
1115                if *y != 0.0 {
1116                    indent(out, depth + 1);
1117                    writeln!(out, "y: {}", format_num(*y)).unwrap();
1118                }
1119            }
1120        }
1121    }
1122
1123    // Animations / when blocks (When and Visual modes)
1124    if matches!(mode, ReadMode::When | ReadMode::Visual) {
1125        for anim in &node.animations {
1126            emit_anim(out, anim, depth + 1);
1127        }
1128    }
1129
1130    indent(out, depth);
1131    out.push_str("}\n");
1132}
1133
1134/// Emit layout mode directive for groups and frames (filtered path).
1135fn emit_layout_mode_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
1136    let layout = match kind {
1137        NodeKind::Frame { layout, .. } => layout,
1138        _ => return, // Group is always Free — no layout emission
1139    };
1140    match layout {
1141        LayoutMode::Free { pad } => {
1142            if *pad > 0.0 {
1143                indent(out, depth);
1144                writeln!(out, "padding: {}", format_num(*pad)).unwrap();
1145            }
1146        }
1147        LayoutMode::Column { gap, pad } => {
1148            indent(out, depth);
1149            writeln!(
1150                out,
1151                "layout: column gap={} pad={}",
1152                format_num(*gap),
1153                format_num(*pad)
1154            )
1155            .unwrap();
1156        }
1157        LayoutMode::Row { gap, pad } => {
1158            indent(out, depth);
1159            writeln!(
1160                out,
1161                "layout: row gap={} pad={}",
1162                format_num(*gap),
1163                format_num(*pad)
1164            )
1165            .unwrap();
1166        }
1167        LayoutMode::Grid { cols, gap, pad } => {
1168            indent(out, depth);
1169            writeln!(
1170                out,
1171                "layout: grid cols={cols} gap={} pad={}",
1172                format_num(*gap),
1173                format_num(*pad)
1174            )
1175            .unwrap();
1176        }
1177    }
1178}
1179
1180/// Emit dimension properties (w/h) for sized nodes (filtered path).
1181fn emit_dimensions_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
1182    match kind {
1183        NodeKind::Rect { width, height } | NodeKind::Frame { width, height, .. } => {
1184            indent(out, depth);
1185            writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
1186        }
1187        NodeKind::Ellipse { rx, ry } => {
1188            indent(out, depth);
1189            writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
1190        }
1191        NodeKind::Image { width, height, .. } => {
1192            indent(out, depth);
1193            writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
1194        }
1195        _ => {}
1196    }
1197}
1198
1199/// Emit a `SceneGraph` as a markdown spec document.
1200///
1201/// Extracts only `@id` names, `spec { ... }` content, hierarchy, and edges —
1202/// all visual properties (fill, stroke, dimensions, animations) are omitted.
1203#[must_use]
1204pub fn emit_spec_markdown(graph: &SceneGraph, title: &str) -> String {
1205    let mut out = String::with_capacity(512);
1206    writeln!(out, "# Spec: {title}\n").unwrap();
1207
1208    // Emit root's children
1209    let children = graph.children(graph.root);
1210    for child_idx in &children {
1211        emit_spec_node(&mut out, graph, *child_idx, 2);
1212    }
1213
1214    // Emit edges as flow descriptions
1215    if !graph.edges.is_empty() {
1216        out.push_str("\n---\n\n## Flows\n\n");
1217        for edge in &graph.edges {
1218            let from_str = match &edge.from {
1219                EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
1220                EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
1221            };
1222            let to_str = match &edge.to {
1223                EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
1224                EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
1225            };
1226            write!(out, "- **{}** → **{}**", from_str, to_str).unwrap();
1227            if let Some(text_id) = edge.text_child
1228                && let Some(node) = graph.get_by_id(text_id)
1229                && let NodeKind::Text { content, .. } = &node.kind
1230            {
1231                write!(out, " — {content}").unwrap();
1232            }
1233            out.push('\n');
1234            // Emit edge spec as indented markdown
1235            if let Some(spec) = &edge.spec {
1236                for line in spec.lines() {
1237                    writeln!(out, "  {line}").unwrap();
1238                }
1239                out.push('\n');
1240            }
1241        }
1242    }
1243
1244    out
1245}
1246
1247fn emit_spec_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, heading_level: usize) {
1248    let node = &graph.graph[idx];
1249
1250    // Skip nodes with no spec and no spec'd children
1251    let has_spec = node.spec.is_some();
1252    let children = graph.children(idx);
1253    let has_spec_children = children.iter().any(|c| has_spec_recursive(graph, *c));
1254
1255    if !has_spec && !has_spec_children {
1256        return;
1257    }
1258
1259    // Heading: ## @node_id (kind)
1260    let hashes = "#".repeat(heading_level.min(6));
1261    let kind_label = match &node.kind {
1262        NodeKind::Root => return,
1263        NodeKind::Generic => "spec",
1264        NodeKind::Group => "group",
1265        NodeKind::Frame { .. } => "frame",
1266        NodeKind::Rect { .. } => "rect",
1267        NodeKind::Ellipse { .. } => "ellipse",
1268        NodeKind::Path { .. } => "path",
1269        NodeKind::Image { .. } => "image",
1270        NodeKind::Text { .. } => "text",
1271    };
1272    writeln!(out, "{hashes} @{} `{kind_label}`\n", node.id.as_str()).unwrap();
1273
1274    // Spec content — output as-is (it's already markdown)
1275    if let Some(spec) = &node.spec {
1276        out.push_str(spec);
1277        out.push_str("\n\n");
1278    }
1279
1280    // Children (recurse with deeper heading level)
1281    for child_idx in &children {
1282        emit_spec_node(out, graph, *child_idx, heading_level + 1);
1283    }
1284}
1285
1286fn has_spec_recursive(graph: &SceneGraph, idx: NodeIndex) -> bool {
1287    let node = &graph.graph[idx];
1288    if node.spec.is_some() {
1289        return true;
1290    }
1291    graph
1292        .children(idx)
1293        .iter()
1294        .any(|c| has_spec_recursive(graph, *c))
1295}
1296
1297/// Check if a `Style` has any non-default properties set.
1298fn has_inline_styles(style: &Properties) -> bool {
1299    style.fill.is_some()
1300        || style.stroke.is_some()
1301        || style.font.is_some()
1302        || style.corner_radius.is_some()
1303        || style.opacity.is_some()
1304        || style.shadow.is_some()
1305        || style.text_align.is_some()
1306        || style.text_valign.is_some()
1307        || style.scale.is_some()
1308}
1309
1310/// Format a float without trailing zeros for compact output.
1311pub(crate) fn format_num(n: f32) -> String {
1312    if n == n.floor() {
1313        format!("{}", n as i32)
1314    } else {
1315        format!("{n:.1}")
1316            .trim_end_matches('0')
1317            .trim_end_matches('.')
1318            .to_string()
1319    }
1320}
1321
1322// ─── Snapshot / Diff ─────────────────────────────────────────────────────
1323
1324use std::collections::hash_map::DefaultHasher;
1325use std::hash::{Hash, Hasher};
1326
1327/// Create a hash-based snapshot of the current graph state.
1328///
1329/// Each node and edge is independently emitted to a string and hashed.
1330/// This avoids implementing `Hash` on f32-containing types while staying
1331/// memory-efficient (only stores u64 per element instead of full copies).
1332#[must_use]
1333pub fn snapshot_graph(graph: &SceneGraph) -> GraphSnapshot {
1334    let mut snapshot = GraphSnapshot::default();
1335
1336    // Hash each node
1337    for idx in graph.graph.node_indices() {
1338        let node = &graph.graph[idx];
1339        if matches!(node.kind, NodeKind::Root) {
1340            continue;
1341        }
1342        let mut buf = String::new();
1343        emit_node(&mut buf, graph, idx, 0);
1344        let mut hasher = DefaultHasher::new();
1345        buf.hash(&mut hasher);
1346        snapshot.node_hashes.insert(node.id, hasher.finish());
1347    }
1348
1349    // Hash each edge
1350    for edge in &graph.edges {
1351        let mut buf = String::new();
1352        emit_edge(&mut buf, edge, graph, graph.edge_defaults.as_ref());
1353        let mut hasher = DefaultHasher::new();
1354        buf.hash(&mut hasher);
1355        snapshot.edge_hashes.insert(edge.id, hasher.finish());
1356    }
1357
1358    snapshot
1359}
1360
1361/// Emit the diff between the current graph and a previous snapshot.
1362///
1363/// Output format:
1364/// - `+ node/edge` block: added (not in snapshot)
1365/// - `~ node/edge` block: modified (hash differs)
1366/// - `- @id`: removed (in snapshot but not in current graph)
1367#[must_use]
1368pub fn emit_diff(graph: &SceneGraph, prev: &GraphSnapshot) -> String {
1369    let mut out = String::with_capacity(512);
1370
1371    // Check nodes: added or modified
1372    for idx in graph.graph.node_indices() {
1373        let node = &graph.graph[idx];
1374        if matches!(node.kind, NodeKind::Root) {
1375            continue;
1376        }
1377
1378        let mut buf = String::new();
1379        emit_node(&mut buf, graph, idx, 0);
1380        let mut hasher = DefaultHasher::new();
1381        buf.hash(&mut hasher);
1382        let current_hash = hasher.finish();
1383
1384        match prev.node_hashes.get(&node.id) {
1385            None => {
1386                // Added
1387                out.push_str("+ ");
1388                out.push_str(&buf);
1389                out.push('\n');
1390            }
1391            Some(&prev_hash) if prev_hash != current_hash => {
1392                // Modified
1393                out.push_str("~ ");
1394                out.push_str(&buf);
1395                out.push('\n');
1396            }
1397            _ => {} // Unchanged
1398        }
1399    }
1400
1401    // Check edges: added or modified
1402    for edge in &graph.edges {
1403        let mut buf = String::new();
1404        emit_edge(&mut buf, edge, graph, graph.edge_defaults.as_ref());
1405        let mut hasher = DefaultHasher::new();
1406        buf.hash(&mut hasher);
1407        let current_hash = hasher.finish();
1408
1409        match prev.edge_hashes.get(&edge.id) {
1410            None => {
1411                out.push_str("+ ");
1412                out.push_str(&buf);
1413                out.push('\n');
1414            }
1415            Some(&prev_hash) if prev_hash != current_hash => {
1416                out.push_str("~ ");
1417                out.push_str(&buf);
1418                out.push('\n');
1419            }
1420            _ => {}
1421        }
1422    }
1423
1424    // Check for removed nodes
1425    for id in prev.node_hashes.keys() {
1426        if graph.get_by_id(*id).is_none() {
1427            writeln!(out, "- @{}", id.as_str()).unwrap();
1428        }
1429    }
1430
1431    // Check for removed edges
1432    let current_edge_ids: std::collections::HashSet<NodeId> =
1433        graph.edges.iter().map(|e| e.id).collect();
1434    for id in prev.edge_hashes.keys() {
1435        if !current_edge_ids.contains(id) {
1436            writeln!(out, "- edge @{}", id.as_str()).unwrap();
1437        }
1438    }
1439
1440    if out.is_empty() {
1441        out.push_str("# No changes\n");
1442    }
1443
1444    out
1445}
1446
1447/// Backward-compatible alias for `emit_spec_markdown`.
1448#[must_use]
1449pub fn emit_notes_markdown(graph: &SceneGraph, title: &str) -> String {
1450    emit_spec_markdown(graph, title)
1451}
1452
1453#[cfg(test)]
1454#[path = "emitter_tests.rs"]
1455mod tests;