Skip to main content

sz_configtool_lib/
fragments.rs

1//! Fragment (CFG_ERFRAG) operations
2//!
3//! Functions for managing entity resolution fragments in the configuration.
4//! Fragments define matching criteria used by rules.
5
6use crate::error::{Result, SzConfigError};
7use crate::helpers;
8use serde_json::{Value, json};
9
10/// Validates a fragment source XPath expression and computes dependencies
11///
12/// Parses the source string to find all ./FRAGMENT[...] references,
13/// validates that referenced fragments exist, and returns their IDs.
14///
15/// # Arguments
16///
17/// * `config_json` - Configuration JSON string
18/// * `source_string` - Fragment source XPath expression
19///
20/// # Returns
21///
22/// Returns `(dependency_ids, error_message)` tuple
23/// - dependency_ids: Vec of fragment IDs as strings (empty if no dependencies)
24/// - error_message: Empty string on success, error description on failure
25///
26/// # Example
27///
28/// ```
29/// use sz_configtool_lib::fragments;
30/// let source = "./FRAGMENT[./SAME_NAME>0 and ./SAME_STAB>0]";
31/// let config = r#"{"G2_CONFIG": {"CFG_ERFRAG": []}}"#;
32/// // Note: validate_fragment_source is private, used internally by add_fragment
33/// ```
34fn validate_fragment_source(config_json: &str, source_string: &str) -> (Vec<String>, String) {
35    // Validate JSON parses correctly
36    if let Err(e) = serde_json::from_str::<Value>(config_json) {
37        return (vec![], format!("Invalid JSON: {e}"));
38    }
39
40    let mut dependency_list = Vec::new();
41    let mut source = source_string.to_string();
42
43    // Find all FRAGMENT[...] patterns
44    while let Some(start_pos) = source.find("FRAGMENT[") {
45        // Find the matching closing bracket
46        let fragment_start = start_pos;
47        if let Some(bracket_pos) = source[fragment_start..].find(']') {
48            let fragment_string = &source[fragment_start..fragment_start + bracket_pos + 1];
49
50            // Parse fragment references within FRAGMENT[...]
51            let mut current_frag = String::new();
52            let mut in_fragment = false;
53
54            for ch in fragment_string.chars() {
55                if ch == '/' {
56                    // Start or continue parsing fragment name
57                    if in_fragment && !current_frag.is_empty() {
58                        // End of previous fragment, lookup and validate
59                        match helpers::find_in_config_array(
60                            config_json,
61                            "CFG_ERFRAG",
62                            "ERFRAG_CODE",
63                            &current_frag,
64                        ) {
65                            Ok(Some(frag_record)) => {
66                                if let Some(frag_id) =
67                                    frag_record.get("ERFRAG_ID").and_then(|v| v.as_i64())
68                                {
69                                    dependency_list.push(frag_id.to_string());
70                                }
71                            }
72                            Ok(None) => {
73                                return (
74                                    vec![],
75                                    format!("Invalid fragment reference: {current_frag}"),
76                                );
77                            }
78                            Err(_) => {
79                                return (
80                                    vec![],
81                                    format!("Invalid fragment reference: {current_frag}"),
82                                );
83                            }
84                        }
85                    }
86                    current_frag.clear();
87                    in_fragment = true;
88                } else if in_fragment {
89                    // Check for delimiters that end fragment name
90                    if "|=><)] ".contains(ch) {
91                        if !current_frag.is_empty() {
92                            // Lookup fragment
93                            match helpers::find_in_config_array(
94                                config_json,
95                                "CFG_ERFRAG",
96                                "ERFRAG_CODE",
97                                &current_frag,
98                            ) {
99                                Ok(Some(frag_record)) => {
100                                    if let Some(frag_id) =
101                                        frag_record.get("ERFRAG_ID").and_then(|v| v.as_i64())
102                                    {
103                                        dependency_list.push(frag_id.to_string());
104                                    }
105                                }
106                                Ok(None) => {
107                                    return (
108                                        vec![],
109                                        format!("Invalid fragment reference: {current_frag}"),
110                                    );
111                                }
112                                Err(_) => {
113                                    return (
114                                        vec![],
115                                        format!("Invalid fragment reference: {current_frag}"),
116                                    );
117                                }
118                            }
119                            current_frag.clear();
120                        }
121                        in_fragment = false;
122                    } else {
123                        current_frag.push(ch);
124                    }
125                }
126            }
127
128            // Remove this FRAGMENT[...] from source to find next one
129            source = source.replace(fragment_string, "");
130        } else {
131            break;
132        }
133    }
134
135    // Remove duplicates and return
136    dependency_list.sort();
137    dependency_list.dedup();
138    (dependency_list, String::new())
139}
140
141/// Add a new fragment to the configuration
142///
143/// # Arguments
144///
145/// * `config_json` - Configuration JSON string
146/// * `fragment_config` - JSON configuration for the fragment (must include ERFRAG_CODE)
147///
148/// # Returns
149///
150/// Returns `(modified_config, new_fragment_id)` tuple on success
151///
152/// # Example
153///
154/// ```
155/// use sz_configtool_lib::fragments;
156/// use serde_json::json;
157///
158/// let config = r#"{"G2_CONFIG": {"CFG_ERFRAG": []}}"#;
159/// let frag_config = json!({
160///     "ERFRAG_CODE": "CUSTOM_FRAG",
161///     "ERFRAG_SOURCE": "NAME+ADDRESS"
162/// });
163/// let (modified, frag_id) = fragments::add_fragment(config, &frag_config).unwrap();
164/// assert_eq!(frag_id, 1);
165/// ```
166pub fn add_fragment(config_json: &str, fragment_config: &Value) -> Result<(String, i64)> {
167    let code = fragment_config
168        .get("ERFRAG_CODE")
169        .and_then(|v| v.as_str())
170        .ok_or_else(|| SzConfigError::MissingField("ERFRAG_CODE".to_string()))?;
171
172    let source = fragment_config
173        .get("ERFRAG_SOURCE")
174        .and_then(|v| v.as_str())
175        .ok_or_else(|| SzConfigError::MissingField("ERFRAG_SOURCE".to_string()))?;
176
177    // Check if fragment already exists (Python line 4520-4522)
178    let code_upper = code.to_uppercase();
179    if helpers::find_in_config_array(config_json, "CFG_ERFRAG", "ERFRAG_CODE", &code_upper)?
180        .is_some()
181    {
182        return Err(SzConfigError::AlreadyExists(
183            "Fragment already exists".to_string(),
184        ));
185    }
186
187    // Validate source and compute dependencies
188    let (dependency_list, error_message) = validate_fragment_source(config_json, source);
189    if !error_message.is_empty() {
190        return Err(SzConfigError::InvalidInput(error_message));
191    }
192
193    let config_data: Value = serde_json::from_str(config_json)?;
194
195    // Get next ID
196    let next_id = if let Some(g2_config) = config_data.get("G2_CONFIG") {
197        if let Some(array) = g2_config.get("CFG_ERFRAG").and_then(|v| v.as_array()) {
198            array
199                .iter()
200                .filter_map(|item| item.get("ERFRAG_ID").and_then(|v| v.as_i64()))
201                .max()
202                .unwrap_or(0)
203                + 1
204        } else {
205            1
206        }
207    } else {
208        return Err(SzConfigError::InvalidConfig(
209            "G2_CONFIG not found".to_string(),
210        ));
211    };
212
213    // Create new item with provided config plus ID and computed dependencies
214    let mut new_item = fragment_config.clone();
215    if let Some(obj) = new_item.as_object_mut() {
216        obj.insert("ERFRAG_ID".to_string(), json!(next_id));
217        obj.insert("ERFRAG_CODE".to_string(), json!(code.to_uppercase()));
218        obj.insert("ERFRAG_DESC".to_string(), json!(code.to_uppercase()));
219
220        // Set ERFRAG_DEPENDS to comma-separated list or null
221        if dependency_list.is_empty() {
222            obj.insert("ERFRAG_DEPENDS".to_string(), Value::Null);
223        } else {
224            obj.insert(
225                "ERFRAG_DEPENDS".to_string(),
226                json!(dependency_list.join(",")),
227            );
228        }
229    }
230
231    // Add to config
232    let modified_json = helpers::add_to_config_array(config_json, "CFG_ERFRAG", new_item)?;
233
234    Ok((modified_json, next_id))
235}
236
237/// Delete a fragment from the configuration
238///
239/// # Arguments
240///
241/// * `config_json` - Configuration JSON string
242/// * `fragment_code` - Fragment code to delete
243///
244/// # Returns
245///
246/// Returns modified configuration JSON on success
247///
248/// # Example
249///
250/// ```
251/// use sz_configtool_lib::fragments;
252///
253/// let config = r#"{"G2_CONFIG": {"CFG_ERFRAG": [{"ERFRAG_ID": 1, "ERFRAG_CODE": "TEST"}]}}"#;
254/// let modified = fragments::delete_fragment(config, "TEST").unwrap();
255/// ```
256pub fn delete_fragment(config_json: &str, fragment_code: &str) -> Result<String> {
257    let frag_code = fragment_code.to_uppercase();
258
259    // Verify fragment exists before deletion
260    let _ = helpers::find_in_config_array(config_json, "CFG_ERFRAG", "ERFRAG_CODE", &frag_code)?
261        .ok_or_else(|| SzConfigError::NotFound(format!("Fragment not found: {frag_code}")))?;
262
263    // Remove from config
264    helpers::remove_from_config_array(config_json, "CFG_ERFRAG", "ERFRAG_CODE", &frag_code)
265}
266
267/// Get a fragment by code or ID
268///
269/// # Arguments
270///
271/// * `config_json` - Configuration JSON string
272/// * `code_or_id` - Fragment code or ID to search for
273///
274/// # Returns
275///
276/// Returns the fragment JSON object on success
277///
278/// # Example
279///
280/// ```
281/// use sz_configtool_lib::fragments;
282///
283/// let config = r#"{"G2_CONFIG": {"CFG_ERFRAG": [{"ERFRAG_ID": 1, "ERFRAG_CODE": "TEST"}]}}"#;
284/// let fragment = fragments::get_fragment(config, "TEST").unwrap();
285/// ```
286pub fn get_fragment(config_json: &str, code_or_id: &str) -> Result<Value> {
287    let search_value = code_or_id.to_uppercase();
288
289    // Try to find by CODE first, then by ID
290    let item = if let Some(item) =
291        helpers::find_in_config_array(config_json, "CFG_ERFRAG", "ERFRAG_CODE", &search_value)?
292    {
293        item
294    } else if let Some(item) =
295        helpers::find_in_config_array(config_json, "CFG_ERFRAG", "ERFRAG_ID", &search_value)?
296    {
297        item
298    } else {
299        return Err(SzConfigError::NotFound(format!(
300            "Fragment not found: {search_value}"
301        )));
302    };
303
304    // Transform to lowercase format (matching list_fragments for consistency)
305    Ok(json!({
306        "id": item.get("ERFRAG_ID").and_then(|v| v.as_i64()).unwrap_or(0),
307        "fragment": item.get("ERFRAG_CODE").and_then(|v| v.as_str()).unwrap_or(""),
308        "source": item.get("ERFRAG_SOURCE").and_then(|v| v.as_str()).unwrap_or(""),
309        "depends": item.get("ERFRAG_DEPENDS").and_then(|v| v.as_str()).unwrap_or("")
310    }))
311}
312
313/// List all fragments in the configuration
314///
315/// # Arguments
316///
317/// * `config_json` - Configuration JSON string
318///
319/// # Returns
320///
321/// Returns a vector of fragment objects in Python sz_configtool format
322///
323/// # Example
324///
325/// ```
326/// use sz_configtool_lib::fragments;
327///
328/// let config = r#"{"G2_CONFIG": {"CFG_ERFRAG": [{"ERFRAG_ID": 1, "ERFRAG_CODE": "TEST", "ERFRAG_SOURCE": "NAME", "ERFRAG_DEPENDS": ""}]}}"#;
329/// let fragments = fragments::list_fragments(config).unwrap();
330/// assert_eq!(fragments.len(), 1);
331/// ```
332pub fn list_fragments(config_json: &str) -> Result<Vec<Value>> {
333    let config_data: Value = serde_json::from_str(config_json)?;
334
335    // Extract fragments and transform to Python format
336    let items: Vec<Value> = if let Some(g2_config) = config_data.get("G2_CONFIG") {
337        if let Some(array) = g2_config.get("CFG_ERFRAG").and_then(|v| v.as_array()) {
338            array
339                .iter()
340                .map(|item| {
341                    json!({
342                        "id": item.get("ERFRAG_ID").and_then(|v| v.as_i64()).unwrap_or(0),
343                        "fragment": item.get("ERFRAG_CODE").and_then(|v| v.as_str()).unwrap_or(""),
344                        "source": item.get("ERFRAG_SOURCE").and_then(|v| v.as_str()).unwrap_or(""),
345                        "depends": item.get("ERFRAG_DEPENDS").and_then(|v| v.as_str()).unwrap_or("")
346                    })
347                })
348                .collect()
349        } else {
350            Vec::new()
351        }
352    } else {
353        Vec::new()
354    };
355
356    Ok(items)
357}
358
359/// Update an existing fragment in the configuration
360///
361/// # Arguments
362///
363/// * `config_json` - Configuration JSON string
364/// * `fragment_code` - Fragment code to update
365/// * `fragment_config` - New configuration for the fragment
366///
367/// # Returns
368///
369/// Returns modified configuration JSON on success
370///
371/// # Example
372///
373/// ```
374/// use sz_configtool_lib::fragments;
375/// use serde_json::json;
376///
377/// let config = r#"{"G2_CONFIG": {"CFG_ERFRAG": [{"ERFRAG_ID": 1, "ERFRAG_CODE": "TEST"}]}}"#;
378/// let new_config = json!({"ERFRAG_SOURCE": "NAME+DOB"});
379/// let modified = fragments::set_fragment(config, "TEST", &new_config).unwrap();
380/// ```
381pub fn set_fragment(
382    config_json: &str,
383    fragment_code: &str,
384    fragment_config: &Value,
385) -> Result<String> {
386    let code = fragment_code.to_uppercase();
387
388    // Validate and compute dependencies if SOURCE is being updated
389    let mut updated_item = fragment_config.clone();
390    if let Some(new_source) = fragment_config
391        .get("ERFRAG_SOURCE")
392        .and_then(|v| v.as_str())
393    {
394        let (dependency_list, error_message) = validate_fragment_source(config_json, new_source);
395        if !error_message.is_empty() {
396            return Err(SzConfigError::InvalidInput(error_message));
397        }
398
399        // Update ERFRAG_DEPENDS in the update
400        if let Some(obj) = updated_item.as_object_mut() {
401            if dependency_list.is_empty() {
402                obj.insert("ERFRAG_DEPENDS".to_string(), Value::Null);
403            } else {
404                obj.insert(
405                    "ERFRAG_DEPENDS".to_string(),
406                    json!(dependency_list.join(",")),
407                );
408            }
409        }
410    }
411
412    // Ensure the code field matches
413    if let Some(obj) = updated_item.as_object_mut() {
414        obj.insert("ERFRAG_CODE".to_string(), json!(code.clone()));
415    }
416
417    // Update the item in the config
418    helpers::update_in_config_array(
419        config_json,
420        "CFG_ERFRAG",
421        "ERFRAG_CODE",
422        &code,
423        updated_item,
424    )
425}