Skip to main content

pite_editor/
ops.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Headless editor operations: pure tree/scene/error logic with no egui.
3//!
4//! `app.rs` calls these from UI callbacks and routes every `Err` to the
5//! console; nothing here touches the UI, so it is all unit-testable.
6
7use std::collections::{BTreeMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11use pite_core::{NodeDesc, NodeId, NodeTree, NodeTypeRegistry, PropValue, Props};
12use pite_scene::{SceneDoc, SceneInstance, SceneNode, SceneScript, FORMAT_VERSION};
13
14/// Closed node-type set. Mirrors `NodeTypeRegistry::new`; kept here because
15/// the registry exposes no listing API and the editor must not touch core.
16pub fn registered_types() -> Vec<String> {
17    [
18        "Node",
19        "Node2D",
20        "Sprite2D",
21        "Camera2D",
22        "Timer",
23        "Label",
24        "Button",
25        "StaticBody2D",
26        "CharacterBody2D",
27        "RigidBody2D",
28        "Area2D",
29        "CollisionShape2D",
30    ]
31    .iter()
32    .map(ToString::to_string)
33    .collect()
34}
35
36fn slugify(name: &str) -> String {
37    let mut slug: String = name
38        .chars()
39        .map(|c| {
40            if c.is_ascii_alphanumeric() {
41                c.to_ascii_lowercase()
42            } else {
43                '_'
44            }
45        })
46        .collect();
47    slug = slug.trim_matches('_').to_string();
48    if slug.is_empty() {
49        slug = "node".to_string();
50    }
51    slug
52}
53
54/// Insert a node of `type_name` under `parent` (defaults to the tree root).
55/// The id is derived from `name` plus a counter until unique.
56/// Unknown types and missing parents are errors.
57pub fn add_node(
58    tree: &mut NodeTree,
59    parent: Option<NodeId>,
60    type_name: &str,
61    name: &str,
62) -> Result<NodeId> {
63    let registry = NodeTypeRegistry::new();
64    let slug = slugify(name);
65    let mut candidate = slug.clone();
66    let mut counter = 2;
67    while tree.contains(&NodeId::from(candidate.clone())) {
68        candidate = format!("{slug}_{counter}");
69        counter += 1;
70    }
71    let id = NodeId::from(candidate);
72    let mut node = registry
73        .create(id.clone(), type_name)
74        .map_err(|e| anyhow::anyhow!("{e}"))?;
75    node.name = name.to_string();
76
77    match type_name {
78        "StaticBody2D" | "Area2D" => {
79            node.props
80                .insert("position".to_string(), PropValue::Vec2(0.0, 0.0));
81        }
82        "CharacterBody2D" => {
83            node.props
84                .insert("position".to_string(), PropValue::Vec2(0.0, 0.0));
85            node.props
86                .insert("velocity".to_string(), PropValue::Vec2(0.0, 0.0));
87        }
88        "RigidBody2D" => {
89            node.props
90                .insert("position".to_string(), PropValue::Vec2(0.0, 0.0));
91            node.props.insert("mass".to_string(), PropValue::Num(1.0));
92            node.props
93                .insert("gravity_scale".to_string(), PropValue::Num(1.0));
94            node.props
95                .insert("lock_rotation".to_string(), PropValue::Bool(false));
96        }
97        "CollisionShape2D" => {
98            node.props
99                .insert("position".to_string(), PropValue::Vec2(0.0, 0.0));
100            node.props
101                .insert("shape".to_string(), PropValue::Str("box".to_string()));
102            node.props
103                .insert("size".to_string(), PropValue::Vec2(32.0, 32.0));
104            node.props
105                .insert("radius".to_string(), PropValue::Num(16.0));
106            node.props
107                .insert("height".to_string(), PropValue::Num(32.0));
108            node.props
109                .insert("disabled".to_string(), PropValue::Bool(false));
110        }
111        _ => {}
112    }
113    let parent = match parent {
114        Some(p) => {
115            if !tree.contains(&p) {
116                anyhow::bail!("parent `{p}` does not exist");
117            }
118            Some(p)
119        }
120        None => tree.root().cloned(),
121    };
122    let desc = NodeDesc {
123        id: id.clone(),
124        type_name: node.type_name.clone(),
125        name: node.name.clone(),
126        parent,
127        props: node.props.clone(),
128        script: None,
129    };
130    tree.insert(desc).map_err(|e| anyhow::anyhow!("{e}"))?;
131    Ok(id)
132}
133
134/// Remove `id` and its subtree. Refusing the root is a console-grade error.
135pub fn remove_node(tree: &mut NodeTree, id: &NodeId) -> Result<()> {
136    if tree.root() == Some(id) {
137        anyhow::bail!("cannot remove the root node `{id}`");
138    }
139    tree.remove(id).map_err(|e| anyhow::anyhow!("{e}"))?;
140    Ok(())
141}
142
143/// Reparent `id` under `new_parent`. Cycle and root errors surface as-is.
144pub fn move_node(tree: &mut NodeTree, id: &NodeId, new_parent: Option<NodeId>) -> Result<()> {
145    tree.reparent(id, new_parent)
146        .map_err(|e| anyhow::anyhow!("{e}"))?;
147    Ok(())
148}
149
150/// Place `id` under `new_parent` at child `index` (clamped to the end).
151/// Unlike `move_node` this controls sibling order: index 0 makes it first.
152/// Refuses unknown nodes, root moves, and cycles (under itself or a
153/// descendant); the node keeps its subtree.
154pub fn place_node(
155    tree: &mut NodeTree,
156    id: &NodeId,
157    new_parent: &NodeId,
158    index: usize,
159) -> Result<()> {
160    if tree.root() == Some(id) {
161        anyhow::bail!("cannot move the root node `{id}`");
162    }
163    let node = tree
164        .get(id)
165        .ok_or_else(|| anyhow::anyhow!("node `{id}` does not exist"))?;
166    if !tree.contains(new_parent) {
167        anyhow::bail!("parent `{new_parent}` does not exist");
168    }
169    if new_parent == id {
170        anyhow::bail!("cannot place `{id}` under itself");
171    }
172    let mut cursor = Some(new_parent.clone());
173    while let Some(current) = cursor {
174        if &current == id {
175            anyhow::bail!("cannot place `{id}` under its descendant `{new_parent}`");
176        }
177        cursor = tree.get(&current).and_then(|n| n.parent.clone());
178    }
179    let old_parent = node.parent.clone();
180    if let Some(old) = &old_parent {
181        if let Some(n) = tree.get_mut(old) {
182            n.children.retain(|c| c != id);
183        }
184    }
185    let node_parent = tree
186        .get_mut(id)
187        .ok_or_else(|| anyhow::anyhow!("node `{id}` does not exist"))?;
188    node_parent.parent = Some(new_parent.clone());
189    let host = tree
190        .get_mut(new_parent)
191        .ok_or_else(|| anyhow::anyhow!("parent `{new_parent}` does not exist"))?;
192    let at = index.min(host.children.len());
193    host.children.insert(at, id.clone());
194    Ok(())
195}
196
197/// Shift `id` by `delta` slots among its siblings (negative moves up).
198/// Out-of-range shifts clamp to the first/last slot; parentless nodes and
199/// the root are errors.
200pub fn move_sibling(tree: &mut NodeTree, id: &NodeId, delta: i32) -> Result<()> {
201    let node = tree
202        .get(id)
203        .ok_or_else(|| anyhow::anyhow!("node `{id}` does not exist"))?;
204    let parent = node
205        .parent
206        .clone()
207        .ok_or_else(|| anyhow::anyhow!("node `{id}` has no parent to reorder in"))?;
208    let siblings = tree.children_of(&parent);
209    let pos = siblings
210        .iter()
211        .position(|s| s == id)
212        .ok_or_else(|| anyhow::anyhow!("node `{id}` is not listed under its parent"))?;
213    let len = siblings.len() as i32;
214    let at = (pos as i32 + delta).clamp(0, len - 1) as usize;
215    place_node(tree, id, &parent, at)
216}
217
218/// Set one prop on `id`. Unknown nodes are errors.
219pub fn set_prop(tree: &mut NodeTree, id: &NodeId, key: &str, value: PropValue) -> Result<()> {
220    match tree.get_mut(id) {
221        Some(node) => {
222            node.props.insert(key.to_string(), value);
223            Ok(())
224        }
225        None => anyhow::bail!("node `{id}` does not exist"),
226    }
227}
228
229fn prop_to_toml(value: &PropValue) -> toml::Value {
230    match value {
231        PropValue::Str(s) => toml::Value::String(s.clone()),
232        PropValue::Num(f) => toml::Value::Float(*f),
233        PropValue::Int(i) => toml::Value::Integer(*i),
234        PropValue::Bool(b) => toml::Value::Boolean(*b),
235        PropValue::Vec2(x, y) => {
236            toml::Value::Array(vec![toml::Value::Float(*x), toml::Value::Float(*y)])
237        }
238    }
239}
240
241/// Paths needed to resolve `[[instance]]` scene references while saving.
242#[derive(Clone, Copy)]
243pub struct InstanceRefs<'a> {
244    pub scene_dir: &'a Path,
245    pub project_dir: Option<&'a Path>,
246}
247
248/// Build a saveable [`SceneDoc`] from the live tree, in tree order.
249///
250/// `source` is the scene file as it exists on disk and stays authoritative:
251/// its `[[instance]]` entries are re-attached instead of being flattened into
252/// plain nodes (a live tree cannot tell instantiated nodes from authored
253/// ones). Prop edits inside an instantiated subtree are written back as
254/// `overrides`, so a save round-trips exactly; edits the format cannot
255/// express (renaming, retyping, reparenting or deleting inside a subtree,
256/// removing an inherited prop) fail loudly instead of being dropped.
257pub fn build_doc(
258    tree: &NodeTree,
259    source: Option<&SceneDoc>,
260    refs: Option<InstanceRefs<'_>>,
261) -> Result<SceneDoc> {
262    if let Some(source) = source {
263        if !source.instance.is_empty() && refs.is_none() {
264            anyhow::bail!(
265                "cannot save: {} instance(s) need scene/project paths to resolve",
266                source.instance.len()
267            );
268        }
269    }
270    let root = tree.root().map(ToString::to_string).unwrap_or_default();
271    let mut owned: HashSet<String> = HashSet::new();
272    let mut instance: Vec<SceneInstance> = Vec::new();
273    if let (Some(source), Some(refs)) = (source, refs) {
274        for inst in &source.instance {
275            let plan = plan_instance(inst, tree, &root, refs)
276                .with_context(|| format!("cannot save instance of {}", inst.scene))?;
277            owned.extend(plan.owned);
278            instance.push(plan.entry);
279        }
280    }
281    let mut node = Vec::new();
282    for n in tree.iter() {
283        if owned.contains(n.id.as_str()) {
284            continue;
285        }
286        if let Some(parent) = &n.parent {
287            if owned.contains(parent.as_str()) {
288                anyhow::bail!(
289                    "node {:?} is parented inside an instanced subtree; the scene format \
290                     cannot express that",
291                    n.id
292                );
293            }
294        }
295        node.push(SceneNode {
296            id: n.id.to_string(),
297            type_name: n.type_name.clone(),
298            name: n.name.clone(),
299            parent: n.parent.clone().map(|p| p.to_string()),
300            props: props_to_toml(&n.props),
301            script: n.script.as_ref().map(|s| SceneScript {
302                path: s.path.clone(),
303                class: s.class_name.clone(),
304            }),
305        });
306    }
307    if let Some(source) = source {
308        reject_foreign_nodes(tree, source, &owned)?;
309    }
310    Ok(SceneDoc {
311        format_version: FORMAT_VERSION,
312        root,
313        node,
314        instance,
315    })
316}
317
318fn props_to_toml(props: &Props) -> BTreeMap<String, toml::Value> {
319    let mut out = BTreeMap::new();
320    for (k, v) in &props.0 {
321        out.insert(k.clone(), prop_to_toml(v));
322    }
323    out
324}
325
326/// A re-attached instance entry plus the live ids it owns.
327struct InstancePlan {
328    entry: SceneInstance,
329    owned: HashSet<String>,
330}
331
332fn resolve_instance_path(scene_ref: &str, refs: InstanceRefs<'_>) -> Result<PathBuf> {
333    if let Some(rel) = scene_ref.strip_prefix("res://") {
334        let root = refs
335            .project_dir
336            .context("instance uses res:// but no project root is known")?;
337        Ok(root.join(rel))
338    } else {
339        let p = Path::new(scene_ref);
340        if p.is_absolute() {
341            Ok(p.to_path_buf())
342        } else {
343            Ok(refs.scene_dir.join(p))
344        }
345    }
346}
347
348/// Re-derive one `[[instance]]` entry from the live tree: the subtree must
349/// still match the referenced scene node-for-node, and every prop that differs
350/// from that scene becomes an override.
351fn plan_instance(
352    inst: &SceneInstance,
353    tree: &NodeTree,
354    doc_root: &str,
355    refs: InstanceRefs<'_>,
356) -> Result<InstancePlan> {
357    let path = resolve_instance_path(&inst.scene, refs)?;
358    let ref_doc = pite_scene::load_scene(&path)?;
359    let mut owned = HashSet::new();
360    let mut overrides: BTreeMap<String, toml::Value> = BTreeMap::new();
361    for ref_node in &ref_doc.node {
362        let live_id = format!("{}{}", inst.prefix, ref_node.id);
363        let live = tree
364            .get(&NodeId::from(live_id.clone()))
365            .with_context(|| format!("live tree is missing `{live_id}`"))?;
366        owned.insert(live_id.clone());
367        if live.type_name != ref_node.type_name || live.name != ref_node.name {
368            anyhow::bail!("`{live_id}` was renamed or retyped; overrides carry props only");
369        }
370        let live_script = live
371            .script
372            .as_ref()
373            .map(|s| (s.path.as_str(), s.class_name.as_str()));
374        let ref_script = ref_node
375            .script
376            .as_ref()
377            .map(|s| (s.path.as_str(), s.class.as_str()));
378        if live_script != ref_script {
379            anyhow::bail!("script on `{live_id}` cannot be overridden per instance");
380        }
381        if ref_node.id != ref_doc.root {
382            let expected = ref_node
383                .parent
384                .as_ref()
385                .map(|p| format!("{}{p}", inst.prefix));
386            if live.parent.as_ref().map(ToString::to_string) != expected {
387                anyhow::bail!("`{live_id}` was reparented inside the instance");
388            }
389        }
390        for key in ref_node.props.keys() {
391            if !live.props.0.contains_key(key) {
392                anyhow::bail!(
393                    "prop `{key}` was removed from `{live_id}`; an instance cannot \
394                     unset an inherited prop"
395                );
396            }
397        }
398        for (key, value) in props_to_toml(&live.props) {
399            if ref_node.props.get(&key) != Some(&value) {
400                overrides.insert(format!("{}.{}", ref_node.id, key), value);
401            }
402        }
403    }
404    let subtree_root = format!("{}{}", inst.prefix, ref_doc.root);
405    let attach = tree
406        .get(&NodeId::from(subtree_root.clone()))
407        .and_then(|n| n.parent.clone())
408        .map(|p| p.to_string());
409    let parent = match (&inst.parent, attach.as_deref()) {
410        (Some(original), Some(current)) if original == current => Some(original.clone()),
411        (None, Some(current)) if current == doc_root => None,
412        (_, Some(current)) => Some(current.to_string()),
413        (_, None) => None,
414    };
415    Ok(InstancePlan {
416        entry: SceneInstance {
417            scene: inst.scene.clone(),
418            parent,
419            prefix: inst.prefix.clone(),
420            overrides,
421        },
422        owned,
423    })
424}
425
426/// A node that belongs to no source entry and hangs under an instanced node
427/// was added inside an instance — unrepresentable, so refuse the save.
428fn reject_foreign_nodes(tree: &NodeTree, source: &SceneDoc, owned: &HashSet<String>) -> Result<()> {
429    let authored: HashSet<&str> = source.node.iter().map(|n| n.id.as_str()).collect();
430    for n in tree.iter() {
431        let id = n.id.as_str();
432        if authored.contains(id) || owned.contains(id) {
433            continue;
434        }
435        let mut cursor = n.parent.clone();
436        while let Some(parent) = cursor {
437            if owned.contains(parent.as_str()) {
438                anyhow::bail!(
439                    "node `{id}` was added inside an instanced subtree; the scene \
440                     format cannot express that"
441                );
442            }
443            cursor = tree.get(&parent).and_then(|p| p.parent.clone());
444        }
445    }
446    Ok(())
447}
448
449/// Parse Python-traceback `line N` (`File "...", line N`) marks: one
450/// 1-based line (last match wins) plus the error's last non-empty line
451/// (the exception line, not the `Traceback` header). Entries without a
452/// line number (clean strings) are ignored.
453pub fn gutter_marks(errors: &[String]) -> Vec<(usize, String)> {
454    let mut out = Vec::new();
455    for err in errors {
456        let last = err
457            .lines()
458            .map(str::trim)
459            .filter(|l| !l.is_empty())
460            .last()
461            .unwrap_or("");
462        let mut line_no: Option<usize> = None;
463        for (idx, _) in err.match_indices("line ") {
464            let rest = &err[idx + "line ".len()..];
465            let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
466            if !digits.is_empty() {
467                if let Ok(n) = digits.parse::<usize>() {
468                    line_no = Some(n);
469                }
470            }
471        }
472        if let Some(n) = line_no {
473            out.push((n, last.to_string()));
474        }
475    }
476    out
477}
478
479/// Thin file write used by scene and script saves.
480pub fn save_text(path: &Path, text: &str) -> Result<()> {
481    std::fs::write(path, text).with_context(|| format!("cannot write {}", path.display()))?;
482    Ok(())
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use pite_core::NodeId;
489    use std::sync::atomic::{AtomicU64, Ordering};
490
491    static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
492
493    const ENEMY_SCENE: &str = r#"format_version = 1
494root = "enemy"
495
496[[node]]
497id = "enemy"
498type = "Node2D"
499name = "Enemy"
500
501[[node]]
502id = "sprite"
503type = "Sprite2D"
504name = "Sprite"
505parent = "enemy"
506
507[node.props]
508position = [0.0, 0.0]
509"#;
510
511    const HOST_SCENE: &str = r#"format_version = 1
512root = "root"
513
514[[node]]
515id = "root"
516type = "Node2D"
517name = "Main"
518
519[[node]]
520id = "player"
521type = "Sprite2D"
522name = "Player"
523parent = "root"
524
525[node.props]
526texture = "res://assets/player.png"
527
528[[instance]]
529scene = "res://scenes/enemy.ptscn"
530parent = "root"
531prefix = "e1_"
532
533[instance.overrides]
534"sprite.position" = [300.0, 120.0]
535"#;
536
537    struct Fixture {
538        dir: PathBuf,
539    }
540
541    impl Fixture {
542        /// Project with `host.ptscn` instantiating `enemy.ptscn`.
543        fn with_instances(tag: &str) -> Self {
544            let n = FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst);
545            let dir = std::env::temp_dir()
546                .join(format!("pite-ops-inst-{tag}-{}-{n}", std::process::id()));
547            let _ = std::fs::remove_dir_all(&dir);
548            std::fs::create_dir_all(dir.join("scenes")).expect("create scenes dir");
549            std::fs::write(dir.join("scenes").join("enemy.ptscn"), ENEMY_SCENE)
550                .expect("write enemy scene");
551            std::fs::write(dir.join("scenes").join("host.ptscn"), HOST_SCENE)
552                .expect("write host scene");
553            Self { dir }
554        }
555
556        fn host_path(&self) -> PathBuf {
557            self.dir.join("scenes").join("host.ptscn")
558        }
559
560        fn scene_dir(&self) -> PathBuf {
561            self.dir.join("scenes")
562        }
563
564        /// Load the host scene into a live tree, exactly like the editor does.
565        fn live_tree(&self) -> (SceneDoc, NodeTree) {
566            let doc = pite_scene::load_scene(&self.host_path()).expect("host parses");
567            let tree = pite_scene::build_tree(&doc, &self.scene_dir(), Some(&self.dir))
568                .expect("tree builds");
569            (doc, tree)
570        }
571
572        /// The editor's save step: live tree + on-disk source -> document.
573        fn save(&self, tree: &NodeTree, source: &SceneDoc) -> Result<SceneDoc> {
574            build_doc(
575                tree,
576                Some(source),
577                Some(InstanceRefs {
578                    scene_dir: &self.scene_dir(),
579                    project_dir: Some(&self.dir),
580                }),
581            )
582        }
583
584        fn save_text(&self, tree: &NodeTree, source: &SceneDoc) -> String {
585            pite_scene::save_scene(&self.save(tree, source).expect("save builds"))
586                .expect("serializes")
587        }
588    }
589
590    impl Drop for Fixture {
591        fn drop(&mut self) {
592            std::fs::remove_dir_all(&self.dir).ok();
593        }
594    }
595
596    #[test]
597    fn instance_entries_are_not_flattened_into_nodes() {
598        let f = Fixture::with_instances("keep");
599        let (source, tree) = f.live_tree();
600        assert!(tree.contains(&NodeId::from("e1_enemy".to_string())));
601        let doc = f.save(&tree, &source).unwrap();
602
603        assert_eq!(doc.instance.len(), 1, "instance entry must survive save");
604        let inst = &doc.instance[0];
605        assert_eq!(inst.scene, "res://scenes/enemy.ptscn");
606        assert_eq!(inst.prefix, "e1_");
607        assert_eq!(inst.parent.as_deref(), Some("root"));
608        let ids: Vec<&str> = doc.node.iter().map(|n| n.id.as_str()).collect();
609        assert_eq!(
610            ids,
611            vec!["root", "player"],
612            "instance nodes must not be inlined"
613        );
614        let text = pite_scene::save_scene(&doc).unwrap();
615        assert!(
616            text.contains("[[instance]]"),
617            "serialized doc lost its instance"
618        );
619        assert!(
620            !text.contains("e1_enemy"),
621            "instance subtree leaked into nodes"
622        );
623    }
624
625    #[test]
626    fn overrides_survive_and_edits_become_overrides() {
627        let f = Fixture::with_instances("override");
628        let (source, mut tree) = f.live_tree();
629        let doc = f.save(&tree, &source).unwrap();
630        assert_eq!(
631            doc.instance[0].overrides.get("sprite.position"),
632            Some(&toml::Value::Array(vec![
633                toml::Value::Float(300.0),
634                toml::Value::Float(120.0)
635            ])),
636            "original override must be preserved verbatim"
637        );
638
639        set_prop(
640            &mut tree,
641            &NodeId::from("e1_sprite".to_string()),
642            "position",
643            PropValue::Vec2(1.0, 2.0),
644        )
645        .unwrap();
646        set_prop(
647            &mut tree,
648            &NodeId::from("player".to_string()),
649            "position",
650            PropValue::Vec2(9.0, 9.0),
651        )
652        .unwrap();
653        let doc = f.save(&tree, &source).unwrap();
654        assert_eq!(
655            doc.instance[0].overrides.get("sprite.position"),
656            Some(&toml::Value::Array(vec![
657                toml::Value::Float(1.0),
658                toml::Value::Float(2.0)
659            ])),
660            "edited instance prop must be written back as an override"
661        );
662        let player = doc.node.iter().find(|n| n.id == "player").unwrap();
663        assert!(
664            player.props.contains_key("position"),
665            "authored prop edits belong to the node, not to overrides"
666        );
667    }
668
669    #[test]
670    fn instance_save_is_byte_identical_on_reload() {
671        let f = Fixture::with_instances("stable");
672        let (source, tree) = f.live_tree();
673        let first = f.save_text(&tree, &source);
674
675        let reparsed = pite_scene::parse_scene_str(&first).unwrap();
676        let tree2 = pite_scene::build_tree(&reparsed, &f.scene_dir(), Some(&f.dir)).unwrap();
677        let second = f.save_text(&tree2, &reparsed);
678        assert_eq!(first, second, "save -> load -> save must be byte-identical");
679    }
680
681    #[test]
682    fn plain_scene_save_is_byte_identical_and_unaffected() {
683        let mut tree = rooted_tree();
684        let root = tree.root().cloned().unwrap();
685        for name in ["alpha", "beta", "gamma"] {
686            set_prop(&mut tree, &root, name, PropValue::Str(format!("v{name}"))).unwrap();
687        }
688        add_node(&mut tree, Some(root.clone()), "Label", "Score").unwrap();
689        let doc = build_doc(&tree, None, None).unwrap();
690        assert!(doc.instance.is_empty());
691        let first = pite_scene::save_scene(&doc).unwrap();
692        let reparsed = pite_scene::parse_scene_str(&first).unwrap();
693        let again = pite_scene::save_scene(&reparsed).unwrap();
694        assert_eq!(
695            first, again,
696            "plain scenes must serialize deterministically"
697        );
698    }
699
700    #[test]
701    fn structural_edits_inside_an_instance_fail_loudly() {
702        let f = Fixture::with_instances("drift");
703        let (source, tree) = f.live_tree();
704        let enemy = NodeId::from("e1_enemy".to_string());
705
706        let mut added = tree;
707        add_node(&mut added, Some(enemy.clone()), "Timer", "Extra").unwrap();
708        let err = f.save(&added, &source).unwrap_err();
709        assert!(
710            format!("{err:#}").contains("instanced subtree"),
711            "unexpected: {err:#}"
712        );
713
714        let mut removed = f.live_tree().1;
715        remove_node(&mut removed, &NodeId::from("e1_sprite".to_string())).unwrap();
716        let err = f.save(&removed, &source).unwrap_err();
717        assert!(
718            format!("{err:#}").contains("missing"),
719            "unexpected: {err:#}"
720        );
721
722        let mut renamed = f.live_tree().1;
723        renamed.get_mut(&enemy).unwrap().name = "Boss".to_string();
724        let err = f.save(&renamed, &source).unwrap_err();
725        assert!(
726            format!("{err:#}").contains("renamed"),
727            "unexpected: {err:#}"
728        );
729    }
730
731    #[test]
732    fn dogfood_scene_save_keeps_its_instance() {
733        let project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
734            .join("..")
735            .join("examples")
736            .join("minimal-2d");
737        let scene = project.join("scenes").join("main.ptscn");
738        let source = pite_scene::load_scene(&scene).expect("dogfood scene parses");
739        assert_eq!(
740            source.instance.len(),
741            1,
742            "dogfood fixture must keep an instance for this guard to mean anything"
743        );
744        let tree = pite_scene::build_tree(&source, &project.join("scenes"), Some(&project))
745            .expect("dogfood tree builds");
746        let refs = InstanceRefs {
747            scene_dir: &project.join("scenes"),
748            project_dir: Some(&project),
749        };
750        let doc = build_doc(&tree, Some(&source), Some(refs)).expect("save builds");
751        assert_eq!(doc.instance.len(), 1);
752        assert_eq!(doc.instance[0].prefix, "e1_");
753        assert!(
754            doc.instance[0].overrides.contains_key("sprite.position"),
755            "dogfood override must survive"
756        );
757        assert!(
758            !doc.node.iter().any(|n| n.id.starts_with("e1_")),
759            "instance subtree must not be inlined into nodes"
760        );
761        let first = pite_scene::save_scene(&doc).unwrap();
762        let reparsed = pite_scene::parse_scene_str(&first).unwrap();
763        let tree2 =
764            pite_scene::build_tree(&reparsed, &project.join("scenes"), Some(&project)).unwrap();
765        let second = pite_scene::save_scene(
766            &build_doc(&tree2, Some(&reparsed), Some(refs)).expect("second save builds"),
767        )
768        .unwrap();
769        assert_eq!(
770            first, second,
771            "dogfood save -> load -> save must be identical"
772        );
773    }
774
775    fn rooted_tree() -> NodeTree {
776        let mut tree = NodeTree::new();
777        add_node(&mut tree, None, "Node2D", "Main").unwrap();
778        tree
779    }
780
781    #[test]
782    fn add_remove_round_trip() {
783        let mut tree = rooted_tree();
784        let root = tree.root().cloned().unwrap();
785        let child = add_node(&mut tree, Some(root.clone()), "Sprite2D", "Player").unwrap();
786        assert_eq!(tree.len(), 2);
787        assert_eq!(tree.children_of(&root), vec![child.clone()]);
788        remove_node(&mut tree, &child).unwrap();
789        assert_eq!(tree.len(), 1);
790        assert!(tree.children_of(&root).is_empty());
791    }
792
793    #[test]
794    fn remove_root_is_refused() {
795        let mut tree = rooted_tree();
796        let root = tree.root().cloned().unwrap();
797        let err = remove_node(&mut tree, &root).unwrap_err();
798        assert!(err.to_string().contains("root"), "unexpected: {err:#}");
799        assert_eq!(tree.len(), 1);
800    }
801
802    #[test]
803    fn reparent_cycle_is_refused() {
804        let mut tree = rooted_tree();
805        let root = tree.root().cloned().unwrap();
806        let a = add_node(&mut tree, Some(root), "Node2D", "a").unwrap();
807        let b = add_node(&mut tree, Some(a.clone()), "Node2D", "b").unwrap();
808        let err = move_node(&mut tree, &a, Some(b.clone())).unwrap_err();
809        assert!(err.to_string().contains("cycle"), "unexpected: {err:#}");
810        assert!(tree.get(&a).unwrap().children.contains(&b));
811    }
812
813    #[test]
814    fn prop_set_get_round_trip() {
815        let mut tree = rooted_tree();
816        let root = tree.root().cloned().unwrap();
817        set_prop(&mut tree, &root, "speed", PropValue::Num(3.5)).unwrap();
818        set_prop(&mut tree, &root, "title", PropValue::Str("hi".to_string())).unwrap();
819        let node = tree.get(&root).unwrap();
820        assert_eq!(node.props.get("speed"), Some(&PropValue::Num(3.5)));
821        assert_eq!(
822            node.props.get("title"),
823            Some(&PropValue::Str("hi".to_string()))
824        );
825    }
826
827    #[test]
828    fn build_save_parse_round_trip_preserves_nodes_and_props() {
829        let mut tree = rooted_tree();
830        let root = tree.root().cloned().unwrap();
831        set_prop(&mut tree, &root, "position", PropValue::Vec2(100.0, 200.0)).unwrap();
832        let child = add_node(&mut tree, Some(root), "Label", "Score").unwrap();
833        set_prop(&mut tree, &child, "text", PropValue::Str("0".to_string())).unwrap();
834        let doc = build_doc(&tree, None, None).unwrap();
835        let text = pite_scene::save_scene(&doc).unwrap();
836        let again = pite_scene::parse_scene_str(&text).unwrap();
837        assert_eq!(again.node.len(), 2);
838        assert_eq!(again.root, doc.root);
839        let player = again
840            .node
841            .iter()
842            .find(|n| n.id == child.to_string())
843            .unwrap();
844        assert_eq!(
845            player.props.get("text"),
846            Some(&toml::Value::String("0".to_string()))
847        );
848        let main = again.node.iter().find(|n| n.id == doc.root).unwrap();
849        let pos = main.props.get("position").unwrap();
850        match pos {
851            toml::Value::Array(items) => assert_eq!(items.len(), 2),
852            other => panic!("expected position array, got {other:?}"),
853        }
854    }
855
856    #[test]
857    fn gutter_marks_extract_lines_and_ignore_clean_strings() {
858        let traceback = "Traceback (most recent call last):\n  File \"res://scripts/player.py\", line 12, in _ready\n    x = 1 / 0\n  File \"res://scripts/player.py\", line 27, in _process\n    update()\nZeroDivisionError: division by zero".to_string();
859        let clean = "all good, no errors".to_string();
860        let marks = gutter_marks(&[traceback, clean]);
861        assert_eq!(marks.len(), 1);
862        assert_eq!(marks[0].0, 27);
863        assert!(marks[0].1.contains("ZeroDivisionError"));
864        assert!(gutter_marks(&["clean".to_string()]).is_empty());
865    }
866
867    fn children_of(tree: &NodeTree, id: &str) -> Vec<String> {
868        tree.children_of(&NodeId::from(id.to_string()))
869            .iter()
870            .map(ToString::to_string)
871            .collect()
872    }
873
874    fn three_kids() -> NodeTree {
875        let mut tree = rooted_tree();
876        let root = tree.root().cloned().unwrap();
877        for name in ["a", "b", "c"] {
878            add_node(&mut tree, Some(root.clone()), "Node2D", name).unwrap();
879        }
880        tree
881    }
882
883    #[test]
884    fn place_node_reorders_and_moves_across_parents() {
885        let mut tree = three_kids();
886        let root = tree.root().cloned().unwrap();
887        place_node(&mut tree, &NodeId::from("c".to_string()), &root, 0).unwrap();
888        assert_eq!(children_of(&tree, "main"), vec!["c", "a", "b"]);
889        move_sibling(&mut tree, &NodeId::from("a".to_string()), 1).unwrap();
890        assert_eq!(children_of(&tree, "main"), vec!["c", "b", "a"]);
891        move_sibling(&mut tree, &NodeId::from("c".to_string()), -5).unwrap();
892        assert_eq!(children_of(&tree, "main"), vec!["c", "b", "a"]);
893        let sub = add_node(&mut tree, Some(root.clone()), "Node2D", "sub").unwrap();
894        place_node(&mut tree, &NodeId::from("a".to_string()), &sub, 0).unwrap();
895        assert_eq!(children_of(&tree, "main"), vec!["c", "b", "sub"]);
896        assert_eq!(children_of(&tree, &sub.to_string()), vec!["a"]);
897    }
898
899    #[test]
900    fn place_node_refuses_root_cycles_and_unknowns() {
901        let mut tree = three_kids();
902        let root = tree.root().cloned().unwrap();
903        assert!(place_node(&mut tree, &root, &NodeId::from("a".to_string()), 0).is_err());
904        assert!(place_node(
905            &mut tree,
906            &NodeId::from("a".to_string()),
907            &NodeId::from("a".to_string()),
908            0
909        )
910        .is_err());
911        place_node(
912            &mut tree,
913            &NodeId::from("b".to_string()),
914            &NodeId::from("a".to_string()),
915            0,
916        )
917        .unwrap();
918        assert!(place_node(
919            &mut tree,
920            &NodeId::from("a".to_string()),
921            &NodeId::from("b".to_string()),
922            0
923        )
924        .is_err());
925        assert!(place_node(&mut tree, &NodeId::from("nope".to_string()), &root, 0).is_err());
926    }
927
928    #[test]
929    fn unknown_type_is_an_error() {
930        let mut tree = rooted_tree();
931        let root = tree.root().cloned().unwrap();
932        let err = add_node(&mut tree, Some(root), "Nope", "x").unwrap_err();
933        assert!(err.to_string().contains("Nope"), "unexpected: {err:#}");
934    }
935}