Skip to main content

alizarin_core/
graph_mutator.rs

1//! # Graph Mutator
2//!
3//! Builder pattern for constructing and modifying Arches graphs using a combination
4//! of the Builder and Command patterns.
5//!
6//! ## Quick Start
7//!
8//! ```rust,ignore
9//! use alizarin_core::graph_mutator::{GraphMutator, Cardinality};
10//!
11//! let graph = GraphMutator::new(base_graph)
12//!     .add_semantic_node("parent", "child", "Child", Cardinality::N, "", "")?
13//!     .add_string_node("child", "name", "Name", Cardinality::One, "", "")?
14//!     .build()?;
15//! ```
16//!
17//! ## Mutations Reference
18//!
19//! All mutations follow the Command pattern and can be serialized/deserialized for
20//! cross-platform use (Rust, Python, WASM). Each mutation has a conformance level
21//! indicating whether it's valid for branches, models, or both.
22//!
23//! ### Conformance Levels
24//!
25//! | Level | Description |
26//! |-------|-------------|
27//! | `AlwaysConformant` | Valid for both branches and resource models |
28//! | `BranchConformant` | Valid only for branches (isresource=false) |
29//! | `ModelConformant` | Valid only for resource models (isresource=true) |
30//!
31//! ---
32//!
33//! ## Structure Mutations (BranchConformant)
34//!
35//! ### AddNode
36//!
37//! Adds a new node to the graph as a child of an existing node.
38//!
39//! **Parameters:**
40//! - `parent_alias` (Option<String>): Alias of parent node (None for root children)
41//! - `alias` (String): Unique alias for the new node
42//! - `name` (String): Display name
43//! - `cardinality` (Cardinality): `One` or `N` - determines if nodegroup is created
44//! - `datatype` (String): Node datatype (e.g., "string", "number", "concept", "semantic")
45//! - `ontology_class` (String | Vec<String>): Ontology class URI(s). Accepts
46//!   a single string or an array; multi-class nodes are validated such that
47//!   the property must be valid for at least one `(parent_class, child_class)`
48//!   pair in the Cartesian product.
49//! - `parent_property` (String): Ontology property for edge to parent
50//! - `description` (Option<String>): Node description
51//! - `config` (Option<Value>): Node configuration JSON
52//! - `options` (NodeOptions): Additional options (exportable, isrequired, etc.)
53//!
54//! **Behavior:**
55//! - Creates nodegroup if cardinality=N, parent is root, or `options.is_collector=true`
56//! - Auto-creates card if `MutatorOptions.autocreate_card` is true
57//! - Auto-creates widget if `MutatorOptions.autocreate_widget` is true and datatype is not "semantic"
58//! - Creates edge from parent to new node
59//!
60//! **Instruction:** `add_node` (subject: parent_alias, object: new_alias)
61//!
62//! ---
63//!
64//! ### AddNodegroup
65//!
66//! Adds a nodegroup to organize nodes with shared cardinality.
67//!
68//! **Parameters:**
69//! - `nodegroup_id` (String): Unique ID for the nodegroup
70//! - `cardinality` (Cardinality): `One` or `N`
71//! - `parent_alias` (Option<String>): Alias of node that owns this nodegroup
72//!
73//! **Instruction:** `add_nodegroup` (subject: parent_alias)
74//!
75//! ---
76//!
77//! ### AddEdge
78//!
79//! Creates an edge connecting two existing nodes.
80//!
81//! **Parameters:**
82//! - `from_node_id` (String): Source node ID or alias
83//! - `to_node_id` (String): Target node ID or alias
84//! - `ontology_property` (String): Ontology property URI
85//! - `name` (Option<String>): Edge name
86//! - `description` (Option<String>): Edge description
87//!
88//! **Instruction:** `add_edge` (subject: from_node, object: to_node)
89//!
90//! ---
91//!
92//! ### AddCard
93//!
94//! Adds a card (UI configuration) for a nodegroup.
95//!
96//! **Parameters:**
97//! - `nodegroup_id` (String): Target nodegroup ID
98//! - `name` (StaticTranslatableString): Card display name
99//! - `component_id` (Option<String>): UI component ID
100//! - `options` (CardOptions): Card options (active, visible, help text, etc.)
101//! - `config` (Option<Value>): Card configuration JSON
102//!
103//! **Instruction:** `add_card` (subject: nodegroup_id, object: card_name)
104//!
105//! ---
106//!
107//! ### AddWidgetToCard
108//!
109//! Adds a widget mapping for a node within a card.
110//!
111//! **Parameters:**
112//! - `node_id` (String): Target node ID or alias
113//! - `widget_id` (String): Widget type ID
114//! - `label` (String): Widget label
115//! - `config` (Value): Widget configuration
116//! - `sortorder` (Option<i32>): Display order
117//! - `visible` (Option<bool>): Widget visibility
118//!
119//! **Instruction:** `add_widget` (subject: node_id)
120//!
121//! ---
122//!
123//! ### UpdateNode (BranchConformant)
124//!
125//! Updates node properties without changing structural IDs or datatype.
126//!
127//! **Parameters:**
128//! - `node_id` (String): Node ID or alias to update
129//! - `name` (Option<String>): New display name
130//! - `ontology_class` (Option<String | Vec<String>>): New ontology class(es).
131//!   Accepts a single string or an array on the wire.
132//! - `parent_property` (Option<String>): New parent property
133//! - `description` (Option<String>): New description
134//! - `config` (Option<Value>): Config to merge into existing
135//! - `options` (UpdateNodeOptions): exportable, fieldname, isrequired, issearchable, sortorder
136//!
137//! **Preserved:** nodeid, nodegroup_id, graph_id, datatype, alias
138//!
139//! **Instruction:** `update_node` (subject: node_id)
140//!
141//! ---
142//!
143//! ### ChangeNodeType (BranchConformant)
144//!
145//! Changes a node's datatype. Requires that no widgets exist for the node.
146//!
147//! **Parameters:**
148//! - `node_id` (String): Node ID or alias
149//! - `datatype` (String): New datatype
150//! - Plus all optional fields from UpdateNode
151//!
152//! **Error:** `NodeHasDependentWidgets` if node has widget mappings
153//!
154//! **Instruction:** `change_node_type` (subject: node_id, object: new_datatype)
155//!
156//! ---
157//!
158//! ### ChangeCardinality (BranchConformant)
159//!
160//! Changes a nodegroup's cardinality from 1 (single) to n (multiple) or vice versa.
161//! The node must be the grouping node of its nodegroup.
162//!
163//! **Parameters:**
164//! - `node_id` (String): Node ID or alias (must be the grouping node)
165//! - `cardinality` (Cardinality): `One` or `N`
166//!
167//! **Error:** If node is not the grouping node for its nodegroup
168//!
169//! **Instruction:** `change_cardinality` (subject: node_id, object: "1" or "n")
170//!
171//! ---
172//!
173//! ## Subgraph Mutations (ModelConformant)
174//!
175//! ### AddSubgraph
176//!
177//! Appends an entire branch to a model with automatic ID remapping.
178//!
179//! **Parameters:**
180//! - `subgraph` (StaticGraph): The branch to add
181//! - `target_node_id` (String): Node to attach branch children to
182//! - `ontology_property` (String): Property for connecting edges
183//! - `alias_suffix` (Option<String>): Suffix for clashing aliases
184//!
185//! **Behavior:**
186//! - Skips branch root; connects children directly to target
187//! - Regenerates: nodeid, nodegroupid, edgeid, cardid, constraint IDs
188//! - Preserves: widget_id, component_id, function_id, ontology URIs
189//! - Sets `sourcebranchpublication_id` on added nodes
190//!
191//! **Instruction:** `add_subgraph` (subject: target_node_id, params.subgraph: JSON)
192//!
193//! ---
194//!
195//! ### UpdateSubgraph
196//!
197//! Updates an existing branch within a model.
198//!
199//! **Parameters:**
200//! - `subgraph` (StaticGraph): Updated branch
201//! - `target_node_id` (String): Root node of existing branch
202//! - `ontology_property` (String): Property for new connecting edges
203//! - `alias_suffix` (Option<String>): Suffix for aliases
204//! - `remove_orphaned` (bool): If true, removes nodes no longer in branch
205//!
206//! **Behavior:**
207//! - Updates existing nodes in place (preserves IDs)
208//! - Adds new nodes from updated branch
209//! - Optionally removes orphaned nodes
210//!
211//! **Instruction:** `update_subgraph` (subject: target_node_id, params.subgraph: JSON)
212//!
213//! ---
214//!
215//! ## Modification Mutations (AlwaysConformant)
216//!
217//! ### ConceptChangeCollection
218//!
219//! Changes the RDM collection for a concept or concept-list node.
220//!
221//! **Parameters:**
222//! - `node_id` (String): Node ID or alias
223//! - `collection_id` (String): New collection UUID
224//!
225//! **Error:** `InvalidDatatype` if node is not concept or concept-list type
226//!
227//! **Instruction:** `concept_change_collection` (subject: node_id, object: collection_id)
228//!
229//! ---
230//!
231//! ### RenameNode (AlwaysConformant)
232//!
233//! Changes text metadata for a node (alias, name, description).
234//!
235//! **Parameters:**
236//! - `node_id` (String): Node ID or alias to rename
237//! - `alias` (Option<String>): New alias
238//! - `name` (Option<String>): New display name
239//! - `description` (Option<String>): New description
240//!
241//! **Error:** `AliasAlreadyExists` if new alias is already used
242//!
243//! **Instruction:** `rename_node` (subject: node_id, object: new_alias)
244//!
245//! ---
246//!
247//! ## Deletion Mutations (AlwaysConformant)
248//!
249//! ### DeleteCard
250//!
251//! Removes a card and its widget mappings.
252//!
253//! **Parameters:**
254//! - `card_id` (String): Card ID to delete
255//!
256//! **Cascades:** Removes all `cards_x_nodes_x_widgets` entries for this card
257//!
258//! **Instruction:** `delete_card` (subject: card_id)
259//!
260//! ---
261//!
262//! ### DeleteWidget
263//!
264//! Removes a widget mapping (cards_x_nodes_x_widgets entry).
265//!
266//! **Parameters:**
267//! - `widget_mapping_id` (String): Widget mapping ID
268//!
269//! **Instruction:** `delete_widget` (subject: widget_mapping_id)
270//!
271//! ---
272//!
273//! ### DeleteFunction
274//!
275//! Removes a function mapping (functions_x_graphs entry).
276//!
277//! **Parameters:**
278//! - `function_mapping_id` (String): Function mapping ID
279//!
280//! **Instruction:** `delete_function` (subject: function_mapping_id)
281//!
282//! ---
283//!
284//! ### DeleteNode
285//!
286//! Removes a node and its related entities.
287//!
288//! **Parameters:**
289//! - `node_id` (String): Node ID or alias
290//!
291//! **Cascades:**
292//! - Removes widget mappings for this node
293//! - Removes edges where node is domain or range
294//!
295//! **Error:** `CannotDeleteRootNode` if node has `istopnode=true`
296//!
297//! **Instruction:** `delete_node` (subject: node_id)
298//!
299//! ---
300//!
301//! ### DeleteNodegroup
302//!
303//! Removes a nodegroup and all descendant entities.
304//!
305//! **Parameters:**
306//! - `nodegroup_id` (String): Nodegroup ID
307//!
308//! **Cascades (recursively):**
309//! - Child nodegroups (via parentnodegroup_id)
310//! - All nodes in deleted nodegroups
311//! - All edges referencing deleted nodes
312//! - All cards for deleted nodegroups
313//! - All widget mappings for deleted nodes
314//!
315//! **Error:** `CannotDeleteRootNode` if any affected node is root
316//!
317//! **Instruction:** `delete_nodegroup` (subject: nodegroup_id)
318//!
319//! ---
320//!
321//! ## Function & Descriptor Mutations
322//!
323//! ### AddFunction (ModelConformant)
324//!
325//! Adds a function mapping (functions_x_graphs entry) to the graph.
326//!
327//! **Parameters:**
328//! - `function_id` (String): Function ID — a UUID or an arbitrary string
329//!   (e.g. `"com.flaxandteal.app/my-func"`) which will be hashed to a
330//!   deterministic UUID v5.
331//! - `config` (Option<Value>): Configuration JSON for the function mapping
332//!
333//! **Instruction:** `add_function` (subject: function_id)
334//!
335//! ---
336//!
337//! ### SetDescriptorFunction (ModelConformant)
338//!
339//! Sets the descriptor function for the graph, replacing any existing
340//! `primarydescriptors`-type function. Adds the specified function and removes
341//! any other descriptor function (including the default).
342//!
343//! **Parameters:**
344//! - `function_id` (String): Function ID (UUID or arbitrary string hashed to UUID v5)
345//!
346//! **Instruction:** `set_descriptor_function` (subject: function_id)
347//!
348//! ---
349//!
350//! ### SetDescriptorTemplate (AlwaysConformant)
351//!
352//! Sets or updates a descriptor template on the graph's descriptor function.
353//! The descriptor function is located (or created) automatically.
354//! For non-default functions (e.g. multicard descriptor), templates use
355//! `<node_alias>` placeholders resolved at runtime. For the default function,
356//! templates use `<Node Name>` placeholders resolved at build time.
357//!
358//! **Parameters:**
359//! - `descriptor_type` (String): Descriptor type key (e.g. `"name"`, `"slug"`,
360//!   `"description"`, `"map_popup"`)
361//! - `string_template` (String): Template with `<Node Name>` or `<node_alias>` placeholders
362//!   (e.g. `"<First Name> <Last Name>"` or `"<monument_name>"`)
363//!
364//! **Instruction:** `set_descriptor_template` (subject: descriptor_type, object: string_template)
365//!
366//! ---
367//!
368//! ### RenameCard (AlwaysConformant)
369//!
370//! Changes a card's name and/or description. Supports both single-language and
371//! full i18n maps.
372//!
373//! **Parameters:**
374//! - `card_id` (String): Card ID or nodegroup ID (searches card ID first, then nodegroup)
375//! - `language` (Option<String>): Language code for `name`/`description` (default: `"en"`)
376//! - `name` (Option<String>): New name in the specified language
377//! - `name_i18n` (Option<Map>): Full translatable name map (overrides `name`)
378//! - `description` (Option<String>): New description in the specified language
379//! - `description_i18n` (Option<Map>): Full translatable description map
380//!
381//! **Instruction:** `rename_card` (subject: card_id, params.name: new_name)
382//!
383//! ---
384//!
385//! ### RenameGraph (AlwaysConformant)
386//!
387//! Updates a graph's name, description, subtitle, or author.
388//!
389//! **Parameters:**
390//! - `name` (Option<Map<String, String>>): New name (language -> value)
391//! - `description` (Option<Map<String, String>>): New description
392//! - `subtitle` (Option<Map<String, String>>): New subtitle
393//! - `author` (Option<String>): New author
394//!
395//! All fields are optional; only provided fields are updated.
396//!
397//! **Instruction:** `rename_graph` (subject: graph_id, params.name: new_name)
398//!
399//! ---
400//!
401//! ### RealignCardFromNode (AlwaysConformant)
402//!
403//! Syncs a card's name and widget label to match the current node name.
404//!
405//! **Parameters:**
406//! - `node_alias` (String): Alias of the node whose name should propagate
407//!
408//! **Instruction:** `realign_card_from_node` (subject: node_alias)
409//!
410//! ---
411//!
412//! ### CoppiceSubgraph (AlwaysConformant)
413//!
414//! Sets `sourcebranchpublication_id` on a subtree by traversing from a root
415//! node via edges. Stops at nodes already claimed by a different publication.
416//!
417//! **Parameters:**
418//! - `subject` (String): Alias of the root node to start traversal from
419//! - `publication_id` (String): The publication ID to set on reachable nodes
420//!
421//! **Instruction:** `coppice_subgraph` (subject: root_alias, params.publication_id: uuid)
422//!
423//! ---
424//!
425//! ## Error Types
426//!
427//! | Error | Description |
428//! |-------|-------------|
429//! | `ParentNotFound` | Parent node alias not found |
430//! | `NodeNotFound` | Node ID or alias not found |
431//! | `NodegroupNotFound` | Nodegroup ID not found |
432//! | `CardNotFound` | Card ID not found |
433//! | `CardAlreadyExists` | Nodegroup already has a card |
434//! | `WidgetNotFound` | Widget mapping ID not found |
435//! | `FunctionNotFound` | Function mapping ID not found |
436//! | `NoWidgetForDatatype` | No default widget for datatype |
437//! | `AliasClash` | Alias conflicts with existing node |
438//! | `AliasAlreadyExists` | New alias already in use |
439//! | `BranchHasNoRoot` | Subgraph has no root node |
440//! | `InvalidSubgraph` | Subgraph structure invalid |
441//! | `InvalidDatatype` | Node datatype doesn't match operation |
442//! | `NodeHasDependentWidgets` | Cannot change type with widgets |
443//! | `CannotDeleteRootNode` | Cannot delete root node |
444//! | `InconsistentBranchPublication` | Branch publication ID mismatch |
445//! | `NoBranchNodesFound` | No branch nodes at target |
446
447use serde::{Deserialize, Serialize};
448use std::collections::{HashMap, HashSet};
449use uuid::Uuid;
450
451use crate::graph::{
452    StaticCard, StaticCardsXNodesXWidgets, StaticEdge, StaticGraph, StaticNode, StaticNodegroup,
453    StaticTranslatableString,
454};
455
456// =============================================================================
457// UUID Generation
458// =============================================================================
459
460/// Base namespace UUID for Alizarin graph mutations
461/// This ensures deterministic UUID generation across all platforms
462const ALIZARIN_NAMESPACE: &str = "1a79f1c8-9505-4bea-a18e-28a053f725ca";
463
464/// Generate a deterministic UUID v5 from a group and key
465///
466/// This matches the JS `generateUuidv5` function for cross-platform compatibility.
467///
468/// # Arguments
469/// * `group` - A tuple of (type, optional_id) that forms the namespace
470/// * `key` - The key to hash within the namespace
471pub fn generate_uuid_v5(group: (&str, Option<&str>), key: &str) -> String {
472    generate_uuid_v5_with_ns(ALIZARIN_NAMESPACE, group, key)
473}
474
475/// Like [`generate_uuid_v5`] but accepts a custom base namespace UUID string.
476///
477/// Use this when building separate layers that share a graph model — each layer
478/// should use a distinct namespace so that deterministic tile IDs don't collide
479/// for the same resource + nodegroup + sortorder.
480pub fn generate_uuid_v5_with_ns(
481    base_namespace_str: &str,
482    group: (&str, Option<&str>),
483    key: &str,
484) -> String {
485    // Build namespace from group
486    let namespace_str = match group.1 {
487        Some(id) => format!("{}/{}", group.0, id),
488        None => group.0.to_string(),
489    };
490
491    // Create namespace UUID from base namespace + group
492    let base_namespace = Uuid::parse_str(base_namespace_str).expect("Invalid base namespace");
493    let namespace = Uuid::new_v5(&base_namespace, namespace_str.as_bytes());
494
495    // Generate final UUID from namespace + key
496    Uuid::new_v5(&namespace, key.as_bytes()).to_string()
497}
498
499/// Convert a display name to a slug (lowercase, spaces to underscores)
500///
501/// # Example
502/// ```
503/// use alizarin_core::graph_mutator::slugify;
504/// assert_eq!(slugify("Heritage Item"), "heritage_item");
505/// assert_eq!(slugify("My Test Graph"), "my_test_graph");
506/// ```
507pub fn slugify(name: &str) -> String {
508    name.to_lowercase().replace(' ', "_")
509}
510
511// =============================================================================
512// Widget and CardComponent Types
513// =============================================================================
514
515/// A widget type for UI rendering
516#[derive(Debug, Clone, Serialize, Deserialize)]
517pub struct Widget {
518    pub id: String,
519    pub name: String,
520    pub datatype: String,
521    pub default_config: serde_json::Value,
522}
523
524impl Widget {
525    pub fn new(id: &str, name: &str, datatype: &str, default_config_json: &str) -> Self {
526        Self {
527            id: id.to_string(),
528            name: name.to_string(),
529            datatype: datatype.to_string(),
530            default_config: serde_json::from_str(default_config_json)
531                .unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
532        }
533    }
534
535    /// Get a fresh copy of the default config
536    pub fn get_default_config(&self) -> serde_json::Value {
537        self.default_config.clone()
538    }
539}
540
541/// Convert a RegisteredWidget (from dynamic registry) to Widget
542impl From<crate::registry::RegisteredWidget> for Widget {
543    fn from(registered: crate::registry::RegisteredWidget) -> Self {
544        Self {
545            id: registered.id,
546            name: registered.name,
547            datatype: registered.datatype,
548            default_config: registered.default_config,
549        }
550    }
551}
552
553/// A card component type
554#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct CardComponent {
556    pub id: String,
557    pub name: String,
558}
559
560impl CardComponent {
561    pub fn new(id: &str, name: &str) -> Self {
562        Self {
563            id: id.to_string(),
564            name: name.to_string(),
565        }
566    }
567}
568
569/// The default card component from Arches
570pub const DEFAULT_CARD_COMPONENT_ID: &str = "f05e4d3a-53c1-11e8-b0ea-784f435179ea";
571pub const DEFAULT_CARD_COMPONENT_NAME: &str = "Default Card";
572
573/// Get the default card component
574pub fn default_card_component() -> CardComponent {
575    CardComponent::new(DEFAULT_CARD_COMPONENT_ID, DEFAULT_CARD_COMPONENT_NAME)
576}
577
578// =============================================================================
579// Default Widget Registry
580// =============================================================================
581
582lazy_static::lazy_static! {
583    /// Default widgets by name (from Arches)
584    pub static ref WIDGETS: HashMap<String, Widget> = {
585        let mut m = HashMap::new();
586        m.insert("text-widget".to_string(), Widget::new(
587            "10000000-0000-0000-0000-000000000001",
588            "text-widget",
589            "string",
590            r#"{ "placeholder": "Enter text", "width": "100%", "maxLength": null}"#
591        ));
592        m.insert("concept-select-widget".to_string(), Widget::new(
593            "10000000-0000-0000-0000-000000000002",
594            "concept-select-widget",
595            "concept",
596            r#"{ "placeholder": "Select an option", "options": [] }"#
597        ));
598        m.insert("resource-instance-multiselect-widget".to_string(), Widget::new(
599            "ff3c400a-76ec-11e7-a793-784f435179ea",
600            "resource-instance-multiselect-widget",
601            "resource-instance-list",
602            r#"{ "placeholder": "Select an option", "options": [] }"#
603        ));
604        m.insert("concept-multiselect-widget".to_string(), Widget::new(
605            "10000000-0000-0000-0000-000000000012",
606            "concept-multiselect-widget",
607            "concept-list",
608            r#"{ "placeholder": "Select an option", "options": [] }"#
609        ));
610        m.insert("domain-select-widget".to_string(), Widget::new(
611            "10000000-0000-0000-0000-000000000015",
612            "domain-select-widget",
613            "domain-value",
614            r#"{ "placeholder": "Select an option" }"#
615        ));
616        m.insert("domain-multiselect-widget".to_string(), Widget::new(
617            "10000000-0000-0000-0000-000000000016",
618            "domain-multiselect-widget",
619            "domain-value-list",
620            r#"{ "placeholder": "Select an option" }"#
621        ));
622        m.insert("switch-widget".to_string(), Widget::new(
623            "10000000-0000-0000-0000-000000000003",
624            "switch-widget",
625            "boolean",
626            r#"{ "subtitle": "Click to switch"}"#
627        ));
628        m.insert("datepicker-widget".to_string(), Widget::new(
629            "10000000-0000-0000-0000-000000000004",
630            "datepicker-widget",
631            "date",
632            r#"{
633                "placeholder": "Enter date",
634                "viewMode": "days",
635                "dateFormat": "YYYY-MM-DD",
636                "minDate": false,
637                "maxDate": false
638            }"#
639        ));
640        m.insert("rich-text-widget".to_string(), Widget::new(
641            "10000000-0000-0000-0000-000000000005",
642            "rich-text-widget",
643            "string",
644            r#"{}"#
645        ));
646        m.insert("radio-boolean-widget".to_string(), Widget::new(
647            "10000000-0000-0000-0000-000000000006",
648            "radio-boolean-widget",
649            "boolean",
650            r#"{"trueLabel": "Yes", "falseLabel": "No"}"#
651        ));
652        m.insert("map-widget".to_string(), Widget::new(
653            "10000000-0000-0000-0000-000000000007",
654            "map-widget",
655            "geojson-feature-collection",
656            r#"{
657                "basemap": "streets",
658                "geometryTypes": [{"text":"Point", "id":"Point"}, {"text":"Line", "id":"Line"}, {"text":"Polygon", "id":"Polygon"}],
659                "overlayConfigs": [],
660                "overlayOpacity": 0.0,
661                "geocodeProvider": "MapzenGeocoder",
662                "zoom": 0,
663                "maxZoom": 20,
664                "minZoom": 0,
665                "centerX": 0,
666                "centerY": 0,
667                "pitch": 0.0,
668                "bearing": 0.0,
669                "geocodePlaceholder": "Search",
670                "geocoderVisible": true,
671                "featureColor": null,
672                "featureLineWidth": null,
673                "featurePointSize": null
674            }"#
675        ));
676        m.insert("number-widget".to_string(), Widget::new(
677            "10000000-0000-0000-0000-000000000008",
678            "number-widget",
679            "number",
680            r#"{ "placeholder": "Enter number", "width": "100%", "min":"", "max":""}"#
681        ));
682        m.insert("concept-radio-widget".to_string(), Widget::new(
683            "10000000-0000-0000-0000-000000000009",
684            "concept-radio-widget",
685            "concept",
686            r#"{ "options": [] }"#
687        ));
688        m.insert("concept-checkbox-widget".to_string(), Widget::new(
689            "10000000-0000-0000-0000-000000000013",
690            "concept-checkbox-widget",
691            "concept-list",
692            r#"{ "options": [] }"#
693        ));
694        m.insert("domain-radio-widget".to_string(), Widget::new(
695            "10000000-0000-0000-0000-000000000017",
696            "domain-radio-widget",
697            "domain-value",
698            r#"{}"#
699        ));
700        m.insert("domain-checkbox-widget".to_string(), Widget::new(
701            "10000000-0000-0000-0000-000000000018",
702            "domain-checkbox-widget",
703            "domain-value-list",
704            r#"{}"#
705        ));
706        m.insert("file-widget".to_string(), Widget::new(
707            "10000000-0000-0000-0000-000000000019",
708            "file-widget",
709            "file-list",
710            r#"{"acceptedFiles": "", "maxFilesize": "200"}"#
711        ));
712        m.insert("urldatatype-widget".to_string(), Widget::new(
713            "ca0c43ff-af73-4349-bafd-53ff9f22eebd",
714            "urldatatype-widget",
715            "url",
716            r#"{ "placeholder": "Enter URL", "url_placeholder": "Enter URL", "url_label_placeholder": "Enter URL label" }"#
717        ));
718        m.insert("resource-instance-select-widget".to_string(), Widget::new(
719            "31f3728c-7613-11e7-a139-784f435179ea",
720            "resource-instance-select-widget",
721            "resource-instance",
722            r#"{ "placeholder": "Select a resource" }"#
723        ));
724        m.insert("edtf-widget".to_string(), Widget::new(
725            "10000000-0000-0000-0000-000000000010",
726            "edtf-widget",
727            "edtf",
728            r#"{ "placeholder": "Enter EDTF date" }"#
729        ));
730        m.insert("non-localized-text-widget".to_string(), Widget::new(
731            "10000000-0000-0000-0000-000000000011",
732            "non-localized-text-widget",
733            "non-localized-string",
734            r#"{ "placeholder": "Enter text", "width": "100%" }"#
735        ));
736        m
737    };
738}
739
740lazy_static::lazy_static! {
741    /// Reverse lookup: widget ID -> widget name
742    pub static ref WIDGET_BY_ID: HashMap<String, String> = {
743        WIDGETS.iter().map(|(name, w)| (w.id.clone(), name.clone())).collect()
744    };
745}
746
747/// Look up a widget name by its UUID.
748///
749/// Checks both the static Arches widget registry and the dynamic extension registry.
750pub fn get_widget_name_by_id(widget_id: &str) -> Option<String> {
751    // Check static registry first
752    if let Some(name) = WIDGET_BY_ID.get(widget_id) {
753        return Some(name.clone());
754    }
755    // Check dynamic (extension) registry
756    for name in crate::registry::registered_widgets() {
757        if let Some(widget) = crate::registry::get_registered_widget(&name) {
758            if widget.id == widget_id {
759                return Some(name);
760            }
761        }
762    }
763    None
764}
765
766/// Get the default widget for a datatype
767///
768/// Checks in order:
769/// 1. Extension widget mapping registry (datatype -> widget name)
770/// 2. Dynamic widget registry (for extension-registered widgets)
771/// 3. Core static widget mappings
772pub fn get_default_widget_for_datatype(datatype: &str) -> Result<Widget, MutationError> {
773    // First check the extension registry for custom datatype mappings
774    if let Some(widget_name) = crate::registry::get_widget_for_datatype(datatype) {
775        // Check dynamic widget registry first
776        if let Some(registered) = crate::registry::get_registered_widget(&widget_name) {
777            return Ok(Widget::from(registered));
778        }
779        // Fall back to static widgets
780        return WIDGETS
781            .get(&widget_name)
782            .cloned()
783            .ok_or(MutationError::WidgetNotFound(widget_name));
784    }
785
786    // Fall back to core datatype mappings
787    let widget_name = match datatype {
788        "number" => "number-widget",
789        "string" => "text-widget",
790        "concept" => "concept-select-widget",
791        "concept-list" => "concept-multiselect-widget",
792        "resource-instance-list" => "resource-instance-multiselect-widget",
793        "domain-value" => "domain-select-widget",
794        "domain-value-list" => "domain-multiselect-widget",
795        "geojson-feature-collection" => "map-widget",
796        "boolean" => "switch-widget",
797        "date" => "datepicker-widget",
798        "url" => "urldatatype-widget",
799        "resource-instance" => "resource-instance-select-widget",
800        "edtf" => "edtf-widget",
801        "non-localized-string" => "non-localized-text-widget",
802        "file-list" => "file-widget",
803        "semantic" => return Err(MutationError::NoWidgetForDatatype(datatype.to_string())),
804        other => return Err(MutationError::NoWidgetForDatatype(other.to_string())),
805    };
806
807    WIDGETS
808        .get(widget_name)
809        .cloned()
810        .ok_or_else(|| MutationError::WidgetNotFound(widget_name.to_string()))
811}
812
813// =============================================================================
814// Cardinality
815// =============================================================================
816
817/// Node cardinality (one or many instances allowed)
818#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
819#[serde(from = "String")]
820pub enum Cardinality {
821    /// Single instance only
822    One,
823    /// Multiple instances allowed
824    N,
825}
826
827impl Cardinality {
828    pub fn as_str(&self) -> &'static str {
829        match self {
830            Cardinality::One => "1",
831            Cardinality::N => "n",
832        }
833    }
834}
835
836impl From<&str> for Cardinality {
837    fn from(s: &str) -> Self {
838        match s.to_lowercase().as_str() {
839            "1" | "one" => Cardinality::One,
840            _ => Cardinality::N,
841        }
842    }
843}
844
845impl From<String> for Cardinality {
846    fn from(s: String) -> Self {
847        Cardinality::from(s.as_str())
848    }
849}
850
851// =============================================================================
852// Mutation Error
853// =============================================================================
854
855/// Errors that can occur during graph mutation
856#[derive(Debug, Clone)]
857pub enum MutationError {
858    /// Parent node not found
859    ParentNotFound(String),
860    /// Node not found
861    NodeNotFound(String),
862    /// Nodegroup not found
863    NodegroupNotFound(String),
864    /// Card not found for nodegroup
865    CardNotFound(String),
866    /// Nodegroup already has a card
867    CardAlreadyExists(String),
868    /// No widget for this datatype
869    NoWidgetForDatatype(String),
870    /// Widget not found
871    WidgetNotFound(String),
872    /// JSON serialization error
873    JsonError(String),
874    /// Alias already exists in target graph
875    AliasClash(String),
876    /// Branch has no root node
877    BranchHasNoRoot,
878    /// Invalid subgraph structure
879    InvalidSubgraph(String),
880    /// Inconsistent branch publication ID during traversal
881    InconsistentBranchPublication {
882        expected: String,
883        found: Option<String>,
884        node_id: String,
885    },
886    /// No nodes found for the branch at target
887    NoBranchNodesFound(String),
888    /// Invalid datatype for operation
889    InvalidDatatype {
890        expected: String,
891        found: String,
892        node_id: String,
893    },
894    /// Function mapping not found
895    FunctionNotFound(String),
896    /// Cannot delete root node
897    CannotDeleteRootNode(String),
898    /// Node has dependent widgets (cannot change type)
899    NodeHasDependentWidgets(String),
900    /// Alias already exists
901    AliasAlreadyExists(String),
902    /// Invalid node config
903    InvalidConfig { alias: String, error: String },
904    /// Extension mutation not found in registry
905    ExtensionNotFound(String),
906    /// Extension mutation used but no registry provided
907    NoExtensionRegistry(String),
908    /// Ontology validation failure
909    OntologyValidation(crate::ontology::OntologyValidationDetail),
910    /// Generic error
911    Other(String),
912}
913
914impl std::fmt::Display for MutationError {
915    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
916        match self {
917            MutationError::ParentNotFound(alias) => write!(f, "Parent node not found: {}", alias),
918            MutationError::NodeNotFound(id) => write!(f, "Node not found: {}", id),
919            MutationError::NodegroupNotFound(id) => write!(f, "Nodegroup not found: {}", id),
920            MutationError::CardNotFound(ng) => write!(f, "Card not found for nodegroup: {}", ng),
921            MutationError::CardAlreadyExists(ng) => {
922                write!(f, "Nodegroup already has a card: {}", ng)
923            }
924            MutationError::NoWidgetForDatatype(dt) => {
925                write!(f, "No default widget for datatype: {} (is the relevant extension loaded? e.g. import alizarin_clm)", dt)
926            }
927            MutationError::WidgetNotFound(name) => write!(f, "Widget not found: {}", name),
928            MutationError::JsonError(msg) => write!(f, "JSON error: {}", msg),
929            MutationError::AliasClash(alias) => {
930                write!(f, "Alias already exists in target graph: {}", alias)
931            }
932            MutationError::BranchHasNoRoot => write!(f, "Branch has no root node"),
933            MutationError::InvalidSubgraph(msg) => write!(f, "Invalid subgraph: {}", msg),
934            MutationError::InconsistentBranchPublication {
935                expected,
936                found,
937                node_id,
938            } => {
939                write!(
940                    f,
941                    "Inconsistent branch publication ID at node {}: expected {}, found {:?}",
942                    node_id, expected, found
943                )
944            }
945            MutationError::NoBranchNodesFound(target_id) => {
946                write!(f, "No branch nodes found at target: {}", target_id)
947            }
948            MutationError::InvalidDatatype {
949                expected,
950                found,
951                node_id,
952            } => {
953                write!(
954                    f,
955                    "Invalid datatype for node {}: expected {}, found {}",
956                    node_id, expected, found
957                )
958            }
959            MutationError::FunctionNotFound(id) => write!(f, "Function mapping not found: {}", id),
960            MutationError::CannotDeleteRootNode(id) => write!(f, "Cannot delete root node: {}", id),
961            MutationError::NodeHasDependentWidgets(id) => {
962                write!(f, "Node has dependent widgets, cannot change type: {}", id)
963            }
964            MutationError::AliasAlreadyExists(alias) => {
965                write!(f, "Alias already exists: {}", alias)
966            }
967            MutationError::InvalidConfig { alias, error } => {
968                write!(f, "Invalid config for node '{}': {}", alias, error)
969            }
970            MutationError::ExtensionNotFound(name) => {
971                write!(f, "Extension mutation not found: {}", name)
972            }
973            MutationError::NoExtensionRegistry(name) => write!(
974                f,
975                "Extension mutation '{}' used but no registry provided",
976                name
977            ),
978            MutationError::OntologyValidation(detail) => {
979                write!(f, "Ontology validation error: {}", detail)
980            }
981            MutationError::Other(msg) => write!(f, "{}", msg),
982        }
983    }
984}
985
986impl std::error::Error for MutationError {}
987
988// =============================================================================
989// Mutation Types (Command Pattern)
990// =============================================================================
991
992/// Parameters for adding a node
993#[derive(Debug, Clone, Serialize, Deserialize)]
994pub struct AddNodeParams {
995    pub parent_alias: Option<String>,
996    pub alias: String,
997    pub name: String,
998    pub cardinality: Cardinality,
999    pub datatype: String,
1000    /// Ontology class URI(s). Accepts a single string or an array; an empty
1001    /// vec means "no ontology class".
1002    #[serde(default, with = "crate::graph::serde_helpers::optional_string_or_vec")]
1003    pub ontology_class: Option<Vec<String>>,
1004    pub parent_property: String,
1005    pub description: Option<String>,
1006    pub config: Option<serde_json::Value>,
1007    pub options: NodeOptions,
1008}
1009
1010/// Options for node creation.
1011///
1012/// These map to the per-node flags stored in Arches graph definitions.
1013#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1014pub struct NodeOptions {
1015    /// Include this node's data in exports.
1016    pub exportable: Option<bool>,
1017    /// Override the generated field name.
1018    pub fieldname: Option<String>,
1019    /// Whether the node has a user-defined alias (as opposed to auto-generated).
1020    pub hascustomalias: Option<bool>,
1021    /// Force this node to own its own nodegroup (`nodeid == nodegroup_id`),
1022    /// matching Arches' collector semantics. When `true`, `AddNode` creates a
1023    /// dedicated nodegroup even if the cardinality is `1` and the parent is not
1024    /// root. Defaults to `Some(true)` for semantic nodes with cardinality N;
1025    /// `None` otherwise.
1026    pub is_collector: Option<bool>,
1027    /// Whether this field is required for data entry.
1028    pub isrequired: Option<bool>,
1029    /// Whether this field is included in search indexes.
1030    pub issearchable: Option<bool>,
1031    /// Whether this is the root (top) node of the graph.
1032    pub istopnode: Option<bool>,
1033    /// Display order within the card/nodegroup.
1034    pub sortorder: Option<i32>,
1035}
1036
1037/// Parameters for adding a card
1038#[derive(Debug, Clone, Serialize, Deserialize)]
1039pub struct AddCardParams {
1040    pub nodegroup_id: String,
1041    pub name: StaticTranslatableString,
1042    pub component_id: Option<String>,
1043    pub options: CardOptions,
1044    pub config: Option<serde_json::Value>,
1045}
1046
1047/// Options for card creation
1048#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1049pub struct CardOptions {
1050    pub active: Option<bool>,
1051    pub cssclass: Option<String>,
1052    pub helpenabled: Option<bool>,
1053    pub helptext: Option<StaticTranslatableString>,
1054    pub helptitle: Option<StaticTranslatableString>,
1055    pub instructions: Option<StaticTranslatableString>,
1056    pub is_editable: Option<bool>,
1057    pub description: Option<StaticTranslatableString>,
1058    pub sortorder: Option<i32>,
1059    pub visible: Option<bool>,
1060}
1061
1062/// Parameters for adding a widget to a card
1063#[derive(Debug, Clone, Serialize, Deserialize)]
1064pub struct AddWidgetParams {
1065    pub node_id: String,
1066    pub widget_id: String,
1067    pub label: String,
1068    pub config: serde_json::Value,
1069    pub sortorder: Option<i32>,
1070    pub visible: Option<bool>,
1071}
1072
1073/// Parameters for adding a nodegroup
1074#[derive(Debug, Clone, Serialize, Deserialize)]
1075pub struct AddNodegroupParams {
1076    pub parent_alias: Option<String>,
1077    pub nodegroup_id: String,
1078    pub cardinality: Cardinality,
1079}
1080
1081/// Parameters for adding an edge
1082#[derive(Debug, Clone, Serialize, Deserialize)]
1083pub struct AddEdgeParams {
1084    pub from_node_id: String,
1085    pub to_node_id: String,
1086    pub ontology_property: String,
1087    pub name: Option<String>,
1088    pub description: Option<String>,
1089}
1090
1091/// Parameters for adding an entire subgraph/branch
1092#[derive(Debug, Clone, Serialize, Deserialize)]
1093pub struct AddSubgraphParams {
1094    /// The subgraph to add (branch)
1095    pub subgraph: StaticGraph,
1096    /// Target node ID to attach the branch's children to
1097    pub target_node_id: String,
1098    /// Ontology property for the connecting edges
1099    pub ontology_property: String,
1100    /// Optional suffix to add to all aliases from the subgraph
1101    #[serde(default)]
1102    pub alias_suffix: Option<String>,
1103    /// Optional prefix for aliases (e.g. "monument" → alias "name" becomes "monument_name")
1104    #[serde(default)]
1105    pub alias_prefix: Option<String>,
1106    /// Optional prefix for display names (e.g. "Monument" → name "Name" becomes "Monument Name")
1107    #[serde(default)]
1108    pub name_prefix: Option<String>,
1109}
1110
1111/// Parameters for updating an existing subgraph/branch in a graph
1112///
1113/// This mutation finds nodes previously added from a branch (by alias matching)
1114/// and updates them to match the current branch version:
1115/// - **Update**: Nodes that exist in both branch and graph (preserves IDs)
1116/// - **Add**: Nodes new in branch that don't exist in graph
1117/// - **Remove**: Nodes in graph from old branch no longer in new branch (optional)
1118#[derive(Debug, Clone, Serialize, Deserialize)]
1119pub struct UpdateSubgraphParams {
1120    /// The new version of the subgraph/branch
1121    pub subgraph: StaticGraph,
1122    /// Target node ID where the branch is attached
1123    pub target_node_id: String,
1124    /// Ontology property for connecting edges (used for new nodes)
1125    pub ontology_property: String,
1126    /// The suffix used when the branch was originally added (if any)
1127    #[serde(default)]
1128    pub alias_suffix: Option<String>,
1129    /// Whether to remove nodes that are no longer in the branch (default: false)
1130    /// WARNING: Setting this to true may orphan resource instance data
1131    #[serde(default)]
1132    pub remove_orphaned: bool,
1133    /// Optional prefix for aliases — used when matching branch aliases to existing
1134    /// prefixed aliases (e.g. "monument" matches branch "name" to existing "monument_name")
1135    /// and when adding new nodes from the branch.
1136    #[serde(default)]
1137    pub alias_prefix: Option<String>,
1138    /// Optional prefix for display names — applied to new nodes added from the branch
1139    /// (e.g. "Monument" → "Name Type" becomes "Monument Name Type")
1140    #[serde(default)]
1141    pub name_prefix: Option<String>,
1142}
1143
1144/// Parameters for changing the collection of a concept/concept-list node
1145#[derive(Debug, Clone, Serialize, Deserialize)]
1146pub struct ConceptChangeCollectionParams {
1147    /// Node alias or ID to update
1148    pub node_id: String,
1149    /// New collection ID (UUID of the RDM collection)
1150    pub collection_id: String,
1151}
1152
1153/// Parameters for deleting a card
1154#[derive(Debug, Clone, Serialize, Deserialize)]
1155pub struct DeleteCardParams {
1156    /// Card ID to delete
1157    pub card_id: String,
1158}
1159
1160/// Parameters for deleting a widget mapping (cards_x_nodes_x_widgets entry)
1161#[derive(Debug, Clone, Serialize, Deserialize)]
1162pub struct DeleteWidgetParams {
1163    /// Widget mapping ID (the id field of cards_x_nodes_x_widgets)
1164    pub widget_mapping_id: String,
1165}
1166
1167/// Parameters for setting the descriptor function on a graph, replacing any existing one
1168#[derive(Debug, Clone, Serialize, Deserialize)]
1169pub struct SetDescriptorFunctionParams {
1170    /// Function ID — either a UUID or an arbitrary string (e.g. "com.flaxandteal.app/my-func")
1171    /// which will be converted to a deterministic UUID v5.
1172    pub function_id: String,
1173}
1174
1175/// Parameters for adding a function mapping to a graph
1176#[derive(Debug, Clone, Serialize, Deserialize)]
1177pub struct AddFunctionParams {
1178    /// Function ID — either a UUID or an arbitrary string (e.g. "com.flaxandteal.app/my-func")
1179    /// which will be converted to a deterministic UUID v5.
1180    pub function_id: String,
1181    /// Optional configuration for the function mapping
1182    #[serde(default)]
1183    pub config: Option<serde_json::Value>,
1184}
1185
1186/// Parameters for deleting a function mapping (functions_x_graphs entry)
1187#[derive(Debug, Clone, Serialize, Deserialize)]
1188pub struct DeleteFunctionParams {
1189    /// Function mapping ID (the id field of functions_x_graphs)
1190    pub function_mapping_id: String,
1191}
1192
1193/// Parameters for setting a descriptor template on the graph's descriptor function
1194#[derive(Debug, Clone, Serialize, Deserialize)]
1195pub struct SetDescriptorTemplateParams {
1196    /// Descriptor type (e.g. "name", "slug", "description", "map_popup")
1197    pub descriptor_type: String,
1198    /// String template with <Node Name> placeholders (e.g. "<First Name> <Last Name>")
1199    pub string_template: String,
1200}
1201
1202/// Parameters for deleting a node
1203#[derive(Debug, Clone, Serialize, Deserialize)]
1204pub struct DeleteNodeParams {
1205    /// Node ID or alias to delete
1206    pub node_id: String,
1207}
1208
1209/// Parameters for deleting a nodegroup (cascades to children)
1210#[derive(Debug, Clone, Serialize, Deserialize)]
1211pub struct DeleteNodegroupParams {
1212    /// Nodegroup ID to delete
1213    pub nodegroup_id: String,
1214}
1215
1216/// Parameters for updating a node (preserves structural IDs, cannot change datatype)
1217#[derive(Debug, Clone, Serialize, Deserialize)]
1218pub struct UpdateNodeParams {
1219    /// Node ID or alias to update
1220    pub node_id: String,
1221    /// New name (if provided)
1222    #[serde(default, skip_serializing_if = "Option::is_none")]
1223    pub name: Option<String>,
1224    /// New ontology class(es) (if provided). `None` means "no change";
1225    /// an empty list, or a list of only blank entries, clears the field.
1226    /// Accepts a single string or an array on the wire.
1227    #[serde(
1228        default,
1229        skip_serializing_if = "Option::is_none",
1230        with = "crate::graph::serde_helpers::optional_string_or_vec"
1231    )]
1232    pub ontology_class: Option<Vec<String>>,
1233    /// New parent property (if provided)
1234    #[serde(default, skip_serializing_if = "Option::is_none")]
1235    pub parent_property: Option<String>,
1236    /// New description (if provided)
1237    #[serde(default, skip_serializing_if = "Option::is_none")]
1238    pub description: Option<String>,
1239    /// New config (if provided, replaces existing)
1240    #[serde(default, skip_serializing_if = "Option::is_none")]
1241    pub config: Option<serde_json::Value>,
1242    /// Update options
1243    #[serde(default)]
1244    pub options: UpdateNodeOptions,
1245}
1246
1247/// Options for updating a node
1248#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1249pub struct UpdateNodeOptions {
1250    #[serde(default, skip_serializing_if = "Option::is_none")]
1251    pub exportable: Option<bool>,
1252    #[serde(default, skip_serializing_if = "Option::is_none")]
1253    pub fieldname: Option<String>,
1254    #[serde(default, skip_serializing_if = "Option::is_none")]
1255    pub isrequired: Option<bool>,
1256    #[serde(default, skip_serializing_if = "Option::is_none")]
1257    pub issearchable: Option<bool>,
1258    #[serde(default, skip_serializing_if = "Option::is_none")]
1259    pub sortorder: Option<i32>,
1260}
1261
1262/// Parameters for changing a node's datatype (requires no dependent widgets)
1263#[derive(Debug, Clone, Serialize, Deserialize)]
1264pub struct ChangeNodeTypeParams {
1265    /// Node ID or alias to update
1266    pub node_id: String,
1267    /// New datatype
1268    pub datatype: String,
1269    /// New name (if provided)
1270    #[serde(default, skip_serializing_if = "Option::is_none")]
1271    pub name: Option<String>,
1272    /// New ontology class(es) (if provided). `None` means "no change";
1273    /// an empty list, or a list of only blank entries, clears the field.
1274    /// Accepts a single string or an array on the wire.
1275    #[serde(
1276        default,
1277        skip_serializing_if = "Option::is_none",
1278        with = "crate::graph::serde_helpers::optional_string_or_vec"
1279    )]
1280    pub ontology_class: Option<Vec<String>>,
1281    /// New parent property (if provided)
1282    #[serde(default, skip_serializing_if = "Option::is_none")]
1283    pub parent_property: Option<String>,
1284    /// New description (if provided)
1285    #[serde(default, skip_serializing_if = "Option::is_none")]
1286    pub description: Option<String>,
1287    /// New config (if provided, replaces existing)
1288    #[serde(default, skip_serializing_if = "Option::is_none")]
1289    pub config: Option<serde_json::Value>,
1290    /// Update options
1291    #[serde(default)]
1292    pub options: UpdateNodeOptions,
1293}
1294
1295/// Parameters for renaming a node (text metadata only)
1296#[derive(Debug, Clone, Serialize, Deserialize)]
1297pub struct RenameNodeParams {
1298    /// Node ID or alias to rename
1299    pub node_id: String,
1300    /// New alias (if provided)
1301    #[serde(default, skip_serializing_if = "Option::is_none")]
1302    pub alias: Option<String>,
1303    /// New name (if provided)
1304    #[serde(default, skip_serializing_if = "Option::is_none")]
1305    pub name: Option<String>,
1306    /// New description (if provided)
1307    #[serde(default, skip_serializing_if = "Option::is_none")]
1308    pub description: Option<String>,
1309    /// Whether to also realign the card and widget label to match the new node name (default: true)
1310    #[serde(default = "default_true")]
1311    pub realign_card: bool,
1312}
1313
1314/// Parameters for renaming/updating a card
1315#[derive(Debug, Clone, Serialize, Deserialize)]
1316pub struct RenameCardParams {
1317    /// Card ID or nodegroup ID — searches by card ID first, then by nodegroup_id
1318    pub card_id: String,
1319    /// Language code for name/description (defaults to "en")
1320    #[serde(default, skip_serializing_if = "Option::is_none")]
1321    pub language: Option<String>,
1322    /// New name — applied to the specified language (or "en" by default).
1323    /// Alternatively, provide `name_i18n` for a full translatable map.
1324    #[serde(default, skip_serializing_if = "Option::is_none")]
1325    pub name: Option<String>,
1326    /// Full translatable name map (e.g. {"en": "Name", "fr": "Nom"}).
1327    /// Overrides `name` if both are provided.
1328    #[serde(default, skip_serializing_if = "Option::is_none")]
1329    pub name_i18n: Option<HashMap<String, String>>,
1330    /// New description — applied to the specified language (or "en" by default).
1331    #[serde(default, skip_serializing_if = "Option::is_none")]
1332    pub description: Option<String>,
1333    /// Full translatable description map. Overrides `description` if both provided.
1334    #[serde(default, skip_serializing_if = "Option::is_none")]
1335    pub description_i18n: Option<HashMap<String, String>>,
1336}
1337
1338/// Parameters for realigning a card (and its widget label) to match the current node name
1339#[derive(Debug, Clone, Serialize, Deserialize)]
1340pub struct RealignCardFromNodeParams {
1341    /// Node alias (or node ID) whose name should be synced to its card and widget label
1342    pub node_alias: String,
1343}
1344
1345/// Parameters for changing a nodegroup's cardinality (1 <-> n)
1346#[derive(Debug, Clone, Serialize, Deserialize)]
1347pub struct ChangeCardinalityParams {
1348    /// Node ID or alias - the grouping node of the nodegroup to change
1349    pub node_id: String,
1350    /// New cardinality
1351    pub cardinality: Cardinality,
1352}
1353
1354/// Parameters for creating a new graph from scratch
1355#[derive(Debug, Clone, Serialize, Deserialize)]
1356pub struct CreateGraphParams {
1357    /// Name for the graph
1358    pub name: String,
1359    /// Whether this is a resource model (true) or branch (false)
1360    pub is_resource: bool,
1361    /// Alias for the root node
1362    pub root_alias: String,
1363    /// Ontology class URI(s) for the root node. Accepts a single string or
1364    /// an array; an empty list (or `null`) means no ontology class.
1365    #[serde(default, with = "crate::graph::serde_helpers::optional_string_or_vec")]
1366    pub root_ontology_class: Option<Vec<String>>,
1367    /// Optional custom graph ID (otherwise generated deterministically)
1368    #[serde(default, skip_serializing_if = "Option::is_none")]
1369    pub graph_id: Option<String>,
1370    /// Optional author
1371    #[serde(default, skip_serializing_if = "Option::is_none")]
1372    pub author: Option<String>,
1373    /// Optional description
1374    #[serde(default, skip_serializing_if = "Option::is_none")]
1375    pub description: Option<String>,
1376    /// Ontology ID(s) for the graph. Accepts a single string or an array.
1377    /// This sets the graph-level ontology (distinct from node-level ontology classes).
1378    #[serde(default, with = "crate::graph::serde_helpers::optional_string_or_vec")]
1379    pub ontology_id: Option<Vec<String>>,
1380}
1381
1382/// Parameters for renaming a graph (updating name, description, etc.)
1383#[derive(Debug, Clone, Serialize, Deserialize)]
1384pub struct RenameGraphParams {
1385    /// New name for the graph (language -> value map)
1386    /// If provided, replaces the graph's name
1387    #[serde(default, skip_serializing_if = "Option::is_none")]
1388    pub name: Option<std::collections::HashMap<String, String>>,
1389    /// New description for the graph (language -> value map)
1390    /// If provided, replaces the graph's description
1391    #[serde(default, skip_serializing_if = "Option::is_none")]
1392    pub description: Option<std::collections::HashMap<String, String>>,
1393    /// New subtitle for the graph (language -> value map)
1394    /// If provided, replaces the graph's subtitle
1395    #[serde(default, skip_serializing_if = "Option::is_none")]
1396    pub subtitle: Option<std::collections::HashMap<String, String>>,
1397    /// New author for the graph
1398    #[serde(default, skip_serializing_if = "Option::is_none")]
1399    pub author: Option<String>,
1400}
1401
1402// =============================================================================
1403// Coppice Subgraph Mutation
1404// =============================================================================
1405
1406/// Parameters for the coppice_subgraph mutation.
1407///
1408/// Sets `sourcebranchpublication_id` on an existing subtree by traversing from
1409/// a root node (identified by alias) via edges. Stops at nodes already claimed
1410/// by a different publication.
1411///
1412/// ## CSV Example
1413///
1414/// ```csv
1415/// coppice_subgraph,monument_names,,,,,,,,,,bf05dedf-85d1-4e1b-8929-7cb42b534b87,,
1416/// ```
1417///
1418/// **Instruction:** `coppice_subgraph` (subject: root_alias)
1419#[derive(Debug, Clone, Serialize, Deserialize)]
1420pub struct CoppiceSubgraphParams {
1421    /// Alias of the root node to start traversal from
1422    pub subject: String,
1423    /// The sourcebranchpublication_id to set on reachable nodes
1424    pub publication_id: String,
1425}
1426
1427// =============================================================================
1428// Update Widget Config
1429// =============================================================================
1430
1431/// Parameters for updating a widget's config (merging keys into existing config)
1432///
1433/// ## CSV Format
1434///
1435/// ```csv
1436/// action,subject,object,params.config
1437/// update_widget_config,location,,"{""centerX"": -5.93, ""centerY"": 54.59}"
1438/// ```
1439///
1440/// - **subject**: node alias (or node ID) that the widget is attached to
1441/// - **params.config**: JSON object whose keys are merged into the widget's existing config
1442///
1443/// **Instruction:** `update_widget_config` (subject: node_alias)
1444#[derive(Debug, Clone, Serialize, Deserialize)]
1445pub struct UpdateWidgetConfigParams {
1446    /// Node alias or node ID to find the widget mapping for
1447    pub node_id: String,
1448    /// Config keys to merge into the existing widget config
1449    pub config: serde_json::Value,
1450}
1451
1452// =============================================================================
1453// Extension Mutations
1454// =============================================================================
1455
1456/// Parameters for an extension mutation
1457///
1458/// Extension mutations allow external crates (like CLM) to define custom
1459/// graph operations that integrate with the mutation system.
1460///
1461/// ## Example
1462///
1463/// ```ignore
1464/// // CLM defines a mutation to change a reference node's controlled list
1465/// let mutation = GraphMutation::Extension(ExtensionMutationParams {
1466///     name: "clm.reference_change_collection".to_string(),
1467///     params: serde_json::json!({
1468///         "node_id": "my_reference_node",
1469///         "collection_id": "new-collection-uuid"
1470///     }),
1471///     conformance: MutationConformance::AlwaysConformant,
1472/// });
1473/// ```
1474#[derive(Debug, Clone, Serialize, Deserialize)]
1475pub struct ExtensionMutationParams {
1476    /// The registered mutation name (e.g., "clm.reference_change_collection")
1477    ///
1478    /// Convention: use "extension_name.mutation_name" format
1479    pub name: String,
1480
1481    /// The mutation parameters as JSON
1482    ///
1483    /// The handler registered for this mutation name interprets these params.
1484    pub params: serde_json::Value,
1485
1486    /// Conformance level for this mutation
1487    ///
1488    /// Set by the extension when defining the mutation.
1489    #[serde(default = "default_extension_conformance")]
1490    pub conformance: MutationConformance,
1491}
1492
1493fn default_extension_conformance() -> MutationConformance {
1494    MutationConformance::AlwaysConformant
1495}
1496
1497/// Handler trait for extension mutations
1498///
1499/// Extensions implement this trait to define custom mutations.
1500/// Handlers are registered with `ExtensionMutationRegistry`.
1501///
1502/// ## Example
1503///
1504/// ```ignore
1505/// struct ReferenceChangeCollectionHandler;
1506///
1507/// impl ExtensionMutationHandler for ReferenceChangeCollectionHandler {
1508///     fn apply(
1509///         &self,
1510///         graph: &mut StaticGraph,
1511///         params: &serde_json::Value,
1512///         _options: &MutatorOptions,
1513///     ) -> Result<(), MutationError> {
1514///         let node_id = params.get("node_id")
1515///             .and_then(|v| v.as_str())
1516///             .ok_or_else(|| MutationError::Other("missing node_id".into()))?;
1517///         // ... apply the mutation
1518///         Ok(())
1519///     }
1520///
1521///     fn conformance(&self) -> MutationConformance {
1522///         MutationConformance::AlwaysConformant
1523///     }
1524/// }
1525/// ```
1526pub trait ExtensionMutationHandler: Send + Sync {
1527    /// Apply the mutation to the graph
1528    fn apply(
1529        &self,
1530        graph: &mut StaticGraph,
1531        params: &serde_json::Value,
1532        options: &MutatorOptions,
1533    ) -> Result<(), MutationError>;
1534
1535    /// Get the default conformance level for this mutation type
1536    ///
1537    /// This can be overridden per-invocation via `ExtensionMutationParams.conformance`.
1538    fn conformance(&self) -> MutationConformance;
1539
1540    /// Get a description of this mutation for documentation
1541    fn description(&self) -> &str {
1542        "Extension mutation"
1543    }
1544}
1545
1546/// Registry for extension mutation handlers
1547///
1548/// Extensions register their mutation handlers here. The registry is passed
1549/// to `apply_mutations` functions to enable extension mutations.
1550///
1551/// ## Thread Safety
1552///
1553/// The registry is thread-safe for concurrent reads. Registration should
1554/// happen at startup before mutations are applied.
1555///
1556/// ## Example
1557///
1558/// ```ignore
1559/// use std::sync::Arc;
1560///
1561/// let mut registry = ExtensionMutationRegistry::new();
1562/// registry.register(
1563///     "clm.reference_change_collection",
1564///     Arc::new(ReferenceChangeCollectionHandler),
1565/// );
1566///
1567/// // Pass registry when applying mutations
1568/// apply_mutations_with_extensions(graph, mutations, options, Some(&registry))?;
1569/// ```
1570pub struct ExtensionMutationRegistry {
1571    handlers: std::collections::HashMap<String, std::sync::Arc<dyn ExtensionMutationHandler>>,
1572}
1573
1574impl ExtensionMutationRegistry {
1575    /// Create a new empty registry
1576    pub fn new() -> Self {
1577        Self {
1578            handlers: std::collections::HashMap::new(),
1579        }
1580    }
1581
1582    /// Register a mutation handler
1583    ///
1584    /// # Arguments
1585    /// * `name` - The mutation name (e.g., "clm.reference_change_collection")
1586    /// * `handler` - The handler implementation
1587    pub fn register(
1588        &mut self,
1589        name: impl Into<String>,
1590        handler: std::sync::Arc<dyn ExtensionMutationHandler>,
1591    ) {
1592        self.handlers.insert(name.into(), handler);
1593    }
1594
1595    /// Get a handler by name
1596    pub fn get(&self, name: &str) -> Option<&std::sync::Arc<dyn ExtensionMutationHandler>> {
1597        self.handlers.get(name)
1598    }
1599
1600    /// Check if a handler is registered
1601    pub fn has(&self, name: &str) -> bool {
1602        self.handlers.contains_key(name)
1603    }
1604
1605    /// List all registered mutation names
1606    pub fn list(&self) -> Vec<&str> {
1607        self.handlers.keys().map(|s| s.as_str()).collect()
1608    }
1609}
1610
1611impl Default for ExtensionMutationRegistry {
1612    fn default() -> Self {
1613        Self::new()
1614    }
1615}
1616
1617impl std::fmt::Debug for ExtensionMutationRegistry {
1618    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1619        f.debug_struct("ExtensionMutationRegistry")
1620            .field("handlers", &self.handlers.keys().collect::<Vec<_>>())
1621            .finish()
1622    }
1623}
1624
1625/// Conformance level for graph mutations
1626///
1627/// Indicates what type of graph a mutation is valid for:
1628/// - `AlwaysConformant`: Valid for both branches and models
1629/// - `BranchConformant`: Valid only when building/modifying branches
1630/// - `ModelConformant`: Valid only when building/modifying models
1631/// - `NonConformant`: Not valid for standard use (deprecated or special-purpose)
1632#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1633pub enum MutationConformance {
1634    /// Valid for both branches and resource models
1635    AlwaysConformant,
1636    /// Valid only for branches (isresource=false)
1637    BranchConformant,
1638    /// Valid only for resource models (isresource=true)
1639    ModelConformant,
1640    /// Not conformant with standard workflows
1641    NonConformant,
1642}
1643
1644/// A graph mutation operation (Command pattern)
1645#[derive(Debug, Clone, Serialize, Deserialize)]
1646pub enum GraphMutation {
1647    AddNode(AddNodeParams),
1648    AddNodegroup(AddNodegroupParams),
1649    AddEdge(AddEdgeParams),
1650    AddCard(AddCardParams),
1651    AddWidgetToCard(AddWidgetParams),
1652    AddSubgraph(AddSubgraphParams),
1653    UpdateSubgraph(UpdateSubgraphParams),
1654    ConceptChangeCollection(ConceptChangeCollectionParams),
1655    DeleteCard(DeleteCardParams),
1656    DeleteWidget(DeleteWidgetParams),
1657    AddFunction(AddFunctionParams),
1658    /// Set the descriptor function for this graph, removing any other descriptor functions
1659    SetDescriptorFunction(SetDescriptorFunctionParams),
1660    DeleteFunction(DeleteFunctionParams),
1661    DeleteNode(DeleteNodeParams),
1662    DeleteNodegroup(DeleteNodegroupParams),
1663    UpdateNode(UpdateNodeParams),
1664    ChangeNodeType(ChangeNodeTypeParams),
1665    ChangeCardinality(ChangeCardinalityParams),
1666    RenameNode(RenameNodeParams),
1667    /// Rename/update a card's display name and description
1668    RenameCard(RenameCardParams),
1669    /// Realign a card and widget label to match the current node name
1670    RealignCardFromNode(RealignCardFromNodeParams),
1671    /// Rename/update graph metadata (name, description, subtitle, author)
1672    RenameGraph(RenameGraphParams),
1673    /// Set a descriptor template (name, slug, etc.) on the graph's descriptor function
1674    SetDescriptorTemplate(SetDescriptorTemplateParams),
1675    /// Create a new graph from scratch (only valid as first mutation in apply_mutations_create)
1676    CreateGraph(CreateGraphParams),
1677    /// Set sourcebranchpublication_id on a subtree rooted at a given alias
1678    CoppiceSubgraph(CoppiceSubgraphParams),
1679    /// Merge config keys into an existing widget's config (found by node alias)
1680    UpdateWidgetConfig(UpdateWidgetConfigParams),
1681    /// Extension mutation - delegated to a registered handler
1682    Extension(ExtensionMutationParams),
1683}
1684
1685impl GraphMutation {
1686    /// Get the conformance level for this mutation
1687    pub fn conformance(&self) -> MutationConformance {
1688        match self {
1689            // Basic structure operations - valid for branches
1690            GraphMutation::AddNode(_) => MutationConformance::BranchConformant,
1691            GraphMutation::AddNodegroup(_) => MutationConformance::BranchConformant,
1692            GraphMutation::AddEdge(_) => MutationConformance::BranchConformant,
1693            GraphMutation::AddCard(_) => MutationConformance::BranchConformant,
1694            GraphMutation::AddWidgetToCard(_) => MutationConformance::BranchConformant,
1695            // Subgraph operations - only valid for models (adding branches to models)
1696            GraphMutation::AddSubgraph(_) => MutationConformance::ModelConformant,
1697            GraphMutation::UpdateSubgraph(_) => MutationConformance::ModelConformant,
1698            // Collection changes - valid for both
1699            GraphMutation::ConceptChangeCollection(_) => MutationConformance::AlwaysConformant,
1700            // Deletion operations - valid for both branches and models
1701            GraphMutation::DeleteCard(_) => MutationConformance::AlwaysConformant,
1702            GraphMutation::DeleteWidget(_) => MutationConformance::AlwaysConformant,
1703            GraphMutation::AddFunction(_) => MutationConformance::ModelConformant,
1704            GraphMutation::SetDescriptorFunction(_) => MutationConformance::ModelConformant,
1705            GraphMutation::DeleteFunction(_) => MutationConformance::AlwaysConformant,
1706            GraphMutation::DeleteNode(_) => MutationConformance::AlwaysConformant,
1707            GraphMutation::DeleteNodegroup(_) => MutationConformance::AlwaysConformant,
1708            // Node update operations
1709            GraphMutation::UpdateNode(_) => MutationConformance::BranchConformant,
1710            GraphMutation::ChangeNodeType(_) => MutationConformance::BranchConformant,
1711            GraphMutation::ChangeCardinality(_) => MutationConformance::BranchConformant,
1712            GraphMutation::RenameNode(_) => MutationConformance::AlwaysConformant,
1713            GraphMutation::RenameCard(_) => MutationConformance::AlwaysConformant,
1714            GraphMutation::RealignCardFromNode(_) => MutationConformance::AlwaysConformant,
1715            // Graph metadata update - valid for both
1716            GraphMutation::RenameGraph(_) => MutationConformance::AlwaysConformant,
1717            // Descriptor template - valid for both branches and models
1718            GraphMutation::SetDescriptorTemplate(_) => MutationConformance::AlwaysConformant,
1719            // CreateGraph - not a standard mutation, only valid in apply_mutations_create
1720            GraphMutation::CreateGraph(_) => MutationConformance::NonConformant,
1721            // Coppice subgraph - valid for both branches and models
1722            GraphMutation::CoppiceSubgraph(_) => MutationConformance::AlwaysConformant,
1723            // Widget config update - valid for both branches and models
1724            GraphMutation::UpdateWidgetConfig(_) => MutationConformance::AlwaysConformant,
1725            // Extension mutations - conformance is specified in params
1726            GraphMutation::Extension(params) => params.conformance,
1727        }
1728    }
1729}
1730
1731// =============================================================================
1732// Graph Mutator Options
1733// =============================================================================
1734
1735/// Options for the GraphMutator
1736#[derive(Debug, Clone)]
1737pub struct MutatorOptions {
1738    /// Automatically create cards for new nodegroups
1739    pub autocreate_card: bool,
1740    /// Automatically add default widgets to cards
1741    pub autocreate_widget: bool,
1742    /// Optional ontology validator for class/property validation
1743    pub ontology_validator: Option<crate::ontology::OntologyValidator>,
1744    /// Skip stamping a new publication ID on the graph.
1745    /// Useful when applying collection assignments to a branch that has
1746    /// already been captured by a resource model via add_subgraph.
1747    pub skip_publication: bool,
1748}
1749
1750impl Default for MutatorOptions {
1751    fn default() -> Self {
1752        Self {
1753            autocreate_card: true,
1754            autocreate_widget: true,
1755            ontology_validator: None,
1756            skip_publication: false,
1757        }
1758    }
1759}
1760
1761/// Normalise a single-class `&str` argument into the `Option<Vec<String>>`
1762/// form used by mutation params. An empty or all-whitespace string becomes
1763/// `None` (i.e. "no ontology class"); any non-empty value becomes a
1764/// single-element list. Use direct `Some(vec![...])` for multi-class nodes.
1765fn normalise_class_arg(class: &str) -> Option<Vec<String>> {
1766    sanitize_class_list(Some(vec![class.to_string()]))
1767}
1768
1769/// Filter out blank entries from an optional class list. Returns `None` if
1770/// the result is empty.
1771fn sanitize_class_list(list: Option<Vec<String>>) -> Option<Vec<String>> {
1772    match list {
1773        None => None,
1774        Some(v) => {
1775            let cleaned: Vec<String> = v.into_iter().filter(|s| !s.trim().is_empty()).collect();
1776            if cleaned.is_empty() {
1777                None
1778            } else {
1779                Some(cleaned)
1780            }
1781        }
1782    }
1783}
1784
1785// =============================================================================
1786// Graph Mutator (Builder Pattern)
1787// =============================================================================
1788
1789/// Builder for constructing and modifying graphs
1790///
1791/// Uses a combination of Builder and Command patterns:
1792/// - Builder pattern for ergonomic, chainable API
1793/// - Command pattern for serializable, replayable mutations
1794pub struct GraphMutator {
1795    base_graph: StaticGraph,
1796    mutations: Vec<GraphMutation>,
1797    options: MutatorOptions,
1798}
1799
1800impl GraphMutator {
1801    /// Create a new GraphMutator from a base graph
1802    pub fn new(base_graph: StaticGraph) -> Self {
1803        Self {
1804            base_graph,
1805            mutations: Vec::new(),
1806            options: MutatorOptions::default(),
1807        }
1808    }
1809
1810    /// Create a new GraphMutator with custom options
1811    pub fn with_options(base_graph: StaticGraph, options: MutatorOptions) -> Self {
1812        Self {
1813            base_graph,
1814            mutations: Vec::new(),
1815            options,
1816        }
1817    }
1818
1819    /// Get the list of pending mutations (for debugging/serialization)
1820    pub fn mutations(&self) -> &[GraphMutation] {
1821        &self.mutations
1822    }
1823
1824    // =========================================================================
1825    // Node Addition Methods
1826    // =========================================================================
1827
1828    /// Add a semantic node (structural grouping node)
1829    #[allow(clippy::too_many_arguments)]
1830    pub fn add_semantic_node(
1831        mut self,
1832        parent_alias: Option<&str>,
1833        alias: &str,
1834        name: &str,
1835        cardinality: Cardinality,
1836        ontology_class: &str,
1837        parent_property: &str,
1838        description: Option<&str>,
1839        options: NodeOptions,
1840        config: Option<serde_json::Value>,
1841    ) -> Self {
1842        self.add_generic_node_mut(
1843            parent_alias,
1844            alias,
1845            name,
1846            cardinality,
1847            "semantic",
1848            ontology_class,
1849            parent_property,
1850            description,
1851            options,
1852            config,
1853        );
1854        self
1855    }
1856
1857    /// Add a string node
1858    #[allow(clippy::too_many_arguments)]
1859    pub fn add_string_node(
1860        mut self,
1861        parent_alias: Option<&str>,
1862        alias: &str,
1863        name: &str,
1864        cardinality: Cardinality,
1865        ontology_class: &str,
1866        parent_property: &str,
1867        description: Option<&str>,
1868        options: NodeOptions,
1869        config: Option<serde_json::Value>,
1870    ) -> Self {
1871        self.add_generic_node_mut(
1872            parent_alias,
1873            alias,
1874            name,
1875            cardinality,
1876            "string",
1877            ontology_class,
1878            parent_property,
1879            description,
1880            options,
1881            config,
1882        );
1883        self
1884    }
1885
1886    /// Add a concept node (RDM collection reference)
1887    #[allow(clippy::too_many_arguments)]
1888    pub fn add_concept_node(
1889        mut self,
1890        parent_alias: Option<&str>,
1891        alias: &str,
1892        name: &str,
1893        collection_id: Option<&str>,
1894        is_list: bool,
1895        cardinality: Cardinality,
1896        ontology_class: &str,
1897        parent_property: &str,
1898        description: Option<&str>,
1899        options: NodeOptions,
1900        config: Option<serde_json::Value>,
1901    ) -> Self {
1902        let mut node_config = config.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
1903        if let Some(coll_id) = collection_id {
1904            if let serde_json::Value::Object(ref mut map) = node_config {
1905                map.insert(
1906                    "rdmCollection".to_string(),
1907                    serde_json::Value::String(coll_id.to_string()),
1908                );
1909            }
1910        }
1911        let datatype = if is_list { "concept-list" } else { "concept" };
1912        self.add_generic_node_mut(
1913            parent_alias,
1914            alias,
1915            name,
1916            cardinality,
1917            datatype,
1918            ontology_class,
1919            parent_property,
1920            description,
1921            options,
1922            Some(node_config),
1923        );
1924        self
1925    }
1926
1927    /// Add a number node
1928    #[allow(clippy::too_many_arguments)]
1929    pub fn add_number_node(
1930        mut self,
1931        parent_alias: Option<&str>,
1932        alias: &str,
1933        name: &str,
1934        cardinality: Cardinality,
1935        ontology_class: &str,
1936        parent_property: &str,
1937        description: Option<&str>,
1938        options: NodeOptions,
1939        config: Option<serde_json::Value>,
1940    ) -> Self {
1941        self.add_generic_node_mut(
1942            parent_alias,
1943            alias,
1944            name,
1945            cardinality,
1946            "number",
1947            ontology_class,
1948            parent_property,
1949            description,
1950            options,
1951            config,
1952        );
1953        self
1954    }
1955
1956    /// Add a date node
1957    #[allow(clippy::too_many_arguments)]
1958    pub fn add_date_node(
1959        mut self,
1960        parent_alias: Option<&str>,
1961        alias: &str,
1962        name: &str,
1963        cardinality: Cardinality,
1964        ontology_class: &str,
1965        parent_property: &str,
1966        description: Option<&str>,
1967        options: NodeOptions,
1968        config: Option<serde_json::Value>,
1969    ) -> Self {
1970        self.add_generic_node_mut(
1971            parent_alias,
1972            alias,
1973            name,
1974            cardinality,
1975            "date",
1976            ontology_class,
1977            parent_property,
1978            description,
1979            options,
1980            config,
1981        );
1982        self
1983    }
1984
1985    /// Add a boolean node
1986    #[allow(clippy::too_many_arguments)]
1987    pub fn add_boolean_node(
1988        mut self,
1989        parent_alias: Option<&str>,
1990        alias: &str,
1991        name: &str,
1992        cardinality: Cardinality,
1993        ontology_class: &str,
1994        parent_property: &str,
1995        description: Option<&str>,
1996        options: NodeOptions,
1997        config: Option<serde_json::Value>,
1998    ) -> Self {
1999        self.add_generic_node_mut(
2000            parent_alias,
2001            alias,
2002            name,
2003            cardinality,
2004            "boolean",
2005            ontology_class,
2006            parent_property,
2007            description,
2008            options,
2009            config,
2010        );
2011        self
2012    }
2013
2014    /// Add a generic node with any datatype (consuming builder pattern)
2015    #[allow(clippy::too_many_arguments)]
2016    pub fn add_generic_node(
2017        mut self,
2018        parent_alias: Option<&str>,
2019        alias: &str,
2020        name: &str,
2021        cardinality: Cardinality,
2022        datatype: &str,
2023        ontology_class: &str,
2024        parent_property: &str,
2025        description: Option<&str>,
2026        options: NodeOptions,
2027        config: Option<serde_json::Value>,
2028    ) -> Self {
2029        self.add_generic_node_mut(
2030            parent_alias,
2031            alias,
2032            name,
2033            cardinality,
2034            datatype,
2035            ontology_class,
2036            parent_property,
2037            description,
2038            options,
2039            config,
2040        );
2041        self
2042    }
2043
2044    /// Internal: add a generic node (mutating)
2045    #[allow(clippy::too_many_arguments)]
2046    fn add_generic_node_mut(
2047        &mut self,
2048        parent_alias: Option<&str>,
2049        alias: &str,
2050        name: &str,
2051        cardinality: Cardinality,
2052        datatype: &str,
2053        ontology_class: &str,
2054        parent_property: &str,
2055        description: Option<&str>,
2056        options: NodeOptions,
2057        config: Option<serde_json::Value>,
2058    ) {
2059        let ontology_class = normalise_class_arg(ontology_class);
2060        self.mutations.push(GraphMutation::AddNode(AddNodeParams {
2061            parent_alias: parent_alias.map(String::from),
2062            alias: alias.to_string(),
2063            name: name.to_string(),
2064            cardinality,
2065            datatype: datatype.to_string(),
2066            ontology_class,
2067            parent_property: parent_property.to_string(),
2068            description: description.map(String::from),
2069            config,
2070            options,
2071        }));
2072    }
2073
2074    // =========================================================================
2075    // Card Methods
2076    // =========================================================================
2077
2078    /// Add a card for a nodegroup
2079    pub fn add_card(
2080        mut self,
2081        nodegroup_id: &str,
2082        name: &str,
2083        options: CardOptions,
2084        config: Option<serde_json::Value>,
2085    ) -> Self {
2086        self.mutations.push(GraphMutation::AddCard(AddCardParams {
2087            nodegroup_id: nodegroup_id.to_string(),
2088            name: StaticTranslatableString::from_string(name),
2089            component_id: Some(DEFAULT_CARD_COMPONENT_ID.to_string()),
2090            options,
2091            config,
2092        }));
2093        self
2094    }
2095
2096    /// Add a widget to a card
2097    pub fn add_widget_to_card(
2098        mut self,
2099        node_id: &str,
2100        widget: &Widget,
2101        label: &str,
2102        config: serde_json::Value,
2103        sortorder: Option<i32>,
2104        visible: Option<bool>,
2105    ) -> Self {
2106        self.mutations
2107            .push(GraphMutation::AddWidgetToCard(AddWidgetParams {
2108                node_id: node_id.to_string(),
2109                widget_id: widget.id.clone(),
2110                label: label.to_string(),
2111                config,
2112                sortorder,
2113                visible,
2114            }));
2115        self
2116    }
2117
2118    // =========================================================================
2119    // Build Methods
2120    // =========================================================================
2121
2122    /// Apply all mutations and return the resulting graph
2123    pub fn build(self) -> Result<StaticGraph, MutationError> {
2124        let mut graph = self.base_graph.deep_clone();
2125
2126        for mutation in self.mutations {
2127            apply_mutation(&mut graph, mutation, &self.options)?;
2128        }
2129
2130        // Rebuild indices after all mutations
2131        graph.build_indices();
2132
2133        Ok(graph)
2134    }
2135}
2136
2137// =============================================================================
2138// Mutation Application
2139// =============================================================================
2140
2141/// Apply a single mutation to a graph
2142///
2143/// For extension mutations, pass an `ExtensionMutationRegistry` via `registry`.
2144/// If an extension mutation is encountered without a registry, an error is returned.
2145fn apply_mutation(
2146    graph: &mut StaticGraph,
2147    mutation: GraphMutation,
2148    options: &MutatorOptions,
2149) -> Result<(), MutationError> {
2150    apply_mutation_with_extensions(graph, mutation, options, None)
2151}
2152
2153/// Apply a single mutation to a graph with extension support
2154///
2155/// # Arguments
2156/// * `graph` - The graph to mutate
2157/// * `mutation` - The mutation to apply
2158/// * `options` - Mutator options
2159/// * `registry` - Optional extension mutation registry
2160fn apply_mutation_with_extensions(
2161    graph: &mut StaticGraph,
2162    mutation: GraphMutation,
2163    options: &MutatorOptions,
2164    registry: Option<&ExtensionMutationRegistry>,
2165) -> Result<(), MutationError> {
2166    match mutation {
2167        GraphMutation::AddNode(params) => apply_add_node(graph, params, options),
2168        GraphMutation::AddNodegroup(params) => apply_add_nodegroup(graph, params, options),
2169        GraphMutation::AddEdge(params) => apply_add_edge(graph, params),
2170        GraphMutation::AddCard(params) => apply_add_card(graph, params),
2171        GraphMutation::AddWidgetToCard(params) => apply_add_widget(graph, params),
2172        GraphMutation::AddSubgraph(params) => apply_add_subgraph(graph, params),
2173        GraphMutation::UpdateSubgraph(params) => apply_update_subgraph(graph, params),
2174        GraphMutation::ConceptChangeCollection(params) => apply_concept_change_collection(graph, params),
2175        GraphMutation::DeleteCard(params) => apply_delete_card(graph, params),
2176        GraphMutation::DeleteWidget(params) => apply_delete_widget(graph, params),
2177        GraphMutation::AddFunction(params) => apply_add_function(graph, params),
2178        GraphMutation::SetDescriptorFunction(params) => apply_set_descriptor_function(graph, params),
2179        GraphMutation::DeleteFunction(params) => apply_delete_function(graph, params),
2180        GraphMutation::DeleteNode(params) => apply_delete_node(graph, params),
2181        GraphMutation::DeleteNodegroup(params) => apply_delete_nodegroup(graph, params),
2182        GraphMutation::UpdateNode(params) => apply_update_node(graph, params, options),
2183        GraphMutation::ChangeNodeType(params) => apply_change_node_type(graph, params),
2184        GraphMutation::ChangeCardinality(params) => apply_change_cardinality(graph, params),
2185        GraphMutation::RenameNode(params) => apply_rename_node(graph, params),
2186        GraphMutation::RenameCard(params) => apply_rename_card(graph, params),
2187        GraphMutation::RealignCardFromNode(params) => apply_realign_card_from_node(graph, params),
2188        GraphMutation::RenameGraph(params) => apply_rename_graph(graph, params),
2189        GraphMutation::SetDescriptorTemplate(params) => apply_set_descriptor_template(graph, params),
2190        GraphMutation::CoppiceSubgraph(params) => apply_coppice_subgraph(graph, params),
2191        GraphMutation::UpdateWidgetConfig(params) => apply_update_widget_config(graph, params),
2192        GraphMutation::CreateGraph(_) => {
2193            Err(MutationError::Other(
2194                "CreateGraph cannot be used as a regular mutation. Use apply_mutations_create_from_json instead.".to_string()
2195            ))
2196        }
2197        GraphMutation::Extension(params) => {
2198            match registry {
2199                Some(reg) => {
2200                    let handler = reg.get(&params.name)
2201                        .ok_or_else(|| MutationError::ExtensionNotFound(params.name.clone()))?;
2202                    handler.apply(graph, &params.params, options)
2203                }
2204                None => Err(MutationError::NoExtensionRegistry(params.name)),
2205            }
2206        }
2207    }
2208}
2209
2210fn apply_add_node(
2211    graph: &mut StaticGraph,
2212    params: AddNodeParams,
2213    options: &MutatorOptions,
2214) -> Result<(), MutationError> {
2215    // Check for duplicate alias
2216    if graph.find_node_by_alias(&params.alias).is_some() {
2217        return Err(MutationError::AliasAlreadyExists(params.alias.clone()));
2218    }
2219
2220    // Find parent node
2221    let parent = if let Some(ref parent_alias) = params.parent_alias {
2222        graph
2223            .find_node_by_alias(parent_alias)
2224            .ok_or_else(|| MutationError::ParentNotFound(parent_alias.clone()))?
2225    } else {
2226        graph.get_root()
2227    };
2228    let parent_nodeid = parent.nodeid.clone();
2229    let parent_nodegroup_id = parent.nodegroup_id.clone();
2230    let parent_classes: Vec<String> = parent.ontologyclass.clone().unwrap_or_default();
2231
2232    // Normalise incoming class list (strip blank entries, collapse to None if empty).
2233    let node_classes: Option<Vec<String>> = sanitize_class_list(params.ontology_class);
2234
2235    // Validate ontology class and property if validator is present
2236    if let Some(ref validator) = options.ontology_validator {
2237        if let Some(ref classes) = node_classes {
2238            validator
2239                .validate_edge_multi(&parent_classes, &params.parent_property, classes)
2240                .map_err(MutationError::OntologyValidation)?;
2241        }
2242    }
2243
2244    // Generate node ID
2245    let node_id = generate_uuid_v5(
2246        ("graph", Some(&graph.graphid)),
2247        &format!("node-{}", params.alias),
2248    );
2249
2250    // Determine nodegroup
2251    // All nodes except root must have a nodegroup. Create a new one if:
2252    // - Cardinality is N (multiple instances allowed), OR
2253    // - Parent is root (direct children of root always get their own nodegroup), OR
2254    // - is_collector is explicitly set (node should own its own nodegroup,
2255    //   matching Arches behaviour where is_collector ≡ nodeid == nodegroup_id)
2256    let (nodegroup_id, created_new_nodegroup) = if params.cardinality == Cardinality::N
2257        || parent.is_root()
2258        || params.options.is_collector == Some(true)
2259    {
2260        // Create new nodegroup for this node
2261        let ng_id = node_id.clone();
2262
2263        // Add nodegroup
2264        let nodegroup = StaticNodegroup {
2265            nodegroupid: ng_id.clone(),
2266            cardinality: Some(params.cardinality.as_str().to_string()),
2267            parentnodegroup_id: parent_nodegroup_id.clone(),
2268            legacygroupid: None,
2269            grouping_node_id: None,
2270        };
2271        graph.push_nodegroup(nodegroup);
2272
2273        // Auto-create card if enabled
2274        if options.autocreate_card {
2275            let card_id = generate_uuid_v5(
2276                ("graph", Some(&graph.graphid)),
2277                &format!("card-ng-{}", ng_id),
2278            );
2279            let card = StaticCard {
2280                active: true,
2281                cardid: card_id,
2282                component_id: DEFAULT_CARD_COMPONENT_ID.to_string(),
2283                config: None,
2284                constraints: vec![],
2285                cssclass: None,
2286                description: None,
2287                graph_id: graph.graphid.clone(),
2288                helpenabled: false,
2289                helptext: StaticTranslatableString::empty(),
2290                helptitle: StaticTranslatableString::empty(),
2291                instructions: StaticTranslatableString::empty(),
2292                is_editable: Some(true),
2293                name: StaticTranslatableString::from_string(&params.name),
2294                nodegroup_id: ng_id.clone(),
2295                sortorder: Some(0),
2296                visible: true,
2297                source_identifier_id: None,
2298            };
2299            graph.push_card(card);
2300        }
2301
2302        (Some(ng_id), true)
2303    } else {
2304        (parent_nodegroup_id, false)
2305    };
2306
2307    // Build config - error if provided but invalid
2308    let config: HashMap<String, serde_json::Value> = match params.config {
2309        Some(v) => serde_json::from_value(v).map_err(|e| MutationError::InvalidConfig {
2310            alias: params.alias.clone(),
2311            error: e.to_string(),
2312        })?,
2313        None => HashMap::new(),
2314    };
2315
2316    // Create node
2317    let node = StaticNode {
2318        nodeid: node_id.clone(),
2319        name: params.name.clone(),
2320        alias: Some(params.alias.clone()),
2321        datatype: params.datatype.clone(),
2322        nodegroup_id: nodegroup_id.clone(),
2323        graph_id: graph.graphid.clone(),
2324        is_collector: params.options.is_collector.unwrap_or(false),
2325        isrequired: params.options.isrequired.unwrap_or(false),
2326        exportable: params.options.exportable.unwrap_or(false),
2327        sortorder: Some(params.options.sortorder.unwrap_or(0)),
2328        config,
2329        parentproperty: Some(params.parent_property.clone()),
2330        ontologyclass: node_classes,
2331        description: params
2332            .description
2333            .map(|d| StaticTranslatableString::from_string(&d)),
2334        fieldname: params.options.fieldname,
2335        hascustomalias: params.options.hascustomalias.unwrap_or(false),
2336        issearchable: params.options.issearchable.unwrap_or(true),
2337        istopnode: params.options.istopnode.unwrap_or(false),
2338        sourcebranchpublication_id: None,
2339        source_identifier_id: None,
2340        is_immutable: None,
2341    };
2342    graph.push_node(node);
2343
2344    // Create edge from parent to new node
2345    let edge_id = generate_uuid_v5(
2346        ("graph", Some(&graph.graphid)),
2347        &format!("edge-{}-{}", parent_nodeid, node_id),
2348    );
2349    let edge = StaticEdge {
2350        domainnode_id: parent_nodeid,
2351        rangenode_id: node_id.clone(),
2352        edgeid: edge_id,
2353        graph_id: graph.graphid.clone(),
2354        name: None,
2355        ontologyproperty: Some(params.parent_property),
2356        description: None,
2357        source_identifier_id: None,
2358    };
2359    graph.push_edge(edge);
2360
2361    // Auto-create widget if enabled and not semantic
2362    if options.autocreate_widget && params.datatype != "semantic" {
2363        let widget = get_default_widget_for_datatype(&params.datatype)
2364            .map_err(|_| MutationError::NoWidgetForDatatype(params.datatype.clone()))?;
2365
2366        let ng_id = nodegroup_id.as_ref().ok_or_else(|| {
2367            MutationError::Other(format!(
2368                "Cannot create widget for node '{}': no nodegroup",
2369                params.alias
2370            ))
2371        })?;
2372
2373        // If we created a new nodegroup, a card must exist (created above if autocreate_card).
2374        // If inheriting an existing nodegroup that has no card, silently skip widget creation.
2375        if let Some(card) = graph.find_card_by_nodegroup(ng_id) {
2376            let mut widget_config = widget.get_default_config();
2377            if let serde_json::Value::Object(ref mut map) = widget_config {
2378                map.insert(
2379                    "label".to_string(),
2380                    serde_json::Value::String(params.name.clone()),
2381                );
2382            }
2383
2384            let cxnxw_id = generate_uuid_v5(
2385                ("graph", Some(&graph.graphid)),
2386                &format!("cxnxw-{}-{}", node_id, widget.id),
2387            );
2388
2389            let cxnxw = StaticCardsXNodesXWidgets {
2390                card_id: card.cardid.clone(),
2391                config: widget_config,
2392                id: cxnxw_id,
2393                label: StaticTranslatableString::from_string(&params.name),
2394                node_id: node_id.clone(),
2395                sortorder: Some(params.options.sortorder.unwrap_or(0)),
2396                visible: true,
2397                widget_id: widget.id.clone(),
2398                source_identifier_id: None,
2399            };
2400            graph.push_card_x_node_x_widget(cxnxw);
2401        } else if created_new_nodegroup {
2402            // We created a new nodegroup but it has no card - this shouldn't happen
2403            // if autocreate_card is true, so this is an error
2404            return Err(MutationError::CardNotFound(ng_id.clone()));
2405        }
2406        // else: inherited nodegroup with no card, silently skip widget creation
2407    }
2408
2409    Ok(())
2410}
2411
2412fn apply_add_nodegroup(
2413    graph: &mut StaticGraph,
2414    params: AddNodegroupParams,
2415    options: &MutatorOptions,
2416) -> Result<(), MutationError> {
2417    // Find parent
2418    let parent_nodegroup_id = if let Some(ref parent_alias) = params.parent_alias {
2419        let parent = graph
2420            .find_node_by_alias(parent_alias)
2421            .ok_or_else(|| MutationError::ParentNotFound(parent_alias.clone()))?;
2422        parent.nodegroup_id.clone()
2423    } else {
2424        graph.get_root().nodegroup_id.clone()
2425    };
2426
2427    let nodegroup = StaticNodegroup {
2428        nodegroupid: params.nodegroup_id.clone(),
2429        cardinality: Some(params.cardinality.as_str().to_string()),
2430        parentnodegroup_id: parent_nodegroup_id,
2431        legacygroupid: None,
2432        grouping_node_id: None,
2433    };
2434    graph.push_nodegroup(nodegroup);
2435
2436    // Auto-create card if enabled
2437    if options.autocreate_card {
2438        let card_id = generate_uuid_v5(
2439            ("graph", Some(&graph.graphid)),
2440            &format!("card-ng-{}", params.nodegroup_id),
2441        );
2442        let card = StaticCard {
2443            active: true,
2444            cardid: card_id,
2445            component_id: DEFAULT_CARD_COMPONENT_ID.to_string(),
2446            config: None,
2447            constraints: vec![],
2448            cssclass: None,
2449            description: None,
2450            graph_id: graph.graphid.clone(),
2451            helpenabled: false,
2452            helptext: StaticTranslatableString::empty(),
2453            helptitle: StaticTranslatableString::empty(),
2454            instructions: StaticTranslatableString::empty(),
2455            is_editable: Some(true),
2456            name: StaticTranslatableString::from_string("(unnamed)"),
2457            nodegroup_id: params.nodegroup_id,
2458            sortorder: Some(0),
2459            visible: true,
2460            source_identifier_id: None,
2461        };
2462        graph.push_card(card);
2463    }
2464
2465    Ok(())
2466}
2467
2468fn apply_add_edge(graph: &mut StaticGraph, params: AddEdgeParams) -> Result<(), MutationError> {
2469    let edge_id = generate_uuid_v5(
2470        ("graph", Some(&graph.graphid)),
2471        &format!("edge-{}-{}", params.from_node_id, params.to_node_id),
2472    );
2473
2474    let edge = StaticEdge {
2475        domainnode_id: params.from_node_id,
2476        rangenode_id: params.to_node_id,
2477        edgeid: edge_id,
2478        graph_id: graph.graphid.clone(),
2479        name: params.name,
2480        ontologyproperty: Some(params.ontology_property),
2481        description: params.description,
2482        source_identifier_id: None,
2483    };
2484    graph.push_edge(edge);
2485
2486    Ok(())
2487}
2488
2489fn apply_add_card(graph: &mut StaticGraph, params: AddCardParams) -> Result<(), MutationError> {
2490    // Resolve alias to actual nodegroup ID: find a node by alias, then use its
2491    // nodegroup_id.  Fall back to the raw string if no alias matches (it may
2492    // already be a UUID).
2493    let nodegroup_id = graph
2494        .find_node_by_alias(&params.nodegroup_id)
2495        .and_then(|n| n.nodegroup_id.clone())
2496        .unwrap_or(params.nodegroup_id);
2497
2498    // Check if card already exists for this nodegroup
2499    if graph.find_card_by_nodegroup(&nodegroup_id).is_some() {
2500        return Err(MutationError::CardAlreadyExists(nodegroup_id));
2501    }
2502
2503    let card_id = generate_uuid_v5(
2504        ("graph", Some(&graph.graphid)),
2505        &format!("card-ng-{}", nodegroup_id),
2506    );
2507
2508    let card = StaticCard {
2509        active: params.options.active.unwrap_or(true),
2510        cardid: card_id,
2511        component_id: params
2512            .component_id
2513            .unwrap_or_else(|| DEFAULT_CARD_COMPONENT_ID.to_string()),
2514        config: params.config,
2515        constraints: vec![],
2516        cssclass: params.options.cssclass,
2517        description: params.options.description,
2518        graph_id: graph.graphid.clone(),
2519        helpenabled: params.options.helpenabled.unwrap_or(false),
2520        helptext: params
2521            .options
2522            .helptext
2523            .unwrap_or_else(StaticTranslatableString::empty),
2524        helptitle: params
2525            .options
2526            .helptitle
2527            .unwrap_or_else(StaticTranslatableString::empty),
2528        instructions: params
2529            .options
2530            .instructions
2531            .unwrap_or_else(StaticTranslatableString::empty),
2532        is_editable: params.options.is_editable,
2533        name: params.name,
2534        nodegroup_id,
2535        sortorder: Some(params.options.sortorder.unwrap_or(0)),
2536        visible: params.options.visible.unwrap_or(true),
2537        source_identifier_id: None,
2538    };
2539    graph.push_card(card);
2540
2541    Ok(())
2542}
2543
2544fn apply_add_widget(graph: &mut StaticGraph, params: AddWidgetParams) -> Result<(), MutationError> {
2545    // Find the node to get its nodegroup
2546    let node = graph
2547        .nodes
2548        .iter()
2549        .find(|n| n.nodeid == params.node_id)
2550        .ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
2551    let nodegroup_id = node
2552        .nodegroup_id
2553        .clone()
2554        .ok_or_else(|| MutationError::NodegroupNotFound(params.node_id.clone()))?;
2555
2556    // Find card for this nodegroup
2557    let card = graph
2558        .find_card_by_nodegroup(&nodegroup_id)
2559        .ok_or_else(|| MutationError::CardNotFound(nodegroup_id.clone()))?;
2560    let card_id = card.cardid.clone();
2561
2562    let cxnxw_id = generate_uuid_v5(
2563        ("graph", Some(&graph.graphid)),
2564        &format!("cxnxw-{}-{}", params.node_id, params.widget_id),
2565    );
2566
2567    let cxnxw = StaticCardsXNodesXWidgets {
2568        card_id,
2569        config: params.config,
2570        id: cxnxw_id,
2571        label: StaticTranslatableString::from_string(&params.label),
2572        node_id: params.node_id,
2573        sortorder: Some(params.sortorder.unwrap_or(0)),
2574        visible: params.visible.unwrap_or(true),
2575        widget_id: params.widget_id,
2576        source_identifier_id: None,
2577    };
2578    graph.push_card_x_node_x_widget(cxnxw);
2579
2580    Ok(())
2581}
2582
2583/// Valid datatypes for ConceptChangeCollection operation
2584const CONCEPT_DATATYPES: &[&str] = &["concept", "concept-list"];
2585
2586fn apply_concept_change_collection(
2587    graph: &mut StaticGraph,
2588    params: ConceptChangeCollectionParams,
2589) -> Result<(), MutationError> {
2590    // Find node by alias first, then by ID
2591    let node = graph
2592        .find_node_by_alias(&params.node_id)
2593        .or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
2594        .ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
2595
2596    // Validate datatype is concept or concept-list
2597    if !CONCEPT_DATATYPES.contains(&node.datatype.as_str()) {
2598        return Err(MutationError::InvalidDatatype {
2599            expected: "concept or concept-list".to_string(),
2600            found: node.datatype.clone(),
2601            node_id: params.node_id.clone(),
2602        });
2603    }
2604
2605    let node_id = node.nodeid.clone();
2606
2607    // Find the mutable node and update its config
2608    let node_mut = graph
2609        .nodes
2610        .iter_mut()
2611        .find(|n| n.nodeid == node_id)
2612        .ok_or_else(|| MutationError::NodeNotFound(node_id.clone()))?;
2613
2614    // Update rdmCollection in config
2615    node_mut.config.insert(
2616        "rdmCollection".to_string(),
2617        serde_json::Value::String(params.collection_id),
2618    );
2619
2620    Ok(())
2621}
2622
2623// =============================================================================
2624// Deletion Operations
2625// =============================================================================
2626
2627fn apply_delete_card(
2628    graph: &mut StaticGraph,
2629    params: DeleteCardParams,
2630) -> Result<(), MutationError> {
2631    // Verify card exists
2632    let card_exists = graph
2633        .cards
2634        .as_ref()
2635        .map(|cards| cards.iter().any(|c| c.cardid == params.card_id))
2636        .unwrap_or(false);
2637
2638    if !card_exists {
2639        return Err(MutationError::CardNotFound(params.card_id));
2640    }
2641
2642    // Remove associated cards_x_nodes_x_widgets entries
2643    if let Some(ref mut cxnxws) = graph.cards_x_nodes_x_widgets {
2644        cxnxws.retain(|c| c.card_id != params.card_id);
2645    }
2646
2647    // Remove the card
2648    if let Some(ref mut cards) = graph.cards {
2649        cards.retain(|c| c.cardid != params.card_id);
2650    }
2651
2652    Ok(())
2653}
2654
2655fn apply_delete_widget(
2656    graph: &mut StaticGraph,
2657    params: DeleteWidgetParams,
2658) -> Result<(), MutationError> {
2659    // Verify widget mapping exists
2660    let widget_exists = graph
2661        .cards_x_nodes_x_widgets
2662        .as_ref()
2663        .map(|cxnxws| cxnxws.iter().any(|c| c.id == params.widget_mapping_id))
2664        .unwrap_or(false);
2665
2666    if !widget_exists {
2667        return Err(MutationError::WidgetNotFound(params.widget_mapping_id));
2668    }
2669
2670    // Remove the widget mapping
2671    if let Some(ref mut cxnxws) = graph.cards_x_nodes_x_widgets {
2672        cxnxws.retain(|c| c.id != params.widget_mapping_id);
2673    }
2674
2675    Ok(())
2676}
2677
2678fn apply_update_widget_config(
2679    graph: &mut StaticGraph,
2680    params: UpdateWidgetConfigParams,
2681) -> Result<(), MutationError> {
2682    // Resolve node by alias first, then by ID
2683    let node_id = graph
2684        .find_node_by_alias(&params.node_id)
2685        .or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
2686        .ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?
2687        .nodeid
2688        .clone();
2689
2690    // Find the widget mapping for this node
2691    let cxnxw = graph
2692        .cards_x_nodes_x_widgets
2693        .as_mut()
2694        .and_then(|cxnxws| cxnxws.iter_mut().find(|c| c.node_id == node_id))
2695        .ok_or_else(|| {
2696            MutationError::WidgetNotFound(format!("no widget for node {}", params.node_id))
2697        })?;
2698
2699    // Merge provided config keys into existing config
2700    if let serde_json::Value::Object(patch) = params.config {
2701        if let serde_json::Value::Object(ref mut existing) = cxnxw.config {
2702            for (key, value) in patch {
2703                existing.insert(key, value);
2704            }
2705        } else {
2706            // Existing config isn't an object — replace it
2707            cxnxw.config = serde_json::Value::Object(patch);
2708        }
2709    } else {
2710        return Err(MutationError::Other(
2711            "update_widget_config: config must be a JSON object".to_string(),
2712        ));
2713    }
2714
2715    Ok(())
2716}
2717
2718/// Resolve a function ID: pass through UUIDs, derive UUID v5 from non-UUID strings.
2719fn resolve_function_id(raw: &str) -> String {
2720    if uuid::Uuid::parse_str(raw).is_ok() {
2721        return raw.to_string();
2722    }
2723    uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, raw.as_bytes()).to_string()
2724}
2725
2726fn apply_add_function(
2727    graph: &mut StaticGraph,
2728    params: AddFunctionParams,
2729) -> Result<(), MutationError> {
2730    use crate::graph::StaticFunctionsXGraphs;
2731
2732    let function_id = resolve_function_id(&params.function_id);
2733    let fxg = graph.functions_x_graphs.get_or_insert_with(Vec::new);
2734
2735    if fxg
2736        .iter()
2737        .any(|f| f.function_id == function_id && f.graph_id == graph.graphid)
2738    {
2739        return Err(MutationError::Other(format!(
2740            "Function {} already mapped to graph {}",
2741            function_id, graph.graphid
2742        )));
2743    }
2744
2745    fxg.push(StaticFunctionsXGraphs {
2746        id: generate_uuid_v5(("function", Some(&graph.graphid)), &function_id),
2747        function_id,
2748        graph_id: graph.graphid.clone(),
2749        config: params
2750            .config
2751            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
2752    });
2753
2754    Ok(())
2755}
2756
2757fn apply_set_descriptor_function(
2758    graph: &mut StaticGraph,
2759    params: SetDescriptorFunctionParams,
2760) -> Result<(), MutationError> {
2761    use crate::graph::StaticFunctionsXGraphs;
2762    use crate::graph::DESCRIPTOR_FUNCTION_ID;
2763
2764    let function_id = resolve_function_id(&params.function_id);
2765    let fxg = graph.functions_x_graphs.get_or_insert_with(Vec::new);
2766
2767    // Remove all existing descriptor functions (default and any others)
2768    // A descriptor function is identified by either:
2769    // - Being the well-known default descriptor function ID
2770    // - Having descriptor_types in its config
2771    fxg.retain(|f| {
2772        if f.function_id == DESCRIPTOR_FUNCTION_ID {
2773            return false;
2774        }
2775        if f.function_id == function_id {
2776            return false; // Remove existing entry for this function too (will re-add)
2777        }
2778        if f.config
2779            .as_object()
2780            .is_some_and(|c| c.contains_key("descriptor_types"))
2781        {
2782            return false;
2783        }
2784        true
2785    });
2786
2787    // Add the new descriptor function with empty config
2788    fxg.push(StaticFunctionsXGraphs {
2789        id: generate_uuid_v5(("function", Some(&graph.graphid)), &function_id),
2790        function_id,
2791        graph_id: graph.graphid.clone(),
2792        config: serde_json::Value::Object(serde_json::Map::new()),
2793    });
2794
2795    Ok(())
2796}
2797
2798fn apply_delete_function(
2799    graph: &mut StaticGraph,
2800    params: DeleteFunctionParams,
2801) -> Result<(), MutationError> {
2802    // Verify function mapping exists
2803    let function_exists = graph
2804        .functions_x_graphs
2805        .as_ref()
2806        .map(|fxgs| fxgs.iter().any(|f| f.id == params.function_mapping_id))
2807        .unwrap_or(false);
2808
2809    if !function_exists {
2810        return Err(MutationError::FunctionNotFound(params.function_mapping_id));
2811    }
2812
2813    // Remove the function mapping
2814    if let Some(ref mut fxgs) = graph.functions_x_graphs {
2815        fxgs.retain(|f| f.id != params.function_mapping_id);
2816    }
2817
2818    Ok(())
2819}
2820
2821fn apply_set_descriptor_template(
2822    graph: &mut StaticGraph,
2823    params: SetDescriptorTemplateParams,
2824) -> Result<(), MutationError> {
2825    graph
2826        .set_descriptor_template(&params.descriptor_type, &params.string_template)
2827        .map_err(MutationError::Other)
2828}
2829
2830fn apply_delete_node(
2831    graph: &mut StaticGraph,
2832    params: DeleteNodeParams,
2833) -> Result<(), MutationError> {
2834    // Find node by alias first, then by ID
2835    let node = graph
2836        .find_node_by_alias(&params.node_id)
2837        .or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
2838        .ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
2839
2840    // Check if this is a root node (cannot delete root)
2841    if node.istopnode {
2842        return Err(MutationError::CannotDeleteRootNode(params.node_id.clone()));
2843    }
2844
2845    let root_id = node.nodeid.clone();
2846
2847    // Cascade: collect all descendant nodes by traversing edges
2848    let mut nodes_to_delete = vec![root_id.clone()];
2849    let mut i = 0;
2850    while i < nodes_to_delete.len() {
2851        let current = &nodes_to_delete[i].clone();
2852        for edge in &graph.edges {
2853            if edge.domainnode_id == *current && !nodes_to_delete.contains(&edge.rangenode_id) {
2854                nodes_to_delete.push(edge.rangenode_id.clone());
2855            }
2856        }
2857        i += 1;
2858    }
2859
2860    // Check none of the cascaded descendants is a root node
2861    for nid in &nodes_to_delete {
2862        if let Some(n) = graph.nodes.iter().find(|n| n.nodeid == *nid) {
2863            if n.istopnode {
2864                return Err(MutationError::CannotDeleteRootNode(nid.clone()));
2865            }
2866        }
2867    }
2868
2869    // Collect nodegroups to delete: where a deleted node IS the collector
2870    let mut nodegroups_to_delete: Vec<String> = Vec::new();
2871    for nid in &nodes_to_delete {
2872        if let Some(n) = graph.nodes.iter().find(|n| n.nodeid == *nid) {
2873            if let Some(ref ng_id) = n.nodegroup_id {
2874                if *ng_id == n.nodeid && !nodegroups_to_delete.contains(ng_id) {
2875                    nodegroups_to_delete.push(ng_id.clone());
2876                }
2877            }
2878        }
2879    }
2880
2881    // Remove associated cards_x_nodes_x_widgets entries
2882    if let Some(ref mut cxnxws) = graph.cards_x_nodes_x_widgets {
2883        cxnxws.retain(|c| !nodes_to_delete.contains(&c.node_id));
2884    }
2885
2886    // Remove edges where any deleted node is domain or range
2887    graph.edges.retain(|e| {
2888        !nodes_to_delete.contains(&e.domainnode_id) && !nodes_to_delete.contains(&e.rangenode_id)
2889    });
2890
2891    // Remove cards for the deleted nodegroups
2892    if let Some(ref mut cards) = graph.cards {
2893        cards.retain(|c| !nodegroups_to_delete.contains(&c.nodegroup_id));
2894    }
2895
2896    // Remove the nodegroups
2897    graph
2898        .nodegroups
2899        .retain(|ng| !nodegroups_to_delete.contains(&ng.nodegroupid));
2900
2901    // Remove the nodes
2902    graph.nodes.retain(|n| !nodes_to_delete.contains(&n.nodeid));
2903
2904    // Invalidate cached indices after retain operations
2905    graph.invalidate_indices();
2906
2907    Ok(())
2908}
2909
2910fn apply_delete_nodegroup(
2911    graph: &mut StaticGraph,
2912    params: DeleteNodegroupParams,
2913) -> Result<(), MutationError> {
2914    // Verify nodegroup exists
2915    if !graph
2916        .nodegroups
2917        .iter()
2918        .any(|ng| ng.nodegroupid == params.nodegroup_id)
2919    {
2920        return Err(MutationError::NodegroupNotFound(params.nodegroup_id));
2921    }
2922
2923    // Collect all nodegroups to delete (this nodegroup and all descendants)
2924    let mut nodegroups_to_delete: Vec<String> = vec![params.nodegroup_id.clone()];
2925    let mut i = 0;
2926    while i < nodegroups_to_delete.len() {
2927        let current_ng = nodegroups_to_delete[i].clone();
2928        // Find child nodegroups
2929        for ng in &graph.nodegroups {
2930            if ng.parentnodegroup_id.as_ref() == Some(&current_ng)
2931                && !nodegroups_to_delete.contains(&ng.nodegroupid)
2932            {
2933                nodegroups_to_delete.push(ng.nodegroupid.clone());
2934            }
2935        }
2936        i += 1;
2937    }
2938
2939    // Collect all nodes in these nodegroups
2940    let nodes_to_delete: Vec<String> = graph
2941        .nodes
2942        .iter()
2943        .filter(|n| {
2944            n.nodegroup_id
2945                .as_ref()
2946                .map(|ng| nodegroups_to_delete.contains(ng))
2947                .unwrap_or(false)
2948        })
2949        .map(|n| n.nodeid.clone())
2950        .collect();
2951
2952    // Check if any node is a root node
2953    for node_id in &nodes_to_delete {
2954        if let Some(node) = graph.nodes.iter().find(|n| n.nodeid == *node_id) {
2955            if node.istopnode {
2956                return Err(MutationError::CannotDeleteRootNode(node_id.clone()));
2957            }
2958        }
2959    }
2960
2961    // Remove cards_x_nodes_x_widgets for all nodes being deleted
2962    if let Some(ref mut cxnxws) = graph.cards_x_nodes_x_widgets {
2963        cxnxws.retain(|c| !nodes_to_delete.contains(&c.node_id));
2964    }
2965
2966    // Remove edges referencing any deleted nodes
2967    graph.edges.retain(|e| {
2968        !nodes_to_delete.contains(&e.domainnode_id) && !nodes_to_delete.contains(&e.rangenode_id)
2969    });
2970
2971    // Remove cards for the deleted nodegroups
2972    if let Some(ref mut cards) = graph.cards {
2973        cards.retain(|c| !nodegroups_to_delete.contains(&c.nodegroup_id));
2974    }
2975
2976    // Remove the nodegroups
2977    graph
2978        .nodegroups
2979        .retain(|ng| !nodegroups_to_delete.contains(&ng.nodegroupid));
2980
2981    // Remove the nodes
2982    graph.nodes.retain(|n| !nodes_to_delete.contains(&n.nodeid));
2983
2984    // Invalidate cached indices after retain operations
2985    graph.invalidate_indices();
2986
2987    Ok(())
2988}
2989
2990// =============================================================================
2991// Node Update Operations
2992// =============================================================================
2993
2994fn apply_update_node(
2995    graph: &mut StaticGraph,
2996    params: UpdateNodeParams,
2997    options: &MutatorOptions,
2998) -> Result<(), MutationError> {
2999    // Find node by alias first, then by ID
3000    let node = graph
3001        .find_node_by_alias(&params.node_id)
3002        .or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
3003        .ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
3004
3005    let node_id = node.nodeid.clone();
3006
3007    // Normalise incoming class list. The outer `Option` preserves the
3008    // "don't change" semantic (None from the caller), while the inner
3009    // `Option<Vec<String>>` follows `sanitize_class_list`: blank-only or
3010    // empty lists collapse to None (i.e. "clear the class").
3011    let class_update: Option<Option<Vec<String>>> =
3012        params.ontology_class.map(|v| sanitize_class_list(Some(v)));
3013
3014    // Validate ontology class if validator is present and class is being changed.
3015    // Every class in the new list must be known to the ontology.
3016    if let Some(ref validator) = options.ontology_validator {
3017        if let Some(Some(ref classes)) = class_update {
3018            for c in classes {
3019                if !validator.is_valid_class(c) {
3020                    return Err(MutationError::OntologyValidation(
3021                        crate::ontology::OntologyValidationDetail::UnknownClass(c.clone()),
3022                    ));
3023                }
3024            }
3025        }
3026    }
3027
3028    // Find the mutable node and update its fields
3029    let node_mut = graph
3030        .nodes
3031        .iter_mut()
3032        .find(|n| n.nodeid == node_id)
3033        .ok_or_else(|| MutationError::NodeNotFound(node_id.clone()))?;
3034
3035    // Update provided fields
3036    if let Some(name) = params.name {
3037        node_mut.name = name;
3038    }
3039    if let Some(new_classes) = class_update {
3040        node_mut.ontologyclass = new_classes;
3041    }
3042    if let Some(parent_property) = params.parent_property {
3043        node_mut.parentproperty = if parent_property.is_empty() {
3044            None
3045        } else {
3046            Some(parent_property)
3047        };
3048    }
3049    if let Some(description) = params.description {
3050        node_mut.description = Some(StaticTranslatableString::from_string(&description));
3051    }
3052    if let Some(serde_json::Value::Object(map)) = params.config {
3053        // Merge config into existing
3054        for (k, v) in map {
3055            node_mut.config.insert(k, v);
3056        }
3057    }
3058
3059    // Update options
3060    if let Some(exportable) = params.options.exportable {
3061        node_mut.exportable = exportable;
3062    }
3063    if let Some(fieldname) = params.options.fieldname {
3064        node_mut.fieldname = if fieldname.is_empty() {
3065            None
3066        } else {
3067            Some(fieldname)
3068        };
3069    }
3070    if let Some(isrequired) = params.options.isrequired {
3071        node_mut.isrequired = isrequired;
3072    }
3073    if let Some(issearchable) = params.options.issearchable {
3074        node_mut.issearchable = issearchable;
3075    }
3076    if let Some(sortorder) = params.options.sortorder {
3077        node_mut.sortorder = Some(sortorder);
3078    }
3079
3080    Ok(())
3081}
3082
3083fn apply_change_node_type(
3084    graph: &mut StaticGraph,
3085    params: ChangeNodeTypeParams,
3086) -> Result<(), MutationError> {
3087    // Find node by alias first, then by ID
3088    let node = graph
3089        .find_node_by_alias(&params.node_id)
3090        .or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
3091        .ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
3092
3093    let node_id = node.nodeid.clone();
3094
3095    // Check for dependent widgets
3096    let has_widgets = graph
3097        .cards_x_nodes_x_widgets
3098        .as_ref()
3099        .map(|cxnxws| cxnxws.iter().any(|c| c.node_id == node_id))
3100        .unwrap_or(false);
3101
3102    if has_widgets {
3103        return Err(MutationError::NodeHasDependentWidgets(params.node_id));
3104    }
3105
3106    // Find the mutable node and update its fields
3107    let node_mut = graph
3108        .nodes
3109        .iter_mut()
3110        .find(|n| n.nodeid == node_id)
3111        .ok_or_else(|| MutationError::NodeNotFound(node_id.clone()))?;
3112
3113    // Update datatype
3114    node_mut.datatype = params.datatype;
3115
3116    // Update other provided fields (same as update_node)
3117    if let Some(name) = params.name {
3118        node_mut.name = name;
3119    }
3120    if let Some(classes) = params.ontology_class {
3121        node_mut.ontologyclass = sanitize_class_list(Some(classes));
3122    }
3123    if let Some(parent_property) = params.parent_property {
3124        node_mut.parentproperty = if parent_property.is_empty() {
3125            None
3126        } else {
3127            Some(parent_property)
3128        };
3129    }
3130    if let Some(description) = params.description {
3131        node_mut.description = Some(StaticTranslatableString::from_string(&description));
3132    }
3133    if let Some(serde_json::Value::Object(map)) = params.config {
3134        // Merge config into existing
3135        for (k, v) in map {
3136            node_mut.config.insert(k, v);
3137        }
3138    }
3139
3140    // Update options
3141    if let Some(exportable) = params.options.exportable {
3142        node_mut.exportable = exportable;
3143    }
3144    if let Some(fieldname) = params.options.fieldname {
3145        node_mut.fieldname = if fieldname.is_empty() {
3146            None
3147        } else {
3148            Some(fieldname)
3149        };
3150    }
3151    if let Some(isrequired) = params.options.isrequired {
3152        node_mut.isrequired = isrequired;
3153    }
3154    if let Some(issearchable) = params.options.issearchable {
3155        node_mut.issearchable = issearchable;
3156    }
3157    if let Some(sortorder) = params.options.sortorder {
3158        node_mut.sortorder = Some(sortorder);
3159    }
3160
3161    Ok(())
3162}
3163
3164fn apply_change_cardinality(
3165    graph: &mut StaticGraph,
3166    params: ChangeCardinalityParams,
3167) -> Result<(), MutationError> {
3168    // Find node by alias first, then by ID - extract what we need
3169    let (node_id, nodegroup_id) = {
3170        let node = graph
3171            .find_node_by_alias(&params.node_id)
3172            .or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
3173            .ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
3174
3175        let nodegroup_id = node.nodegroup_id.clone().ok_or_else(|| {
3176            MutationError::Other(format!(
3177                "Node '{}' has no nodegroup_id - cannot change cardinality",
3178                params.node_id
3179            ))
3180        })?;
3181
3182        (node.nodeid.clone(), nodegroup_id)
3183    };
3184
3185    // Verify the node is the grouping node for the nodegroup
3186    // The grouping node is defined as:
3187    // 1. If nodegroup.grouping_node_id is set, the node must match it
3188    // 2. If not set, the node's nodegroup_id must equal the nodegroup's nodegroupid
3189    //    AND the nodegroupid must equal the node's nodeid (semantic node pattern)
3190    let nodegroup = graph
3191        .nodegroups
3192        .iter()
3193        .find(|ng| ng.nodegroupid == nodegroup_id)
3194        .ok_or_else(|| MutationError::NodegroupNotFound(nodegroup_id.clone()))?;
3195
3196    let is_grouping_node = match &nodegroup.grouping_node_id {
3197        Some(grouping_id) => grouping_id == &node_id,
3198        None => {
3199            // If grouping_node_id not set, check if this is the semantic/grouping node pattern:
3200            // The grouping node typically has nodegroup_id == nodegroupid == nodeid
3201            nodegroup_id == node_id
3202        }
3203    };
3204
3205    if !is_grouping_node {
3206        return Err(MutationError::Other(format!(
3207            "Node '{}' is not the grouping node for nodegroup '{}'. Only the grouping node can change cardinality.",
3208            params.node_id, nodegroup_id
3209        )));
3210    }
3211
3212    // Find the nodegroup mutably and update its cardinality
3213    let nodegroup_mut = graph
3214        .nodegroups
3215        .iter_mut()
3216        .find(|ng| ng.nodegroupid == nodegroup_id)
3217        .ok_or_else(|| MutationError::NodegroupNotFound(nodegroup_id.clone()))?;
3218
3219    nodegroup_mut.cardinality = Some(params.cardinality.as_str().to_string());
3220
3221    Ok(())
3222}
3223
3224fn apply_rename_node(
3225    graph: &mut StaticGraph,
3226    params: RenameNodeParams,
3227) -> Result<(), MutationError> {
3228    // Find node by alias first, then by ID
3229    let node = graph
3230        .find_node_by_alias(&params.node_id)
3231        .or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
3232        .ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
3233
3234    let node_id = node.nodeid.clone();
3235
3236    // Check if new alias already exists (if changing alias)
3237    if let Some(ref new_alias) = params.alias {
3238        // Check if another node already has this alias
3239        let alias_exists = graph
3240            .nodes
3241            .iter()
3242            .any(|n| n.nodeid != node_id && n.alias.as_ref() == Some(new_alias));
3243        if alias_exists {
3244            return Err(MutationError::AliasAlreadyExists(new_alias.clone()));
3245        }
3246    }
3247
3248    // Find the mutable node and update text fields
3249    let node_mut = graph
3250        .nodes
3251        .iter_mut()
3252        .find(|n| n.nodeid == node_id)
3253        .ok_or_else(|| MutationError::NodeNotFound(node_id.clone()))?;
3254
3255    if let Some(alias) = params.alias {
3256        node_mut.alias = if alias.is_empty() { None } else { Some(alias) };
3257    }
3258    let name_changed = params.name.is_some();
3259    if let Some(name) = params.name {
3260        node_mut.name = name;
3261    }
3262    if let Some(description) = params.description {
3263        node_mut.description = Some(StaticTranslatableString::from_string(&description));
3264    }
3265
3266    // Auto-realign card and widget label if name was changed
3267    if name_changed && params.realign_card {
3268        let _ = apply_realign_card_from_node(
3269            graph,
3270            RealignCardFromNodeParams {
3271                node_alias: node_id,
3272            },
3273        );
3274    }
3275
3276    Ok(())
3277}
3278
3279fn apply_rename_card(
3280    graph: &mut StaticGraph,
3281    params: RenameCardParams,
3282) -> Result<(), MutationError> {
3283    let lang = params.language.unwrap_or_else(|| "en".to_string());
3284
3285    // Find card by card_id first, then by nodegroup_id
3286    let card_mut = graph
3287        .cards
3288        .as_mut()
3289        .and_then(|cards| {
3290            cards
3291                .iter_mut()
3292                .find(|c| c.cardid == params.card_id || c.nodegroup_id == params.card_id)
3293        })
3294        .ok_or(MutationError::CardNotFound(params.card_id))?;
3295
3296    // Update name
3297    if let Some(name_i18n) = params.name_i18n {
3298        card_mut.name = StaticTranslatableString::from_translations(name_i18n, Some(lang.clone()));
3299    } else if let Some(name) = params.name {
3300        card_mut.name.translations.insert(lang.clone(), name);
3301    }
3302
3303    // Update description
3304    if let Some(desc_i18n) = params.description_i18n {
3305        card_mut.description = Some(StaticTranslatableString::from_translations(
3306            desc_i18n,
3307            Some(lang),
3308        ));
3309    } else if let Some(desc) = params.description {
3310        let description = card_mut
3311            .description
3312            .get_or_insert_with(StaticTranslatableString::empty);
3313        description.translations.insert(lang, desc);
3314    }
3315
3316    Ok(())
3317}
3318
3319fn apply_realign_card_from_node(
3320    graph: &mut StaticGraph,
3321    params: RealignCardFromNodeParams,
3322) -> Result<(), MutationError> {
3323    // Find node by alias first, then by ID
3324    let node = graph
3325        .find_node_by_alias(&params.node_alias)
3326        .or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_alias))
3327        .ok_or_else(|| MutationError::NodeNotFound(params.node_alias.clone()))?;
3328
3329    let node_name = node.name.clone();
3330    let node_id = node.nodeid.clone();
3331    let nodegroup_id = node
3332        .nodegroup_id
3333        .clone()
3334        .ok_or_else(|| MutationError::NodegroupNotFound(params.node_alias.clone()))?;
3335
3336    // Update card name to match node name
3337    if let Some(cards) = graph.cards.as_mut() {
3338        if let Some(card) = cards.iter_mut().find(|c| c.nodegroup_id == nodegroup_id) {
3339            card.name = StaticTranslatableString::from_string(&node_name);
3340        }
3341    }
3342
3343    // Update widget label for this node
3344    if let Some(cxnxws) = graph.cards_x_nodes_x_widgets.as_mut() {
3345        for cxnxw in cxnxws.iter_mut().filter(|c| c.node_id == node_id) {
3346            cxnxw.label = StaticTranslatableString::from_string(&node_name);
3347            // Also update the label in widget config if present
3348            if let serde_json::Value::Object(ref mut map) = cxnxw.config {
3349                map.insert(
3350                    "label".to_string(),
3351                    serde_json::Value::String(node_name.clone()),
3352                );
3353            }
3354        }
3355    }
3356
3357    Ok(())
3358}
3359
3360fn apply_rename_graph(
3361    graph: &mut StaticGraph,
3362    params: RenameGraphParams,
3363) -> Result<(), MutationError> {
3364    // Update name if provided
3365    if let Some(name_map) = params.name {
3366        let new_name = StaticTranslatableString::from_translations(name_map, None);
3367
3368        // Update graph name
3369        graph.name = new_name.clone();
3370
3371        // Also update root node name to match (root node name should equal graph name)
3372        let root_display_name = new_name.to_string_default();
3373        graph.root.name = root_display_name.clone();
3374
3375        // Generate slug from name and update graph slug and root alias
3376        let new_slug = slugify(&root_display_name);
3377        graph.slug = Some(new_slug.clone());
3378        graph.root.alias = Some(new_slug.clone());
3379
3380        // Update the root node in the nodes array as well
3381        if let Some(root_node) = graph.nodes.iter_mut().find(|n| n.istopnode) {
3382            root_node.name = root_display_name;
3383            root_node.alias = Some(new_slug);
3384        }
3385    }
3386
3387    // Update description if provided
3388    if let Some(desc_map) = params.description {
3389        graph.description = Some(StaticTranslatableString::from_translations(desc_map, None));
3390    }
3391
3392    // Update subtitle if provided
3393    if let Some(subtitle_map) = params.subtitle {
3394        graph.subtitle = Some(StaticTranslatableString::from_translations(
3395            subtitle_map,
3396            None,
3397        ));
3398    }
3399
3400    // Update author if provided
3401    if let Some(author) = params.author {
3402        graph.author = if author.is_empty() {
3403            None
3404        } else {
3405            Some(author)
3406        };
3407    }
3408
3409    Ok(())
3410}
3411
3412// =============================================================================
3413// Coppice Subgraph
3414// =============================================================================
3415
3416/// Set `sourcebranchpublication_id` on a subtree rooted at the node with the
3417/// given alias. Traverses via edges (domain -> range), setting the publication
3418/// ID on all reachable nodes that are unset or already match. Stops at nodes
3419/// claimed by a different branch.
3420fn apply_coppice_subgraph(
3421    graph: &mut StaticGraph,
3422    params: CoppiceSubgraphParams,
3423) -> Result<(), MutationError> {
3424    // Find root node by alias
3425    let root_nodeid = graph
3426        .nodes
3427        .iter()
3428        .find(|n| n.alias.as_deref() == Some(&params.subject))
3429        .map(|n| n.nodeid.clone())
3430        .ok_or_else(|| {
3431            MutationError::NodeNotFound(format!(
3432                "coppice_subgraph: node with alias '{}' not found",
3433                params.subject
3434            ))
3435        })?;
3436
3437    // Build children map: domainnode_id -> [rangenode_id]
3438    let mut children: HashMap<String, Vec<String>> = HashMap::new();
3439    for edge in &graph.edges {
3440        children
3441            .entry(edge.domainnode_id.clone())
3442            .or_default()
3443            .push(edge.rangenode_id.clone());
3444    }
3445
3446    // BFS from root
3447    let mut queue = std::collections::VecDeque::new();
3448    queue.push_back(root_nodeid);
3449
3450    while let Some(nid) = queue.pop_front() {
3451        if let Some(node) = graph.nodes.iter_mut().find(|n| n.nodeid == nid) {
3452            let existing = node.sourcebranchpublication_id.as_deref();
3453            if existing.is_some() && existing != Some(&params.publication_id) {
3454                // Claimed by a different branch — stop traversal here
3455                continue;
3456            }
3457            node.sourcebranchpublication_id = Some(params.publication_id.clone());
3458        }
3459        if let Some(child_ids) = children.get(&nid) {
3460            for child_id in child_ids {
3461                queue.push_back(child_id.clone());
3462            }
3463        }
3464    }
3465
3466    Ok(())
3467}
3468
3469// =============================================================================
3470// Subgraph Addition
3471// =============================================================================
3472
3473/// Tracks ID remapping during subgraph addition
3474struct IdRemapper {
3475    /// Target graph ID for UUID generation
3476    graph_id: String,
3477    /// Suffix for UUID generation (used in deterministic ID creation)
3478    suffix: String,
3479    /// Branch publication ID to set on added nodes
3480    branch_publication_id: Option<String>,
3481    /// Maps old node ID -> new node ID
3482    node_map: HashMap<String, String>,
3483    /// Maps old nodegroup ID -> new nodegroup ID
3484    nodegroup_map: HashMap<String, String>,
3485    /// Maps old edge ID -> new edge ID
3486    edge_map: HashMap<String, String>,
3487    /// Maps old card ID -> new card ID
3488    card_map: HashMap<String, String>,
3489    /// Maps old cxnxw ID -> new cxnxw ID
3490    cxnxw_map: HashMap<String, String>,
3491    /// Maps old constraint ID -> new constraint ID
3492    constraint_map: HashMap<String, String>,
3493    /// Maps old alias -> new alias (only for clashing aliases that need suffixing)
3494    alias_map: HashMap<String, String>,
3495}
3496
3497impl IdRemapper {
3498    fn new(graph_id: &str, suffix: Option<&str>, branch_publication_id: Option<String>) -> Self {
3499        Self {
3500            graph_id: graph_id.to_string(),
3501            suffix: suffix.unwrap_or("").to_string(),
3502            branch_publication_id,
3503            node_map: HashMap::new(),
3504            nodegroup_map: HashMap::new(),
3505            edge_map: HashMap::new(),
3506            card_map: HashMap::new(),
3507            cxnxw_map: HashMap::new(),
3508            constraint_map: HashMap::new(),
3509            alias_map: HashMap::new(),
3510        }
3511    }
3512
3513    /// Generate a new node ID and store the mapping
3514    fn remap_node(&mut self, old_id: &str) -> String {
3515        let new_id = generate_uuid_v5(
3516            ("graph", Some(&self.graph_id)),
3517            &format!("subgraph-node-{}-{}", old_id, self.suffix),
3518        );
3519        self.node_map.insert(old_id.to_string(), new_id.clone());
3520        new_id
3521    }
3522
3523    /// Generate a new nodegroup ID and store the mapping
3524    fn remap_nodegroup(&mut self, old_id: &str) -> String {
3525        let new_id = generate_uuid_v5(
3526            ("graph", Some(&self.graph_id)),
3527            &format!("subgraph-ng-{}-{}", old_id, self.suffix),
3528        );
3529        self.nodegroup_map
3530            .insert(old_id.to_string(), new_id.clone());
3531        new_id
3532    }
3533
3534    /// Generate a new edge ID and store the mapping
3535    fn remap_edge(&mut self, old_id: &str) -> String {
3536        let new_id = generate_uuid_v5(
3537            ("graph", Some(&self.graph_id)),
3538            &format!("subgraph-edge-{}-{}", old_id, self.suffix),
3539        );
3540        self.edge_map.insert(old_id.to_string(), new_id.clone());
3541        new_id
3542    }
3543
3544    /// Generate a new card ID and store the mapping
3545    fn remap_card(&mut self, old_id: &str) -> String {
3546        let new_id = generate_uuid_v5(
3547            ("graph", Some(&self.graph_id)),
3548            &format!("subgraph-card-{}-{}", old_id, self.suffix),
3549        );
3550        self.card_map.insert(old_id.to_string(), new_id.clone());
3551        new_id
3552    }
3553
3554    /// Generate a new cxnxw ID and store the mapping
3555    fn remap_cxnxw(&mut self, old_id: &str) -> String {
3556        let new_id = generate_uuid_v5(
3557            ("graph", Some(&self.graph_id)),
3558            &format!("subgraph-cxnxw-{}-{}", old_id, self.suffix),
3559        );
3560        self.cxnxw_map.insert(old_id.to_string(), new_id.clone());
3561        new_id
3562    }
3563
3564    /// Generate a new constraint ID and store the mapping
3565    fn remap_constraint(&mut self, old_id: &str) -> String {
3566        let new_id = generate_uuid_v5(
3567            ("graph", Some(&self.graph_id)),
3568            &format!("subgraph-constraint-{}-{}", old_id, self.suffix),
3569        );
3570        self.constraint_map
3571            .insert(old_id.to_string(), new_id.clone());
3572        new_id
3573    }
3574
3575    /// Get the remapped node ID, or return the original if not mapped
3576    fn get_node(&self, old_id: &str) -> Option<&String> {
3577        self.node_map.get(old_id)
3578    }
3579
3580    /// Get the remapped nodegroup ID, or return the original if not mapped
3581    fn get_nodegroup(&self, old_id: &str) -> Option<&String> {
3582        self.nodegroup_map.get(old_id)
3583    }
3584
3585    /// Get the remapped card ID
3586    fn get_card(&self, old_id: &str) -> Option<&String> {
3587        self.card_map.get(old_id)
3588    }
3589
3590    /// Register an alias mapping (for clashing aliases that need unique suffixes)
3591    fn register_alias(&mut self, old_alias: &str, new_alias: String) {
3592        self.alias_map.insert(old_alias.to_string(), new_alias);
3593    }
3594
3595    /// Get the remapped alias, or return original if no clash was registered
3596    fn get_alias(&self, alias: Option<&str>) -> Option<String> {
3597        alias.map(|a| {
3598            self.alias_map
3599                .get(a)
3600                .cloned()
3601                .unwrap_or_else(|| a.to_string())
3602        })
3603    }
3604}
3605
3606/// Make a name unique by appending _n1, _n2, etc. (matching Arches behavior)
3607fn make_name_unique(name: &str, existing: &HashSet<String>) -> String {
3608    if !existing.contains(name) {
3609        return name.to_string();
3610    }
3611
3612    let mut counter = 1;
3613    loop {
3614        let candidate = format!("{}_n{}", name, counter);
3615        if !existing.contains(&candidate) {
3616            return candidate;
3617        }
3618        counter += 1;
3619    }
3620}
3621
3622/// Apply an AddSubgraph mutation to a graph
3623fn apply_add_subgraph(
3624    graph: &mut StaticGraph,
3625    params: AddSubgraphParams,
3626) -> Result<(), MutationError> {
3627    let subgraph = params.subgraph;
3628    let target_node_id = params.target_node_id;
3629    let ontology_property = params.ontology_property;
3630    let alias_suffix = params.alias_suffix;
3631
3632    // Get the branch's publicationid for sourcebranchpublication_id tracking.
3633    // In Arches, sourcebranchpublication_id is an FK to graphs_x_published_graphs,
3634    // so it must be the publication's publicationid, not the branch's graphid.
3635    let branch_publication_id = subgraph
3636        .publication
3637        .as_ref()
3638        .and_then(|p| p.get("publicationid"))
3639        .and_then(|v| v.as_str())
3640        .map(|s| s.to_string())
3641        .ok_or_else(|| MutationError::InvalidSubgraph(format!(
3642            "Subgraph '{}' has no publication.publicationid — the branch must be published before it can be added as a subgraph",
3643            subgraph.graphid
3644        )))?;
3645
3646    // 1. VALIDATE: Target node exists (find by alias first, then by ID)
3647    let target_node = graph
3648        .find_node_by_alias(&target_node_id)
3649        .or_else(|| graph.nodes.iter().find(|n| n.nodeid == target_node_id))
3650        .ok_or_else(|| MutationError::NodeNotFound(target_node_id.clone()))?;
3651    let target_node_id = target_node.nodeid.clone();
3652    let target_nodegroup_id = target_node.nodegroup_id.clone();
3653
3654    // 2. IDENTIFY ROOT: Find the root node of the subgraph
3655    let root_node = subgraph
3656        .nodes
3657        .iter()
3658        .find(|n| n.istopnode)
3659        .or(Some(&subgraph.root))
3660        .ok_or(MutationError::BranchHasNoRoot)?;
3661    let root_node_id = root_node.nodeid.clone();
3662    let root_nodegroup_id = root_node
3663        .nodegroup_id
3664        .clone()
3665        .unwrap_or_else(|| root_node_id.clone());
3666
3667    // 3. BUILD ALIAS MAPPINGS (Arches-style: only suffix clashing aliases)
3668    // Collect all existing aliases in the target graph
3669    let mut existing_aliases: HashSet<String> =
3670        graph.nodes.iter().filter_map(|n| n.alias.clone()).collect();
3671
3672    // Build the remapper with branch publication ID for tracking
3673    let suffix_ref = alias_suffix.as_deref();
3674    let mut remapper = IdRemapper::new(&graph.graphid, suffix_ref, Some(branch_publication_id));
3675
3676    // Process aliases from subgraph nodes (excluding root)
3677    // Apply alias_prefix if set, then dedup with _n1, _n2 if clashing.
3678    for node in &subgraph.nodes {
3679        if node.nodeid == root_node_id {
3680            continue; // Skip root node
3681        }
3682        if let Some(ref alias) = node.alias {
3683            let prefixed_alias = if let Some(ref prefix) = params.alias_prefix {
3684                format!("{}_{}", prefix, alias)
3685            } else {
3686                alias.clone()
3687            };
3688            let new_alias = make_name_unique(&prefixed_alias, &existing_aliases);
3689            if new_alias != *alias {
3690                // Alias was changed (prefix and/or clash) - record the mapping
3691                remapper.register_alias(alias, new_alias.clone());
3692            }
3693            // Add to existing set so subsequent branch aliases don't clash with each other
3694            existing_aliases.insert(new_alias);
3695        }
3696    }
3697
3698    // 4. BUILD ID MAPPINGS
3699    // Remap all nodes including the branch root — matching Arches' append_branch
3700    // which always keeps the branch root as a demoted (istopnode=false) node.
3701    for node in &subgraph.nodes {
3702        remapper.remap_node(&node.nodeid);
3703    }
3704
3705    // Pre-generate all nodegroup mappings (excluding root's nodegroup)
3706    // In Arches, nodegroupid == groupingnodeid (the grouping node's nodeid).
3707    // If a nodegroup's ID matches a node's ID, use the node's remapped ID
3708    // so the constraint is preserved after remapping.
3709    for nodegroup in &subgraph.nodegroups {
3710        if nodegroup.nodegroupid != root_nodegroup_id {
3711            if let Some(node_id) = remapper.get_node(&nodegroup.nodegroupid) {
3712                // This nodegroup's ID matches a node ID - reuse the node's remapped ID
3713                let node_id = node_id.clone();
3714                remapper
3715                    .nodegroup_map
3716                    .insert(nodegroup.nodegroupid.clone(), node_id);
3717            } else {
3718                remapper.remap_nodegroup(&nodegroup.nodegroupid);
3719            }
3720        }
3721    }
3722
3723    // Pre-generate edge mappings for all edges (root→child edges are kept)
3724    for edge in &subgraph.edges {
3725        remapper.remap_edge(&edge.edgeid);
3726    }
3727
3728    // Pre-generate card mappings (including root-nodegroup cards, since
3729    // non-root CXNXWs may reference them)
3730    if let Some(ref cards) = subgraph.cards {
3731        for card in cards {
3732            remapper.remap_card(&card.cardid);
3733        }
3734    }
3735
3736    // Pre-generate cxnxw mappings for all widgets
3737    if let Some(ref cxnxws) = subgraph.cards_x_nodes_x_widgets {
3738        for cxnxw in cxnxws {
3739            remapper.remap_cxnxw(&cxnxw.id);
3740        }
3741    }
3742
3743    // 5. ADD ALL NODES
3744    // Like Arches' append_branch, the branch root is always kept as a demoted
3745    // node (istopnode=false). It becomes a collector (nodegroup_id = its own
3746    // new nodeid) so that Arches' populate_null_nodegroups can discover it
3747    // during import.
3748    for node in subgraph.nodes {
3749        let is_branch_root = node.nodeid == root_node_id;
3750
3751        let new_node_id = remapper
3752            .get_node(&node.nodeid)
3753            .ok_or_else(|| {
3754                MutationError::InvalidSubgraph(format!("Node {} not mapped", node.nodeid))
3755            })?
3756            .clone();
3757
3758        // Remap nodegroup_id
3759        let new_nodegroup_id = if is_branch_root {
3760            // Branch root becomes a collector: nodegroup_id = its own new nodeid
3761            Some(new_node_id.clone())
3762        } else {
3763            node.nodegroup_id.as_ref().and_then(|ng_id| {
3764                if *ng_id == root_nodegroup_id {
3765                    // Was in root's nodegroup — point to the kept branch root's new ID
3766                    remapper.get_node(&root_node_id).cloned()
3767                } else {
3768                    remapper.get_nodegroup(ng_id).cloned()
3769                }
3770            })
3771        };
3772
3773        let prefixed_name = if let Some(ref prefix) = params.name_prefix {
3774            format!("{} {}", prefix, node.name)
3775        } else {
3776            node.name
3777        };
3778
3779        let new_node = StaticNode {
3780            nodeid: new_node_id,
3781            name: prefixed_name,
3782            alias: remapper.get_alias(node.alias.as_deref()),
3783            datatype: node.datatype,
3784            nodegroup_id: new_nodegroup_id,
3785            graph_id: graph.graphid.clone(),
3786            is_collector: node.is_collector,
3787            isrequired: node.isrequired,
3788            exportable: node.exportable,
3789            sortorder: node.sortorder,
3790            config: node.config,
3791            parentproperty: node.parentproperty,
3792            ontologyclass: node.ontologyclass,
3793            description: node.description,
3794            fieldname: node.fieldname,
3795            hascustomalias: node.hascustomalias,
3796            issearchable: node.issearchable,
3797            istopnode: false, // Not a top node in the target graph
3798            // Set sourcebranchpublication_id to track which branch this node came from
3799            sourcebranchpublication_id: remapper.branch_publication_id.clone(),
3800            source_identifier_id: node.source_identifier_id,
3801            is_immutable: node.is_immutable,
3802        };
3803        graph.push_node(new_node);
3804    }
3805
3806    // 6. ADD NODEGROUPS
3807    // The root's nodegroup is remapped to the branch root's new node ID (since
3808    // the branch root is now a collector in the target graph).
3809    for nodegroup in subgraph.nodegroups {
3810        if nodegroup.nodegroupid == root_nodegroup_id {
3811            let new_root_id = remapper
3812                .get_node(&root_node_id)
3813                .ok_or_else(|| {
3814                    MutationError::InvalidSubgraph("Branch root not mapped".to_string())
3815                })?
3816                .clone();
3817            let new_ng = StaticNodegroup {
3818                nodegroupid: new_root_id.clone(),
3819                cardinality: nodegroup.cardinality.clone(),
3820                parentnodegroup_id: target_nodegroup_id.clone(),
3821                legacygroupid: nodegroup.legacygroupid.clone(),
3822                grouping_node_id: Some(new_root_id),
3823            };
3824            graph.push_nodegroup(new_ng);
3825            continue;
3826        }
3827
3828        let new_ng_id = remapper
3829            .get_nodegroup(&nodegroup.nodegroupid)
3830            .ok_or_else(|| {
3831                MutationError::InvalidSubgraph(format!(
3832                    "Nodegroup {} not mapped",
3833                    nodegroup.nodegroupid
3834                ))
3835            })?
3836            .clone();
3837
3838        // Remap parentnodegroup_id
3839        let new_parent_ng_id = nodegroup.parentnodegroup_id.as_ref().and_then(|parent_id| {
3840            if *parent_id == root_nodegroup_id {
3841                // Parent was root's nodegroup — now points to the kept branch root
3842                remapper.get_node(&root_node_id).cloned()
3843            } else {
3844                remapper.get_nodegroup(parent_id).cloned()
3845            }
3846        });
3847
3848        // Remap grouping_node_id
3849        let new_grouping_node_id = nodegroup
3850            .grouping_node_id
3851            .as_ref()
3852            .and_then(|gn_id| remapper.get_node(gn_id).cloned());
3853
3854        let new_nodegroup = StaticNodegroup {
3855            nodegroupid: new_ng_id,
3856            cardinality: nodegroup.cardinality,
3857            parentnodegroup_id: new_parent_ng_id,
3858            legacygroupid: nodegroup.legacygroupid,
3859            grouping_node_id: new_grouping_node_id,
3860        };
3861        graph.push_nodegroup(new_nodegroup);
3862    }
3863
3864    // 7. ADD EDGES
3865    // Create a connecting edge from target → branch root (like Arches' append_branch),
3866    // then remap all branch-internal edges normally.
3867    {
3868        let new_root_id = remapper
3869            .get_node(&root_node_id)
3870            .ok_or_else(|| MutationError::InvalidSubgraph("Branch root not mapped".to_string()))?
3871            .clone();
3872        let connect_edge_id = generate_uuid_v5(
3873            ("graph", Some(&graph.graphid)),
3874            &format!(
3875                "subgraph-connect-{}-{}-{}",
3876                target_node_id, new_root_id, remapper.suffix
3877            ),
3878        );
3879        let connect_edge = StaticEdge {
3880            edgeid: connect_edge_id,
3881            domainnode_id: target_node_id.clone(),
3882            rangenode_id: new_root_id,
3883            graph_id: graph.graphid.clone(),
3884            name: None,
3885            ontologyproperty: if ontology_property.is_empty() {
3886                None
3887            } else {
3888                Some(ontology_property.clone())
3889            },
3890            description: None,
3891            source_identifier_id: None,
3892        };
3893        graph.push_edge(connect_edge);
3894    }
3895    for edge in subgraph.edges {
3896        {
3897            // Regular edge within the subgraph
3898            let new_edge_id = remapper
3899                .edge_map
3900                .get(&edge.edgeid)
3901                .ok_or_else(|| {
3902                    MutationError::InvalidSubgraph(format!("Edge {} not mapped", edge.edgeid))
3903                })?
3904                .clone();
3905
3906            let new_domain = remapper
3907                .get_node(&edge.domainnode_id)
3908                .ok_or_else(|| {
3909                    MutationError::InvalidSubgraph(format!(
3910                        "Domain node {} not mapped",
3911                        edge.domainnode_id
3912                    ))
3913                })?
3914                .clone();
3915
3916            let new_range = remapper
3917                .get_node(&edge.rangenode_id)
3918                .ok_or_else(|| {
3919                    MutationError::InvalidSubgraph(format!(
3920                        "Range node {} not mapped",
3921                        edge.rangenode_id
3922                    ))
3923                })?
3924                .clone();
3925
3926            let new_edge = StaticEdge {
3927                edgeid: new_edge_id,
3928                domainnode_id: new_domain,
3929                rangenode_id: new_range,
3930                graph_id: graph.graphid.clone(),
3931                name: edge.name,
3932                ontologyproperty: edge.ontologyproperty,
3933                description: edge.description,
3934                source_identifier_id: None,
3935            };
3936            graph.push_edge(new_edge);
3937        }
3938    }
3939
3940    // 8. ADD CARDS (root-nodegroup cards get reassigned to the branch root's new nodegroup)
3941    if let Some(cards) = subgraph.cards {
3942        for card in cards {
3943            let new_card_id = remapper
3944                .get_card(&card.cardid)
3945                .ok_or_else(|| {
3946                    MutationError::InvalidSubgraph(format!("Card {} not mapped", card.cardid))
3947                })?
3948                .clone();
3949
3950            let new_ng_id = if card.nodegroup_id == root_nodegroup_id {
3951                // Root-nodegroup card points to the kept branch root's new nodegroup
3952                remapper
3953                    .get_node(&root_node_id)
3954                    .ok_or_else(|| {
3955                        MutationError::InvalidSubgraph("Branch root not mapped".to_string())
3956                    })?
3957                    .clone()
3958            } else {
3959                remapper
3960                    .get_nodegroup(&card.nodegroup_id)
3961                    .ok_or_else(|| {
3962                        MutationError::InvalidSubgraph(format!(
3963                            "Card nodegroup {} not mapped",
3964                            card.nodegroup_id
3965                        ))
3966                    })?
3967                    .clone()
3968            };
3969
3970            // Remap constraints
3971            let new_constraints: Vec<_> = card
3972                .constraints
3973                .into_iter()
3974                .map(|c| {
3975                    let new_constraint_id = remapper.remap_constraint(&c.constraintid);
3976                    let new_nodes: Vec<_> = c
3977                        .nodes
3978                        .into_iter()
3979                        .filter_map(|n| remapper.get_node(&n).cloned())
3980                        .collect();
3981                    crate::graph::StaticConstraint {
3982                        card_id: new_card_id.clone(),
3983                        constraintid: new_constraint_id,
3984                        nodes: new_nodes,
3985                        uniquetoallinstances: c.uniquetoallinstances,
3986                    }
3987                })
3988                .collect();
3989
3990            let new_card = StaticCard {
3991                active: card.active,
3992                cardid: new_card_id,
3993                component_id: card.component_id, // Preserve external ID
3994                config: card.config,
3995                constraints: new_constraints,
3996                cssclass: card.cssclass,
3997                description: card.description,
3998                graph_id: graph.graphid.clone(),
3999                helpenabled: card.helpenabled,
4000                helptext: card.helptext,
4001                helptitle: card.helptitle,
4002                instructions: card.instructions,
4003                is_editable: card.is_editable,
4004                name: card.name,
4005                nodegroup_id: new_ng_id,
4006                sortorder: Some(card.sortorder.unwrap_or(0)),
4007                visible: card.visible,
4008                source_identifier_id: None,
4009            };
4010            graph.push_card(new_card);
4011        }
4012    }
4013
4014    // 9. ADD CARDS_X_NODES_X_WIDGETS (all, including branch root's)
4015    if let Some(cxnxws) = subgraph.cards_x_nodes_x_widgets {
4016        for cxnxw in cxnxws {
4017            let new_id = remapper
4018                .cxnxw_map
4019                .get(&cxnxw.id)
4020                .ok_or_else(|| {
4021                    MutationError::InvalidSubgraph(format!("CXNXW {} not mapped", cxnxw.id))
4022                })?
4023                .clone();
4024
4025            let new_card_id = remapper
4026                .get_card(&cxnxw.card_id)
4027                .ok_or_else(|| {
4028                    MutationError::InvalidSubgraph(format!(
4029                        "CXNXW card {} not mapped",
4030                        cxnxw.card_id
4031                    ))
4032                })?
4033                .clone();
4034
4035            let new_node_id = remapper
4036                .get_node(&cxnxw.node_id)
4037                .ok_or_else(|| {
4038                    MutationError::InvalidSubgraph(format!(
4039                        "CXNXW node {} not mapped",
4040                        cxnxw.node_id
4041                    ))
4042                })?
4043                .clone();
4044
4045            let new_cxnxw = StaticCardsXNodesXWidgets {
4046                id: new_id,
4047                card_id: new_card_id,
4048                node_id: new_node_id,
4049                widget_id: cxnxw.widget_id, // Preserve external ID
4050                config: cxnxw.config,
4051                label: cxnxw.label,
4052                sortorder: Some(cxnxw.sortorder.unwrap_or(0)),
4053                visible: cxnxw.visible,
4054                source_identifier_id: None,
4055            };
4056            graph.push_card_x_node_x_widget(new_cxnxw);
4057        }
4058    }
4059
4060    Ok(())
4061}
4062
4063/// Apply an UpdateSubgraph mutation to a graph
4064///
4065/// This finds nodes previously added from a branch by traversing edges from
4066/// the target node and using sourcebranchpublication_id for validation.
4067fn apply_update_subgraph(
4068    graph: &mut StaticGraph,
4069    params: UpdateSubgraphParams,
4070) -> Result<(), MutationError> {
4071    let subgraph = params.subgraph;
4072    let target_node_id = params.target_node_id.clone();
4073    let ontology_property = params.ontology_property;
4074    let remove_orphaned = params.remove_orphaned;
4075
4076    // Get the branch's publicationid for sourcebranchpublication_id tracking
4077    let branch_publication_id = subgraph
4078        .publication
4079        .as_ref()
4080        .and_then(|p| p.get("publicationid"))
4081        .and_then(|v| v.as_str())
4082        .map(|s| s.to_string())
4083        .ok_or_else(|| MutationError::InvalidSubgraph(format!(
4084            "Subgraph '{}' has no publication.publicationid — the branch must be published before it can be added as a subgraph",
4085            subgraph.graphid
4086        )))?;
4087
4088    // 1. VALIDATE: Target node exists (find by alias first, then by ID)
4089    let target_node_ref = graph
4090        .find_node_by_alias(&target_node_id)
4091        .or_else(|| graph.nodes.iter().find(|n| n.nodeid == target_node_id))
4092        .ok_or_else(|| MutationError::NodeNotFound(target_node_id.clone()))?;
4093    let target_node_id = target_node_ref.nodeid.clone();
4094
4095    // 2. IDENTIFY ROOT: Find the root node of the new subgraph
4096    let root_node = subgraph
4097        .nodes
4098        .iter()
4099        .find(|n| n.istopnode)
4100        .or(Some(&subgraph.root))
4101        .ok_or(MutationError::BranchHasNoRoot)?;
4102    let root_node_id = root_node.nodeid.clone();
4103
4104    // 3. TRAVERSE: Find existing branch nodes by following edges from target
4105    // and validating sourcebranchpublication_id consistency
4106    let existing_branch_nodes =
4107        find_branch_nodes_by_traversal(graph, &target_node_id, &branch_publication_id)?;
4108
4109    // If no nodes found, this might be a first-time add (fallback to AddSubgraph)
4110    if existing_branch_nodes.is_empty() {
4111        // No existing branch nodes - treat as new AddSubgraph
4112        return apply_add_subgraph(
4113            graph,
4114            AddSubgraphParams {
4115                subgraph,
4116                target_node_id: params.target_node_id,
4117                ontology_property,
4118                alias_suffix: params.alias_suffix,
4119                alias_prefix: params.alias_prefix,
4120                name_prefix: params.name_prefix,
4121            },
4122        );
4123    }
4124
4125    // 4. BUILD MAPPING: Map branch aliases to existing node IDs
4126    // existing_branch_nodes: HashMap<nodeid, (alias, node)>
4127    let existing_by_alias: HashMap<String, String> = existing_branch_nodes
4128        .iter()
4129        .filter_map(|(node_id, alias)| alias.clone().map(|a| (a, node_id.clone())))
4130        .collect();
4131
4132    // Collect aliases from new branch (excluding root)
4133    let new_branch_aliases: HashSet<String> = subgraph
4134        .nodes
4135        .iter()
4136        .filter(|n| n.nodeid != root_node_id)
4137        .filter_map(|n| n.alias.clone())
4138        .collect();
4139
4140    // 5. CATEGORIZE NODES
4141    // - Update: alias exists in both existing and new branch
4142    // - Add: alias in new branch but not in existing (new nodes)
4143    // - Remove: alias in existing but not in new branch (orphaned nodes)
4144
4145    let mut nodes_to_update: Vec<(&StaticNode, String)> = Vec::new(); // (new_node, existing_node_id)
4146    let mut nodes_to_add: Vec<&StaticNode> = Vec::new();
4147
4148    for node in &subgraph.nodes {
4149        if node.nodeid == root_node_id {
4150            continue; // Skip root
4151        }
4152        if let Some(ref alias) = node.alias {
4153            // When alias_prefix is set, look for prefixed version in existing nodes
4154            // e.g. branch "name" matches existing "monument_name" with prefix "monument"
4155            let lookup_alias = if let Some(ref prefix) = params.alias_prefix {
4156                format!("{}_{}", prefix, alias)
4157            } else {
4158                alias.clone()
4159            };
4160            if let Some(existing_node_id) = existing_by_alias.get(&lookup_alias) {
4161                nodes_to_update.push((node, existing_node_id.clone()));
4162            } else {
4163                nodes_to_add.push(node);
4164            }
4165        } else {
4166            // Node without alias - treat as new
4167            nodes_to_add.push(node);
4168        }
4169    }
4170
4171    // Build the set of expected existing aliases (with prefix applied) for orphan detection
4172    let expected_existing_aliases: HashSet<String> = if let Some(ref prefix) = params.alias_prefix {
4173        new_branch_aliases
4174            .iter()
4175            .map(|a| format!("{}_{}", prefix, a))
4176            .collect()
4177    } else {
4178        new_branch_aliases.clone()
4179    };
4180
4181    let orphaned_node_ids: HashSet<String> = if remove_orphaned {
4182        existing_branch_nodes
4183            .iter()
4184            .filter(|(_, alias)| {
4185                alias
4186                    .as_ref()
4187                    .map(|a| !expected_existing_aliases.contains(a))
4188                    .unwrap_or(true)
4189            })
4190            .map(|(node_id, _)| node_id.clone())
4191            .collect()
4192    } else {
4193        HashSet::new()
4194    };
4195
4196    // 6. UPDATE EXISTING NODES
4197    for (new_node, existing_node_id) in nodes_to_update {
4198        // Find and update the existing node
4199        if let Some(existing) = graph
4200            .nodes
4201            .iter_mut()
4202            .find(|n| n.nodeid == existing_node_id)
4203        {
4204            // Update mutable fields (preserve IDs and structural references)
4205            existing.name = if let Some(ref prefix) = params.name_prefix {
4206                format!("{} {}", prefix, new_node.name)
4207            } else {
4208                new_node.name.clone()
4209            };
4210            existing.datatype = new_node.datatype.clone();
4211            existing.ontologyclass = new_node.ontologyclass.clone();
4212            existing.config = new_node.config.clone();
4213            existing.description = new_node.description.clone();
4214            existing.isrequired = new_node.isrequired;
4215            existing.issearchable = new_node.issearchable;
4216            existing.exportable = new_node.exportable;
4217            existing.sortorder = new_node.sortorder;
4218            existing.is_collector = new_node.is_collector;
4219            existing.is_immutable = new_node.is_immutable;
4220            // Keep: nodeid, alias, nodegroup_id, graph_id, istopnode, sourcebranchpublication_id
4221        }
4222    }
4223
4224    // 7. ADD NEW NODES (similar to AddSubgraph logic)
4225    if !nodes_to_add.is_empty() {
4226        // Need to get target nodegroup for new nodes
4227        let target_node = graph
4228            .nodes
4229            .iter()
4230            .find(|n| n.nodeid == target_node_id)
4231            .ok_or_else(|| MutationError::NodeNotFound(target_node_id.clone()))?;
4232        let target_nodegroup_id = target_node.nodegroup_id.clone();
4233
4234        // Get root nodegroup from subgraph
4235        let root_nodegroup_id = root_node
4236            .nodegroup_id
4237            .clone()
4238            .unwrap_or_else(|| root_node_id.clone());
4239
4240        // Build alias uniqueness set
4241        let mut existing_aliases: HashSet<String> =
4242            graph.nodes.iter().filter_map(|n| n.alias.clone()).collect();
4243
4244        let suffix_ref = params.alias_suffix.as_deref();
4245        let mut remapper = IdRemapper::new(
4246            &graph.graphid,
4247            suffix_ref,
4248            Some(branch_publication_id.clone()),
4249        );
4250
4251        // Register aliases for new nodes — apply prefix if set, then dedup if clashing
4252        for node in &nodes_to_add {
4253            if let Some(ref alias) = node.alias {
4254                let prefixed_alias = if let Some(ref prefix) = params.alias_prefix {
4255                    format!("{}_{}", prefix, alias)
4256                } else {
4257                    alias.clone()
4258                };
4259                let new_alias = make_name_unique(&prefixed_alias, &existing_aliases);
4260                if new_alias != *alias {
4261                    remapper.register_alias(alias, new_alias.clone());
4262                }
4263                existing_aliases.insert(new_alias);
4264            }
4265        }
4266
4267        // Generate IDs for new nodes
4268        for node in &nodes_to_add {
4269            remapper.remap_node(&node.nodeid);
4270        }
4271
4272        // Generate nodegroup IDs for nodegroups of new nodes
4273        let new_node_nodegroups: HashSet<String> = nodes_to_add
4274            .iter()
4275            .filter_map(|n| n.nodegroup_id.clone())
4276            .filter(|ng_id| *ng_id != root_nodegroup_id)
4277            .collect();
4278
4279        for nodegroup in &subgraph.nodegroups {
4280            if new_node_nodegroups.contains(&nodegroup.nodegroupid) {
4281                remapper.remap_nodegroup(&nodegroup.nodegroupid);
4282            }
4283        }
4284
4285        // Add the new nodes
4286        for node in nodes_to_add {
4287            let new_node_id = remapper
4288                .get_node(&node.nodeid)
4289                .ok_or_else(|| {
4290                    MutationError::InvalidSubgraph(format!("Node {} not mapped", node.nodeid))
4291                })?
4292                .clone();
4293
4294            let new_nodegroup_id = node.nodegroup_id.as_ref().and_then(|ng_id| {
4295                if *ng_id == root_nodegroup_id {
4296                    target_nodegroup_id.clone()
4297                } else {
4298                    remapper.get_nodegroup(ng_id).cloned()
4299                }
4300            });
4301
4302            let prefixed_name = if let Some(ref prefix) = params.name_prefix {
4303                format!("{} {}", prefix, node.name)
4304            } else {
4305                node.name.clone()
4306            };
4307
4308            let new_node = StaticNode {
4309                nodeid: new_node_id.clone(),
4310                name: prefixed_name,
4311                alias: remapper.get_alias(node.alias.as_deref()),
4312                datatype: node.datatype.clone(),
4313                nodegroup_id: new_nodegroup_id,
4314                graph_id: graph.graphid.clone(),
4315                is_collector: node.is_collector,
4316                isrequired: node.isrequired,
4317                exportable: node.exportable,
4318                sortorder: node.sortorder,
4319                config: node.config.clone(),
4320                parentproperty: node.parentproperty.clone(),
4321                ontologyclass: node.ontologyclass.clone(),
4322                description: node.description.clone(),
4323                fieldname: node.fieldname.clone(),
4324                hascustomalias: node.hascustomalias,
4325                issearchable: node.issearchable,
4326                istopnode: false,
4327                sourcebranchpublication_id: Some(branch_publication_id.clone()),
4328                source_identifier_id: node.source_identifier_id.clone(),
4329                is_immutable: node.is_immutable,
4330            };
4331            graph.push_node(new_node);
4332
4333            // Create edge from target to new node
4334            let original_edge = subgraph
4335                .edges
4336                .iter()
4337                .find(|e| e.domainnode_id == root_node_id && e.rangenode_id == node.nodeid);
4338            let new_edge_id = generate_uuid_v5(
4339                ("graph", Some(&graph.graphid)),
4340                &format!("update-subgraph-edge-{}-{}", target_node_id, new_node_id),
4341            );
4342            let new_edge = StaticEdge {
4343                edgeid: new_edge_id,
4344                domainnode_id: target_node_id.clone(),
4345                rangenode_id: new_node_id,
4346                graph_id: graph.graphid.clone(),
4347                name: original_edge.and_then(|e| e.name.clone()),
4348                ontologyproperty: if ontology_property.is_empty() {
4349                    original_edge.and_then(|e| e.ontologyproperty.clone())
4350                } else {
4351                    Some(ontology_property.clone())
4352                },
4353                description: original_edge.and_then(|e| e.description.clone()),
4354                source_identifier_id: None,
4355            };
4356            graph.push_edge(new_edge);
4357        }
4358
4359        // Add nodegroups for new nodes
4360        for nodegroup in subgraph.nodegroups {
4361            if !new_node_nodegroups.contains(&nodegroup.nodegroupid) {
4362                continue;
4363            }
4364
4365            let new_ng_id = remapper
4366                .get_nodegroup(&nodegroup.nodegroupid)
4367                .ok_or_else(|| {
4368                    MutationError::InvalidSubgraph(format!(
4369                        "Nodegroup {} not mapped",
4370                        nodegroup.nodegroupid
4371                    ))
4372                })?
4373                .clone();
4374
4375            let new_parent_ng_id = nodegroup.parentnodegroup_id.as_ref().and_then(|parent_id| {
4376                if *parent_id == root_nodegroup_id {
4377                    target_nodegroup_id.clone()
4378                } else {
4379                    remapper.get_nodegroup(parent_id).cloned()
4380                }
4381            });
4382
4383            let new_grouping_node_id = nodegroup
4384                .grouping_node_id
4385                .as_ref()
4386                .and_then(|gn_id| remapper.get_node(gn_id).cloned());
4387
4388            let new_nodegroup = StaticNodegroup {
4389                nodegroupid: new_ng_id,
4390                cardinality: nodegroup.cardinality,
4391                parentnodegroup_id: new_parent_ng_id,
4392                legacygroupid: nodegroup.legacygroupid,
4393                grouping_node_id: new_grouping_node_id,
4394            };
4395            graph.push_nodegroup(new_nodegroup);
4396        }
4397    }
4398
4399    // 8. REMOVE ORPHANED NODES (if requested)
4400    if remove_orphaned && !orphaned_node_ids.is_empty() {
4401        // Remove nodes
4402        graph
4403            .nodes
4404            .retain(|n| !orphaned_node_ids.contains(&n.nodeid));
4405
4406        // Remove edges referencing orphaned nodes
4407        graph.edges.retain(|e| {
4408            !orphaned_node_ids.contains(&e.domainnode_id)
4409                && !orphaned_node_ids.contains(&e.rangenode_id)
4410        });
4411
4412        // Remove cards_x_nodes_x_widgets for orphaned nodes
4413        if let Some(ref mut cxnxws) = graph.cards_x_nodes_x_widgets {
4414            cxnxws.retain(|c| !orphaned_node_ids.contains(&c.node_id));
4415        }
4416    }
4417
4418    Ok(())
4419}
4420
4421/// Find branch nodes by traversing edges from target and validating sourcebranchpublication_id
4422fn find_branch_nodes_by_traversal(
4423    graph: &StaticGraph,
4424    target_node_id: &str,
4425    expected_branch_id: &str,
4426) -> Result<HashMap<String, Option<String>>, MutationError> {
4427    let mut branch_nodes: HashMap<String, Option<String>> = HashMap::new();
4428    let mut visited: HashSet<String> = HashSet::new();
4429    let mut queue: Vec<String> = Vec::new();
4430
4431    // Find immediate children of target node
4432    for edge in &graph.edges {
4433        if edge.domainnode_id == target_node_id {
4434            queue.push(edge.rangenode_id.clone());
4435        }
4436    }
4437
4438    while let Some(node_id) = queue.pop() {
4439        if visited.contains(&node_id) {
4440            continue;
4441        }
4442        visited.insert(node_id.clone());
4443
4444        // Find the node
4445        let node = match graph.nodes.iter().find(|n| n.nodeid == node_id) {
4446            Some(n) => n,
4447            None => continue, // Edge points to non-existent node (shouldn't happen)
4448        };
4449
4450        // Check sourcebranchpublication_id
4451        match &node.sourcebranchpublication_id {
4452            Some(pub_id) if pub_id == expected_branch_id => {
4453                // This node belongs to the branch - add it and explore its children
4454                branch_nodes.insert(node_id.clone(), node.alias.clone());
4455
4456                // Add children to queue
4457                for edge in &graph.edges {
4458                    if edge.domainnode_id == node_id && !visited.contains(&edge.rangenode_id) {
4459                        queue.push(edge.rangenode_id.clone());
4460                    }
4461                }
4462            }
4463            Some(_pub_id) => {
4464                // Different branch publication ID — this node belongs to a
4465                // different branch attached to the same parent. Skip it,
4466                // just like nodes with no publication ID.
4467                continue;
4468            }
4469            None => {
4470                // No sourcebranchpublication_id - this node wasn't added via branch
4471                // Stop traversing this path (it's part of original graph or different branch)
4472                continue;
4473            }
4474        }
4475    }
4476
4477    Ok(branch_nodes)
4478}
4479
4480// =============================================================================
4481// JSON-based Mutation API
4482// =============================================================================
4483
4484/// Request structure for applying mutations via JSON
4485#[derive(Debug, Clone, Serialize, Deserialize)]
4486pub struct MutationRequest {
4487    /// List of mutations to apply in order
4488    pub mutations: Vec<GraphMutation>,
4489    /// Options for mutation application
4490    #[serde(default)]
4491    pub options: MutationRequestOptions,
4492}
4493
4494/// Options for JSON mutation requests
4495#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4496pub struct MutationRequestOptions {
4497    /// Automatically create cards for new nodegroups
4498    #[serde(default = "default_true")]
4499    pub autocreate_card: bool,
4500    /// Automatically add default widgets to cards
4501    #[serde(default = "default_true")]
4502    pub autocreate_widget: bool,
4503}
4504
4505fn default_true() -> bool {
4506    true
4507}
4508
4509impl From<MutationRequestOptions> for MutatorOptions {
4510    fn from(opts: MutationRequestOptions) -> Self {
4511        MutatorOptions {
4512            autocreate_card: opts.autocreate_card,
4513            autocreate_widget: opts.autocreate_widget,
4514            ontology_validator: None,
4515            skip_publication: false,
4516        }
4517    }
4518}
4519
4520/// Apply mutations to a graph from a JSON string
4521///
4522/// # Arguments
4523/// * `graph` - The graph to mutate (will be cloned, original unchanged)
4524/// * `mutations_json` - JSON string containing a MutationRequest
4525///
4526/// # Returns
4527/// * `Ok(StaticGraph)` - The mutated graph
4528/// * `Err(String)` - Error message if parsing or mutation failed
4529///
4530/// # Example JSON
4531/// ```json
4532/// {
4533///   "mutations": [
4534///     {
4535///       "AddNode": {
4536///         "parent_alias": "root",
4537///         "alias": "child",
4538///         "name": "Child Node",
4539///         "cardinality": "N",
4540///         "datatype": "string",
4541///         "ontology_class": "E41_Appellation",
4542///         "parent_property": "P1_is_identified_by",
4543///         "description": null,
4544///         "config": null,
4545///         "options": {}
4546///       }
4547///     }
4548///   ],
4549///   "options": {
4550///     "autocreate_card": true,
4551///     "autocreate_widget": true
4552///   }
4553/// }
4554/// ```
4555pub fn apply_mutations_from_json(
4556    graph: &StaticGraph,
4557    mutations_json: &str,
4558) -> Result<StaticGraph, String> {
4559    apply_mutations_from_json_with_extensions(graph, mutations_json, None)
4560}
4561
4562/// Apply mutations from JSON with extension support
4563///
4564/// Same as `apply_mutations_from_json` but allows extension mutations.
4565///
4566/// # Arguments
4567/// * `graph` - The graph to mutate
4568/// * `mutations_json` - JSON string containing the mutation request
4569/// * `registry` - Optional extension mutation registry
4570pub fn apply_mutations_from_json_with_extensions(
4571    graph: &StaticGraph,
4572    mutations_json: &str,
4573    registry: Option<&ExtensionMutationRegistry>,
4574) -> Result<StaticGraph, String> {
4575    // Parse the JSON request
4576    let request: MutationRequest = serde_json::from_str(mutations_json)
4577        .map_err(|e| format!("Failed to parse mutations JSON: {}", e))?;
4578
4579    apply_mutations_with_extensions(graph, request.mutations, request.options.into(), registry)
4580}
4581
4582/// Apply mutations that may start with a CreateGraph mutation
4583///
4584/// If `graph` is `None`, the first mutation must be `CreateGraph` which creates
4585/// a new graph from scratch. Remaining mutations are applied to the created graph.
4586///
4587/// If `graph` is `Some`, the first mutation must NOT be `CreateGraph`, and all
4588/// mutations are applied to the existing graph (delegating to the normal path).
4589///
4590/// # Arguments
4591/// * `mutations_json` - JSON string containing a MutationRequest
4592/// * `graph` - Optional existing graph. If None, first mutation must be CreateGraph.
4593///
4594/// # Returns
4595/// * `Ok(StaticGraph)` - The resulting graph
4596/// * `Err(String)` - Error message if mutation failed
4597pub fn apply_mutations_create_from_json(
4598    mutations_json: &str,
4599    graph: Option<&StaticGraph>,
4600) -> Result<StaticGraph, String> {
4601    let request: MutationRequest = serde_json::from_str(mutations_json)
4602        .map_err(|e| format!("Failed to parse mutations JSON: {}", e))?;
4603
4604    let mut mutations = request.mutations;
4605    let options: MutatorOptions = request.options.into();
4606
4607    match graph {
4608        None => {
4609            // No graph provided - first mutation must be CreateGraph
4610            if mutations.is_empty() {
4611                return Err("No graph provided and no mutations to apply".to_string());
4612            }
4613
4614            let first = mutations.remove(0);
4615            match first {
4616                GraphMutation::CreateGraph(params) => {
4617                    // Create skeleton graph
4618                    let mut new_graph = create_skeleton_graph(
4619                        &params.name,
4620                        &params.root_alias,
4621                        params.is_resource,
4622                        params.root_ontology_class.as_deref(),
4623                    );
4624
4625                    // Override graph ID if provided
4626                    if let Some(ref custom_id) = params.graph_id {
4627                        // Update graphid and all node graph_id references
4628                        let old_id = new_graph.graphid.clone();
4629                        new_graph.graphid = custom_id.clone();
4630                        for node in &mut new_graph.nodes {
4631                            if node.graph_id == old_id {
4632                                node.graph_id = custom_id.clone();
4633                            }
4634                        }
4635                        if new_graph.root.graph_id == old_id {
4636                            new_graph.root.graph_id = custom_id.clone();
4637                        }
4638                    }
4639
4640                    // Set author if provided
4641                    if let Some(author) = params.author {
4642                        new_graph.author = Some(author);
4643                    }
4644
4645                    // Set description if provided
4646                    if let Some(desc) = params.description {
4647                        new_graph.description = Some(StaticTranslatableString::from_string(&desc));
4648                    }
4649
4650                    // Set graph-level ontology_id if provided
4651                    if params.ontology_id.is_some() {
4652                        new_graph.ontology_id = params.ontology_id;
4653                    }
4654
4655                    // Apply remaining mutations to the new graph
4656                    if mutations.is_empty() {
4657                        if !options.skip_publication {
4658                            stamp_publication(&mut new_graph);
4659                        }
4660                        new_graph.build_indices();
4661                        Ok(new_graph)
4662                    } else {
4663                        apply_mutations_with_extensions(&new_graph, mutations, options, None)
4664                    }
4665                }
4666                _ => Err("No graph provided and first mutation is not CreateGraph".to_string()),
4667            }
4668        }
4669        Some(existing_graph) => {
4670            // Graph provided - first mutation must NOT be CreateGraph
4671            if let Some(GraphMutation::CreateGraph(_)) = mutations.first() {
4672                return Err("CreateGraph cannot be used when a graph already exists".to_string());
4673            }
4674
4675            apply_mutations_with_extensions(existing_graph, mutations, options, None)
4676        }
4677    }
4678}
4679
4680/// Apply a list of mutations to a graph
4681///
4682/// # Arguments
4683/// * `graph` - The graph to mutate (will be cloned, original unchanged)
4684/// * `mutations` - List of mutations to apply
4685/// * `options` - Options for mutation application
4686///
4687/// # Returns
4688/// * `Ok(StaticGraph)` - The mutated graph
4689/// * `Err(String)` - Error message if mutation failed
4690///
4691/// # Note
4692/// This function does not support extension mutations. For extension support,
4693/// use `apply_mutations_with_extensions` instead.
4694pub fn apply_mutations(
4695    graph: &StaticGraph,
4696    mutations: Vec<GraphMutation>,
4697    options: MutatorOptions,
4698) -> Result<StaticGraph, String> {
4699    apply_mutations_with_extensions(graph, mutations, options, None)
4700}
4701
4702/// Apply a list of mutations to a graph with extension support
4703///
4704/// # Arguments
4705/// * `graph` - The graph to mutate (will be cloned, original unchanged)
4706/// * `mutations` - List of mutations to apply
4707/// * `options` - Options for mutation application
4708/// * `registry` - Optional extension mutation registry
4709///
4710/// # Returns
4711/// * `Ok(StaticGraph)` - The mutated graph
4712/// * `Err(String)` - Error message if mutation failed
4713///
4714/// # Example
4715/// ```ignore
4716/// use std::sync::Arc;
4717/// use alizarin_core::graph_mutator::{
4718///     apply_mutations_with_extensions, ExtensionMutationRegistry,
4719///     GraphMutation, ExtensionMutationParams, MutatorOptions,
4720/// };
4721///
4722/// let mut registry = ExtensionMutationRegistry::new();
4723/// registry.register("my_ext.custom_mutation", Arc::new(MyHandler));
4724///
4725/// let mutations = vec![
4726///     GraphMutation::Extension(ExtensionMutationParams {
4727///         name: "my_ext.custom_mutation".to_string(),
4728///         params: serde_json::json!({"key": "value"}),
4729///         conformance: MutationConformance::AlwaysConformant,
4730///     }),
4731/// ];
4732///
4733/// let result = apply_mutations_with_extensions(
4734///     &graph,
4735///     mutations,
4736///     MutatorOptions::default(),
4737///     Some(&registry),
4738/// )?;
4739/// ```
4740pub fn apply_mutations_with_extensions(
4741    graph: &StaticGraph,
4742    mutations: Vec<GraphMutation>,
4743    options: MutatorOptions,
4744    registry: Option<&ExtensionMutationRegistry>,
4745) -> Result<StaticGraph, String> {
4746    let mut result = graph.deep_clone();
4747
4748    for mutation in mutations {
4749        apply_mutation_with_extensions(&mut result, mutation, &options, registry)
4750            .map_err(|e| e.to_string())?;
4751    }
4752
4753    // Stamp publication after applying mutations
4754    if !options.skip_publication {
4755        stamp_publication(&mut result);
4756    }
4757
4758    result.build_indices();
4759    Ok(result)
4760}
4761
4762/// Stamp a graph with a new publication entry.
4763///
4764/// Generates a deterministic `publicationid` (UUID5 from graphid + timestamp)
4765/// and sets `published_time` to the current UTC time. This ensures the graph
4766/// can be used as a subgraph source (add_subgraph requires a publicationid).
4767fn stamp_publication(graph: &mut StaticGraph) {
4768    let now = chrono::Utc::now();
4769    let timestamp = now.timestamp_millis().to_string();
4770
4771    let publication_id = generate_uuid_v5(("publication", Some(&graph.graphid)), &timestamp);
4772    let published_time = now.format("%Y-%m-%dT%H:%M:%S%.3f").to_string();
4773
4774    graph.publication = Some(serde_json::json!({
4775        "publicationid": publication_id,
4776        "graph_id": graph.graphid,
4777        "published_time": published_time,
4778        "notes": null
4779    }));
4780}
4781
4782/// Serialize mutations to JSON
4783///
4784/// Useful for debugging or persisting mutation sequences
4785pub fn mutations_to_json(mutations: &[GraphMutation]) -> Result<String, String> {
4786    serde_json::to_string_pretty(mutations)
4787        .map_err(|e| format!("Failed to serialize mutations: {}", e))
4788}
4789
4790// =============================================================================
4791// Skeleton Graph Creation
4792// =============================================================================
4793
4794/// Create a minimal valid skeleton graph with just a root node
4795///
4796/// This creates a graph that can be built up from scratch using mutations.
4797/// The root node is created with:
4798/// - `istopnode: true`
4799/// - `nodegroup_id: None` (root has no nodegroup)
4800/// - `datatype: "semantic"`
4801///
4802/// # Arguments
4803/// * `name` - The name of the graph (used for display and UUID generation)
4804/// * `root_alias` - Alias for the root node (used to reference it in mutations)
4805/// * `is_resource` - Whether this is a resource model (true) or branch (false)
4806/// * `ontology_classes` - Optional ontology class URI(s) for the root node.
4807///   Accepts a slice so callers with a single class or a `Vec<String>` can both
4808///   use this with minimal ceremony.
4809///
4810/// # Example
4811/// ```rust,ignore
4812/// use alizarin_core::graph_mutator::{create_skeleton_graph, apply_mutations, Cardinality};
4813///
4814/// let classes = vec!["http://example.org/Person".to_string()];
4815/// let graph = create_skeleton_graph("Person", "person", true, Some(classes.as_slice()));
4816/// // Now add nodes using apply_mutations...
4817/// ```
4818pub fn create_skeleton_graph(
4819    name: &str,
4820    root_alias: &str,
4821    is_resource: bool,
4822    ontology_classes: Option<&[String]>,
4823) -> StaticGraph {
4824    // Generate deterministic graph ID from name
4825    let graphid = generate_uuid_v5(("skeleton", None), name);
4826
4827    // Generate root node ID
4828    let root_nodeid = generate_uuid_v5(("graph", Some(&graphid)), &format!("root-{}", root_alias));
4829
4830    // For branches, the root must be a collector (Arches validation requirement).
4831    // In Arches, is_collector is a computed property: nodeid == nodegroup_id && nodegroup_id != null.
4832    // So for branches, the root's nodegroup_id must equal its nodeid.
4833    let root_nodegroup_id: serde_json::Value = if is_resource {
4834        serde_json::Value::Null
4835    } else {
4836        serde_json::Value::String(root_nodeid.clone())
4837    };
4838    let root_is_collector = !is_resource;
4839
4840    // Serialise the ontology class list: None/empty -> null, single -> string,
4841    // multi -> array. Matches the `optional_string_or_vec` serde helper round-trip.
4842    let ontology_class_json = match ontology_classes {
4843        None | Some([]) => serde_json::Value::Null,
4844        Some([single]) => serde_json::Value::String(single.clone()),
4845        Some(list) => serde_json::Value::Array(
4846            list.iter()
4847                .map(|s| serde_json::Value::String(s.clone()))
4848                .collect(),
4849        ),
4850    };
4851
4852    // Build graph as JSON to handle private fields correctly
4853    let graph_json = serde_json::json!({
4854        "graphid": graphid,
4855        "name": { "en": name },
4856        "isresource": is_resource,
4857        "is_active": is_resource,
4858        "is_editable": true,
4859        "config": {},
4860        "template_id": "50000000-0000-0000-0000-000000000001",
4861        "version": "1",
4862        "nodes": [{
4863            "nodeid": root_nodeid,
4864            "name": name,
4865            "alias": root_alias,
4866            "datatype": "semantic",
4867            "nodegroup_id": root_nodegroup_id,
4868            "graph_id": graphid,
4869            "is_collector": root_is_collector,
4870            "isrequired": false,
4871            "exportable": true,
4872            "sortorder": 0,
4873            "istopnode": true,
4874            "issearchable": true,
4875            "ontologyclass": ontology_class_json.clone()
4876        }],
4877        "root": {
4878            "nodeid": root_nodeid,
4879            "name": name,
4880            "alias": root_alias,
4881            "datatype": "semantic",
4882            "nodegroup_id": root_nodegroup_id,
4883            "graph_id": graphid,
4884            "is_collector": root_is_collector,
4885            "isrequired": false,
4886            "exportable": true,
4887            "sortorder": 0,
4888            "istopnode": true,
4889            "issearchable": true,
4890            "ontologyclass": ontology_class_json.clone()
4891        },
4892        "nodegroups": [],
4893        "edges": [],
4894        "cards": [],
4895        "cards_x_nodes_x_widgets": [],
4896        "functions_x_graphs": []
4897    });
4898
4899    let mut graph: StaticGraph =
4900        serde_json::from_value(graph_json).expect("Failed to create skeleton graph");
4901    graph.build_indices();
4902    graph
4903}
4904
4905// =============================================================================
4906// Instruction-based Graph Building (Triple-like DSL)
4907// =============================================================================
4908
4909/// A row-based instruction for graph building
4910///
4911/// This provides a triple-like DSL for building graphs:
4912/// - `action`: The operation to perform (add_node, add_edge, etc.)
4913/// - `subject`: The source/parent entity (alias or ID)
4914/// - `object`: The target/new entity (alias, name, or ID)
4915/// - `params`: Additional parameters as key-value pairs
4916///
4917/// # Actions and their semantics
4918///
4919/// | Action | Subject | Object | Key Params |
4920/// |--------|---------|--------|------------|
4921/// | `create_model` | root_alias | graphid (optional) | `name`, `ontology_class`, `ontology_id`, `slug` |
4922/// | `create_branch` | root_alias | graphid (optional) | `name`, `ontology_class`, `ontology_id`, `slug` |
4923/// | `add_node` | parent_alias | new_alias | `datatype`, `name`, `ontology_class`, `cardinality`, `parent_property` |
4924/// | `add_edge` | domain_alias | range_alias | `ontology_property` |
4925/// | `add_nodegroup` | node_alias | (unused) | `cardinality` |
4926/// | `add_card` | nodegroup_id | card_name | `component_id` |
4927/// | `add_widget` | node_alias | (unused) | `widget_id` |
4928/// | `add_subgraph` | target_alias | (unused) | `subgraph` (JSON), `ontology_property`, `alias_suffix` |
4929/// | `update_subgraph` | target_alias | (unused) | `subgraph` (JSON), `ontology_property`, `alias_suffix`, `remove_orphaned` |
4930/// | `concept_change_collection` | node_alias | collection_id | (none) |
4931///
4932/// Note: `create_model` and `create_branch` must be the first instruction when using
4933/// `build_graph_from_instructions()`. They create a new graph rather than mutating one.
4934#[derive(Debug, Clone, Serialize, Deserialize)]
4935pub struct GraphInstruction {
4936    /// The action to perform
4937    pub action: String,
4938    /// The subject (parent/source entity alias)
4939    pub subject: String,
4940    /// The object (child/target entity alias or name)
4941    #[serde(default)]
4942    pub object: String,
4943    /// Additional parameters
4944    #[serde(default)]
4945    pub params: HashMap<String, serde_json::Value>,
4946}
4947
4948impl GraphInstruction {
4949    /// Create a new instruction
4950    pub fn new(action: &str, subject: &str, object: &str) -> Self {
4951        Self {
4952            action: action.to_string(),
4953            subject: subject.to_string(),
4954            object: object.to_string(),
4955            params: HashMap::new(),
4956        }
4957    }
4958
4959    /// Add a parameter
4960    pub fn with_param(mut self, key: &str, value: serde_json::Value) -> Self {
4961        self.params.insert(key.to_string(), value);
4962        self
4963    }
4964
4965    /// Add a string parameter
4966    pub fn with_str(self, key: &str, value: &str) -> Self {
4967        self.with_param(key, serde_json::Value::String(value.to_string()))
4968    }
4969
4970    /// Helper to get a string param
4971    fn get_str(&self, key: &str) -> Option<String> {
4972        self.params
4973            .get(key)
4974            .and_then(|v| v.as_str())
4975            .map(|s| s.to_string())
4976    }
4977
4978    /// Helper to get a string param with default
4979    fn get_str_or(&self, key: &str, default: &str) -> String {
4980        self.get_str(key).unwrap_or_else(|| default.to_string())
4981    }
4982
4983    /// Helper to read a param as a class list. Accepts either a single string
4984    /// or an array of strings. Returns `None` when the key is missing or the
4985    /// resulting list is empty/blank.
4986    fn get_class_list(&self, key: &str) -> Option<Vec<String>> {
4987        let raw: Vec<String> = match self.params.get(key)? {
4988            serde_json::Value::String(s) => vec![s.clone()],
4989            serde_json::Value::Array(arr) => arr
4990                .iter()
4991                .filter_map(|v| v.as_str().map(|s| s.to_string()))
4992                .collect(),
4993            _ => return None,
4994        };
4995        sanitize_class_list(Some(raw))
4996    }
4997
4998    /// Resolve a subgraph from either `params.subgraph` (inline JSON) or
4999    /// `object` (graph ID looked up from the global registry).
5000    fn resolve_subgraph(&self) -> Result<StaticGraph, MutationError> {
5001        if let Some(subgraph_value) = self.params.get("subgraph") {
5002            serde_json::from_value(subgraph_value.clone()).map_err(|e| {
5003                MutationError::InvalidSubgraph(format!("Failed to parse subgraph: {}", e))
5004            })
5005        } else if !self.object.is_empty() {
5006            let graph = crate::registry::get_graph(&self.object).ok_or_else(|| {
5007                MutationError::InvalidSubgraph(format!(
5008                    "Branch '{}' not found in graph registry",
5009                    self.object
5010                ))
5011            })?;
5012            Ok((*graph).clone())
5013        } else {
5014            Err(MutationError::InvalidSubgraph(
5015                "add_subgraph/update_subgraph requires either 'subgraph' param or a branch graph ID as object".to_string(),
5016            ))
5017        }
5018    }
5019
5020    /// Helper to get a translatable map (language -> value) from params
5021    fn get_translatable_map(&self, key: &str) -> Option<HashMap<String, String>> {
5022        self.params.get(key).and_then(|v| {
5023            if let Some(obj) = v.as_object() {
5024                let map: HashMap<String, String> = obj
5025                    .iter()
5026                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
5027                    .collect();
5028                if map.is_empty() {
5029                    None
5030                } else {
5031                    Some(map)
5032                }
5033            } else {
5034                None
5035            }
5036        })
5037    }
5038
5039    /// Convert this instruction to a GraphMutation
5040    pub fn to_mutation(&self) -> Result<GraphMutation, MutationError> {
5041        match self.action.as_str() {
5042            "add_node" => {
5043                let cardinality_str = self.get_str_or("cardinality", "1");
5044                let cardinality = match cardinality_str.as_str() {
5045                    "1" | "one" | "One" => Cardinality::One,
5046                    "n" | "N" | "many" => Cardinality::N,
5047                    _ => {
5048                        return Err(MutationError::InvalidSubgraph(format!(
5049                            "Invalid cardinality: {}",
5050                            cardinality_str
5051                        )))
5052                    }
5053                };
5054
5055                Ok(GraphMutation::AddNode(AddNodeParams {
5056                    parent_alias: if self.subject.is_empty() {
5057                        None
5058                    } else {
5059                        Some(self.subject.clone())
5060                    },
5061                    alias: self.object.clone(),
5062                    name: self.get_str_or("name", &self.object),
5063                    cardinality,
5064                    datatype: self.get_str_or("datatype", "semantic"),
5065                    ontology_class: self.get_class_list("ontology_class"),
5066                    parent_property: self.get_str_or("parent_property", ""),
5067                    description: self.get_str("description"),
5068                    config: self.params.get("config").cloned(),
5069                    options: {
5070                        let mut opts: NodeOptions = self
5071                            .params
5072                            .get("options")
5073                            .and_then(|v| serde_json::from_value(v.clone()).ok())
5074                            .unwrap_or_default();
5075                        // Default: semantic nodes with cardinality N are collectors
5076                        if opts.is_collector.is_none() {
5077                            let dt = self.get_str_or("datatype", "semantic");
5078                            if dt == "semantic" && cardinality == Cardinality::N {
5079                                opts.is_collector = Some(true);
5080                            }
5081                        }
5082                        opts
5083                    },
5084                }))
5085            }
5086            "add_edge" => Ok(GraphMutation::AddEdge(AddEdgeParams {
5087                from_node_id: self.subject.clone(),
5088                to_node_id: self.object.clone(),
5089                ontology_property: self.get_str_or("ontology_property", ""),
5090                name: self.get_str("name"),
5091                description: self.get_str("description"),
5092            })),
5093            "add_nodegroup" => {
5094                let cardinality_str = self.get_str_or("cardinality", "n");
5095                let cardinality = match cardinality_str.as_str() {
5096                    "1" | "one" | "One" => Cardinality::One,
5097                    "n" | "N" | "many" => Cardinality::N,
5098                    _ => Cardinality::N,
5099                };
5100
5101                // Generate nodegroup ID from subject alias
5102                let nodegroup_id = self
5103                    .get_str("nodegroup_id")
5104                    .unwrap_or_else(|| format!("ng-{}", self.subject));
5105
5106                Ok(GraphMutation::AddNodegroup(AddNodegroupParams {
5107                    nodegroup_id,
5108                    cardinality,
5109                    parent_alias: if self.subject.is_empty() {
5110                        None
5111                    } else {
5112                        Some(self.subject.clone())
5113                    },
5114                }))
5115            }
5116            "add_card" => {
5117                let name = if self.object.is_empty() {
5118                    StaticTranslatableString::from_string("Card")
5119                } else {
5120                    StaticTranslatableString::from_string(&self.object)
5121                };
5122                Ok(GraphMutation::AddCard(AddCardParams {
5123                    nodegroup_id: self.subject.clone(),
5124                    name,
5125                    component_id: self.get_str("component_id"),
5126                    options: CardOptions {
5127                        description: self
5128                            .get_str("description")
5129                            .map(|s| StaticTranslatableString::from_string(&s)),
5130                        ..CardOptions::default()
5131                    },
5132                    config: self.params.get("config").cloned(),
5133                }))
5134            }
5135            "add_widget" => Ok(GraphMutation::AddWidgetToCard(AddWidgetParams {
5136                node_id: self.subject.clone(),
5137                widget_id: self.get_str_or("widget_id", "10000000-0000-0000-0000-000000000001"),
5138                label: self.get_str_or("label", ""),
5139                config: self
5140                    .params
5141                    .get("config")
5142                    .cloned()
5143                    .unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
5144                sortorder: self
5145                    .params
5146                    .get("sortorder")
5147                    .and_then(|v| v.as_i64())
5148                    .map(|i| i as i32),
5149                visible: self.params.get("visible").and_then(|v| v.as_bool()),
5150            })),
5151            "add_subgraph" => {
5152                let subgraph = self.resolve_subgraph()?;
5153
5154                Ok(GraphMutation::AddSubgraph(AddSubgraphParams {
5155                    subgraph,
5156                    target_node_id: self.subject.clone(),
5157                    ontology_property: self.get_str_or("ontology_property", ""),
5158                    alias_suffix: self.get_str("alias_suffix"),
5159                    alias_prefix: self.get_str("alias_prefix"),
5160                    name_prefix: self.get_str("name_prefix"),
5161                }))
5162            }
5163            "update_subgraph" => {
5164                let subgraph = self.resolve_subgraph()?;
5165
5166                let remove_orphaned = self
5167                    .params
5168                    .get("remove_orphaned")
5169                    .and_then(|v| v.as_bool())
5170                    .unwrap_or(false);
5171
5172                Ok(GraphMutation::UpdateSubgraph(UpdateSubgraphParams {
5173                    subgraph,
5174                    target_node_id: self.subject.clone(),
5175                    ontology_property: self.get_str_or("ontology_property", ""),
5176                    alias_suffix: self.get_str("alias_suffix"),
5177                    remove_orphaned,
5178                    alias_prefix: self.get_str("alias_prefix"),
5179                    name_prefix: self.get_str("name_prefix"),
5180                }))
5181            }
5182            "concept_change_collection" => Ok(GraphMutation::ConceptChangeCollection(
5183                ConceptChangeCollectionParams {
5184                    node_id: self.subject.clone(),
5185                    collection_id: self.object.clone(),
5186                },
5187            )),
5188            "delete_card" => Ok(GraphMutation::DeleteCard(DeleteCardParams {
5189                card_id: self.subject.clone(),
5190            })),
5191            "delete_widget" => Ok(GraphMutation::DeleteWidget(DeleteWidgetParams {
5192                widget_mapping_id: self.subject.clone(),
5193            })),
5194            "add_function" => Ok(GraphMutation::AddFunction(AddFunctionParams {
5195                function_id: self.subject.clone(),
5196                config: self.params.get("config").cloned(),
5197            })),
5198            "set_descriptor_function" => Ok(GraphMutation::SetDescriptorFunction(
5199                SetDescriptorFunctionParams {
5200                    function_id: self.subject.clone(),
5201                },
5202            )),
5203            "delete_function" => Ok(GraphMutation::DeleteFunction(DeleteFunctionParams {
5204                function_mapping_id: self.subject.clone(),
5205            })),
5206            "set_descriptor_template" => Ok(GraphMutation::SetDescriptorTemplate(
5207                SetDescriptorTemplateParams {
5208                    descriptor_type: self.subject.clone(),
5209                    string_template: self.object.clone(),
5210                },
5211            )),
5212            "delete_node" => Ok(GraphMutation::DeleteNode(DeleteNodeParams {
5213                node_id: self.subject.clone(),
5214            })),
5215            "delete_nodegroup" => Ok(GraphMutation::DeleteNodegroup(DeleteNodegroupParams {
5216                nodegroup_id: self.subject.clone(),
5217            })),
5218            "update_node" => Ok(GraphMutation::UpdateNode(UpdateNodeParams {
5219                node_id: self.subject.clone(),
5220                name: self.get_str("name"),
5221                ontology_class: self.get_class_list("ontology_class"),
5222                parent_property: self.get_str("parent_property"),
5223                description: self.get_str("description"),
5224                config: self.params.get("config").cloned(),
5225                options: UpdateNodeOptions {
5226                    exportable: self.params.get("exportable").and_then(|v| v.as_bool()),
5227                    fieldname: self.get_str("fieldname"),
5228                    isrequired: self.params.get("isrequired").and_then(|v| v.as_bool()),
5229                    issearchable: self.params.get("issearchable").and_then(|v| v.as_bool()),
5230                    sortorder: self
5231                        .params
5232                        .get("sortorder")
5233                        .and_then(|v| v.as_i64())
5234                        .map(|i| i as i32),
5235                },
5236            })),
5237            "change_node_type" => {
5238                let datatype = self
5239                    .get_str("datatype")
5240                    .or_else(|| {
5241                        if self.object.is_empty() {
5242                            None
5243                        } else {
5244                            Some(self.object.clone())
5245                        }
5246                    })
5247                    .ok_or_else(|| {
5248                        MutationError::InvalidSubgraph(
5249                            "change_node_type requires 'datatype' param or object".to_string(),
5250                        )
5251                    })?;
5252
5253                Ok(GraphMutation::ChangeNodeType(ChangeNodeTypeParams {
5254                    node_id: self.subject.clone(),
5255                    datatype,
5256                    name: self.get_str("name"),
5257                    ontology_class: self.get_class_list("ontology_class"),
5258                    parent_property: self.get_str("parent_property"),
5259                    description: self.get_str("description"),
5260                    config: self.params.get("config").cloned(),
5261                    options: UpdateNodeOptions {
5262                        exportable: self.params.get("exportable").and_then(|v| v.as_bool()),
5263                        fieldname: self.get_str("fieldname"),
5264                        isrequired: self.params.get("isrequired").and_then(|v| v.as_bool()),
5265                        issearchable: self.params.get("issearchable").and_then(|v| v.as_bool()),
5266                        sortorder: self
5267                            .params
5268                            .get("sortorder")
5269                            .and_then(|v| v.as_i64())
5270                            .map(|i| i as i32),
5271                    },
5272                }))
5273            }
5274            "change_cardinality" => {
5275                let cardinality_str = self
5276                    .get_str("cardinality")
5277                    .or_else(|| {
5278                        if self.object.is_empty() {
5279                            None
5280                        } else {
5281                            Some(self.object.clone())
5282                        }
5283                    })
5284                    .ok_or_else(|| {
5285                        MutationError::InvalidSubgraph(
5286                            "change_cardinality requires 'cardinality' param or object (1 or n)"
5287                                .to_string(),
5288                        )
5289                    })?;
5290
5291                let cardinality = match cardinality_str.to_lowercase().as_str() {
5292                    "1" | "one" => Cardinality::One,
5293                    "n" | "many" => Cardinality::N,
5294                    _ => {
5295                        return Err(MutationError::InvalidSubgraph(format!(
5296                            "Invalid cardinality '{}', expected '1', 'one', 'n', or 'many'",
5297                            cardinality_str
5298                        )))
5299                    }
5300                };
5301
5302                Ok(GraphMutation::ChangeCardinality(ChangeCardinalityParams {
5303                    node_id: self.subject.clone(),
5304                    cardinality,
5305                }))
5306            }
5307            "rename_node" => Ok(GraphMutation::RenameNode(RenameNodeParams {
5308                node_id: self.subject.clone(),
5309                alias: self.get_str("alias").or_else(|| {
5310                    if self.object.is_empty() {
5311                        None
5312                    } else {
5313                        Some(self.object.clone())
5314                    }
5315                }),
5316                name: self.get_str("name"),
5317                description: self.get_str("description"),
5318                realign_card: self
5319                    .params
5320                    .get("realign_card")
5321                    .and_then(|v| v.as_bool())
5322                    .unwrap_or(true),
5323            })),
5324            "rename_card" => {
5325                // name: either from params.name (simple string) or params.name_i18n (map)
5326                // If object is set and name is not, treat object as the name (simple en string)
5327                let name = self.get_str("name").or_else(|| {
5328                    if self.object.is_empty() {
5329                        None
5330                    } else {
5331                        Some(self.object.clone())
5332                    }
5333                });
5334                Ok(GraphMutation::RenameCard(RenameCardParams {
5335                    card_id: self.subject.clone(),
5336                    language: self.get_str("language"),
5337                    name,
5338                    name_i18n: self.get_translatable_map("name_i18n"),
5339                    description: self.get_str("description"),
5340                    description_i18n: self.get_translatable_map("description_i18n"),
5341                }))
5342            }
5343            "realign_card_from_node" => Ok(GraphMutation::RealignCardFromNode(
5344                RealignCardFromNodeParams {
5345                    node_alias: self.subject.clone(),
5346                },
5347            )),
5348            "rename_graph" => {
5349                // Parse name: either from params.name (as map) or object (as simple en string)
5350                let name = self.get_translatable_map("name").or_else(|| {
5351                    if self.object.is_empty() {
5352                        None
5353                    } else {
5354                        let mut map = HashMap::new();
5355                        map.insert("en".to_string(), self.object.clone());
5356                        Some(map)
5357                    }
5358                });
5359                Ok(GraphMutation::RenameGraph(RenameGraphParams {
5360                    name,
5361                    description: self.get_translatable_map("description"),
5362                    subtitle: self.get_translatable_map("subtitle"),
5363                    author: self.get_str("author"),
5364                }))
5365            }
5366            "update_widget_config" => {
5367                let config = self.params.get("config").cloned().ok_or_else(|| {
5368                    MutationError::Other("update_widget_config requires params.config".to_string())
5369                })?;
5370                Ok(GraphMutation::UpdateWidgetConfig(
5371                    UpdateWidgetConfigParams {
5372                        node_id: self.subject.clone(),
5373                        config,
5374                    },
5375                ))
5376            }
5377            "coppice_subgraph" => {
5378                let publication_id = self.get_str("publication_id").ok_or_else(|| {
5379                    MutationError::Other(
5380                        "coppice_subgraph requires params.publication_id".to_string(),
5381                    )
5382                })?;
5383                Ok(GraphMutation::CoppiceSubgraph(CoppiceSubgraphParams {
5384                    subject: self.subject.clone(),
5385                    publication_id,
5386                }))
5387            }
5388            // create_model and create_branch are handled separately via to_skeleton_graph()
5389            "create_model" | "create_branch" => Err(MutationError::InvalidSubgraph(format!(
5390                "'{}' creates a new graph, use build_graph_from_instructions() instead",
5391                self.action
5392            ))),
5393            // Unrecognized actions are treated as extension mutations
5394            other => Ok(GraphMutation::Extension(ExtensionMutationParams {
5395                name: other.to_string(),
5396                params: {
5397                    let mut map = serde_json::Map::new();
5398                    // Pass subject and object as params for the extension handler
5399                    if !self.subject.is_empty() {
5400                        map.insert(
5401                            "subject".to_string(),
5402                            serde_json::Value::String(self.subject.clone()),
5403                        );
5404                    }
5405                    if !self.object.is_empty() {
5406                        map.insert(
5407                            "object".to_string(),
5408                            serde_json::Value::String(self.object.clone()),
5409                        );
5410                    }
5411                    // Merge all instruction params
5412                    for (k, v) in &self.params {
5413                        map.insert(k.clone(), v.clone());
5414                    }
5415                    serde_json::Value::Object(map)
5416                },
5417                conformance: MutationConformance::AlwaysConformant,
5418            })),
5419        }
5420    }
5421
5422    /// Check if this instruction creates or loads a graph
5423    pub fn is_create_action(&self) -> bool {
5424        matches!(
5425            self.action.as_str(),
5426            "create_model" | "create_branch" | "load_graph"
5427        )
5428    }
5429
5430    /// Get the conformance level for this instruction action
5431    pub fn conformance(&self) -> MutationConformance {
5432        match self.action.as_str() {
5433            // Basic structure operations - valid for branches
5434            "add_node" | "add_edge" | "add_nodegroup" | "add_card" | "add_widget" => {
5435                MutationConformance::BranchConformant
5436            }
5437            // Subgraph and function operations - only valid for models
5438            "add_subgraph" | "update_subgraph" | "add_function" | "set_descriptor_function" => {
5439                MutationConformance::ModelConformant
5440            }
5441            // Collection changes - valid for both
5442            "concept_change_collection" => MutationConformance::AlwaysConformant,
5443            // Deletion operations - valid for both branches and models
5444            "delete_card" | "delete_widget" | "delete_function" | "delete_node"
5445            | "delete_nodegroup" => MutationConformance::AlwaysConformant,
5446            // Node update operations
5447            "update_node" | "change_node_type" | "change_cardinality" => {
5448                MutationConformance::BranchConformant
5449            }
5450            "rename_node" | "rename_graph" | "coppice_subgraph" => {
5451                MutationConformance::AlwaysConformant
5452            }
5453            // Create operations
5454            "create_model" => MutationConformance::ModelConformant,
5455            "create_branch" => MutationConformance::BranchConformant,
5456            // Unknown actions
5457            _ => MutationConformance::NonConformant,
5458        }
5459    }
5460
5461    /// Convert a create_model or create_branch instruction to a skeleton graph
5462    ///
5463    /// # Arguments
5464    /// - `subject`: root alias for the graph
5465    /// - `object`: graphid (if empty, generated from name)
5466    /// - `params.name`: graph name (defaults to subject if not provided)
5467    /// - `params.ontology_class`: optional ontology class for root node
5468    ///
5469    /// # Returns
5470    /// A new skeleton StaticGraph, or error if not a create action
5471    pub fn to_skeleton_graph(&self) -> Result<StaticGraph, MutationError> {
5472        let is_resource = match self.action.as_str() {
5473            "create_model" => true,
5474            "create_branch" => false,
5475            _ => {
5476                return Err(MutationError::InvalidSubgraph(format!(
5477                    "'{}' is not a create action, use to_mutation() instead",
5478                    self.action
5479                )))
5480            }
5481        };
5482
5483        let root_alias = &self.subject;
5484        let name = self.get_str_or("name", root_alias);
5485        let ontology_classes = self.get_class_list("ontology_class");
5486
5487        // If object is provided, use it as the graphid override
5488        let mut graph =
5489            create_skeleton_graph(&name, root_alias, is_resource, ontology_classes.as_deref());
5490
5491        // Set graph-level ontology_id if provided
5492        let ontology_ids = self.get_class_list("ontology_id");
5493        if ontology_ids.is_some() {
5494            graph.ontology_id = ontology_ids;
5495        }
5496
5497        // Override graphid if object is provided and non-empty
5498        if !self.object.is_empty() {
5499            let new_graphid = self.object.clone();
5500            // Update graphid in graph and all nodes
5501            graph.graphid = new_graphid.clone();
5502            graph.root.graph_id = new_graphid.clone();
5503            for node in &mut graph.nodes {
5504                node.graph_id = new_graphid.clone();
5505            }
5506        }
5507
5508        // Apply slug (both models and branches can have slugs)
5509        graph.slug = self
5510            .get_str("slug")
5511            .or_else(|| Some(root_alias.to_lowercase()));
5512
5513        Ok(graph)
5514    }
5515}
5516
5517/// Build a graph from scratch using instructions
5518///
5519/// The first instruction must be `create_model` or `create_branch` to create the skeleton.
5520/// Subsequent instructions are applied as mutations.
5521///
5522/// # Arguments
5523/// * `instructions` - List of instructions, first must be a create action
5524/// * `options` - Options for mutation application
5525///
5526/// # Returns
5527/// * `Ok(StaticGraph)` - The built graph
5528/// * `Err(String)` - Error message if build failed
5529///
5530/// # Example
5531/// ```rust,ignore
5532/// let instructions = vec![
5533///     GraphInstruction::new("create_model", "person", "")
5534///         .with_str("name", "Person"),
5535///     GraphInstruction::new("add_node", "person", "name")
5536///         .with_str("datatype", "string")
5537///         .with_str("cardinality", "n"),
5538/// ];
5539/// let graph = build_graph_from_instructions(instructions, MutatorOptions::default())?;
5540/// ```
5541pub fn build_graph_from_instructions(
5542    instructions: Vec<GraphInstruction>,
5543    options: MutatorOptions,
5544) -> Result<StaticGraph, String> {
5545    build_graph_from_instructions_with_extensions(instructions, options, None)
5546}
5547
5548/// Build a graph from instructions with extension mutation support.
5549///
5550/// Same as `build_graph_from_instructions` but accepts an optional extension
5551/// mutation registry for handling custom/extension actions in the CSV.
5552pub fn build_graph_from_instructions_with_extensions(
5553    instructions: Vec<GraphInstruction>,
5554    options: MutatorOptions,
5555    registry: Option<&ExtensionMutationRegistry>,
5556) -> Result<StaticGraph, String> {
5557    if instructions.is_empty() {
5558        return Err("No instructions provided".to_string());
5559    }
5560
5561    let mut iter = instructions.into_iter();
5562    let first = iter.next().unwrap();
5563
5564    // First instruction must create or load the graph
5565    if !first.is_create_action() {
5566        return Err(format!(
5567            "First instruction must be 'create_model', 'create_branch', or 'load_graph', got '{}'",
5568            first.action
5569        ));
5570    }
5571
5572    let graph = if first.action == "load_graph" {
5573        let graph_id = &first.subject;
5574        let arc = crate::registry::get_graph(graph_id).ok_or_else(|| {
5575            format!(
5576                "Graph '{}' not found in registry. Call register_graph() first.",
5577                graph_id
5578            )
5579        })?;
5580        (*arc).clone()
5581    } else {
5582        first.to_skeleton_graph().map_err(|e| e.to_string())?
5583    };
5584
5585    // Apply remaining instructions as mutations
5586    let remaining: Vec<GraphInstruction> = iter.collect();
5587    if remaining.is_empty() {
5588        let mut graph = graph;
5589        if !options.skip_publication {
5590            stamp_publication(&mut graph);
5591        }
5592        return Ok(graph);
5593    }
5594
5595    apply_instructions(&graph, remaining, options, registry)
5596}
5597
5598/// Build a graph from scratch using JSON instructions
5599///
5600/// # JSON Format
5601/// ```json
5602/// {
5603///   "instructions": [
5604///     { "action": "create_model", "subject": "person", "object": "", "params": { "name": "Person" } },
5605///     { "action": "add_node", "subject": "person", "object": "name", "params": { "datatype": "string" } }
5606///   ],
5607///   "options": { "autocreate_card": true, "autocreate_widget": true }
5608/// }
5609/// ```
5610pub fn build_graph_from_instructions_json(json: &str) -> Result<StaticGraph, String> {
5611    #[derive(Deserialize)]
5612    struct BuildRequest {
5613        instructions: Vec<GraphInstruction>,
5614        #[serde(default)]
5615        options: MutationRequestOptions,
5616    }
5617
5618    let request: BuildRequest = serde_json::from_str(json)
5619        .map_err(|e| format!("Failed to parse build request JSON: {}", e))?;
5620
5621    build_graph_from_instructions(request.instructions, request.options.into())
5622}
5623
5624/// Parse CSV text into a list of GraphInstructions.
5625///
5626/// Expected columns: `action`, `subject`, `object`, plus any `params.*` columns.
5627/// The first row is the header. Empty `params.*` values are ignored.
5628///
5629/// # Example CSV
5630/// ```csv
5631/// action,subject,object,params.name,params.datatype,params.ontology_class,params.parent_property
5632/// create_model,registry,,Registry,,,E78_Collection,
5633/// add_node,registry,names,Names,semantic,E41_Appellation,P1_is_identified_by
5634/// ```
5635pub fn parse_instructions_from_csv(csv_text: &str) -> Result<Vec<GraphInstruction>, String> {
5636    // Pre-filter comment lines (starting with #) before CSV parsing,
5637    // since they may have mismatched column counts
5638    let filtered: String = csv_text
5639        .lines()
5640        .filter(|line| {
5641            let trimmed = line.trim();
5642            !trimmed.starts_with('#')
5643        })
5644        .collect::<Vec<_>>()
5645        .join("\n");
5646
5647    let mut reader = csv::Reader::from_reader(filtered.as_bytes());
5648    let headers = reader
5649        .headers()
5650        .map_err(|e| format!("Failed to parse CSV headers: {}", e))?
5651        .clone();
5652
5653    let param_indices: Vec<(usize, String)> = headers
5654        .iter()
5655        .enumerate()
5656        .filter_map(|(i, h)| h.strip_prefix("params.").map(|p| (i, p.to_string())))
5657        .collect();
5658
5659    let action_idx = headers
5660        .iter()
5661        .position(|h| h == "action")
5662        .ok_or("CSV missing 'action' column")?;
5663    let subject_idx = headers
5664        .iter()
5665        .position(|h| h == "subject")
5666        .ok_or("CSV missing 'subject' column")?;
5667    let object_idx = headers
5668        .iter()
5669        .position(|h| h == "object")
5670        .ok_or("CSV missing 'object' column")?;
5671
5672    let mut instructions = Vec::new();
5673    for result in reader.records() {
5674        let record = result.map_err(|e| format!("Failed to parse CSV row: {}", e))?;
5675
5676        let action = record.get(action_idx).unwrap_or("").to_string();
5677        if action.is_empty() || action.starts_with('#') {
5678            continue;
5679        }
5680
5681        let mut params = serde_json::Map::new();
5682        for (idx, param_name) in &param_indices {
5683            if let Some(value) = record.get(*idx) {
5684                if !value.is_empty() {
5685                    // Try to parse as JSON first (for config objects), fall back to string
5686                    let json_value = serde_json::from_str(value)
5687                        .unwrap_or(serde_json::Value::String(value.to_string()));
5688                    // Handle dot-notation (e.g. "options.is_collector") as nested objects
5689                    let parts: Vec<&str> = param_name.splitn(2, '.').collect();
5690                    if parts.len() == 2 {
5691                        let outer = parts[0];
5692                        let inner = parts[1];
5693                        let nested = params
5694                            .entry(outer.to_string())
5695                            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
5696                        if let serde_json::Value::Object(ref mut map) = nested {
5697                            map.insert(inner.to_string(), json_value);
5698                        }
5699                    } else {
5700                        params.insert(param_name.clone(), json_value);
5701                    }
5702                }
5703            }
5704        }
5705
5706        instructions.push(GraphInstruction {
5707            action,
5708            subject: record.get(subject_idx).unwrap_or("").to_string(),
5709            object: record.get(object_idx).unwrap_or("").to_string(),
5710            params: serde_json::Value::Object(params)
5711                .as_object()
5712                .unwrap()
5713                .iter()
5714                .map(|(k, v)| (k.clone(), v.clone()))
5715                .collect(),
5716        });
5717    }
5718
5719    Ok(instructions)
5720}
5721
5722/// Build or mutate a graph from CSV instructions.
5723///
5724/// Parses CSV text into instructions, then builds or mutates a graph.
5725/// The first row must be `create_model`, `create_branch`, or `load_graph`.
5726///
5727/// Use `load_graph` with the graph ID as `subject` to load an existing graph
5728/// from the registry and apply subsequent instructions to it.
5729///
5730/// # Arguments
5731/// * `csv_text` - CSV string with header row
5732/// * `options` - Options for mutation application
5733pub fn build_graph_from_instructions_csv(
5734    csv_text: &str,
5735    options: MutatorOptions,
5736) -> Result<StaticGraph, String> {
5737    let instructions = parse_instructions_from_csv(csv_text)?;
5738    build_graph_from_instructions(instructions, options)
5739}
5740
5741/// Apply a list of instructions to a graph
5742///
5743/// Instructions are converted to mutations and applied in order.
5744///
5745/// # Arguments
5746/// * `graph` - The graph to mutate (will be cloned, original unchanged)
5747/// * `instructions` - List of instructions to apply
5748/// * `options` - Options for mutation application
5749///
5750/// # Returns
5751/// * `Ok(StaticGraph)` - The mutated graph
5752/// * `Err(String)` - Error message if any instruction failed
5753pub fn apply_instructions(
5754    graph: &StaticGraph,
5755    instructions: Vec<GraphInstruction>,
5756    options: MutatorOptions,
5757    registry: Option<&ExtensionMutationRegistry>,
5758) -> Result<StaticGraph, String> {
5759    let mutations: Vec<GraphMutation> = instructions
5760        .into_iter()
5761        .map(|i| i.to_mutation())
5762        .collect::<Result<Vec<_>, _>>()
5763        .map_err(|e| e.to_string())?;
5764
5765    apply_mutations_with_extensions(graph, mutations, options, registry)
5766}
5767
5768/// Apply instructions from JSON
5769///
5770/// # JSON Format
5771/// ```json
5772/// {
5773///   "instructions": [
5774///     { "action": "add_node", "subject": "root", "object": "name", "params": { "datatype": "string" } }
5775///   ],
5776///   "options": { "autocreate_card": true, "autocreate_widget": true }
5777/// }
5778/// ```
5779pub fn apply_instructions_from_json(
5780    graph: &StaticGraph,
5781    json: &str,
5782) -> Result<StaticGraph, String> {
5783    #[derive(Deserialize)]
5784    struct InstructionRequest {
5785        instructions: Vec<GraphInstruction>,
5786        #[serde(default)]
5787        options: MutationRequestOptions,
5788    }
5789
5790    let request: InstructionRequest = serde_json::from_str(json)
5791        .map_err(|e| format!("Failed to parse instructions JSON: {}", e))?;
5792
5793    apply_instructions(graph, request.instructions, request.options.into(), None)
5794}
5795
5796/// Get the JSON schema for mutation types (as documentation)
5797///
5798/// Returns a descriptive JSON object showing the structure of each mutation type
5799pub fn get_mutation_schema() -> serde_json::Value {
5800    serde_json::json!({
5801        "MutationRequest": {
5802            "description": "Container for a list of mutations to apply",
5803            "properties": {
5804                "mutations": {
5805                    "type": "array",
5806                    "items": { "$ref": "#/GraphMutation" }
5807                },
5808                "options": { "$ref": "#/MutationRequestOptions" }
5809            }
5810        },
5811        "MutationRequestOptions": {
5812            "properties": {
5813                "autocreate_card": { "type": "boolean", "default": true },
5814                "autocreate_widget": { "type": "boolean", "default": true }
5815            }
5816        },
5817        "GraphMutation": {
5818            "oneOf": [
5819                { "AddNode": { "$ref": "#/AddNodeParams" } },
5820                { "AddNodegroup": { "$ref": "#/AddNodegroupParams" } },
5821                { "AddEdge": { "$ref": "#/AddEdgeParams" } },
5822                { "AddCard": { "$ref": "#/AddCardParams" } },
5823                { "AddWidgetToCard": { "$ref": "#/AddWidgetParams" } },
5824                { "CreateGraph": { "$ref": "#/CreateGraphParams" } },
5825                { "SetDescriptorTemplate": { "$ref": "#/SetDescriptorTemplateParams" } }
5826            ]
5827        },
5828        "SetDescriptorTemplateParams": {
5829            "required": ["descriptor_type", "string_template"],
5830            "properties": {
5831                "descriptor_type": { "type": "string", "description": "Descriptor type (e.g. 'name', 'slug', 'description', 'map_popup')" },
5832                "string_template": { "type": "string", "description": "String template with <Node Name> placeholders" }
5833            }
5834        },
5835        "CreateGraphParams": {
5836            "required": ["name", "is_resource", "root_alias"],
5837            "properties": {
5838                "name": { "type": "string", "description": "Name for the graph" },
5839                "is_resource": { "type": "boolean", "description": "Whether this is a resource model (true) or branch (false)" },
5840                "root_alias": { "type": "string", "description": "Alias for the root node" },
5841                "root_ontology_class": {
5842                    "oneOf": [
5843                        { "type": "string" },
5844                        { "type": "array", "items": { "type": "string" } },
5845                        { "type": "null" }
5846                    ],
5847                    "nullable": true,
5848                    "description": "Ontology class URI(s) for the root node. Accepts a single string or an array of strings."
5849                },
5850                "graph_id": { "type": "string", "nullable": true, "description": "Optional custom graph ID" },
5851                "author": { "type": "string", "nullable": true, "description": "Optional author" },
5852                "description": { "type": "string", "nullable": true, "description": "Optional description" }
5853            }
5854        },
5855        "AddNodeParams": {
5856            "required": ["alias", "name", "cardinality", "datatype", "parent_property"],
5857            "properties": {
5858                "parent_alias": { "type": "string", "nullable": true },
5859                "alias": { "type": "string" },
5860                "name": { "type": "string" },
5861                "cardinality": { "enum": ["One", "N"] },
5862                "datatype": { "type": "string", "examples": ["semantic", "string", "number", "date", "boolean", "concept", "concept-list"] },
5863                "ontology_class": {
5864                    "oneOf": [
5865                        { "type": "string" },
5866                        { "type": "array", "items": { "type": "string" } },
5867                        { "type": "null" }
5868                    ],
5869                    "nullable": true,
5870                    "description": "Ontology class URI(s) for the node. Accepts a single string or an array of strings."
5871                },
5872                "parent_property": { "type": "string" },
5873                "description": { "type": "string", "nullable": true },
5874                "config": { "type": "object", "nullable": true },
5875                "options": { "$ref": "#/NodeOptions" }
5876            }
5877        },
5878        "NodeOptions": {
5879            "properties": {
5880                "exportable": { "type": "boolean" },
5881                "fieldname": { "type": "string" },
5882                "hascustomalias": { "type": "boolean" },
5883                "is_collector": { "type": "boolean" },
5884                "isrequired": { "type": "boolean" },
5885                "issearchable": { "type": "boolean" },
5886                "istopnode": { "type": "boolean" },
5887                "sortorder": { "type": "integer" }
5888            }
5889        },
5890        "Cardinality": {
5891            "enum": ["One", "N"],
5892            "description": "One = single instance only, N = multiple instances allowed"
5893        }
5894    })
5895}
5896
5897#[cfg(test)]
5898mod tests {
5899    use super::*;
5900
5901    fn create_test_graph() -> StaticGraph {
5902        let graph_json = r#"{
5903            "graphid": "test-graph-id",
5904            "name": {"en": "Test Graph"},
5905            "isresource": true,
5906            "is_editable": true,
5907            "nodes": [{
5908                "nodeid": "root-node-id",
5909                "name": "Root",
5910                "alias": "root",
5911                "datatype": "semantic",
5912                "nodegroup_id": "root-nodegroup",
5913                "graph_id": "test-graph-id",
5914                "is_collector": false,
5915                "isrequired": false,
5916                "exportable": false,
5917                "ontologyclass": "E1_CRM_Entity",
5918                "hascustomalias": false,
5919                "issearchable": false,
5920                "istopnode": true
5921            }],
5922            "nodegroups": [{
5923                "nodegroupid": "root-nodegroup",
5924                "cardinality": "1"
5925            }],
5926            "edges": [],
5927            "cards": [],
5928            "cards_x_nodes_x_widgets": [],
5929            "root": {
5930                "nodeid": "root-node-id",
5931                "name": "Root",
5932                "alias": "root",
5933                "datatype": "semantic",
5934                "nodegroup_id": "root-nodegroup",
5935                "graph_id": "test-graph-id",
5936                "is_collector": false,
5937                "isrequired": false,
5938                "exportable": false,
5939                "ontologyclass": "E1_CRM_Entity",
5940                "hascustomalias": false,
5941                "issearchable": false,
5942                "istopnode": true
5943            }
5944        }"#;
5945
5946        let mut graph: StaticGraph =
5947            serde_json::from_str(graph_json).expect("Failed to parse test graph JSON");
5948        graph.build_indices();
5949        graph
5950    }
5951
5952    #[test]
5953    fn test_uuid_generation() {
5954        let uuid1 = generate_uuid_v5(("graph", Some("test-id")), "node-1");
5955        let uuid2 = generate_uuid_v5(("graph", Some("test-id")), "node-1");
5956        let uuid3 = generate_uuid_v5(("graph", Some("test-id")), "node-2");
5957
5958        // Same inputs should produce same output
5959        assert_eq!(uuid1, uuid2);
5960        // Different inputs should produce different output
5961        assert_ne!(uuid1, uuid3);
5962        // Should be valid UUID format
5963        assert!(Uuid::parse_str(&uuid1).is_ok());
5964    }
5965
5966    #[test]
5967    fn test_add_semantic_node() {
5968        let graph = create_test_graph();
5969
5970        let result = GraphMutator::new(graph)
5971            .add_semantic_node(
5972                Some("root"),
5973                "child",
5974                "Child Node",
5975                Cardinality::N,
5976                "E1_CRM_Entity",
5977                "P1_is_identified_by",
5978                Some("A child node"),
5979                NodeOptions::default(),
5980                None,
5981            )
5982            .build();
5983
5984        assert!(result.is_ok());
5985        let built_graph = result.unwrap();
5986
5987        // Should have 2 nodes now (root + child)
5988        assert_eq!(built_graph.nodes.len(), 2);
5989
5990        // Should have 2 nodegroups
5991        assert_eq!(built_graph.nodegroups.len(), 2);
5992
5993        // Should have 1 edge
5994        assert_eq!(built_graph.edges.len(), 1);
5995
5996        // Should have a card auto-created
5997        assert_eq!(built_graph.cards_slice().len(), 1);
5998    }
5999
6000    #[test]
6001    fn test_add_string_node() {
6002        let graph = create_test_graph();
6003
6004        let result = GraphMutator::new(graph)
6005            .add_string_node(
6006                Some("root"),
6007                "name",
6008                "Name",
6009                Cardinality::One,
6010                "E41_Appellation",
6011                "P1_is_identified_by",
6012                None,
6013                NodeOptions::default(),
6014                None,
6015            )
6016            .build();
6017
6018        assert!(result.is_ok());
6019        let built_graph = result.unwrap();
6020
6021        // String node with cardinality One should inherit parent's nodegroup
6022        // So we should still have 1 nodegroup (root's)
6023        assert_eq!(built_graph.nodegroups.len(), 1);
6024
6025        // Should have a widget auto-created (string -> text-widget)
6026        // But only if there's a card for the nodegroup
6027        // Since cardinality is One, no new card is created, so no widget
6028    }
6029
6030    #[test]
6031    fn test_add_node_duplicate_alias_error() {
6032        let graph = create_test_graph();
6033
6034        // Try to add a node with alias "root" which already exists
6035        let result = GraphMutator::new(graph)
6036            .add_string_node(
6037                Some("root"),
6038                "root", // This alias already exists!
6039                "Duplicate",
6040                Cardinality::One,
6041                "E41_Appellation",
6042                "P1_is_identified_by",
6043                None,
6044                NodeOptions::default(),
6045                None,
6046            )
6047            .build();
6048
6049        assert!(result.is_err());
6050        assert!(matches!(result, Err(MutationError::AliasAlreadyExists(_))));
6051    }
6052
6053    #[test]
6054    fn test_add_node_invalid_config_error() {
6055        let graph = create_test_graph();
6056
6057        // Provide invalid config (not an object)
6058        let result = GraphMutator::new(graph)
6059            .add_generic_node(
6060                Some("root"),
6061                "child",
6062                "Child",
6063                Cardinality::N,
6064                "string",
6065                "E41_Appellation",
6066                "P1_is_identified_by",
6067                None,
6068                NodeOptions::default(),
6069                Some(serde_json::json!("not an object")), // Invalid: should be object
6070            )
6071            .build();
6072
6073        assert!(result.is_err());
6074        assert!(matches!(result, Err(MutationError::InvalidConfig { .. })));
6075    }
6076
6077    #[test]
6078    fn test_get_default_widget() {
6079        assert!(get_default_widget_for_datatype("string").is_ok());
6080        assert!(get_default_widget_for_datatype("number").is_ok());
6081        assert!(get_default_widget_for_datatype("concept").is_ok());
6082        assert!(get_default_widget_for_datatype("semantic").is_err());
6083        assert!(get_default_widget_for_datatype("unknown").is_err());
6084    }
6085
6086    #[test]
6087    fn test_json_mutation_api() {
6088        let graph = create_test_graph();
6089
6090        let mutations_json = r#"{
6091            "mutations": [
6092                {
6093                    "AddNode": {
6094                        "parent_alias": "root",
6095                        "alias": "child",
6096                        "name": "Child Node",
6097                        "cardinality": "N",
6098                        "datatype": "string",
6099                        "ontology_class": "E41_Appellation",
6100                        "parent_property": "P1_is_identified_by",
6101                        "description": "A test child node",
6102                        "config": null,
6103                        "options": {
6104                            "isrequired": true
6105                        }
6106                    }
6107                }
6108            ],
6109            "options": {
6110                "autocreate_card": true,
6111                "autocreate_widget": true
6112            }
6113        }"#;
6114
6115        let result = apply_mutations_from_json(&graph, mutations_json);
6116        assert!(result.is_ok(), "JSON mutation failed: {:?}", result.err());
6117
6118        let mutated = result.unwrap();
6119        // Should have 2 nodes (root + child)
6120        assert_eq!(mutated.nodes.len(), 2);
6121        // Should have 2 nodegroups (root + child with cardinality N)
6122        assert_eq!(mutated.nodegroups.len(), 2);
6123        // Should have 1 edge (root -> child)
6124        assert_eq!(mutated.edges.len(), 1);
6125    }
6126
6127    #[test]
6128    fn test_mutations_serialization() {
6129        let mutations = vec![GraphMutation::AddNode(AddNodeParams {
6130            parent_alias: Some("root".to_string()),
6131            alias: "test".to_string(),
6132            name: "Test".to_string(),
6133            cardinality: Cardinality::One,
6134            datatype: "string".to_string(),
6135            ontology_class: Some(vec!["E41".to_string()]),
6136            parent_property: "P1".to_string(),
6137            description: None,
6138            config: None,
6139            options: NodeOptions::default(),
6140        })];
6141
6142        let json = mutations_to_json(&mutations);
6143        assert!(json.is_ok());
6144
6145        // Verify it's valid JSON
6146        let parsed: Result<Vec<GraphMutation>, _> = serde_json::from_str(&json.unwrap());
6147        assert!(parsed.is_ok());
6148    }
6149
6150    fn create_test_subgraph() -> StaticGraph {
6151        // Create a simple subgraph/branch to add
6152        let subgraph_json = r#"{
6153            "graphid": "subgraph-id",
6154            "name": {"en": "Test Subgraph"},
6155            "isresource": false,
6156            "publication": {
6157                "publicationid": "test-publication-id",
6158                "graph_id": "subgraph-id",
6159                "published_time": "2024-01-01T00:00:00.000"
6160            },
6161            "nodes": [
6162                {
6163                    "nodeid": "sub-root-id",
6164                    "name": "Subgraph Root",
6165                    "alias": "sub_root",
6166                    "datatype": "semantic",
6167                    "nodegroup_id": "sub-root-ng",
6168                    "graph_id": "subgraph-id",
6169                    "is_collector": true,
6170                    "isrequired": false,
6171                    "exportable": false,
6172                    "ontologyclass": "E41_Appellation",
6173                    "hascustomalias": false,
6174                    "issearchable": false,
6175                    "istopnode": true
6176                },
6177                {
6178                    "nodeid": "sub-child1-id",
6179                    "name": "Child 1",
6180                    "alias": "child1",
6181                    "datatype": "string",
6182                    "nodegroup_id": "sub-child1-ng",
6183                    "graph_id": "subgraph-id",
6184                    "is_collector": false,
6185                    "isrequired": false,
6186                    "exportable": true,
6187                    "ontologyclass": "E41_Appellation",
6188                    "hascustomalias": false,
6189                    "issearchable": true,
6190                    "istopnode": false
6191                },
6192                {
6193                    "nodeid": "sub-child2-id",
6194                    "name": "Child 2",
6195                    "alias": "child2",
6196                    "datatype": "concept",
6197                    "nodegroup_id": "sub-child1-ng",
6198                    "graph_id": "subgraph-id",
6199                    "is_collector": false,
6200                    "isrequired": false,
6201                    "exportable": true,
6202                    "ontologyclass": "E55_Type",
6203                    "hascustomalias": false,
6204                    "issearchable": true,
6205                    "istopnode": false
6206                }
6207            ],
6208            "nodegroups": [
6209                {
6210                    "nodegroupid": "sub-root-ng",
6211                    "cardinality": "n",
6212                    "parentnodegroup_id": null
6213                },
6214                {
6215                    "nodegroupid": "sub-child1-ng",
6216                    "cardinality": "1",
6217                    "parentnodegroup_id": "sub-root-ng"
6218                }
6219            ],
6220            "edges": [
6221                {
6222                    "edgeid": "sub-edge1-id",
6223                    "domainnode_id": "sub-root-id",
6224                    "rangenode_id": "sub-child1-id",
6225                    "graph_id": "subgraph-id",
6226                    "ontologyproperty": "P3_has_note"
6227                },
6228                {
6229                    "edgeid": "sub-edge2-id",
6230                    "domainnode_id": "sub-child1-id",
6231                    "rangenode_id": "sub-child2-id",
6232                    "graph_id": "subgraph-id",
6233                    "ontologyproperty": "P2_has_type"
6234                }
6235            ],
6236            "cards": [
6237                {
6238                    "cardid": "sub-card1-id",
6239                    "nodegroup_id": "sub-child1-ng",
6240                    "graph_id": "subgraph-id",
6241                    "name": {"en": "Child Card"},
6242                    "active": true,
6243                    "visible": true,
6244                    "component_id": "f05e4d3a-53c1-11e8-b0ea-784f435179ea",
6245                    "helpenabled": false,
6246                    "helptext": {"en": ""},
6247                    "helptitle": {"en": ""},
6248                    "instructions": {"en": ""},
6249                    "constraints": []
6250                }
6251            ],
6252            "cards_x_nodes_x_widgets": [
6253                {
6254                    "id": "sub-cxnxw1-id",
6255                    "card_id": "sub-card1-id",
6256                    "node_id": "sub-child1-id",
6257                    "widget_id": "10000000-0000-0000-0000-000000000001",
6258                    "config": {},
6259                    "label": {"en": "Child 1 Label"},
6260                    "sortorder": 1,
6261                    "visible": true
6262                },
6263                {
6264                    "id": "sub-cxnxw2-id",
6265                    "card_id": "sub-card1-id",
6266                    "node_id": "sub-child2-id",
6267                    "widget_id": "10000000-0000-0000-0000-000000000002",
6268                    "config": {},
6269                    "label": {"en": "Child 2 Label"},
6270                    "sortorder": 2,
6271                    "visible": true
6272                }
6273            ],
6274            "root": {
6275                "nodeid": "sub-root-id",
6276                "name": "Subgraph Root",
6277                "alias": "sub_root",
6278                "datatype": "semantic",
6279                "nodegroup_id": "sub-root-ng",
6280                "graph_id": "subgraph-id",
6281                "is_collector": true,
6282                "isrequired": false,
6283                "exportable": false,
6284                "ontologyclass": "E41_Appellation",
6285                "hascustomalias": false,
6286                "issearchable": false,
6287                "istopnode": true
6288            }
6289        }"#;
6290
6291        let mut graph: StaticGraph =
6292            serde_json::from_str(subgraph_json).expect("Failed to parse test subgraph JSON");
6293        graph.build_indices();
6294        graph
6295    }
6296
6297    #[test]
6298    fn test_add_subgraph_basic() {
6299        let graph = create_test_graph();
6300        let subgraph = create_test_subgraph();
6301
6302        let params = AddSubgraphParams {
6303            subgraph,
6304            target_node_id: "root-node-id".to_string(),
6305            ontology_property: "P106_is_composed_of".to_string(),
6306            alias_suffix: None,
6307            alias_prefix: None,
6308            name_prefix: None,
6309        };
6310
6311        let mut graph_clone = graph.deep_clone();
6312        let result = apply_add_subgraph(&mut graph_clone, params);
6313
6314        assert!(result.is_ok(), "AddSubgraph failed: {:?}", result.err());
6315
6316        // Original graph had 1 node, subgraph has 3 (1 root + 2 children)
6317        // Branch root is kept as collector, so all 3 branch nodes are added
6318        assert_eq!(graph_clone.nodes.len(), 4); // 1 original + 3 from subgraph
6319
6320        // Original had 1 nodegroup, subgraph has 2 (root + child)
6321        // Branch root becomes a new collector nodegroup under the target's nodegroup
6322        assert_eq!(graph_clone.nodegroups.len(), 3); // 1 original + 2 from subgraph
6323
6324        // Original had 0 edges
6325        // 1 connecting edge (target → branch root) + 2 internal
6326        assert_eq!(graph_clone.edges.len(), 3);
6327
6328        // Original had 0 cards, subgraph has 1 (for non-root nodegroup)
6329        assert_eq!(graph_clone.cards_slice().len(), 1);
6330
6331        // Subgraph had 2 cxnxw (both for non-root nodes)
6332        assert_eq!(graph_clone.cards_x_nodes_x_widgets_slice().len(), 2);
6333    }
6334
6335    #[test]
6336    fn test_add_subgraph_with_alias_suffix() {
6337        // With Arches-compatible behavior, alias_suffix is used for UUID generation,
6338        // not for alias suffixing. Aliases are only suffixed when they clash.
6339        let graph = create_test_graph();
6340        let subgraph = create_test_subgraph();
6341
6342        let params = AddSubgraphParams {
6343            subgraph,
6344            target_node_id: "root-node-id".to_string(),
6345            ontology_property: "P106_is_composed_of".to_string(),
6346            alias_suffix: Some("v2".to_string()),
6347            alias_prefix: None,
6348            name_prefix: None,
6349        };
6350
6351        let mut graph_clone = graph.deep_clone();
6352        let result = apply_add_subgraph(&mut graph_clone, params);
6353
6354        assert!(
6355            result.is_ok(),
6356            "AddSubgraph with suffix failed: {:?}",
6357            result.err()
6358        );
6359
6360        // Aliases should be preserved (no clash in base graph)
6361        let child1 = graph_clone
6362            .nodes
6363            .iter()
6364            .find(|n| n.alias.as_deref() == Some("child1"));
6365        assert!(
6366            child1.is_some(),
6367            "Node with alias 'child1' not found (aliases should be preserved when no clash)"
6368        );
6369
6370        let child2 = graph_clone
6371            .nodes
6372            .iter()
6373            .find(|n| n.alias.as_deref() == Some("child2"));
6374        assert!(
6375            child2.is_some(),
6376            "Node with alias 'child2' not found (aliases should be preserved when no clash)"
6377        );
6378
6379        // Verify sourcebranchpublication_id is set
6380        let child1_node = child1.unwrap();
6381        assert!(
6382            child1_node.sourcebranchpublication_id.is_some(),
6383            "sourcebranchpublication_id should be set on branch nodes"
6384        );
6385    }
6386
6387    #[test]
6388    fn test_add_subgraph_alias_clash() {
6389        // With Arches-compatible behavior, clashing aliases are auto-suffixed
6390        // with _n1, _n2, etc. instead of throwing an error.
6391        let graph = create_test_graph();
6392
6393        // First, add a node with alias "child1" to the base graph
6394        let mut graph_with_child = GraphMutator::new(graph)
6395            .add_string_node(
6396                Some("root"),
6397                "child1",
6398                "Existing Child",
6399                Cardinality::N,
6400                "E41_Appellation",
6401                "P1_is_identified_by",
6402                None,
6403                NodeOptions::default(),
6404                None,
6405            )
6406            .build()
6407            .expect("Failed to create graph with child");
6408
6409        // Now try to add subgraph which also has "child1" alias
6410        let subgraph = create_test_subgraph();
6411
6412        let params = AddSubgraphParams {
6413            subgraph,
6414            target_node_id: "root-node-id".to_string(),
6415            ontology_property: "P106_is_composed_of".to_string(),
6416            alias_suffix: None,
6417            alias_prefix: None,
6418            name_prefix: None,
6419        };
6420
6421        let result = apply_add_subgraph(&mut graph_with_child, params);
6422
6423        // Should succeed - clashing aliases get auto-suffixed
6424        assert!(
6425            result.is_ok(),
6426            "AddSubgraph should auto-suffix clashing aliases: {:?}",
6427            result.err()
6428        );
6429
6430        // Original child1 should still exist
6431        let original_child1 = graph_with_child
6432            .nodes
6433            .iter()
6434            .find(|n| n.alias.as_deref() == Some("child1") && n.name == "Existing Child");
6435        assert!(
6436            original_child1.is_some(),
6437            "Original 'child1' node should still exist"
6438        );
6439
6440        // New child1 from branch should be renamed to child1_n1
6441        let new_child1 = graph_with_child
6442            .nodes
6443            .iter()
6444            .find(|n| n.alias.as_deref() == Some("child1_n1"));
6445        assert!(
6446            new_child1.is_some(),
6447            "Clashing alias should be renamed to 'child1_n1'"
6448        );
6449
6450        // child2 should be unchanged (no clash)
6451        let child2 = graph_with_child
6452            .nodes
6453            .iter()
6454            .find(|n| n.alias.as_deref() == Some("child2"));
6455        assert!(
6456            child2.is_some(),
6457            "Non-clashing alias 'child2' should be preserved"
6458        );
6459    }
6460
6461    #[test]
6462    fn test_add_subgraph_id_remapping() {
6463        let graph = create_test_graph();
6464        let subgraph = create_test_subgraph();
6465
6466        let params = AddSubgraphParams {
6467            subgraph,
6468            target_node_id: "root-node-id".to_string(),
6469            ontology_property: "P106_is_composed_of".to_string(),
6470            alias_suffix: None,
6471            alias_prefix: None,
6472            name_prefix: None,
6473        };
6474
6475        let mut graph_clone = graph.deep_clone();
6476        let result = apply_add_subgraph(&mut graph_clone, params);
6477        assert!(result.is_ok());
6478
6479        // Verify that the new nodes have different IDs from the original subgraph
6480        let original_ids = ["sub-root-id", "sub-child1-id", "sub-child2-id"];
6481        for node in &graph_clone.nodes {
6482            assert!(
6483                !original_ids.contains(&node.nodeid.as_str()),
6484                "Node ID {} was not remapped",
6485                node.nodeid
6486            );
6487        }
6488
6489        // Verify that edges have been remapped
6490        let original_edge_ids = ["sub-edge1-id", "sub-edge2-id"];
6491        for edge in &graph_clone.edges {
6492            assert!(
6493                !original_edge_ids.contains(&edge.edgeid.as_str()),
6494                "Edge ID {} was not remapped",
6495                edge.edgeid
6496            );
6497        }
6498
6499        // Verify graph_id is remapped to target graph
6500        for node in &graph_clone.nodes {
6501            assert_eq!(
6502                node.graph_id, "test-graph-id",
6503                "Node graph_id not remapped to target graph"
6504            );
6505        }
6506    }
6507
6508    #[test]
6509    fn test_add_subgraph_preserves_external_ids() {
6510        let graph = create_test_graph();
6511        let subgraph = create_test_subgraph();
6512
6513        let params = AddSubgraphParams {
6514            subgraph,
6515            target_node_id: "root-node-id".to_string(),
6516            ontology_property: "P106_is_composed_of".to_string(),
6517            alias_suffix: None,
6518            alias_prefix: None,
6519            name_prefix: None,
6520        };
6521
6522        let mut graph_clone = graph.deep_clone();
6523        let result = apply_add_subgraph(&mut graph_clone, params);
6524        assert!(result.is_ok());
6525
6526        // Verify widget_ids are preserved (not remapped)
6527        let cxnxws = graph_clone.cards_x_nodes_x_widgets_slice();
6528        assert!(
6529            cxnxws
6530                .iter()
6531                .any(|c| c.widget_id == "10000000-0000-0000-0000-000000000001"),
6532            "Widget ID for text-widget should be preserved"
6533        );
6534        assert!(
6535            cxnxws
6536                .iter()
6537                .any(|c| c.widget_id == "10000000-0000-0000-0000-000000000002"),
6538            "Widget ID for concept-select-widget should be preserved"
6539        );
6540
6541        // Verify component_id is preserved
6542        let cards = graph_clone.cards_slice();
6543        assert!(
6544            cards
6545                .iter()
6546                .any(|c| c.component_id == "f05e4d3a-53c1-11e8-b0ea-784f435179ea"),
6547            "Component ID should be preserved"
6548        );
6549    }
6550
6551    #[test]
6552    fn test_add_subgraph_target_not_found() {
6553        let graph = create_test_graph();
6554        let subgraph = create_test_subgraph();
6555
6556        let params = AddSubgraphParams {
6557            subgraph,
6558            target_node_id: "nonexistent-node-id".to_string(),
6559            ontology_property: "P106_is_composed_of".to_string(),
6560            alias_suffix: None,
6561            alias_prefix: None,
6562            name_prefix: None,
6563        };
6564
6565        let mut graph_clone = graph.deep_clone();
6566        let result = apply_add_subgraph(&mut graph_clone, params);
6567
6568        assert!(result.is_err(), "Expected NodeNotFound error");
6569        match result {
6570            Err(MutationError::NodeNotFound(id)) => {
6571                assert_eq!(id, "nonexistent-node-id");
6572            }
6573            Err(e) => panic!("Expected NodeNotFound error, got: {:?}", e),
6574            Ok(_) => panic!("Expected error but got Ok"),
6575        }
6576    }
6577
6578    #[test]
6579    fn test_add_subgraph_via_json_api() {
6580        let graph = create_test_graph();
6581        let subgraph = create_test_subgraph();
6582
6583        // Serialize the subgraph to include in the mutation
6584        let subgraph_json = serde_json::to_string(&subgraph).expect("Failed to serialize subgraph");
6585
6586        let mutations_json = format!(
6587            r#"{{
6588            "mutations": [
6589                {{
6590                    "AddSubgraph": {{
6591                        "subgraph": {},
6592                        "target_node_id": "root-node-id",
6593                        "ontology_property": "P106_is_composed_of",
6594                        "alias_suffix": "json"
6595                    }}
6596                }}
6597            ],
6598            "options": {{
6599                "autocreate_card": true,
6600                "autocreate_widget": true
6601            }}
6602        }}"#,
6603            subgraph_json
6604        );
6605
6606        let result = apply_mutations_from_json(&graph, &mutations_json);
6607        assert!(
6608            result.is_ok(),
6609            "JSON AddSubgraph mutation failed: {:?}",
6610            result.err()
6611        );
6612
6613        let mutated = result.unwrap();
6614        // Should have 4 nodes (1 original + 3 from subgraph, branch root kept as collector)
6615        assert_eq!(mutated.nodes.len(), 4);
6616
6617        // With Arches-compatible behavior, aliases are preserved (no clash)
6618        // alias_suffix is only used for UUID generation
6619        assert!(
6620            mutated
6621                .nodes
6622                .iter()
6623                .any(|n| n.alias.as_deref() == Some("child1")),
6624            "Alias 'child1' should be preserved (no clash)"
6625        );
6626
6627        // Verify sourcebranchpublication_id is set
6628        let branch_node = mutated
6629            .nodes
6630            .iter()
6631            .find(|n| n.alias.as_deref() == Some("child1"))
6632            .unwrap();
6633        assert!(
6634            branch_node.sourcebranchpublication_id.is_some(),
6635            "sourcebranchpublication_id should be set on branch nodes"
6636        );
6637    }
6638
6639    // =========================================================================
6640    // UpdateSubgraph Tests
6641    // =========================================================================
6642
6643    #[test]
6644    fn test_update_subgraph_first_time_acts_like_add() {
6645        // When there are no existing branch nodes, UpdateSubgraph falls back to AddSubgraph
6646        let graph = create_test_graph();
6647        let subgraph = create_test_subgraph();
6648
6649        let params = UpdateSubgraphParams {
6650            subgraph,
6651            target_node_id: "root-node-id".to_string(),
6652            ontology_property: "P106_is_composed_of".to_string(),
6653            alias_suffix: None,
6654            remove_orphaned: false,
6655            alias_prefix: None,
6656            name_prefix: None,
6657        };
6658
6659        let mut graph_clone = graph.deep_clone();
6660        let result = apply_update_subgraph(&mut graph_clone, params);
6661
6662        assert!(
6663            result.is_ok(),
6664            "UpdateSubgraph should succeed: {:?}",
6665            result.err()
6666        );
6667
6668        // Should have added the branch nodes (branch root kept as collector)
6669        assert_eq!(
6670            graph_clone.nodes.len(),
6671            4,
6672            "Should have 4 nodes: root + 3 from branch (branch root kept as collector)"
6673        );
6674
6675        // Verify sourcebranchpublication_id is set
6676        let child1 = graph_clone
6677            .nodes
6678            .iter()
6679            .find(|n| n.alias.as_deref() == Some("child1"))
6680            .unwrap();
6681        assert!(child1.sourcebranchpublication_id.is_some());
6682    }
6683
6684    #[test]
6685    fn test_update_subgraph_updates_existing_nodes() {
6686        // First add a subgraph, then update it
6687        let graph = create_test_graph();
6688        let subgraph = create_test_subgraph();
6689
6690        // Add subgraph first
6691        let add_params = AddSubgraphParams {
6692            subgraph: subgraph.clone(),
6693            target_node_id: "root-node-id".to_string(),
6694            ontology_property: "P106_is_composed_of".to_string(),
6695            alias_suffix: None,
6696            alias_prefix: None,
6697            name_prefix: None,
6698        };
6699        let mut graph_with_branch = graph.deep_clone();
6700        apply_add_subgraph(&mut graph_with_branch, add_params).expect("Add should succeed");
6701
6702        // Now update with a modified subgraph
6703        let mut updated_subgraph = subgraph.deep_clone();
6704        // Modify a node's name
6705        for node in &mut updated_subgraph.nodes {
6706            if node.alias.as_deref() == Some("child1") {
6707                node.name = "Updated Child 1".to_string();
6708            }
6709        }
6710
6711        let update_params = UpdateSubgraphParams {
6712            subgraph: updated_subgraph,
6713            target_node_id: "root-node-id".to_string(),
6714            ontology_property: "P106_is_composed_of".to_string(),
6715            alias_suffix: None,
6716            remove_orphaned: false,
6717            alias_prefix: None,
6718            name_prefix: None,
6719        };
6720        let result = apply_update_subgraph(&mut graph_with_branch, update_params);
6721
6722        assert!(
6723            result.is_ok(),
6724            "UpdateSubgraph should succeed: {:?}",
6725            result.err()
6726        );
6727
6728        // Still should have 4 nodes (branch root kept as collector)
6729        assert_eq!(graph_with_branch.nodes.len(), 4);
6730
6731        // Check that the name was updated
6732        let child1 = graph_with_branch
6733            .nodes
6734            .iter()
6735            .find(|n| n.alias.as_deref() == Some("child1"))
6736            .unwrap();
6737        assert_eq!(
6738            child1.name, "Updated Child 1",
6739            "Node name should be updated"
6740        );
6741    }
6742
6743    #[test]
6744    fn test_update_subgraph_adds_new_nodes() {
6745        // First add a subgraph, then update with additional nodes
6746        let graph = create_test_graph();
6747        let subgraph = create_test_subgraph();
6748
6749        // Add subgraph first
6750        let add_params = AddSubgraphParams {
6751            subgraph: subgraph.clone(),
6752            target_node_id: "root-node-id".to_string(),
6753            ontology_property: "P106_is_composed_of".to_string(),
6754            alias_suffix: None,
6755            alias_prefix: None,
6756            name_prefix: None,
6757        };
6758        let mut graph_with_branch = graph.deep_clone();
6759        apply_add_subgraph(&mut graph_with_branch, add_params).expect("Add should succeed");
6760        assert_eq!(graph_with_branch.nodes.len(), 4);
6761
6762        // Now update with an additional node
6763        let mut updated_subgraph = subgraph.deep_clone();
6764        // Add a new node (child3)
6765        let new_node = StaticNode {
6766            nodeid: "sub-child3-id".to_string(),
6767            name: "Child 3".to_string(),
6768            alias: Some("child3".to_string()),
6769            datatype: "string".to_string(),
6770            nodegroup_id: Some("sub-child-ng-id".to_string()),
6771            graph_id: "sub-graph-id".to_string(),
6772            is_collector: false,
6773            isrequired: false,
6774            exportable: true,
6775            sortorder: Some(3),
6776            config: HashMap::new(),
6777            parentproperty: None,
6778            ontologyclass: Some(vec!["E41_Appellation".to_string()]),
6779            description: None,
6780            fieldname: None,
6781            hascustomalias: false,
6782            issearchable: true,
6783            istopnode: false,
6784            sourcebranchpublication_id: None,
6785            source_identifier_id: None,
6786            is_immutable: None,
6787        };
6788        updated_subgraph.nodes.push(new_node);
6789
6790        let update_params = UpdateSubgraphParams {
6791            subgraph: updated_subgraph,
6792            target_node_id: "root-node-id".to_string(),
6793            ontology_property: "P106_is_composed_of".to_string(),
6794            alias_suffix: None,
6795            remove_orphaned: false,
6796            alias_prefix: None,
6797            name_prefix: None,
6798        };
6799        let result = apply_update_subgraph(&mut graph_with_branch, update_params);
6800
6801        assert!(
6802            result.is_ok(),
6803            "UpdateSubgraph should succeed: {:?}",
6804            result.err()
6805        );
6806
6807        // Should now have 5 nodes (root + branch_root + 2 children + child3)
6808        assert_eq!(
6809            graph_with_branch.nodes.len(),
6810            5,
6811            "Should have added new node"
6812        );
6813
6814        // Check that child3 was added
6815        let child3 = graph_with_branch
6816            .nodes
6817            .iter()
6818            .find(|n| n.alias.as_deref() == Some("child3"));
6819        assert!(child3.is_some(), "New node child3 should be added");
6820    }
6821
6822    #[test]
6823    fn test_update_subgraph_removes_orphaned() {
6824        // First add a subgraph, then update with fewer nodes and remove_orphaned=true
6825        let graph = create_test_graph();
6826        let subgraph = create_test_subgraph();
6827
6828        // Add subgraph first
6829        let add_params = AddSubgraphParams {
6830            subgraph: subgraph.clone(),
6831            target_node_id: "root-node-id".to_string(),
6832            ontology_property: "P106_is_composed_of".to_string(),
6833            alias_suffix: None,
6834            alias_prefix: None,
6835            name_prefix: None,
6836        };
6837        let mut graph_with_branch = graph.deep_clone();
6838        apply_add_subgraph(&mut graph_with_branch, add_params).expect("Add should succeed");
6839        assert_eq!(graph_with_branch.nodes.len(), 4);
6840
6841        // Now update with only child1 (remove child2)
6842        let mut updated_subgraph = subgraph.deep_clone();
6843        updated_subgraph
6844            .nodes
6845            .retain(|n| n.alias.as_deref() != Some("child2"));
6846
6847        let update_params = UpdateSubgraphParams {
6848            subgraph: updated_subgraph,
6849            target_node_id: "root-node-id".to_string(),
6850            ontology_property: "P106_is_composed_of".to_string(),
6851            alias_suffix: None,
6852            remove_orphaned: true, // Enable orphan removal
6853            alias_prefix: None,
6854            name_prefix: None,
6855        };
6856        let result = apply_update_subgraph(&mut graph_with_branch, update_params);
6857
6858        assert!(
6859            result.is_ok(),
6860            "UpdateSubgraph should succeed: {:?}",
6861            result.err()
6862        );
6863
6864        // Should now have 2 nodes — orphan removal strips child2 and the branch
6865        // root (branch root is not in the updated subgraph's explicit node list)
6866        assert_eq!(
6867            graph_with_branch.nodes.len(),
6868            2,
6869            "Orphaned child2 and branch root should be removed"
6870        );
6871
6872        // child2 should be gone
6873        let child2 = graph_with_branch
6874            .nodes
6875            .iter()
6876            .find(|n| n.alias.as_deref() == Some("child2"));
6877        assert!(child2.is_none(), "child2 should be removed");
6878    }
6879
6880    #[test]
6881    fn test_update_subgraph_target_not_found() {
6882        let graph = create_test_graph();
6883        let subgraph = create_test_subgraph();
6884
6885        let params = UpdateSubgraphParams {
6886            subgraph,
6887            target_node_id: "non-existent-node".to_string(),
6888            ontology_property: "P106_is_composed_of".to_string(),
6889            alias_suffix: None,
6890            remove_orphaned: false,
6891            alias_prefix: None,
6892            name_prefix: None,
6893        };
6894
6895        let mut graph_clone = graph.deep_clone();
6896        let result = apply_update_subgraph(&mut graph_clone, params);
6897
6898        assert!(result.is_err(), "Should fail when target not found");
6899        match result {
6900            Err(MutationError::NodeNotFound(id)) => {
6901                assert_eq!(id, "non-existent-node");
6902            }
6903            Err(e) => panic!("Expected NodeNotFound error, got: {:?}", e),
6904            Ok(_) => panic!("Expected error but got Ok"),
6905        }
6906    }
6907
6908    // =========================================================================
6909    // ConceptChangeCollection Tests
6910    // =========================================================================
6911
6912    #[test]
6913    fn test_concept_change_collection_concept_node() {
6914        // Create a graph with a concept node
6915        let mut graph = create_test_graph();
6916
6917        // Add a concept node
6918        let concept_node = StaticNode {
6919            nodeid: "concept-node-id".to_string(),
6920            name: "Test Concept".to_string(),
6921            alias: Some("test_concept".to_string()),
6922            datatype: "concept".to_string(),
6923            nodegroup_id: Some("root-node-id".to_string()),
6924            graph_id: "test-graph-id".to_string(),
6925            is_collector: false,
6926            isrequired: false,
6927            exportable: true,
6928            sortorder: Some(1),
6929            config: HashMap::new(),
6930            parentproperty: None,
6931            ontologyclass: Some(vec!["E55_Type".to_string()]),
6932            description: None,
6933            fieldname: None,
6934            hascustomalias: false,
6935            issearchable: true,
6936            istopnode: false,
6937            sourcebranchpublication_id: None,
6938            source_identifier_id: None,
6939            is_immutable: None,
6940        };
6941        graph.push_node(concept_node);
6942
6943        let params = ConceptChangeCollectionParams {
6944            node_id: "test_concept".to_string(),
6945            collection_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
6946        };
6947
6948        let result = apply_concept_change_collection(&mut graph, params);
6949        assert!(
6950            result.is_ok(),
6951            "ConceptChangeCollection should succeed: {:?}",
6952            result.err()
6953        );
6954
6955        // Verify config was updated
6956        let node = graph.find_node_by_alias("test_concept").unwrap();
6957        let rdm_collection = node.config.get("rdmCollection").and_then(|v| v.as_str());
6958        assert_eq!(rdm_collection, Some("550e8400-e29b-41d4-a716-446655440000"));
6959    }
6960
6961    #[test]
6962    fn test_concept_change_collection_concept_list_node() {
6963        let mut graph = create_test_graph();
6964
6965        // Add a concept-list node
6966        let concept_list_node = StaticNode {
6967            nodeid: "concept-list-node-id".to_string(),
6968            name: "Test Concept List".to_string(),
6969            alias: Some("test_concept_list".to_string()),
6970            datatype: "concept-list".to_string(),
6971            nodegroup_id: Some("root-node-id".to_string()),
6972            graph_id: "test-graph-id".to_string(),
6973            is_collector: false,
6974            isrequired: false,
6975            exportable: true,
6976            sortorder: Some(1),
6977            config: HashMap::new(),
6978            parentproperty: None,
6979            ontologyclass: Some(vec!["E55_Type".to_string()]),
6980            description: None,
6981            fieldname: None,
6982            hascustomalias: false,
6983            issearchable: true,
6984            istopnode: false,
6985            sourcebranchpublication_id: None,
6986            source_identifier_id: None,
6987            is_immutable: None,
6988        };
6989        graph.push_node(concept_list_node);
6990
6991        let params = ConceptChangeCollectionParams {
6992            node_id: "test_concept_list".to_string(),
6993            collection_id: "my-new-collection-id".to_string(),
6994        };
6995
6996        let result = apply_concept_change_collection(&mut graph, params);
6997        assert!(
6998            result.is_ok(),
6999            "ConceptChangeCollection should succeed for concept-list: {:?}",
7000            result.err()
7001        );
7002
7003        let node = graph.find_node_by_alias("test_concept_list").unwrap();
7004        assert_eq!(
7005            node.config.get("rdmCollection").and_then(|v| v.as_str()),
7006            Some("my-new-collection-id")
7007        );
7008    }
7009
7010    #[test]
7011    fn test_concept_change_collection_invalid_datatype() {
7012        let mut graph = create_test_graph();
7013
7014        // Add a string node (not concept)
7015        let string_node = StaticNode {
7016            nodeid: "string-node-id".to_string(),
7017            name: "Test String".to_string(),
7018            alias: Some("test_string".to_string()),
7019            datatype: "string".to_string(),
7020            nodegroup_id: Some("root-node-id".to_string()),
7021            graph_id: "test-graph-id".to_string(),
7022            is_collector: false,
7023            isrequired: false,
7024            exportable: true,
7025            sortorder: Some(1),
7026            config: HashMap::new(),
7027            parentproperty: None,
7028            ontologyclass: Some(vec!["E41_Appellation".to_string()]),
7029            description: None,
7030            fieldname: None,
7031            hascustomalias: false,
7032            issearchable: true,
7033            istopnode: false,
7034            sourcebranchpublication_id: None,
7035            source_identifier_id: None,
7036            is_immutable: None,
7037        };
7038        graph.push_node(string_node);
7039
7040        let params = ConceptChangeCollectionParams {
7041            node_id: "test_string".to_string(),
7042            collection_id: "some-collection".to_string(),
7043        };
7044
7045        let result = apply_concept_change_collection(&mut graph, params);
7046        assert!(result.is_err(), "Should fail for non-concept datatype");
7047
7048        match result {
7049            Err(MutationError::InvalidDatatype {
7050                expected,
7051                found,
7052                node_id,
7053            }) => {
7054                assert!(expected.contains("concept"));
7055                assert_eq!(found, "string");
7056                assert_eq!(node_id, "test_string");
7057            }
7058            Err(e) => panic!("Expected InvalidDatatype error, got: {:?}", e),
7059            Ok(_) => panic!("Expected error but got Ok"),
7060        }
7061    }
7062
7063    #[test]
7064    fn test_concept_change_collection_node_not_found() {
7065        let mut graph = create_test_graph();
7066
7067        let params = ConceptChangeCollectionParams {
7068            node_id: "nonexistent_node".to_string(),
7069            collection_id: "some-collection".to_string(),
7070        };
7071
7072        let result = apply_concept_change_collection(&mut graph, params);
7073        assert!(result.is_err(), "Should fail when node not found");
7074
7075        match result {
7076            Err(MutationError::NodeNotFound(id)) => {
7077                assert_eq!(id, "nonexistent_node");
7078            }
7079            Err(e) => panic!("Expected NodeNotFound error, got: {:?}", e),
7080            Ok(_) => panic!("Expected error but got Ok"),
7081        }
7082    }
7083
7084    #[test]
7085    fn test_concept_change_collection_by_node_id() {
7086        // Test that we can also find nodes by ID, not just alias
7087        let mut graph = create_test_graph();
7088
7089        let concept_node = StaticNode {
7090            nodeid: "concept-node-uuid".to_string(),
7091            name: "Test Concept".to_string(),
7092            alias: None, // No alias
7093            datatype: "concept".to_string(),
7094            nodegroup_id: Some("root-node-id".to_string()),
7095            graph_id: "test-graph-id".to_string(),
7096            is_collector: false,
7097            isrequired: false,
7098            exportable: true,
7099            sortorder: Some(1),
7100            config: HashMap::new(),
7101            parentproperty: None,
7102            ontologyclass: Some(vec!["E55_Type".to_string()]),
7103            description: None,
7104            fieldname: None,
7105            hascustomalias: false,
7106            issearchable: true,
7107            istopnode: false,
7108            sourcebranchpublication_id: None,
7109            source_identifier_id: None,
7110            is_immutable: None,
7111        };
7112        graph.push_node(concept_node);
7113
7114        let params = ConceptChangeCollectionParams {
7115            node_id: "concept-node-uuid".to_string(), // Use node ID
7116            collection_id: "new-collection".to_string(),
7117        };
7118
7119        let result = apply_concept_change_collection(&mut graph, params);
7120        assert!(result.is_ok(), "Should find node by ID: {:?}", result.err());
7121
7122        let node = graph
7123            .nodes
7124            .iter()
7125            .find(|n| n.nodeid == "concept-node-uuid")
7126            .unwrap();
7127        assert_eq!(
7128            node.config.get("rdmCollection").and_then(|v| v.as_str()),
7129            Some("new-collection")
7130        );
7131    }
7132
7133    #[test]
7134    fn test_concept_change_collection_via_instruction() {
7135        let mut graph = create_test_graph();
7136
7137        // Add a concept node
7138        let concept_node = StaticNode {
7139            nodeid: "concept-node-id".to_string(),
7140            name: "Test Concept".to_string(),
7141            alias: Some("my_concept".to_string()),
7142            datatype: "concept".to_string(),
7143            nodegroup_id: Some("root-node-id".to_string()),
7144            graph_id: "test-graph-id".to_string(),
7145            is_collector: false,
7146            isrequired: false,
7147            exportable: true,
7148            sortorder: Some(1),
7149            config: HashMap::new(),
7150            parentproperty: None,
7151            ontologyclass: Some(vec!["E55_Type".to_string()]),
7152            description: None,
7153            fieldname: None,
7154            hascustomalias: false,
7155            issearchable: true,
7156            istopnode: false,
7157            sourcebranchpublication_id: None,
7158            source_identifier_id: None,
7159            is_immutable: None,
7160        };
7161        graph.push_node(concept_node);
7162
7163        // Create instruction
7164        let instruction = GraphInstruction {
7165            action: "concept_change_collection".to_string(),
7166            subject: "my_concept".to_string(),
7167            object: "new-collection-uuid".to_string(),
7168            params: HashMap::new(),
7169        };
7170
7171        let mutation = instruction.to_mutation().expect("Should create mutation");
7172        let options = MutatorOptions::default();
7173        let result = apply_mutation(&mut graph, mutation, &options);
7174        assert!(
7175            result.is_ok(),
7176            "Instruction should apply: {:?}",
7177            result.err()
7178        );
7179
7180        let node = graph.find_node_by_alias("my_concept").unwrap();
7181        assert_eq!(
7182            node.config.get("rdmCollection").and_then(|v| v.as_str()),
7183            Some("new-collection-uuid")
7184        );
7185    }
7186
7187    // =========================================================================
7188    // Skeleton Graph Tests
7189    // =========================================================================
7190
7191    #[test]
7192    fn test_create_skeleton_graph() {
7193        let classes = vec!["http://example.org/Person".to_string()];
7194        let graph = create_skeleton_graph("Person", "person", true, Some(classes.as_slice()));
7195
7196        // Check basic structure
7197        assert!(!graph.graphid.is_empty());
7198        assert_eq!(graph.name.to_string_default(), "Person".to_string());
7199        assert_eq!(graph.isresource, Some(true));
7200
7201        // Check root node
7202        assert_eq!(graph.root.alias, Some("person".to_string()));
7203        assert_eq!(graph.root.datatype, "semantic");
7204        assert!(graph.root.istopnode);
7205        assert!(
7206            graph.root.nodegroup_id.is_none(),
7207            "Root should have no nodegroup"
7208        );
7209        assert_eq!(
7210            graph.root.ontologyclass,
7211            Some(vec!["http://example.org/Person".to_string()])
7212        );
7213
7214        // Check nodes vector includes root
7215        assert_eq!(graph.nodes.len(), 1);
7216        assert_eq!(graph.nodes[0].nodeid, graph.root.nodeid);
7217
7218        // Check empty collections
7219        assert!(graph.nodegroups.is_empty());
7220        assert!(graph.edges.is_empty());
7221    }
7222
7223    #[test]
7224    fn test_skeleton_graph_deterministic_ids() {
7225        let graph1 = create_skeleton_graph("Person", "person", true, None);
7226        let graph2 = create_skeleton_graph("Person", "person", true, None);
7227
7228        // Same input should produce same IDs
7229        assert_eq!(graph1.graphid, graph2.graphid);
7230        assert_eq!(graph1.root.nodeid, graph2.root.nodeid);
7231
7232        // Different input should produce different IDs
7233        let graph3 = create_skeleton_graph("Monument", "monument", true, None);
7234        assert_ne!(graph1.graphid, graph3.graphid);
7235    }
7236
7237    #[test]
7238    fn test_skeleton_graph_branch_vs_resource() {
7239        let resource = create_skeleton_graph("Person", "person", true, None);
7240        let branch = create_skeleton_graph("Addresses", "addresses", false, None);
7241
7242        assert_eq!(resource.isresource, Some(true));
7243        assert_eq!(branch.isresource, Some(false));
7244    }
7245
7246    // =========================================================================
7247    // Instruction DSL Tests
7248    // =========================================================================
7249
7250    #[test]
7251    fn test_instruction_add_node() {
7252        let graph = create_skeleton_graph("Person", "person", true, None);
7253
7254        let instructions = vec![GraphInstruction::new("add_node", "person", "name")
7255            .with_str("datatype", "string")
7256            .with_str("name", "Full Name")
7257            .with_str("cardinality", "n")
7258            .with_str("ontology_class", "http://example.org/Name")
7259            .with_str("parent_property", "http://example.org/hasName")];
7260
7261        let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
7262        assert!(result.is_ok(), "Instruction failed: {:?}", result.err());
7263
7264        let mutated = result.unwrap();
7265        assert_eq!(mutated.nodes.len(), 2);
7266
7267        let name_node = mutated
7268            .nodes
7269            .iter()
7270            .find(|n| n.alias.as_deref() == Some("name"));
7271        assert!(name_node.is_some(), "Should find 'name' node");
7272
7273        let name_node = name_node.unwrap();
7274        assert_eq!(name_node.datatype, "string");
7275        assert_eq!(name_node.name, "Full Name");
7276        assert!(
7277            name_node.nodegroup_id.is_some(),
7278            "Non-root node should have nodegroup"
7279        );
7280    }
7281
7282    #[test]
7283    fn test_instruction_multiple_nodes() {
7284        let graph = create_skeleton_graph("Person", "person", true, None);
7285
7286        let instructions = vec![
7287            GraphInstruction::new("add_node", "person", "names")
7288                .with_str("datatype", "semantic")
7289                .with_str("cardinality", "n"),
7290            GraphInstruction::new("add_node", "names", "full_name")
7291                .with_str("datatype", "string")
7292                .with_str("cardinality", "1"),
7293            GraphInstruction::new("add_node", "names", "alias_name")
7294                .with_str("datatype", "string")
7295                .with_str("cardinality", "1"),
7296        ];
7297
7298        let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
7299        assert!(result.is_ok(), "Instructions failed: {:?}", result.err());
7300
7301        let mutated = result.unwrap();
7302        assert_eq!(mutated.nodes.len(), 4); // person, names, full_name, alias_name
7303
7304        // Check 'names' semantic node has its own nodegroup
7305        let names_node = mutated
7306            .nodes
7307            .iter()
7308            .find(|n| n.alias.as_deref() == Some("names"))
7309            .unwrap();
7310        assert!(names_node.nodegroup_id.is_some());
7311        let names_ng = names_node.nodegroup_id.clone().unwrap();
7312
7313        // Check full_name and alias_name share names' nodegroup (cardinality 1)
7314        let full_name = mutated
7315            .nodes
7316            .iter()
7317            .find(|n| n.alias.as_deref() == Some("full_name"))
7318            .unwrap();
7319        let alias_name = mutated
7320            .nodes
7321            .iter()
7322            .find(|n| n.alias.as_deref() == Some("alias_name"))
7323            .unwrap();
7324        assert_eq!(full_name.nodegroup_id, Some(names_ng.clone()));
7325        assert_eq!(alias_name.nodegroup_id, Some(names_ng.clone()));
7326    }
7327
7328    #[test]
7329    fn test_instruction_from_json() {
7330        let graph = create_skeleton_graph("Person", "person", true, None);
7331
7332        let json = r#"{
7333            "instructions": [
7334                {
7335                    "action": "add_node",
7336                    "subject": "person",
7337                    "object": "name",
7338                    "params": {
7339                        "datatype": "string",
7340                        "cardinality": "n"
7341                    }
7342                }
7343            ],
7344            "options": {
7345                "autocreate_card": true,
7346                "autocreate_widget": true
7347            }
7348        }"#;
7349
7350        let result = apply_instructions_from_json(&graph, json);
7351        assert!(
7352            result.is_ok(),
7353            "JSON instructions failed: {:?}",
7354            result.err()
7355        );
7356
7357        let mutated = result.unwrap();
7358        assert_eq!(mutated.nodes.len(), 2);
7359    }
7360
7361    #[test]
7362    fn test_instruction_unknown_action_becomes_extension() {
7363        let graph = create_skeleton_graph("Test", "test", true, None);
7364
7365        let instructions = vec![GraphInstruction::new("invalid_action", "test", "foo")];
7366
7367        // Without a registry, unknown actions are treated as extension mutations
7368        // and fail because no handler is registered
7369        let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
7370        assert!(result.is_err());
7371        assert!(result.unwrap_err().contains("Extension mutation"));
7372    }
7373
7374    #[test]
7375    fn test_root_children_always_get_nodegroup() {
7376        // This tests the fix for apply_add_node where direct children of root
7377        // should always get their own nodegroup, regardless of cardinality
7378        let graph = create_skeleton_graph("Test", "test", true, None);
7379
7380        let instructions = vec![
7381            // Even with cardinality 1, direct children of root should get their own nodegroup
7382            GraphInstruction::new("add_node", "test", "child")
7383                .with_str("datatype", "string")
7384                .with_str("cardinality", "1"),
7385        ];
7386
7387        let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
7388        assert!(result.is_ok());
7389
7390        let mutated = result.unwrap();
7391        let child = mutated
7392            .nodes
7393            .iter()
7394            .find(|n| n.alias.as_deref() == Some("child"))
7395            .unwrap();
7396
7397        // Child of root should have its own nodegroup (not inherit None from root)
7398        assert!(
7399            child.nodegroup_id.is_some(),
7400            "Direct children of root must have their own nodegroup"
7401        );
7402    }
7403
7404    #[test]
7405    fn test_is_collector_option_creates_own_nodegroup() {
7406        // A non-root child with cardinality 1 normally inherits its parent's
7407        // nodegroup. Setting is_collector = true should give it its own.
7408        let graph = create_skeleton_graph("Test", "test", true, None);
7409
7410        let instructions = vec![
7411            // Parent semantic node (cardinality N → gets own nodegroup automatically)
7412            GraphInstruction::new("add_node", "test", "parent_group")
7413                .with_str("datatype", "semantic")
7414                .with_str("cardinality", "n"),
7415            // Child with cardinality 1 and is_collector: should get own nodegroup
7416            GraphInstruction::new("add_node", "parent_group", "collector_child")
7417                .with_str("datatype", "string")
7418                .with_str("cardinality", "1")
7419                .with_param("options", serde_json::json!({"is_collector": true})),
7420            // Child with cardinality 1 without is_collector: inherits parent's nodegroup
7421            GraphInstruction::new("add_node", "parent_group", "plain_child")
7422                .with_str("datatype", "string")
7423                .with_str("cardinality", "1"),
7424        ];
7425
7426        let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
7427        assert!(result.is_ok(), "Instructions failed: {:?}", result.err());
7428
7429        let mutated = result.unwrap();
7430        let parent = mutated
7431            .nodes
7432            .iter()
7433            .find(|n| n.alias.as_deref() == Some("parent_group"))
7434            .unwrap();
7435        let collector = mutated
7436            .nodes
7437            .iter()
7438            .find(|n| n.alias.as_deref() == Some("collector_child"))
7439            .unwrap();
7440        let plain = mutated
7441            .nodes
7442            .iter()
7443            .find(|n| n.alias.as_deref() == Some("plain_child"))
7444            .unwrap();
7445
7446        // Collector child: nodeid == nodegroup_id (Arches is_collector semantics)
7447        assert_eq!(
7448            collector.nodegroup_id.as_ref(),
7449            Some(&collector.nodeid),
7450            "is_collector node should have nodegroup_id == nodeid"
7451        );
7452        assert!(collector.is_collector, "is_collector flag should be set");
7453
7454        // Corresponding nodegroup entry must exist with correct parent
7455        let collector_ng = mutated
7456            .nodegroups
7457            .iter()
7458            .find(|ng| ng.nodegroupid == collector.nodeid);
7459        assert!(
7460            collector_ng.is_some(),
7461            "Collector node must have a nodegroup entry"
7462        );
7463        assert_eq!(
7464            collector_ng.unwrap().parentnodegroup_id,
7465            parent.nodegroup_id,
7466            "Collector nodegroup parent should be the parent's nodegroup"
7467        );
7468
7469        // Plain child: inherits parent's nodegroup
7470        assert_eq!(
7471            plain.nodegroup_id, parent.nodegroup_id,
7472            "Non-collector child should share parent's nodegroup"
7473        );
7474    }
7475
7476    #[test]
7477    fn test_is_collector_via_csv_dot_notation() {
7478        // CSV with params.options.is_collector should produce a collector node
7479        let graph = create_skeleton_graph("Test", "test", true, None);
7480
7481        let csv = "\
7482action,subject,object,params.name,params.datatype,params.cardinality,params.ontology_class,params.parent_property,params.options.is_collector
7483add_node,test,parent_group,Parent,semantic,n,,,
7484add_node,parent_group,impact,Impact,string,1,,,true
7485add_node,parent_group,other,Other,string,1,,,
7486";
7487        let instructions = parse_instructions_from_csv(csv).expect("CSV should parse");
7488        let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
7489        assert!(
7490            result.is_ok(),
7491            "CSV instructions failed: {:?}",
7492            result.err()
7493        );
7494
7495        let mutated = result.unwrap();
7496        let impact = mutated
7497            .nodes
7498            .iter()
7499            .find(|n| n.alias.as_deref() == Some("impact"))
7500            .unwrap();
7501        let other = mutated
7502            .nodes
7503            .iter()
7504            .find(|n| n.alias.as_deref() == Some("other"))
7505            .unwrap();
7506        let parent = mutated
7507            .nodes
7508            .iter()
7509            .find(|n| n.alias.as_deref() == Some("parent_group"))
7510            .unwrap();
7511
7512        assert_eq!(
7513            impact.nodegroup_id.as_ref(),
7514            Some(&impact.nodeid),
7515            "CSV is_collector node should have nodegroup_id == nodeid"
7516        );
7517        assert_eq!(
7518            other.nodegroup_id, parent.nodegroup_id,
7519            "Non-collector CSV node should share parent's nodegroup"
7520        );
7521    }
7522
7523    // =========================================================================
7524    // Create Model/Branch Instruction Tests
7525    // =========================================================================
7526
7527    #[test]
7528    fn test_create_model_instruction() {
7529        let instructions = vec![GraphInstruction::new("create_model", "person", "")
7530            .with_str("name", "Person")
7531            .with_str("ontology_class", "http://example.org/Person")
7532            .with_str("slug", "person")];
7533
7534        let result = build_graph_from_instructions(instructions, MutatorOptions::default());
7535        assert!(result.is_ok(), "create_model failed: {:?}", result.err());
7536
7537        let graph = result.unwrap();
7538        assert_eq!(graph.isresource, Some(true));
7539        assert_eq!(graph.name.to_string_default(), "Person");
7540        assert_eq!(graph.root.alias, Some("person".to_string()));
7541        assert_eq!(
7542            graph.root.ontologyclass,
7543            Some(vec!["http://example.org/Person".to_string()])
7544        );
7545        assert_eq!(graph.slug, Some("person".to_string()));
7546        assert!(
7547            graph.root.nodegroup_id.is_none(),
7548            "Root should have no nodegroup"
7549        );
7550    }
7551
7552    #[test]
7553    fn test_create_model_default_slug() {
7554        let instructions =
7555            vec![GraphInstruction::new("create_model", "Person", "").with_str("name", "Person")];
7556
7557        let result = build_graph_from_instructions(instructions, MutatorOptions::default());
7558        assert!(result.is_ok());
7559
7560        let graph = result.unwrap();
7561        // Slug should default to lowercase root_alias for models too
7562        assert_eq!(graph.slug, Some("person".to_string()));
7563    }
7564
7565    #[test]
7566    fn test_create_branch_instruction() {
7567        let instructions = vec![GraphInstruction::new("create_branch", "addresses", "")
7568            .with_str("name", "Addresses")
7569            .with_str("slug", "addresses-branch")];
7570
7571        let result = build_graph_from_instructions(instructions, MutatorOptions::default());
7572        assert!(result.is_ok(), "create_branch failed: {:?}", result.err());
7573
7574        let graph = result.unwrap();
7575        assert_eq!(graph.isresource, Some(false));
7576        assert_eq!(graph.name.to_string_default(), "Addresses");
7577        assert_eq!(graph.root.alias, Some("addresses".to_string()));
7578        assert_eq!(graph.slug, Some("addresses-branch".to_string()));
7579    }
7580
7581    #[test]
7582    fn test_create_branch_default_slug() {
7583        let instructions = vec![GraphInstruction::new("create_branch", "MyAddresses", "")
7584            .with_str("name", "My Addresses")];
7585
7586        let result = build_graph_from_instructions(instructions, MutatorOptions::default());
7587        assert!(result.is_ok());
7588
7589        let graph = result.unwrap();
7590        // Slug should default to lowercase root_alias
7591        assert_eq!(graph.slug, Some("myaddresses".to_string()));
7592    }
7593
7594    #[test]
7595    fn test_create_with_explicit_graphid() {
7596        let custom_graphid = "12345678-1234-1234-1234-123456789abc";
7597        let instructions = vec![
7598            GraphInstruction::new("create_model", "person", custom_graphid)
7599                .with_str("name", "Person"),
7600        ];
7601
7602        let result = build_graph_from_instructions(instructions, MutatorOptions::default());
7603        assert!(result.is_ok());
7604
7605        let graph = result.unwrap();
7606        assert_eq!(graph.graphid, custom_graphid);
7607        assert_eq!(graph.root.graph_id, custom_graphid);
7608    }
7609
7610    #[test]
7611    fn test_build_graph_with_nodes() {
7612        let instructions = vec![
7613            GraphInstruction::new("create_model", "person", "").with_str("name", "Person"),
7614            GraphInstruction::new("add_node", "person", "names")
7615                .with_str("datatype", "semantic")
7616                .with_str("cardinality", "n"),
7617            GraphInstruction::new("add_node", "names", "full_name")
7618                .with_str("datatype", "string")
7619                .with_str("cardinality", "1"),
7620        ];
7621
7622        let result = build_graph_from_instructions(instructions, MutatorOptions::default());
7623        assert!(result.is_ok(), "Build failed: {:?}", result.err());
7624
7625        let graph = result.unwrap();
7626        assert_eq!(graph.nodes.len(), 3); // person, names, full_name
7627
7628        // Verify structure
7629        let names = graph
7630            .nodes
7631            .iter()
7632            .find(|n| n.alias.as_deref() == Some("names"))
7633            .unwrap();
7634        let full_name = graph
7635            .nodes
7636            .iter()
7637            .find(|n| n.alias.as_deref() == Some("full_name"))
7638            .unwrap();
7639
7640        assert!(names.nodegroup_id.is_some());
7641        assert_eq!(full_name.nodegroup_id, names.nodegroup_id);
7642    }
7643
7644    #[test]
7645    fn test_build_graph_from_json() {
7646        let json = r#"{
7647            "instructions": [
7648                {
7649                    "action": "create_model",
7650                    "subject": "monument",
7651                    "object": "",
7652                    "params": { "name": "Monument" }
7653                },
7654                {
7655                    "action": "add_node",
7656                    "subject": "monument",
7657                    "object": "name",
7658                    "params": { "datatype": "string", "cardinality": "n" }
7659                }
7660            ],
7661            "options": {
7662                "autocreate_card": true,
7663                "autocreate_widget": true
7664            }
7665        }"#;
7666
7667        let result = build_graph_from_instructions_json(json);
7668        assert!(result.is_ok(), "JSON build failed: {:?}", result.err());
7669
7670        let graph = result.unwrap();
7671        assert_eq!(graph.isresource, Some(true));
7672        assert_eq!(graph.nodes.len(), 2);
7673    }
7674
7675    #[test]
7676    fn test_build_graph_requires_create_first() {
7677        let instructions = vec![
7678            // Missing create_model/create_branch as first instruction
7679            GraphInstruction::new("add_node", "person", "name").with_str("datatype", "string"),
7680        ];
7681
7682        let result = build_graph_from_instructions(instructions, MutatorOptions::default());
7683        assert!(result.is_err());
7684        assert!(result.unwrap_err().contains("First instruction must be"));
7685    }
7686
7687    #[test]
7688    fn test_build_graph_empty_instructions() {
7689        let result = build_graph_from_instructions(vec![], MutatorOptions::default());
7690        assert!(result.is_err());
7691        assert!(result.unwrap_err().contains("No instructions provided"));
7692    }
7693
7694    #[test]
7695    fn test_create_action_in_apply_instructions_errors() {
7696        // create_model/create_branch should error when used with apply_instructions
7697        let graph = create_skeleton_graph("Test", "test", true, None);
7698
7699        let instructions = vec![GraphInstruction::new("create_model", "other", "")];
7700
7701        let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
7702        assert!(result.is_err());
7703        assert!(result.unwrap_err().contains("creates a new graph"));
7704    }
7705
7706    // =============================================================================
7707    // Deletion Mutation Tests
7708    // =============================================================================
7709
7710    #[test]
7711    fn test_delete_card() {
7712        // Build a graph with a card
7713        let mut graph = create_skeleton_graph("Test", "test", false, None);
7714        let options = MutatorOptions::default();
7715
7716        // Add a node (this creates a nodegroup and card automatically)
7717        apply_mutation(
7718            &mut graph,
7719            GraphMutation::AddNode(AddNodeParams {
7720                parent_alias: Some("test".to_string()),
7721                alias: "field1".to_string(),
7722                name: "Field 1".to_string(),
7723                cardinality: Cardinality::N,
7724                datatype: "string".to_string(),
7725                ontology_class: None,
7726                parent_property: String::new(),
7727                description: None,
7728                config: None,
7729                options: NodeOptions::default(),
7730            }),
7731            &options,
7732        )
7733        .unwrap();
7734
7735        // Get the card ID
7736        let card_id = graph.cards.as_ref().unwrap()[0].cardid.clone();
7737        assert!(!card_id.is_empty());
7738
7739        // Should have widgets
7740        assert!(graph
7741            .cards_x_nodes_x_widgets
7742            .as_ref()
7743            .map(|c| !c.is_empty())
7744            .unwrap_or(false));
7745
7746        // Delete the card
7747        apply_mutation(
7748            &mut graph,
7749            GraphMutation::DeleteCard(DeleteCardParams {
7750                card_id: card_id.clone(),
7751            }),
7752            &options,
7753        )
7754        .unwrap();
7755
7756        // Card should be gone
7757        assert!(graph
7758            .cards
7759            .as_ref()
7760            .map(|c| c.iter().all(|card| card.cardid != card_id))
7761            .unwrap_or(true));
7762
7763        // Widgets for that card should be gone
7764        assert!(graph
7765            .cards_x_nodes_x_widgets
7766            .as_ref()
7767            .map(|c| c.iter().all(|w| w.card_id != card_id))
7768            .unwrap_or(true));
7769    }
7770
7771    #[test]
7772    fn test_delete_card_not_found() {
7773        let mut graph = create_skeleton_graph("Test", "test", false, None);
7774        let options = MutatorOptions::default();
7775
7776        let result = apply_mutation(
7777            &mut graph,
7778            GraphMutation::DeleteCard(DeleteCardParams {
7779                card_id: "nonexistent".to_string(),
7780            }),
7781            &options,
7782        );
7783
7784        assert!(matches!(result, Err(MutationError::CardNotFound(_))));
7785    }
7786
7787    #[test]
7788    fn test_rename_card_by_nodegroup_id() {
7789        let mut graph = create_skeleton_graph("Test", "test", false, None);
7790        let options = MutatorOptions::default();
7791
7792        // Add a node with cardinality N (auto-creates card)
7793        apply_mutation(
7794            &mut graph,
7795            GraphMutation::AddNode(AddNodeParams {
7796                parent_alias: Some("test".to_string()),
7797                alias: "my_field".to_string(),
7798                name: "My Field".to_string(),
7799                cardinality: Cardinality::N,
7800                datatype: "string".to_string(),
7801                ontology_class: None,
7802                parent_property: String::new(),
7803                description: None,
7804                config: None,
7805                options: NodeOptions::default(),
7806            }),
7807            &options,
7808        )
7809        .unwrap();
7810
7811        // Card name should initially be "My Field"
7812        let node = graph.find_node_by_alias("my_field").unwrap();
7813        let ng_id = node.nodegroup_id.clone().unwrap();
7814        let card = graph.find_card_by_nodegroup(&ng_id).unwrap();
7815        assert_eq!(card.name.get("en"), "My Field");
7816
7817        // Rename card by nodegroup_id
7818        apply_mutation(
7819            &mut graph,
7820            GraphMutation::RenameCard(RenameCardParams {
7821                card_id: ng_id.clone(),
7822                language: None,
7823                name: Some("Renamed Card".to_string()),
7824                name_i18n: None,
7825                description: Some("A description".to_string()),
7826                description_i18n: None,
7827            }),
7828            &options,
7829        )
7830        .unwrap();
7831
7832        let card = graph.find_card_by_nodegroup(&ng_id).unwrap();
7833        assert_eq!(card.name.get("en"), "Renamed Card");
7834        assert_eq!(
7835            card.description.as_ref().unwrap().get("en"),
7836            "A description"
7837        );
7838    }
7839
7840    #[test]
7841    fn test_rename_card_multilingual() {
7842        let mut graph = create_skeleton_graph("Test", "test", false, None);
7843        let options = MutatorOptions::default();
7844
7845        apply_mutation(
7846            &mut graph,
7847            GraphMutation::AddNode(AddNodeParams {
7848                parent_alias: Some("test".to_string()),
7849                alias: "my_field".to_string(),
7850                name: "My Field".to_string(),
7851                cardinality: Cardinality::N,
7852                datatype: "string".to_string(),
7853                ontology_class: None,
7854                parent_property: String::new(),
7855                description: None,
7856                config: None,
7857                options: NodeOptions::default(),
7858            }),
7859            &options,
7860        )
7861        .unwrap();
7862
7863        let node = graph.find_node_by_alias("my_field").unwrap();
7864        let ng_id = node.nodegroup_id.clone().unwrap();
7865
7866        // Rename with i18n map
7867        let mut name_map = HashMap::new();
7868        name_map.insert("en".to_string(), "English Name".to_string());
7869        name_map.insert("fr".to_string(), "Nom Français".to_string());
7870
7871        apply_mutation(
7872            &mut graph,
7873            GraphMutation::RenameCard(RenameCardParams {
7874                card_id: ng_id.clone(),
7875                language: None,
7876                name: None,
7877                name_i18n: Some(name_map),
7878                description: None,
7879                description_i18n: None,
7880            }),
7881            &options,
7882        )
7883        .unwrap();
7884
7885        let card = graph.find_card_by_nodegroup(&ng_id).unwrap();
7886        assert_eq!(card.name.get("en"), "English Name");
7887        assert_eq!(card.name.get("fr"), "Nom Français");
7888    }
7889
7890    #[test]
7891    fn test_rename_card_specific_language() {
7892        let mut graph = create_skeleton_graph("Test", "test", false, None);
7893        let options = MutatorOptions::default();
7894
7895        apply_mutation(
7896            &mut graph,
7897            GraphMutation::AddNode(AddNodeParams {
7898                parent_alias: Some("test".to_string()),
7899                alias: "my_field".to_string(),
7900                name: "My Field".to_string(),
7901                cardinality: Cardinality::N,
7902                datatype: "string".to_string(),
7903                ontology_class: None,
7904                parent_property: String::new(),
7905                description: None,
7906                config: None,
7907                options: NodeOptions::default(),
7908            }),
7909            &options,
7910        )
7911        .unwrap();
7912
7913        let node = graph.find_node_by_alias("my_field").unwrap();
7914        let ng_id = node.nodegroup_id.clone().unwrap();
7915
7916        // Add a French name without disturbing the English one
7917        apply_mutation(
7918            &mut graph,
7919            GraphMutation::RenameCard(RenameCardParams {
7920                card_id: ng_id.clone(),
7921                language: Some("fr".to_string()),
7922                name: Some("Mon Champ".to_string()),
7923                name_i18n: None,
7924                description: None,
7925                description_i18n: None,
7926            }),
7927            &options,
7928        )
7929        .unwrap();
7930
7931        let card = graph.find_card_by_nodegroup(&ng_id).unwrap();
7932        assert_eq!(card.name.get("en"), "My Field");
7933        assert_eq!(card.name.get("fr"), "Mon Champ");
7934    }
7935
7936    #[test]
7937    fn test_rename_card_not_found() {
7938        let mut graph = create_skeleton_graph("Test", "test", false, None);
7939        let options = MutatorOptions::default();
7940
7941        let result = apply_mutation(
7942            &mut graph,
7943            GraphMutation::RenameCard(RenameCardParams {
7944                card_id: "nonexistent".to_string(),
7945                language: None,
7946                name: Some("Whatever".to_string()),
7947                name_i18n: None,
7948                description: None,
7949                description_i18n: None,
7950            }),
7951            &options,
7952        );
7953
7954        assert!(matches!(result, Err(MutationError::CardNotFound(_))));
7955    }
7956
7957    #[test]
7958    fn test_realign_card_from_node() {
7959        let mut graph = create_skeleton_graph("Test", "test", false, None);
7960        let options = MutatorOptions::default();
7961
7962        // Add a node with cardinality N (auto-creates card + widget)
7963        apply_mutation(
7964            &mut graph,
7965            GraphMutation::AddNode(AddNodeParams {
7966                parent_alias: Some("test".to_string()),
7967                alias: "my_field".to_string(),
7968                name: "Original Name".to_string(),
7969                cardinality: Cardinality::N,
7970                datatype: "string".to_string(),
7971                ontology_class: None,
7972                parent_property: String::new(),
7973                description: None,
7974                config: None,
7975                options: NodeOptions::default(),
7976            }),
7977            &options,
7978        )
7979        .unwrap();
7980
7981        // Verify initial state
7982        let node = graph.find_node_by_alias("my_field").unwrap();
7983        let ng_id = node.nodegroup_id.clone().unwrap();
7984        let node_id = node.nodeid.clone();
7985        assert_eq!(
7986            graph.find_card_by_nodegroup(&ng_id).unwrap().name.get("en"),
7987            "Original Name"
7988        );
7989        let widget = graph
7990            .cards_x_nodes_x_widgets
7991            .as_ref()
7992            .unwrap()
7993            .iter()
7994            .find(|c| c.node_id == node_id)
7995            .unwrap();
7996        assert_eq!(widget.label.get("en"), "Original Name");
7997
7998        // Rename the node with realign_card=false (card and widget stay stale)
7999        apply_mutation(
8000            &mut graph,
8001            GraphMutation::RenameNode(RenameNodeParams {
8002                node_id: "my_field".to_string(),
8003                alias: None,
8004                name: Some("Updated Name".to_string()),
8005                description: None,
8006                realign_card: false,
8007            }),
8008            &options,
8009        )
8010        .unwrap();
8011
8012        // Card still has old name
8013        assert_eq!(
8014            graph.find_card_by_nodegroup(&ng_id).unwrap().name.get("en"),
8015            "Original Name"
8016        );
8017
8018        // Realign
8019        apply_mutation(
8020            &mut graph,
8021            GraphMutation::RealignCardFromNode(RealignCardFromNodeParams {
8022                node_alias: "my_field".to_string(),
8023            }),
8024            &options,
8025        )
8026        .unwrap();
8027
8028        // Card and widget now match the node
8029        assert_eq!(
8030            graph.find_card_by_nodegroup(&ng_id).unwrap().name.get("en"),
8031            "Updated Name"
8032        );
8033        let widget = graph
8034            .cards_x_nodes_x_widgets
8035            .as_ref()
8036            .unwrap()
8037            .iter()
8038            .find(|c| c.node_id == node_id)
8039            .unwrap();
8040        assert_eq!(widget.label.get("en"), "Updated Name");
8041    }
8042
8043    #[test]
8044    fn test_realign_card_from_node_not_found() {
8045        let mut graph = create_skeleton_graph("Test", "test", false, None);
8046        let options = MutatorOptions::default();
8047
8048        let result = apply_mutation(
8049            &mut graph,
8050            GraphMutation::RealignCardFromNode(RealignCardFromNodeParams {
8051                node_alias: "nonexistent".to_string(),
8052            }),
8053            &options,
8054        );
8055
8056        assert!(matches!(result, Err(MutationError::NodeNotFound(_))));
8057    }
8058
8059    #[test]
8060    fn test_delete_widget() {
8061        // Build a graph with a widget
8062        let mut graph = create_skeleton_graph("Test", "test", false, None);
8063        let options = MutatorOptions::default();
8064
8065        // Add a node (this creates a widget automatically)
8066        apply_mutation(
8067            &mut graph,
8068            GraphMutation::AddNode(AddNodeParams {
8069                parent_alias: Some("test".to_string()),
8070                alias: "field1".to_string(),
8071                name: "Field 1".to_string(),
8072                cardinality: Cardinality::N,
8073                datatype: "string".to_string(),
8074                ontology_class: None,
8075                parent_property: String::new(),
8076                description: None,
8077                config: None,
8078                options: NodeOptions::default(),
8079            }),
8080            &options,
8081        )
8082        .unwrap();
8083
8084        // Get the widget mapping ID
8085        let widget_id = graph.cards_x_nodes_x_widgets.as_ref().unwrap()[0]
8086            .id
8087            .clone();
8088        let initial_count = graph.cards_x_nodes_x_widgets.as_ref().unwrap().len();
8089
8090        // Delete the widget
8091        apply_mutation(
8092            &mut graph,
8093            GraphMutation::DeleteWidget(DeleteWidgetParams {
8094                widget_mapping_id: widget_id.clone(),
8095            }),
8096            &options,
8097        )
8098        .unwrap();
8099
8100        // Widget should be gone
8101        let final_count = graph
8102            .cards_x_nodes_x_widgets
8103            .as_ref()
8104            .map(|c| c.len())
8105            .unwrap_or(0);
8106        assert_eq!(final_count, initial_count - 1);
8107    }
8108
8109    #[test]
8110    fn test_delete_widget_not_found() {
8111        let mut graph = create_skeleton_graph("Test", "test", false, None);
8112        let options = MutatorOptions::default();
8113
8114        let result = apply_mutation(
8115            &mut graph,
8116            GraphMutation::DeleteWidget(DeleteWidgetParams {
8117                widget_mapping_id: "nonexistent".to_string(),
8118            }),
8119            &options,
8120        );
8121
8122        assert!(matches!(result, Err(MutationError::WidgetNotFound(_))));
8123    }
8124
8125    #[test]
8126    fn test_add_function_with_uuid() {
8127        let mut graph = create_skeleton_graph("Test", "test", false, None);
8128        let options = MutatorOptions::default();
8129
8130        apply_mutation(
8131            &mut graph,
8132            GraphMutation::AddFunction(AddFunctionParams {
8133                function_id: "00b2d15a-fda0-4578-b79a-784e4138664b".to_string(),
8134                config: Some(serde_json::json!({"key": "value"})),
8135            }),
8136            &options,
8137        )
8138        .unwrap();
8139
8140        let fxgs = graph.functions_x_graphs.as_ref().unwrap();
8141        assert_eq!(fxgs.len(), 1);
8142        assert_eq!(fxgs[0].function_id, "00b2d15a-fda0-4578-b79a-784e4138664b");
8143        assert_eq!(fxgs[0].graph_id, graph.graphid);
8144        assert_eq!(fxgs[0].config["key"], "value");
8145    }
8146
8147    #[test]
8148    fn test_add_function_with_non_uuid_string() {
8149        let mut graph = create_skeleton_graph("Test", "test", false, None);
8150        let options = MutatorOptions::default();
8151
8152        apply_mutation(
8153            &mut graph,
8154            GraphMutation::AddFunction(AddFunctionParams {
8155                function_id: "com.flaxandteal.app/my-func".to_string(),
8156                config: None,
8157            }),
8158            &options,
8159        )
8160        .unwrap();
8161
8162        let fxgs = graph.functions_x_graphs.as_ref().unwrap();
8163        assert_eq!(fxgs.len(), 1);
8164        // Should be a valid UUID (derived via v5)
8165        assert!(uuid::Uuid::parse_str(&fxgs[0].function_id).is_ok());
8166        // Should NOT be the raw string
8167        assert_ne!(fxgs[0].function_id, "com.flaxandteal.app/my-func");
8168        // Should be deterministic
8169        let expected =
8170            uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, b"com.flaxandteal.app/my-func")
8171                .to_string();
8172        assert_eq!(fxgs[0].function_id, expected);
8173    }
8174
8175    #[test]
8176    fn test_add_function_duplicate() {
8177        let mut graph = create_skeleton_graph("Test", "test", false, None);
8178        let options = MutatorOptions::default();
8179
8180        apply_mutation(
8181            &mut graph,
8182            GraphMutation::AddFunction(AddFunctionParams {
8183                function_id: "00b2d15a-fda0-4578-b79a-784e4138664b".to_string(),
8184                config: None,
8185            }),
8186            &options,
8187        )
8188        .unwrap();
8189
8190        let result = apply_mutation(
8191            &mut graph,
8192            GraphMutation::AddFunction(AddFunctionParams {
8193                function_id: "00b2d15a-fda0-4578-b79a-784e4138664b".to_string(),
8194                config: None,
8195            }),
8196            &options,
8197        );
8198
8199        assert!(matches!(result, Err(MutationError::Other(_))));
8200    }
8201
8202    #[test]
8203    fn test_set_descriptor_template_with_non_default_function_allows_multi_nodegroup() {
8204        use crate::graph::StaticFunctionsXGraphs;
8205
8206        let mut graph = create_skeleton_graph("Test", "test", true, None);
8207        let options = MutatorOptions::default();
8208
8209        // Add two nodes in separate nodegroups
8210        apply_mutation(
8211            &mut graph,
8212            GraphMutation::AddNode(AddNodeParams {
8213                parent_alias: Some("test".to_string()),
8214                alias: "name_node".to_string(),
8215                name: "Name".to_string(),
8216                datatype: "string".to_string(),
8217                cardinality: Cardinality::One,
8218                ontology_class: None,
8219                parent_property: String::new(),
8220                description: None,
8221                config: None,
8222                options: NodeOptions::default(),
8223            }),
8224            &options,
8225        )
8226        .unwrap();
8227        apply_mutation(
8228            &mut graph,
8229            GraphMutation::AddNode(AddNodeParams {
8230                parent_alias: Some("test".to_string()),
8231                alias: "desc_node".to_string(),
8232                name: "Description".to_string(),
8233                datatype: "string".to_string(),
8234                cardinality: Cardinality::One,
8235                ontology_class: None,
8236                parent_property: String::new(),
8237                description: None,
8238                config: None,
8239                options: NodeOptions::default(),
8240            }),
8241            &options,
8242        )
8243        .unwrap();
8244
8245        // Verify the two nodes are in different nodegroups
8246        let name_ng = graph
8247            .nodes
8248            .iter()
8249            .find(|n| n.alias.as_deref() == Some("name_node"))
8250            .unwrap()
8251            .nodegroup_id
8252            .as_ref()
8253            .unwrap()
8254            .clone();
8255        let desc_ng = graph
8256            .nodes
8257            .iter()
8258            .find(|n| n.alias.as_deref() == Some("desc_node"))
8259            .unwrap()
8260            .nodegroup_id
8261            .as_ref()
8262            .unwrap()
8263            .clone();
8264        assert_ne!(
8265            name_ng, desc_ng,
8266            "Test setup: nodes should be in different nodegroups"
8267        );
8268
8269        // With default descriptor function, multi-nodegroup template should fail
8270        let result = graph.set_descriptor_template("name", "<Name> - <Description>");
8271        assert!(
8272            result.is_err(),
8273            "Default function should reject multi-nodegroup template"
8274        );
8275        assert!(result.unwrap_err().contains("expected exactly 1"));
8276
8277        // Add a non-default descriptor function (e.g. Multi-card Resource Descriptor)
8278        let fxg = graph.functions_x_graphs.get_or_insert_with(Vec::new);
8279        fxg.push(StaticFunctionsXGraphs {
8280            config: serde_json::json!({}),
8281            function_id: "00b2d15a-fda0-4578-b79a-784e4138664b".to_string(),
8282            graph_id: graph.graphid.clone(),
8283            id: "test-fxg-1".to_string(),
8284        });
8285
8286        // With non-default function, multi-nodegroup template should succeed
8287        let result = graph.set_descriptor_template("name", "<Name> - <Description>");
8288        assert!(
8289            result.is_ok(),
8290            "Non-default function should allow multi-nodegroup template: {:?}",
8291            result
8292        );
8293
8294        // Verify the config was applied to the non-default function
8295        let func = graph
8296            .functions_x_graphs
8297            .as_ref()
8298            .unwrap()
8299            .iter()
8300            .find(|f| f.function_id == "00b2d15a-fda0-4578-b79a-784e4138664b")
8301            .expect("Non-default function should still exist");
8302        let dt = func.config["descriptor_types"]["name"].as_object().unwrap();
8303        assert_eq!(dt["string_template"], "<Name> - <Description>");
8304        // Non-default functions store empty nodegroup_id (resolved at runtime)
8305        assert_eq!(dt["nodegroup_id"], "");
8306        // Empty defaults seeded for other descriptor types
8307        assert_eq!(
8308            func.config["descriptor_types"]["description"]["string_template"],
8309            ""
8310        );
8311        assert_eq!(
8312            func.config["descriptor_types"]["map_popup"]["string_template"],
8313            ""
8314        );
8315    }
8316
8317    #[test]
8318    fn test_set_descriptor_template_with_non_default_function_single_nodegroup() {
8319        use crate::graph::StaticFunctionsXGraphs;
8320
8321        let mut graph = create_skeleton_graph("Test", "test", true, None);
8322        let options = MutatorOptions::default();
8323
8324        // Add a node
8325        apply_mutation(
8326            &mut graph,
8327            GraphMutation::AddNode(AddNodeParams {
8328                parent_alias: Some("test".to_string()),
8329                alias: "name_node".to_string(),
8330                name: "Name".to_string(),
8331                datatype: "string".to_string(),
8332                cardinality: Cardinality::One,
8333                ontology_class: None,
8334                parent_property: String::new(),
8335                description: None,
8336                config: None,
8337                options: NodeOptions::default(),
8338            }),
8339            &options,
8340        )
8341        .unwrap();
8342
8343        // Add a non-default function with empty config
8344        let fxg = graph.functions_x_graphs.get_or_insert_with(Vec::new);
8345        fxg.push(StaticFunctionsXGraphs {
8346            config: serde_json::json!({}),
8347            function_id: "00b2d15a-fda0-4578-b79a-784e4138664b".to_string(),
8348            graph_id: graph.graphid.clone(),
8349            id: "test-fxg-1".to_string(),
8350        });
8351
8352        // Single-nodegroup template should also work with non-default function
8353        let result = graph.set_descriptor_template("name", "<Name>");
8354        assert!(
8355            result.is_ok(),
8356            "Non-default function should allow single-nodegroup template: {:?}",
8357            result
8358        );
8359
8360        let func = graph
8361            .functions_x_graphs
8362            .as_ref()
8363            .unwrap()
8364            .iter()
8365            .find(|f| f.function_id == "00b2d15a-fda0-4578-b79a-784e4138664b")
8366            .expect("Non-default function should still exist");
8367        let dt = func.config["descriptor_types"]["name"].as_object().unwrap();
8368        assert_eq!(dt["string_template"], "<Name>");
8369    }
8370
8371    #[test]
8372    fn test_delete_function() {
8373        use crate::graph::StaticFunctionsXGraphs;
8374
8375        // Build a graph with a function mapping
8376        let mut graph = create_skeleton_graph("Test", "test", false, None);
8377        let options = MutatorOptions::default();
8378
8379        // Add a function mapping
8380        graph.functions_x_graphs = Some(vec![StaticFunctionsXGraphs {
8381            id: "func-mapping-1".to_string(),
8382            function_id: "60000000-0000-0000-0000-000000000001".to_string(),
8383            graph_id: graph.graphid.clone(),
8384            config: serde_json::Value::Object(serde_json::Map::new()),
8385        }]);
8386
8387        // Delete the function
8388        apply_mutation(
8389            &mut graph,
8390            GraphMutation::DeleteFunction(DeleteFunctionParams {
8391                function_mapping_id: "func-mapping-1".to_string(),
8392            }),
8393            &options,
8394        )
8395        .unwrap();
8396
8397        // Function mapping should be gone
8398        assert!(graph
8399            .functions_x_graphs
8400            .as_ref()
8401            .map(|f| f.is_empty())
8402            .unwrap_or(true));
8403    }
8404
8405    #[test]
8406    fn test_delete_function_not_found() {
8407        let mut graph = create_skeleton_graph("Test", "test", false, None);
8408        let options = MutatorOptions::default();
8409
8410        let result = apply_mutation(
8411            &mut graph,
8412            GraphMutation::DeleteFunction(DeleteFunctionParams {
8413                function_mapping_id: "nonexistent".to_string(),
8414            }),
8415            &options,
8416        );
8417
8418        assert!(matches!(result, Err(MutationError::FunctionNotFound(_))));
8419    }
8420
8421    #[test]
8422    fn test_delete_node() {
8423        let mut graph = create_skeleton_graph("Test", "test", false, None);
8424        let options = MutatorOptions::default();
8425
8426        // Add a node
8427        apply_mutation(
8428            &mut graph,
8429            GraphMutation::AddNode(AddNodeParams {
8430                parent_alias: Some("test".to_string()),
8431                alias: "field1".to_string(),
8432                name: "Field 1".to_string(),
8433                cardinality: Cardinality::N,
8434                datatype: "string".to_string(),
8435                ontology_class: None,
8436                parent_property: String::new(),
8437                description: None,
8438                config: None,
8439                options: NodeOptions::default(),
8440            }),
8441            &options,
8442        )
8443        .unwrap();
8444
8445        let initial_node_count = graph.nodes.len();
8446        let initial_edge_count = graph.edges.len();
8447
8448        // Delete the node by alias
8449        apply_mutation(
8450            &mut graph,
8451            GraphMutation::DeleteNode(DeleteNodeParams {
8452                node_id: "field1".to_string(),
8453            }),
8454            &options,
8455        )
8456        .unwrap();
8457
8458        // Node should be gone
8459        assert_eq!(graph.nodes.len(), initial_node_count - 1);
8460        assert!(graph.find_node_by_alias("field1").is_none());
8461
8462        // Edge should be gone
8463        assert!(graph.edges.len() < initial_edge_count);
8464
8465        // Widget should be gone
8466        let has_widget_for_node = graph
8467            .cards_x_nodes_x_widgets
8468            .as_ref()
8469            .map(|c| c.iter().any(|w| w.node_id.contains("field1")))
8470            .unwrap_or(false);
8471        assert!(!has_widget_for_node);
8472    }
8473
8474    #[test]
8475    fn test_delete_node_not_found() {
8476        let mut graph = create_skeleton_graph("Test", "test", false, None);
8477        let options = MutatorOptions::default();
8478
8479        let result = apply_mutation(
8480            &mut graph,
8481            GraphMutation::DeleteNode(DeleteNodeParams {
8482                node_id: "nonexistent".to_string(),
8483            }),
8484            &options,
8485        );
8486
8487        assert!(matches!(result, Err(MutationError::NodeNotFound(_))));
8488    }
8489
8490    #[test]
8491    fn test_delete_node_cannot_delete_root() {
8492        let mut graph = create_skeleton_graph("Test", "test", false, None);
8493        let options = MutatorOptions::default();
8494
8495        // Try to delete the root node
8496        let result = apply_mutation(
8497            &mut graph,
8498            GraphMutation::DeleteNode(DeleteNodeParams {
8499                node_id: "test".to_string(),
8500            }),
8501            &options,
8502        );
8503
8504        assert!(matches!(
8505            result,
8506            Err(MutationError::CannotDeleteRootNode(_))
8507        ));
8508    }
8509
8510    #[test]
8511    fn test_delete_nodegroup_cascade() {
8512        let mut graph = create_skeleton_graph("Test", "test", false, None);
8513        let options = MutatorOptions::default();
8514
8515        // Add a parent node (creates nodegroup)
8516        apply_mutation(
8517            &mut graph,
8518            GraphMutation::AddNode(AddNodeParams {
8519                parent_alias: Some("test".to_string()),
8520                alias: "parent_field".to_string(),
8521                name: "Parent Field".to_string(),
8522                cardinality: Cardinality::N,
8523                datatype: "semantic".to_string(),
8524                ontology_class: None,
8525                parent_property: String::new(),
8526                description: None,
8527                config: None,
8528                options: NodeOptions::default(),
8529            }),
8530            &options,
8531        )
8532        .unwrap();
8533
8534        // Add a child node under it
8535        apply_mutation(
8536            &mut graph,
8537            GraphMutation::AddNode(AddNodeParams {
8538                parent_alias: Some("parent_field".to_string()),
8539                alias: "child_field".to_string(),
8540                name: "Child Field".to_string(),
8541                cardinality: Cardinality::One,
8542                datatype: "string".to_string(),
8543                ontology_class: None,
8544                parent_property: String::new(),
8545                description: None,
8546                config: None,
8547                options: NodeOptions::default(),
8548            }),
8549            &options,
8550        )
8551        .unwrap();
8552
8553        // Find the nodegroup for parent_field
8554        let parent_node = graph.find_node_by_alias("parent_field").unwrap();
8555        let nodegroup_id = parent_node.nodegroup_id.clone().unwrap();
8556
8557        let initial_node_count = graph.nodes.len();
8558        let initial_nodegroup_count = graph.nodegroups.len();
8559
8560        // Delete the nodegroup
8561        apply_mutation(
8562            &mut graph,
8563            GraphMutation::DeleteNodegroup(DeleteNodegroupParams {
8564                nodegroup_id: nodegroup_id.clone(),
8565            }),
8566            &options,
8567        )
8568        .unwrap();
8569
8570        // Nodegroup should be gone
8571        assert!(graph
8572            .nodegroups
8573            .iter()
8574            .all(|ng| ng.nodegroupid != nodegroup_id));
8575
8576        // Parent node should be gone
8577        assert!(graph.find_node_by_alias("parent_field").is_none());
8578
8579        // Child node should also be gone (cascade)
8580        assert!(graph.find_node_by_alias("child_field").is_none());
8581
8582        // Counts should be reduced
8583        assert!(graph.nodes.len() < initial_node_count);
8584        assert!(graph.nodegroups.len() < initial_nodegroup_count);
8585    }
8586
8587    #[test]
8588    fn test_delete_nodegroup_not_found() {
8589        let mut graph = create_skeleton_graph("Test", "test", false, None);
8590        let options = MutatorOptions::default();
8591
8592        let result = apply_mutation(
8593            &mut graph,
8594            GraphMutation::DeleteNodegroup(DeleteNodegroupParams {
8595                nodegroup_id: "nonexistent".to_string(),
8596            }),
8597            &options,
8598        );
8599
8600        assert!(matches!(result, Err(MutationError::NodegroupNotFound(_))));
8601    }
8602
8603    #[test]
8604    fn test_delete_node_via_instruction() {
8605        let mut graph = create_skeleton_graph("Test", "test", false, None);
8606        let options = MutatorOptions::default();
8607
8608        // Add a node
8609        apply_mutation(
8610            &mut graph,
8611            GraphMutation::AddNode(AddNodeParams {
8612                parent_alias: Some("test".to_string()),
8613                alias: "my_field".to_string(),
8614                name: "My Field".to_string(),
8615                cardinality: Cardinality::N,
8616                datatype: "string".to_string(),
8617                ontology_class: None,
8618                parent_property: String::new(),
8619                description: None,
8620                config: None,
8621                options: NodeOptions::default(),
8622            }),
8623            &options,
8624        )
8625        .unwrap();
8626
8627        // Delete via instruction
8628        let instruction = GraphInstruction::new("delete_node", "my_field", "");
8629        let mutation = instruction.to_mutation().unwrap();
8630
8631        apply_mutation(&mut graph, mutation, &options).unwrap();
8632
8633        // Node should be gone
8634        assert!(graph.find_node_by_alias("my_field").is_none());
8635    }
8636
8637    // =============================================================================
8638    // Node Update Mutation Tests
8639    // =============================================================================
8640
8641    #[test]
8642    fn test_update_node() {
8643        let mut graph = create_skeleton_graph("Test", "test", false, None);
8644        let options = MutatorOptions::default();
8645
8646        // Add a node
8647        apply_mutation(
8648            &mut graph,
8649            GraphMutation::AddNode(AddNodeParams {
8650                parent_alias: Some("test".to_string()),
8651                alias: "field1".to_string(),
8652                name: "Original Name".to_string(),
8653                cardinality: Cardinality::N,
8654                datatype: "string".to_string(),
8655                ontology_class: None,
8656                parent_property: String::new(),
8657                description: None,
8658                config: None,
8659                options: NodeOptions::default(),
8660            }),
8661            &options,
8662        )
8663        .unwrap();
8664
8665        // Update the node
8666        apply_mutation(
8667            &mut graph,
8668            GraphMutation::UpdateNode(UpdateNodeParams {
8669                node_id: "field1".to_string(),
8670                name: Some("Updated Name".to_string()),
8671                ontology_class: Some(vec!["http://example.org/Class".to_string()]),
8672                parent_property: None,
8673                description: Some("A description".to_string()),
8674                config: None,
8675                options: UpdateNodeOptions {
8676                    isrequired: Some(true),
8677                    ..UpdateNodeOptions::default()
8678                },
8679            }),
8680            &options,
8681        )
8682        .unwrap();
8683
8684        // Verify updates
8685        let node = graph.find_node_by_alias("field1").unwrap();
8686        assert_eq!(node.name, "Updated Name");
8687        assert_eq!(
8688            node.ontologyclass,
8689            Some(vec!["http://example.org/Class".to_string()])
8690        );
8691        assert!(node.description.is_some());
8692        assert!(node.isrequired);
8693        // Datatype should be unchanged
8694        assert_eq!(node.datatype, "string");
8695    }
8696
8697    #[test]
8698    fn test_update_node_not_found() {
8699        let mut graph = create_skeleton_graph("Test", "test", false, None);
8700        let options = MutatorOptions::default();
8701
8702        let result = apply_mutation(
8703            &mut graph,
8704            GraphMutation::UpdateNode(UpdateNodeParams {
8705                node_id: "nonexistent".to_string(),
8706                name: Some("New Name".to_string()),
8707                ontology_class: None,
8708                parent_property: None,
8709                description: None,
8710                config: None,
8711                options: UpdateNodeOptions::default(),
8712            }),
8713            &options,
8714        );
8715
8716        assert!(matches!(result, Err(MutationError::NodeNotFound(_))));
8717    }
8718
8719    #[test]
8720    fn test_change_node_type() {
8721        let mut graph = create_skeleton_graph("Test", "test", false, None);
8722        // Disable autocreate_widget so we can change type
8723        let options = MutatorOptions {
8724            autocreate_card: true,
8725            autocreate_widget: false,
8726            ontology_validator: None,
8727            skip_publication: false,
8728        };
8729
8730        // Add a semantic node (no widget)
8731        apply_mutation(
8732            &mut graph,
8733            GraphMutation::AddNode(AddNodeParams {
8734                parent_alias: Some("test".to_string()),
8735                alias: "field1".to_string(),
8736                name: "Field 1".to_string(),
8737                cardinality: Cardinality::N,
8738                datatype: "semantic".to_string(),
8739                ontology_class: None,
8740                parent_property: String::new(),
8741                description: None,
8742                config: None,
8743                options: NodeOptions::default(),
8744            }),
8745            &options,
8746        )
8747        .unwrap();
8748
8749        // Change to string type
8750        apply_mutation(
8751            &mut graph,
8752            GraphMutation::ChangeNodeType(ChangeNodeTypeParams {
8753                node_id: "field1".to_string(),
8754                datatype: "string".to_string(),
8755                name: Some("Field 1 String".to_string()),
8756                ontology_class: None,
8757                parent_property: None,
8758                description: None,
8759                config: None,
8760                options: UpdateNodeOptions::default(),
8761            }),
8762            &options,
8763        )
8764        .unwrap();
8765
8766        // Verify type changed
8767        let node = graph.find_node_by_alias("field1").unwrap();
8768        assert_eq!(node.datatype, "string");
8769        assert_eq!(node.name, "Field 1 String");
8770    }
8771
8772    #[test]
8773    fn test_change_node_type_with_widgets_error() {
8774        let mut graph = create_skeleton_graph("Test", "test", false, None);
8775        let options = MutatorOptions::default(); // Creates widgets
8776
8777        // Add a node (creates widget automatically)
8778        apply_mutation(
8779            &mut graph,
8780            GraphMutation::AddNode(AddNodeParams {
8781                parent_alias: Some("test".to_string()),
8782                alias: "field1".to_string(),
8783                name: "Field 1".to_string(),
8784                cardinality: Cardinality::N,
8785                datatype: "string".to_string(),
8786                ontology_class: None,
8787                parent_property: String::new(),
8788                description: None,
8789                config: None,
8790                options: NodeOptions::default(),
8791            }),
8792            &options,
8793        )
8794        .unwrap();
8795
8796        // Verify widget exists
8797        let node = graph.find_node_by_alias("field1").unwrap();
8798        let has_widget = graph
8799            .cards_x_nodes_x_widgets
8800            .as_ref()
8801            .map(|cxnxws| cxnxws.iter().any(|c| c.node_id == node.nodeid))
8802            .unwrap_or(false);
8803        assert!(has_widget, "Widget should exist");
8804
8805        // Try to change type - should fail
8806        let result = apply_mutation(
8807            &mut graph,
8808            GraphMutation::ChangeNodeType(ChangeNodeTypeParams {
8809                node_id: "field1".to_string(),
8810                datatype: "number".to_string(),
8811                name: None,
8812                ontology_class: None,
8813                parent_property: None,
8814                description: None,
8815                config: None,
8816                options: UpdateNodeOptions::default(),
8817            }),
8818            &options,
8819        );
8820
8821        assert!(matches!(
8822            result,
8823            Err(MutationError::NodeHasDependentWidgets(_))
8824        ));
8825    }
8826
8827    #[test]
8828    fn test_rename_node() {
8829        let mut graph = create_skeleton_graph("Test", "test", false, None);
8830        let options = MutatorOptions::default();
8831
8832        // Add a node
8833        apply_mutation(
8834            &mut graph,
8835            GraphMutation::AddNode(AddNodeParams {
8836                parent_alias: Some("test".to_string()),
8837                alias: "old_alias".to_string(),
8838                name: "Old Name".to_string(),
8839                cardinality: Cardinality::N,
8840                datatype: "string".to_string(),
8841                ontology_class: None,
8842                parent_property: String::new(),
8843                description: None,
8844                config: None,
8845                options: NodeOptions::default(),
8846            }),
8847            &options,
8848        )
8849        .unwrap();
8850
8851        // Rename the node
8852        apply_mutation(
8853            &mut graph,
8854            GraphMutation::RenameNode(RenameNodeParams {
8855                node_id: "old_alias".to_string(),
8856                alias: Some("new_alias".to_string()),
8857                name: Some("New Name".to_string()),
8858                description: Some("New description".to_string()),
8859                realign_card: true,
8860            }),
8861            &options,
8862        )
8863        .unwrap();
8864
8865        // Old alias should not find it
8866        assert!(graph.find_node_by_alias("old_alias").is_none());
8867
8868        // New alias should find it
8869        let node = graph.find_node_by_alias("new_alias").unwrap();
8870        assert_eq!(node.name, "New Name");
8871        assert!(node.description.is_some());
8872    }
8873
8874    #[test]
8875    fn test_rename_node_alias_conflict() {
8876        let mut graph = create_skeleton_graph("Test", "test", false, None);
8877        let options = MutatorOptions::default();
8878
8879        // Add two nodes
8880        apply_mutation(
8881            &mut graph,
8882            GraphMutation::AddNode(AddNodeParams {
8883                parent_alias: Some("test".to_string()),
8884                alias: "field1".to_string(),
8885                name: "Field 1".to_string(),
8886                cardinality: Cardinality::N,
8887                datatype: "string".to_string(),
8888                ontology_class: None,
8889                parent_property: String::new(),
8890                description: None,
8891                config: None,
8892                options: NodeOptions::default(),
8893            }),
8894            &options,
8895        )
8896        .unwrap();
8897
8898        apply_mutation(
8899            &mut graph,
8900            GraphMutation::AddNode(AddNodeParams {
8901                parent_alias: Some("test".to_string()),
8902                alias: "field2".to_string(),
8903                name: "Field 2".to_string(),
8904                cardinality: Cardinality::N,
8905                datatype: "string".to_string(),
8906                ontology_class: None,
8907                parent_property: String::new(),
8908                description: None,
8909                config: None,
8910                options: NodeOptions::default(),
8911            }),
8912            &options,
8913        )
8914        .unwrap();
8915
8916        // Try to rename field1 to field2 - should fail
8917        let result = apply_mutation(
8918            &mut graph,
8919            GraphMutation::RenameNode(RenameNodeParams {
8920                node_id: "field1".to_string(),
8921                alias: Some("field2".to_string()),
8922                name: None,
8923                description: None,
8924                realign_card: true,
8925            }),
8926            &options,
8927        );
8928
8929        assert!(matches!(result, Err(MutationError::AliasAlreadyExists(_))));
8930    }
8931
8932    #[test]
8933    fn test_update_node_via_instruction() {
8934        let mut graph = create_skeleton_graph("Test", "test", false, None);
8935        let options = MutatorOptions::default();
8936
8937        // Add a node
8938        apply_mutation(
8939            &mut graph,
8940            GraphMutation::AddNode(AddNodeParams {
8941                parent_alias: Some("test".to_string()),
8942                alias: "my_field".to_string(),
8943                name: "My Field".to_string(),
8944                cardinality: Cardinality::N,
8945                datatype: "string".to_string(),
8946                ontology_class: None,
8947                parent_property: String::new(),
8948                description: None,
8949                config: None,
8950                options: NodeOptions::default(),
8951            }),
8952            &options,
8953        )
8954        .unwrap();
8955
8956        // Update via instruction
8957        let instruction = GraphInstruction::new("update_node", "my_field", "")
8958            .with_str("name", "Updated Field Name")
8959            .with_param("isrequired", serde_json::Value::Bool(true));
8960        let mutation = instruction.to_mutation().unwrap();
8961
8962        apply_mutation(&mut graph, mutation, &options).unwrap();
8963
8964        // Verify update
8965        let node = graph.find_node_by_alias("my_field").unwrap();
8966        assert_eq!(node.name, "Updated Field Name");
8967        assert!(node.isrequired);
8968    }
8969
8970    #[test]
8971    fn test_rename_node_via_instruction() {
8972        let mut graph = create_skeleton_graph("Test", "test", false, None);
8973        let options = MutatorOptions::default();
8974
8975        // Add a node
8976        apply_mutation(
8977            &mut graph,
8978            GraphMutation::AddNode(AddNodeParams {
8979                parent_alias: Some("test".to_string()),
8980                alias: "old_name".to_string(),
8981                name: "Old Name".to_string(),
8982                cardinality: Cardinality::N,
8983                datatype: "string".to_string(),
8984                ontology_class: None,
8985                parent_property: String::new(),
8986                description: None,
8987                config: None,
8988                options: NodeOptions::default(),
8989            }),
8990            &options,
8991        )
8992        .unwrap();
8993
8994        // Rename via instruction (object becomes new alias)
8995        let instruction = GraphInstruction::new("rename_node", "old_name", "new_name")
8996            .with_str("name", "New Display Name");
8997        let mutation = instruction.to_mutation().unwrap();
8998
8999        apply_mutation(&mut graph, mutation, &options).unwrap();
9000
9001        // Old alias gone, new alias works
9002        assert!(graph.find_node_by_alias("old_name").is_none());
9003        let node = graph.find_node_by_alias("new_name").unwrap();
9004        assert_eq!(node.name, "New Display Name");
9005    }
9006
9007    // =========================================================================
9008    // Extension Mutation Tests
9009    // =========================================================================
9010
9011    /// Test extension handler that adds a prefix to the root node's name
9012    struct TestPrefixHandler {
9013        prefix: String,
9014    }
9015
9016    impl ExtensionMutationHandler for TestPrefixHandler {
9017        fn apply(
9018            &self,
9019            graph: &mut StaticGraph,
9020            params: &serde_json::Value,
9021            _options: &MutatorOptions,
9022        ) -> Result<(), MutationError> {
9023            let suffix = params.get("suffix").and_then(|v| v.as_str()).unwrap_or("");
9024
9025            // Find the root node and modify it
9026            let root_id = graph.get_root().nodeid.clone();
9027            let root_name = graph.get_root().name.clone();
9028            if let Some(node) = graph.nodes.iter_mut().find(|n| n.nodeid == root_id) {
9029                node.name = format!("{}{}{}", self.prefix, root_name, suffix);
9030            }
9031            Ok(())
9032        }
9033
9034        fn conformance(&self) -> MutationConformance {
9035            MutationConformance::AlwaysConformant
9036        }
9037
9038        fn description(&self) -> &str {
9039            "Test handler that adds prefix/suffix to root name"
9040        }
9041    }
9042
9043    #[test]
9044    fn test_extension_mutation_with_registry() {
9045        let graph = create_test_graph();
9046        let options = MutatorOptions::default();
9047
9048        // Create registry with test handler
9049        let mut registry = ExtensionMutationRegistry::new();
9050        registry.register(
9051            "test.prefix_name",
9052            std::sync::Arc::new(TestPrefixHandler {
9053                prefix: "[PREFIX] ".to_string(),
9054            }),
9055        );
9056
9057        // Create extension mutation
9058        let mutation = GraphMutation::Extension(ExtensionMutationParams {
9059            name: "test.prefix_name".to_string(),
9060            params: serde_json::json!({"suffix": " [SUFFIX]"}),
9061            conformance: MutationConformance::AlwaysConformant,
9062        });
9063
9064        // Apply with registry
9065        let result =
9066            apply_mutations_with_extensions(&graph, vec![mutation], options, Some(&registry));
9067
9068        assert!(result.is_ok());
9069        let mutated = result.unwrap();
9070        // Check via nodes array since root field is a cached copy
9071        let root_node = mutated.nodes.iter().find(|n| n.istopnode).unwrap();
9072        assert_eq!(root_node.name, "[PREFIX] Root [SUFFIX]");
9073    }
9074
9075    #[test]
9076    fn test_extension_mutation_without_registry() {
9077        let graph = create_test_graph();
9078        let options = MutatorOptions::default();
9079
9080        // Create extension mutation
9081        let mutation = GraphMutation::Extension(ExtensionMutationParams {
9082            name: "test.some_mutation".to_string(),
9083            params: serde_json::json!({}),
9084            conformance: MutationConformance::AlwaysConformant,
9085        });
9086
9087        // Apply without registry
9088        let result = apply_mutations(&graph, vec![mutation], options);
9089
9090        assert!(result.is_err());
9091        assert!(result.unwrap_err().contains("no registry provided"));
9092    }
9093
9094    #[test]
9095    fn test_extension_mutation_not_found() {
9096        let graph = create_test_graph();
9097        let options = MutatorOptions::default();
9098
9099        // Create empty registry
9100        let registry = ExtensionMutationRegistry::new();
9101
9102        // Create extension mutation for non-existent handler
9103        let mutation = GraphMutation::Extension(ExtensionMutationParams {
9104            name: "test.nonexistent".to_string(),
9105            params: serde_json::json!({}),
9106            conformance: MutationConformance::AlwaysConformant,
9107        });
9108
9109        // Apply with empty registry
9110        let result =
9111            apply_mutations_with_extensions(&graph, vec![mutation], options, Some(&registry));
9112
9113        assert!(result.is_err());
9114        assert!(result.unwrap_err().contains("not found"));
9115    }
9116
9117    #[test]
9118    fn test_extension_registry_operations() {
9119        let mut registry = ExtensionMutationRegistry::new();
9120
9121        assert!(!registry.has("test.handler"));
9122        assert!(registry.list().is_empty());
9123
9124        registry.register(
9125            "test.handler",
9126            std::sync::Arc::new(TestPrefixHandler {
9127                prefix: "x".to_string(),
9128            }),
9129        );
9130
9131        assert!(registry.has("test.handler"));
9132        assert!(!registry.has("test.other"));
9133        assert_eq!(registry.list().len(), 1);
9134        assert!(registry.get("test.handler").is_some());
9135    }
9136
9137    #[test]
9138    fn test_extension_mutation_conformance() {
9139        // Test that conformance is read from params
9140        let mutation = GraphMutation::Extension(ExtensionMutationParams {
9141            name: "test.mutation".to_string(),
9142            params: serde_json::json!({}),
9143            conformance: MutationConformance::BranchConformant,
9144        });
9145
9146        assert_eq!(
9147            mutation.conformance(),
9148            MutationConformance::BranchConformant
9149        );
9150
9151        let mutation2 = GraphMutation::Extension(ExtensionMutationParams {
9152            name: "test.mutation".to_string(),
9153            params: serde_json::json!({}),
9154            conformance: MutationConformance::ModelConformant,
9155        });
9156
9157        assert_eq!(
9158            mutation2.conformance(),
9159            MutationConformance::ModelConformant
9160        );
9161    }
9162
9163    #[test]
9164    fn test_extension_mutation_serialization() {
9165        let mutation = GraphMutation::Extension(ExtensionMutationParams {
9166            name: "clm.reference_change_collection".to_string(),
9167            params: serde_json::json!({
9168                "node_id": "my_node",
9169                "collection_id": "new-collection"
9170            }),
9171            conformance: MutationConformance::AlwaysConformant,
9172        });
9173
9174        // Serialize
9175        let json = serde_json::to_string(&mutation).unwrap();
9176        assert!(json.contains("clm.reference_change_collection"));
9177        assert!(json.contains("my_node"));
9178
9179        // Deserialize
9180        let parsed: GraphMutation = serde_json::from_str(&json).unwrap();
9181        if let GraphMutation::Extension(params) = parsed {
9182            assert_eq!(params.name, "clm.reference_change_collection");
9183            assert_eq!(params.params["node_id"], "my_node");
9184        } else {
9185            panic!("Expected Extension mutation");
9186        }
9187    }
9188
9189    #[test]
9190    fn test_extension_mutation_from_json() {
9191        let graph = create_test_graph();
9192
9193        let mut registry = ExtensionMutationRegistry::new();
9194        registry.register(
9195            "test.prefix_name",
9196            std::sync::Arc::new(TestPrefixHandler {
9197                prefix: "[TEST] ".to_string(),
9198            }),
9199        );
9200
9201        let mutations_json = r#"{
9202            "mutations": [{
9203                "Extension": {
9204                    "name": "test.prefix_name",
9205                    "params": {"suffix": "!"},
9206                    "conformance": "AlwaysConformant"
9207                }
9208            }],
9209            "options": {}
9210        }"#;
9211
9212        let result =
9213            apply_mutations_from_json_with_extensions(&graph, mutations_json, Some(&registry));
9214
9215        assert!(result.is_ok());
9216        let mutated = result.unwrap();
9217        // Check via nodes array since root field is a cached copy
9218        let root_node = mutated.nodes.iter().find(|n| n.istopnode).unwrap();
9219        assert_eq!(root_node.name, "[TEST] Root!");
9220    }
9221
9222    // =========================================================================
9223    // RenameGraph Tests
9224    // =========================================================================
9225
9226    #[test]
9227    fn test_rename_graph() {
9228        let mut graph = create_skeleton_graph("Test Graph", "test", false, None);
9229        let options = MutatorOptions::default();
9230
9231        // Verify initial state
9232        assert_eq!(graph.name.get("en"), "Test Graph");
9233        assert!(graph.description.is_none());
9234        assert!(graph.subtitle.is_none());
9235        assert!(graph.author.is_none());
9236
9237        // Rename the graph with all fields
9238        let mut name_map = HashMap::new();
9239        name_map.insert("en".to_string(), "New Name".to_string());
9240        name_map.insert("es".to_string(), "Nuevo Nombre".to_string());
9241
9242        let mut desc_map = HashMap::new();
9243        desc_map.insert("en".to_string(), "A description".to_string());
9244
9245        let mut subtitle_map = HashMap::new();
9246        subtitle_map.insert("en".to_string(), "A subtitle".to_string());
9247
9248        apply_mutation(
9249            &mut graph,
9250            GraphMutation::RenameGraph(RenameGraphParams {
9251                name: Some(name_map),
9252                description: Some(desc_map),
9253                subtitle: Some(subtitle_map),
9254                author: Some("Test Author".to_string()),
9255            }),
9256            &options,
9257        )
9258        .unwrap();
9259
9260        // Verify all fields were updated
9261        assert_eq!(graph.name.get("en"), "New Name");
9262        assert_eq!(graph.name.translations.get("es").unwrap(), "Nuevo Nombre");
9263        assert!(graph.description.is_some());
9264        assert_eq!(
9265            graph.description.as_ref().unwrap().get("en"),
9266            "A description"
9267        );
9268        assert!(graph.subtitle.is_some());
9269        assert_eq!(graph.subtitle.as_ref().unwrap().get("en"), "A subtitle");
9270        assert_eq!(graph.author, Some("Test Author".to_string()));
9271
9272        // Verify root node name matches graph name
9273        assert_eq!(graph.root.name, "New Name");
9274        let root_in_nodes = graph.nodes.iter().find(|n| n.istopnode).unwrap();
9275        assert_eq!(root_in_nodes.name, "New Name");
9276
9277        // Verify slug and alias are updated
9278        assert_eq!(graph.slug, Some("new_name".to_string()));
9279        assert_eq!(graph.root.alias, Some("new_name".to_string()));
9280        assert_eq!(root_in_nodes.alias, Some("new_name".to_string()));
9281    }
9282
9283    #[test]
9284    fn test_rename_graph_partial() {
9285        let mut graph = create_skeleton_graph("Original Name", "test", false, None);
9286        let options = MutatorOptions::default();
9287
9288        // Only update description, leave name unchanged
9289        let mut desc_map = HashMap::new();
9290        desc_map.insert("en".to_string(), "New description".to_string());
9291
9292        apply_mutation(
9293            &mut graph,
9294            GraphMutation::RenameGraph(RenameGraphParams {
9295                name: None,
9296                description: Some(desc_map),
9297                subtitle: None,
9298                author: None,
9299            }),
9300            &options,
9301        )
9302        .unwrap();
9303
9304        // Name should be unchanged
9305        assert_eq!(graph.name.get("en"), "Original Name");
9306        // Description should be updated
9307        assert!(graph.description.is_some());
9308        assert_eq!(
9309            graph.description.as_ref().unwrap().get("en"),
9310            "New description"
9311        );
9312    }
9313
9314    #[test]
9315    fn test_rename_graph_via_instruction() {
9316        let mut graph = create_skeleton_graph("Test Graph", "test", false, None);
9317        let options = MutatorOptions::default();
9318
9319        // Use instruction pattern: action=rename_graph, subject=graphid, object=new name (simple string)
9320        let instruction = GraphInstruction::new("rename_graph", "test", "New Graph Name")
9321            .with_str("author", "Instruction Author");
9322
9323        // Verify conformance
9324        assert_eq!(
9325            instruction.conformance(),
9326            MutationConformance::AlwaysConformant
9327        );
9328
9329        let mutation = instruction.to_mutation().unwrap();
9330        apply_mutation(&mut graph, mutation, &options).unwrap();
9331
9332        // Name should be updated (object becomes English name)
9333        assert_eq!(graph.name.get("en"), "New Graph Name");
9334        assert_eq!(graph.author, Some("Instruction Author".to_string()));
9335    }
9336
9337    #[test]
9338    fn test_rename_graph_via_instruction_multilingual() {
9339        let mut graph = create_skeleton_graph("Test Graph", "test", false, None);
9340        let options = MutatorOptions::default();
9341
9342        // Use instruction with translatable map in params
9343        let mut name_obj = serde_json::Map::new();
9344        name_obj.insert(
9345            "en".to_string(),
9346            serde_json::Value::String("English Name".to_string()),
9347        );
9348        name_obj.insert(
9349            "de".to_string(),
9350            serde_json::Value::String("Deutscher Name".to_string()),
9351        );
9352
9353        let mut desc_obj = serde_json::Map::new();
9354        desc_obj.insert(
9355            "en".to_string(),
9356            serde_json::Value::String("English description".to_string()),
9357        );
9358
9359        let instruction = GraphInstruction::new("rename_graph", "test", "")
9360            .with_param("name", serde_json::Value::Object(name_obj))
9361            .with_param("description", serde_json::Value::Object(desc_obj));
9362
9363        let mutation = instruction.to_mutation().unwrap();
9364        apply_mutation(&mut graph, mutation, &options).unwrap();
9365
9366        // Verify multilingual name
9367        assert_eq!(graph.name.get("en"), "English Name");
9368        assert_eq!(graph.name.translations.get("de").unwrap(), "Deutscher Name");
9369        // Verify description
9370        assert!(graph.description.is_some());
9371        assert_eq!(
9372            graph.description.as_ref().unwrap().get("en"),
9373            "English description"
9374        );
9375    }
9376}