Skip to main content

sz_configtool_lib/
features.rs

1use crate::error::{Result, SzConfigError};
2use crate::helpers;
3use serde_json::{Value, json};
4
5// ============================================================================
6// Parameter Structs
7// ============================================================================
8
9/// Parameters for adding a new feature
10#[derive(Debug, Clone, Default)]
11pub struct AddFeatureParams<'a> {
12    pub feature: &'a str,
13    pub element_list: &'a Value,
14    pub class: Option<&'a str>,
15    pub behavior: Option<&'a str>,
16    pub candidates: Option<&'a str>,
17    pub anonymize: Option<&'a str>,
18    pub derived: Option<&'a str>,
19    pub history: Option<&'a str>,
20    pub matchkey: Option<&'a str>,
21    pub standardize: Option<&'a str>,
22    pub expression: Option<&'a str>,
23    pub comparison: Option<&'a str>,
24    pub version: Option<i64>,
25    pub rtype_id: Option<i64>,
26}
27
28impl<'a> AddFeatureParams<'a> {
29    pub fn new(feature: &'a str, element_list: &'a Value) -> Self {
30        Self {
31            feature,
32            element_list,
33            ..Default::default()
34        }
35    }
36}
37
38impl<'a> TryFrom<&'a Value> for AddFeatureParams<'a> {
39    type Error = SzConfigError;
40
41    fn try_from(json: &'a Value) -> Result<Self> {
42        let feature = json
43            .get("feature")
44            .and_then(|v| v.as_str())
45            .ok_or_else(|| SzConfigError::MissingField("feature".to_string()))?;
46
47        let element_list = json
48            .get("elementList")
49            .ok_or_else(|| SzConfigError::MissingField("elementList".to_string()))?;
50
51        Ok(Self {
52            feature,
53            element_list,
54            class: json.get("class").and_then(|v| v.as_str()),
55            behavior: json.get("behavior").and_then(|v| v.as_str()),
56            candidates: json.get("candidates").and_then(|v| v.as_str()),
57            anonymize: json.get("anonymize").and_then(|v| v.as_str()),
58            derived: json.get("derived").and_then(|v| v.as_str()),
59            history: json.get("history").and_then(|v| v.as_str()),
60            matchkey: json.get("matchKey").and_then(|v| v.as_str()),
61            standardize: json
62                .get("standardize")
63                .and_then(|v| v.as_str())
64                .filter(|s| !s.is_empty()),
65            expression: json
66                .get("expression")
67                .and_then(|v| v.as_str())
68                .filter(|s| !s.is_empty()),
69            comparison: json
70                .get("comparison")
71                .and_then(|v| v.as_str())
72                .filter(|s| !s.is_empty()),
73            version: json.get("version").and_then(|v| v.as_i64()),
74            rtype_id: json.get("rtypeId").and_then(|v| v.as_i64()),
75        })
76    }
77}
78
79/// Parameters for setting/updating a feature
80#[derive(Debug, Clone, Default)]
81pub struct SetFeatureParams<'a> {
82    pub feature: &'a str,
83    pub candidates: Option<&'a str>,
84    pub anonymize: Option<&'a str>,
85    pub derived: Option<&'a str>,
86    pub history: Option<&'a str>,
87    pub matchkey: Option<&'a str>,
88    pub behavior: Option<&'a str>,
89    pub class: Option<&'a str>,
90    pub version: Option<i64>,
91    pub rtype_id: Option<i64>,
92}
93
94impl<'a> SetFeatureParams<'a> {
95    pub fn new(feature: &'a str) -> Self {
96        Self {
97            feature,
98            ..Default::default()
99        }
100    }
101}
102
103impl<'a> TryFrom<&'a Value> for SetFeatureParams<'a> {
104    type Error = SzConfigError;
105
106    fn try_from(json: &'a Value) -> Result<Self> {
107        let feature = json
108            .get("feature")
109            .and_then(|v| v.as_str())
110            .ok_or_else(|| SzConfigError::MissingField("feature".to_string()))?;
111
112        Ok(Self {
113            feature,
114            candidates: json.get("candidates").and_then(|v| v.as_str()),
115            anonymize: json.get("anonymize").and_then(|v| v.as_str()),
116            derived: json.get("derived").and_then(|v| v.as_str()),
117            history: json.get("history").and_then(|v| v.as_str()),
118            matchkey: json.get("matchKey").and_then(|v| v.as_str()),
119            behavior: json.get("behavior").and_then(|v| v.as_str()),
120            class: json.get("class").and_then(|v| v.as_str()),
121            version: json.get("version").and_then(|v| v.as_i64()),
122            rtype_id: json.get("rtypeId").and_then(|v| v.as_i64()),
123        })
124    }
125}
126
127/// Parameters for adding a feature comparison (FBOM)
128#[derive(Debug, Clone, Default)]
129pub struct AddFeatureComparisonParams<'a> {
130    pub feature_code: Option<&'a str>,
131    pub element_code: Option<&'a str>,
132    pub exec_order: Option<i64>,
133    pub display_level: Option<i64>,
134    pub display_delim: Option<&'a str>,
135    pub derived: Option<&'a str>,
136}
137
138impl<'a> AddFeatureComparisonParams<'a> {
139    pub fn new(feature_code: &'a str, element_code: &'a str) -> Self {
140        Self {
141            feature_code: Some(feature_code),
142            element_code: Some(element_code),
143            exec_order: None,
144            display_level: None,
145            display_delim: None,
146            derived: None,
147        }
148    }
149
150    pub fn with_exec_order(mut self, order: i64) -> Self {
151        self.exec_order = Some(order);
152        self
153    }
154
155    pub fn with_display_level(mut self, level: i64) -> Self {
156        self.display_level = Some(level);
157        self
158    }
159
160    pub fn with_display_delim(mut self, delim: &'a str) -> Self {
161        self.display_delim = Some(delim);
162        self
163    }
164
165    pub fn with_derived(mut self, derived: &'a str) -> Self {
166        self.derived = Some(derived);
167        self
168    }
169}
170
171impl<'a> TryFrom<&'a Value> for AddFeatureComparisonParams<'a> {
172    type Error = SzConfigError;
173
174    fn try_from(json: &'a Value) -> Result<Self> {
175        let feature_code = json
176            .get("featureCode")
177            .and_then(|v| v.as_str())
178            .ok_or_else(|| SzConfigError::MissingField("featureCode".to_string()))?;
179
180        let element_code = json
181            .get("elementCode")
182            .and_then(|v| v.as_str())
183            .ok_or_else(|| SzConfigError::MissingField("elementCode".to_string()))?;
184
185        Ok(Self {
186            feature_code: Some(feature_code),
187            element_code: Some(element_code),
188            exec_order: json.get("execOrder").and_then(|v| v.as_i64()),
189            display_level: json.get("displayLevel").and_then(|v| v.as_i64()),
190            display_delim: json.get("displayDelim").and_then(|v| v.as_str()),
191            derived: json.get("derived").and_then(|v| v.as_str()),
192        })
193    }
194}
195
196/// Parameters for getting a feature comparison
197#[derive(Debug, Clone, Default)]
198pub struct GetFeatureComparisonParams<'a> {
199    pub feature_code: Option<&'a str>,
200    pub element_code: Option<&'a str>,
201}
202
203impl<'a> GetFeatureComparisonParams<'a> {
204    pub fn new(feature_code: &'a str, element_code: &'a str) -> Self {
205        Self {
206            feature_code: Some(feature_code),
207            element_code: Some(element_code),
208        }
209    }
210}
211
212impl<'a> TryFrom<&'a Value> for GetFeatureComparisonParams<'a> {
213    type Error = SzConfigError;
214
215    fn try_from(json: &'a Value) -> Result<Self> {
216        let feature_code = json
217            .get("featureCode")
218            .and_then(|v| v.as_str())
219            .ok_or_else(|| SzConfigError::MissingField("featureCode".to_string()))?;
220
221        let element_code = json
222            .get("elementCode")
223            .and_then(|v| v.as_str())
224            .ok_or_else(|| SzConfigError::MissingField("elementCode".to_string()))?;
225
226        Ok(Self {
227            feature_code: Some(feature_code),
228            element_code: Some(element_code),
229        })
230    }
231}
232
233/// Parameters for adding a feature distinct call element (CFG_DFCALL)
234#[derive(Debug, Clone, Default)]
235pub struct AddFeatureDistinctCallElementParams<'a> {
236    pub feature_code: Option<&'a str>,
237    pub distinct_func_code: Option<&'a str>,
238    pub element_code: Option<&'a str>,
239    pub exec_order: Option<i64>,
240}
241
242impl<'a> AddFeatureDistinctCallElementParams<'a> {
243    pub fn new(feature_code: &'a str, distinct_func_code: &'a str) -> Self {
244        Self {
245            feature_code: Some(feature_code),
246            distinct_func_code: Some(distinct_func_code),
247            element_code: None,
248            exec_order: None,
249        }
250    }
251
252    pub fn with_element_code(mut self, element_code: &'a str) -> Self {
253        self.element_code = Some(element_code);
254        self
255    }
256
257    pub fn with_exec_order(mut self, order: i64) -> Self {
258        self.exec_order = Some(order);
259        self
260    }
261}
262
263// Protected features that cannot be deleted
264const LOCKED_FEATURES: &[&str] = &[
265    "NAME",
266    "ADDRESS",
267    "PHONE",
268    "EMAIL",
269    "RECORD_TYPE",
270    "DATE_OF_BIRTH",
271    "NATIONAL_ID",
272    "TAX_ID",
273    "ACCT_NUM",
274    "SSN_NUM",
275    "PASSPORT_NUM",
276    "DRIVERS_LICENSE_NUM",
277];
278
279/// Add a new feature to the configuration
280///
281/// # Arguments
282/// * `config_json` - JSON configuration string
283/// * `params` - Feature parameters (feature, element_list required; others optional)
284///
285/// # Returns
286/// Modified configuration JSON string
287///
288/// # Example
289/// ```no_run
290/// use sz_configtool_lib::features::{add_feature, AddFeatureParams};
291/// use serde_json::json;
292///
293/// let config = r#"{"G2_CONFIG":{"CFG_FTYPE":[],...}}"#;
294/// let elements = json!([{"element": "NAME"}]);
295/// let result = add_feature(config, AddFeatureParams {
296///     feature: "PERSON",
297///     element_list: &elements,
298///     class: Some("IDENTITY"),
299///     behavior: Some("FM"),
300///     ..Default::default()
301/// })?;
302/// # Ok::<(), sz_configtool_lib::error::SzConfigError>(())
303/// ```
304pub fn add_feature(config_json: &str, params: AddFeatureParams) -> Result<String> {
305    let mut config: Value =
306        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
307
308    let feature_upper = params.feature.to_uppercase();
309
310    // Check if feature already exists
311    let ftypes = config
312        .get("G2_CONFIG")
313        .and_then(|g| g.get("CFG_FTYPE"))
314        .and_then(|v| v.as_array())
315        .ok_or_else(|| SzConfigError::MissingSection("CFG_FTYPE".to_string()))?;
316
317    if ftypes
318        .iter()
319        .any(|f| f["FTYPE_CODE"].as_str() == Some(&feature_upper))
320    {
321        return Err(SzConfigError::AlreadyExists(format!(
322            "Feature already exists: {feature_upper}"
323        )));
324    }
325
326    // Validate element_list
327    let elements = params
328        .element_list
329        .as_array()
330        .ok_or_else(|| SzConfigError::InvalidInput("elementList must be an array".to_string()))?;
331
332    if elements.is_empty() {
333        return Err(SzConfigError::InvalidInput(
334            "elementList must contain at least one element".to_string(),
335        ));
336    }
337
338    // Validate and normalize domain values (Python parity lines 1432-1461)
339    let class = params.class.unwrap_or("OTHER");
340    let behavior = params.behavior.unwrap_or("FM");
341
342    // Validate CANDIDATES domain (Python lines 1432-1437)
343    let candidates_val = if let Some(val) = params.candidates {
344        let val_upper = val.to_uppercase();
345        match val_upper.as_str() {
346            "YES" => "Yes",
347            "NO" => "No",
348            _ => {
349                return Err(SzConfigError::InvalidInput(format!(
350                    "Invalid CANDIDATES value '{val}'. Must be 'Yes' or 'No'"
351                )));
352            }
353        }
354    } else {
355        "No"
356    };
357
358    // Validate ANONYMIZE domain (Python lines 1439-1444)
359    let anonymize_val = if let Some(val) = params.anonymize {
360        let val_upper = val.to_uppercase();
361        match val_upper.as_str() {
362            "YES" => "Yes",
363            "NO" => "No",
364            _ => {
365                return Err(SzConfigError::InvalidInput(format!(
366                    "Invalid ANONYMIZE value '{val}'. Must be 'Yes' or 'No'"
367                )));
368            }
369        }
370    } else {
371        "No"
372    };
373
374    // Validate DERIVED domain (Python lines 1446-1449)
375    let derived_val = if let Some(val) = params.derived {
376        let val_upper = val.to_uppercase();
377        match val_upper.as_str() {
378            "YES" => "Yes",
379            "NO" => "No",
380            _ => {
381                return Err(SzConfigError::InvalidInput(format!(
382                    "Invalid DERIVED value '{val}'. Must be 'Yes' or 'No'"
383                )));
384            }
385        }
386    } else {
387        "No"
388    };
389
390    // Validate HISTORY domain (Python lines 1451-1454)
391    let history_val = if let Some(val) = params.history {
392        let val_upper = val.to_uppercase();
393        match val_upper.as_str() {
394            "YES" => "Yes",
395            "NO" => "No",
396            _ => {
397                return Err(SzConfigError::InvalidInput(format!(
398                    "Invalid HISTORY value '{val}'. Must be 'Yes' or 'No'"
399                )));
400            }
401        }
402    } else {
403        "Yes"
404    };
405
406    // Validate MATCHKEY domain (Python lines 1456-1461)
407    let matchkey_default = if params.comparison.is_some() {
408        "Yes"
409    } else {
410        "No"
411    };
412    let matchkey_val = if let Some(val) = params.matchkey {
413        let val_upper = val.to_uppercase();
414        match val_upper.as_str() {
415            "YES" => "Yes",
416            "NO" => "No",
417            "CONFIRM" => "Confirm",
418            "DENIAL" => "Denial",
419            _ => {
420                return Err(SzConfigError::InvalidInput(format!(
421                    "Invalid MATCHKEY value '{val}'. Must be one of: Yes, No, Confirm, Denial"
422                )));
423            }
424        }
425    } else {
426        matchkey_default
427    };
428
429    // Get next FTYPE_ID (seed at 1000 for user-created features)
430    let ftype_id = helpers::get_next_id_with_min(ftypes, "FTYPE_ID", 1000)?;
431
432    // Parse behavior code (like Python's parseFeatureBehavior)
433    // Valid frequency codes: A1, F1, FF, FM, FVM, NONE, NAME
434    // E suffix means EXCLUSIVITY = "Yes"
435    // S suffix means STABILITY = "Yes"
436    let behavior_upper = behavior.to_uppercase();
437    let (frequency, exclusivity, stability) = parse_behavior_code(&behavior_upper)?;
438
439    // Lookup feature class
440    let fclass_array = config["G2_CONFIG"]["CFG_FCLASS"]
441        .as_array()
442        .ok_or_else(|| SzConfigError::MissingSection("CFG_FCLASS".to_string()))?;
443
444    let fclass_id = fclass_array
445        .iter()
446        .find(|c| {
447            c["FCLASS_CODE"]
448                .as_str()
449                .map(|s| s.eq_ignore_ascii_case(class))
450                .unwrap_or(false)
451        })
452        .and_then(|c| c["FCLASS_ID"].as_i64())
453        .ok_or_else(|| SzConfigError::NotFound(format!("Feature class: {class}")))?;
454
455    // Lookup optional functions (validate they exist if provided)
456    let sfunc_id = if let Some(func_code) = params.standardize {
457        helpers::lookup_sfunc_id(config_json, func_code)?
458    } else {
459        0
460    };
461
462    let efunc_id = if let Some(func_code) = params.expression {
463        helpers::lookup_efunc_id(config_json, func_code)?
464    } else {
465        0
466    };
467
468    let cfunc_id = if let Some(func_code) = params.comparison {
469        helpers::lookup_cfunc_id(config_json, func_code)?
470    } else {
471        0
472    };
473
474    // Validate that elements are marked expressed/compared if functions are specified
475    if efunc_id > 0 || cfunc_id > 0 {
476        let mut expressed_cnt = 0;
477        let mut compared_cnt = 0;
478
479        for element_item in elements {
480            if let Some(obj) = element_item.as_object() {
481                if obj
482                    .get("expressed")
483                    .or_else(|| obj.get("EXPRESSED"))
484                    .and_then(|v| v.as_str())
485                    .map(|s| s.eq_ignore_ascii_case("yes"))
486                    .unwrap_or(false)
487                {
488                    expressed_cnt += 1;
489                }
490                if obj
491                    .get("compared")
492                    .or_else(|| obj.get("COMPARED"))
493                    .and_then(|v| v.as_str())
494                    .map(|s| s.eq_ignore_ascii_case("yes"))
495                    .unwrap_or(false)
496                {
497                    compared_cnt += 1;
498                }
499            }
500        }
501
502        if efunc_id > 0 && expressed_cnt == 0 {
503            return Err(SzConfigError::InvalidInput(
504                "No elements marked \"expressed\" for expression routine".to_string(),
505            ));
506        }
507        if cfunc_id > 0 && compared_cnt == 0 {
508            return Err(SzConfigError::InvalidInput(
509                "No elements marked \"compared\" for comparison routine".to_string(),
510            ));
511        }
512    }
513
514    // Create CFG_FTYPE record
515    let ftype_record = json!({
516        "FTYPE_ID": ftype_id,
517        "FTYPE_CODE": feature_upper.clone(),
518        "FTYPE_DESC": feature_upper.clone(),
519        "FCLASS_ID": fclass_id,
520        "FTYPE_FREQ": frequency,
521        "FTYPE_EXCL": exclusivity,
522        "FTYPE_STAB": stability,
523        "ANONYMIZE": anonymize_val,
524        "DERIVED": derived_val,
525        "USED_FOR_CAND": candidates_val,
526        "SHOW_IN_MATCH_KEY": matchkey_val,
527        "PERSIST_HISTORY": history_val,
528        "VERSION": params.version.unwrap_or(1),
529        "RTYPE_ID": params.rtype_id.unwrap_or(0)
530    });
531
532    // Add to CFG_FTYPE
533    if let Some(ftype_array) = config["G2_CONFIG"]["CFG_FTYPE"].as_array_mut() {
534        ftype_array.push(ftype_record);
535    }
536
537    // Add standardize call if function specified
538    if sfunc_id > 0 {
539        let sfcall_array = config["G2_CONFIG"]["CFG_SFCALL"]
540            .as_array()
541            .ok_or_else(|| SzConfigError::MissingSection("CFG_SFCALL".to_string()))?;
542        let id = helpers::get_next_id_with_min(sfcall_array, "SFCALL_ID", 1000)?;
543        let record = json!({
544            "SFCALL_ID": id,
545            "SFUNC_ID": sfunc_id,
546            "EXEC_ORDER": 1,
547            "FTYPE_ID": ftype_id,
548            "FELEM_ID": -1
549        });
550        if let Some(array) = config["G2_CONFIG"]["CFG_SFCALL"].as_array_mut() {
551            array.push(record);
552        }
553    }
554
555    // Add expression call if function specified
556    let efcall_id = if efunc_id > 0 {
557        let efcall_array = config["G2_CONFIG"]["CFG_EFCALL"]
558            .as_array()
559            .ok_or_else(|| SzConfigError::MissingSection("CFG_EFCALL".to_string()))?;
560        let id = helpers::get_next_id_with_min(efcall_array, "EFCALL_ID", 1000)?;
561        let record = json!({
562            "EFCALL_ID": id,
563            "EFUNC_ID": efunc_id,
564            "EXEC_ORDER": 1,
565            "FTYPE_ID": ftype_id,
566            "FELEM_ID": -1,
567            "EFEAT_FTYPE_ID": -1,
568            "IS_VIRTUAL": "No"
569        });
570        if let Some(array) = config["G2_CONFIG"]["CFG_EFCALL"].as_array_mut() {
571            array.push(record);
572        }
573        id
574    } else {
575        0
576    };
577
578    // Add comparison call if function specified
579    let cfcall_id = if cfunc_id > 0 {
580        let cfcall_array = config["G2_CONFIG"]["CFG_CFCALL"]
581            .as_array()
582            .ok_or_else(|| SzConfigError::MissingSection("CFG_CFCALL".to_string()))?;
583        let id = helpers::get_next_id_with_min(cfcall_array, "CFCALL_ID", 1000)?;
584        let record = json!({
585            "CFCALL_ID": id,
586            "CFUNC_ID": cfunc_id,
587            "FTYPE_ID": ftype_id
588        });
589        if let Some(array) = config["G2_CONFIG"]["CFG_CFCALL"].as_array_mut() {
590            array.push(record);
591        }
592        id
593    } else {
594        0
595    };
596
597    // Process element list
598    let mut fbom_order = 0;
599    for element_item in elements {
600        fbom_order += 1;
601
602        // Parse element (can be string or object)
603        let (element_code, expressed, compared, display_level, display_delim, elem_derived) =
604            if let Some(elem_str) = element_item.as_str() {
605                (
606                    elem_str.to_uppercase(),
607                    "No".to_string(),
608                    "No".to_string(),
609                    1,
610                    None,
611                    "No".to_string(),
612                )
613            } else if let Some(elem_obj) = element_item.as_object() {
614                let code = elem_obj
615                    .get("element")
616                    .or_else(|| elem_obj.get("ELEMENT"))
617                    .and_then(|v| v.as_str())
618                    .ok_or_else(|| {
619                        SzConfigError::InvalidInput(format!(
620                            "Missing element code in elementList item {fbom_order}"
621                        ))
622                    })?
623                    .to_uppercase();
624
625                let expr = elem_obj
626                    .get("expressed")
627                    .or_else(|| elem_obj.get("EXPRESSED"))
628                    .and_then(|v| v.as_str())
629                    .unwrap_or("No")
630                    .to_uppercase();
631
632                let comp = elem_obj
633                    .get("compared")
634                    .or_else(|| elem_obj.get("COMPARED"))
635                    .and_then(|v| v.as_str())
636                    .unwrap_or("No")
637                    .to_uppercase();
638
639                // Handle display (backwards compatibility)
640                let disp_level = if let Some(display) = elem_obj
641                    .get("display")
642                    .or_else(|| elem_obj.get("DISPLAY"))
643                    .and_then(|v| v.as_str())
644                {
645                    if display.eq_ignore_ascii_case("yes") {
646                        1
647                    } else {
648                        0
649                    }
650                } else {
651                    elem_obj
652                        .get("displaylevel")
653                        .or_else(|| elem_obj.get("DISPLAYLEVEL"))
654                        .or_else(|| elem_obj.get("display_level"))
655                        .and_then(|v| v.as_i64())
656                        .unwrap_or(1)
657                };
658
659                let disp_delim = elem_obj
660                    .get("displaydelim")
661                    .or_else(|| elem_obj.get("DISPLAYDELIM"))
662                    .or_else(|| elem_obj.get("display_delim"))
663                    .and_then(|v| v.as_str())
664                    .map(|s| s.to_string());
665
666                let elem_deriv = elem_obj
667                    .get("derived")
668                    .or_else(|| elem_obj.get("DERIVED"))
669                    .and_then(|v| v.as_str())
670                    .map(|s| {
671                        if s.eq_ignore_ascii_case("yes") {
672                            "Yes"
673                        } else {
674                            "No"
675                        }
676                        .to_string()
677                    })
678                    .unwrap_or_else(|| "No".to_string());
679
680                (code, expr, comp, disp_level, disp_delim, elem_deriv)
681            } else {
682                return Err(SzConfigError::InvalidInput(format!(
683                    "Invalid element in elementList item {fbom_order}"
684                )));
685            };
686
687        // Get or create element
688        let felem_array = config["G2_CONFIG"]["CFG_FELEM"]
689            .as_array()
690            .ok_or_else(|| SzConfigError::MissingSection("CFG_FELEM".to_string()))?;
691
692        let felem_id = if let Some(felem) = felem_array
693            .iter()
694            .find(|e| e["FELEM_CODE"].as_str() == Some(element_code.as_str()))
695        {
696            felem["FELEM_ID"]
697                .as_i64()
698                .ok_or_else(|| SzConfigError::InvalidStructure("Invalid FELEM_ID".to_string()))?
699        } else {
700            // Create new element
701            let new_id = helpers::get_next_id_with_min(felem_array, "FELEM_ID", 1000)?;
702            let new_element = json!({
703                "FELEM_ID": new_id,
704                "FELEM_CODE": element_code.clone(),
705                "FELEM_DESC": element_code.clone(),
706                "DATA_TYPE": "string",
707                "TOKENIZE": "No"
708            });
709            if let Some(array) = config["G2_CONFIG"]["CFG_FELEM"].as_array_mut() {
710                array.push(new_element);
711            }
712            new_id
713        };
714
715        // Add to EFBOM if expressed
716        if efcall_id > 0 && expressed.eq_ignore_ascii_case("yes") {
717            let record = json!({
718                "EFCALL_ID": efcall_id,
719                "EXEC_ORDER": fbom_order,
720                "FTYPE_ID": ftype_id,
721                "FELEM_ID": felem_id,
722                "FELEM_REQ": "Yes"
723            });
724            if let Some(array) = config["G2_CONFIG"]["CFG_EFBOM"].as_array_mut() {
725                array.push(record);
726            }
727        }
728
729        // Add to CFBOM if compared
730        if cfcall_id > 0 && compared.eq_ignore_ascii_case("yes") {
731            let record = json!({
732                "CFCALL_ID": cfcall_id,
733                "EXEC_ORDER": fbom_order,
734                "FTYPE_ID": ftype_id,
735                "FELEM_ID": felem_id
736            });
737            if let Some(array) = config["G2_CONFIG"]["CFG_CFBOM"].as_array_mut() {
738                array.push(record);
739            }
740        }
741
742        // Add to FBOM (always)
743        let mut fbom_record = json!({
744            "FTYPE_ID": ftype_id,
745            "FELEM_ID": felem_id,
746            "EXEC_ORDER": fbom_order,
747            "DISPLAY_LEVEL": display_level,
748            "DERIVED": elem_derived
749        });
750
751        fbom_record["DISPLAY_DELIM"] = match display_delim {
752            Some(delim) => json!(delim),
753            None => Value::Null,
754        };
755
756        if let Some(array) = config["G2_CONFIG"]["CFG_FBOM"].as_array_mut() {
757            array.push(fbom_record);
758        }
759    }
760
761    serde_json::to_string(&config).map_err(|e| SzConfigError::JsonParse(e.to_string()))
762}
763
764/// Delete a feature from the configuration
765///
766/// # Arguments
767/// * `config_json` - JSON configuration string
768/// * `feature_code_or_id` - Feature code or numeric ID
769///
770/// # Returns
771/// Modified configuration JSON string
772///
773/// # Errors
774/// - `NotFound` if feature doesn't exist
775/// - `InvalidInput` if trying to delete a protected feature
776pub fn delete_feature(config_json: &str, feature_code_or_id: &str) -> Result<String> {
777    let mut config: Value =
778        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
779
780    // Try to parse as ID first, then as code
781    let ftype_id = if let Ok(id) = feature_code_or_id.trim().parse::<i64>() {
782        // Validate ID exists
783        let ftypes = config["G2_CONFIG"]["CFG_FTYPE"]
784            .as_array()
785            .ok_or_else(|| SzConfigError::MissingSection("CFG_FTYPE".to_string()))?;
786
787        if !ftypes.iter().any(|f| f["FTYPE_ID"].as_i64() == Some(id)) {
788            return Err(SzConfigError::NotFound(format!("Feature: {id}")));
789        }
790        id
791    } else {
792        lookup_feature_id(&config, feature_code_or_id)?
793    };
794
795    // Get feature code for validation
796    let feature_code = config["G2_CONFIG"]["CFG_FTYPE"]
797        .as_array()
798        .and_then(|arr| {
799            arr.iter()
800                .find(|f| f["FTYPE_ID"].as_i64() == Some(ftype_id))
801                .and_then(|f| f["FTYPE_CODE"].as_str())
802        })
803        .ok_or_else(|| SzConfigError::NotFound(format!("Feature: {ftype_id}")))?
804        .to_string();
805
806    // Check if feature is locked
807    if LOCKED_FEATURES
808        .iter()
809        .any(|&locked| locked.eq_ignore_ascii_case(&feature_code))
810    {
811        return Err(SzConfigError::InvalidInput(format!(
812            "The feature {feature_code} cannot be deleted (it is a protected system feature)"
813        )));
814    }
815
816    // Delete FBOM records
817    if let Some(fbom_array) = config["G2_CONFIG"]["CFG_FBOM"].as_array_mut() {
818        fbom_array.retain(|record| record["FTYPE_ID"].as_i64() != Some(ftype_id));
819    }
820
821    // Delete CFG_ATTR records
822    if let Some(attr_array) = config["G2_CONFIG"]["CFG_ATTR"].as_array_mut() {
823        attr_array.retain(|record| {
824            record["FTYPE_CODE"]
825                .as_str()
826                .map(|s| !s.eq_ignore_ascii_case(&feature_code))
827                .unwrap_or(true)
828        });
829    }
830
831    // Delete standardize calls
832    if let Some(sfcall_array) = config["G2_CONFIG"]["CFG_SFCALL"].as_array_mut() {
833        sfcall_array.retain(|record| record["FTYPE_ID"].as_i64() != Some(ftype_id));
834    }
835
836    // Delete expression calls and their BOM records
837    let efcall_ids: Vec<i64> = config["G2_CONFIG"]["CFG_EFCALL"]
838        .as_array()
839        .map(|arr| {
840            arr.iter()
841                .filter(|call| call["FTYPE_ID"].as_i64() == Some(ftype_id))
842                .filter_map(|call| call["EFCALL_ID"].as_i64())
843                .collect()
844        })
845        .unwrap_or_default();
846
847    if let Some(efbom_array) = config["G2_CONFIG"]["CFG_EFBOM"].as_array_mut() {
848        efbom_array.retain(|record| {
849            record["EFCALL_ID"]
850                .as_i64()
851                .map(|id| !efcall_ids.contains(&id))
852                .unwrap_or(true)
853        });
854    }
855
856    if let Some(efcall_array) = config["G2_CONFIG"]["CFG_EFCALL"].as_array_mut() {
857        efcall_array.retain(|record| record["FTYPE_ID"].as_i64() != Some(ftype_id));
858    }
859
860    // Delete comparison calls and their BOM records
861    let cfcall_ids: Vec<i64> = config["G2_CONFIG"]["CFG_CFCALL"]
862        .as_array()
863        .map(|arr| {
864            arr.iter()
865                .filter(|call| call["FTYPE_ID"].as_i64() == Some(ftype_id))
866                .filter_map(|call| call["CFCALL_ID"].as_i64())
867                .collect()
868        })
869        .unwrap_or_default();
870
871    if let Some(cfbom_array) = config["G2_CONFIG"]["CFG_CFBOM"].as_array_mut() {
872        cfbom_array.retain(|record| {
873            record["CFCALL_ID"]
874                .as_i64()
875                .map(|id| !cfcall_ids.contains(&id))
876                .unwrap_or(true)
877        });
878    }
879
880    if let Some(cfcall_array) = config["G2_CONFIG"]["CFG_CFCALL"].as_array_mut() {
881        cfcall_array.retain(|record| record["FTYPE_ID"].as_i64() != Some(ftype_id));
882    }
883
884    // Delete distinct calls and their BOM records
885    let dfcall_ids: Vec<i64> = config["G2_CONFIG"]["CFG_DFCALL"]
886        .as_array()
887        .map(|arr| {
888            arr.iter()
889                .filter(|call| call["FTYPE_ID"].as_i64() == Some(ftype_id))
890                .filter_map(|call| call["DFCALL_ID"].as_i64())
891                .collect()
892        })
893        .unwrap_or_default();
894
895    if let Some(dfbom_array) = config["G2_CONFIG"]["CFG_DFBOM"].as_array_mut() {
896        dfbom_array.retain(|record| {
897            record["DFCALL_ID"]
898                .as_i64()
899                .map(|id| !dfcall_ids.contains(&id))
900                .unwrap_or(true)
901        });
902    }
903
904    if let Some(dfcall_array) = config["G2_CONFIG"]["CFG_DFCALL"].as_array_mut() {
905        dfcall_array.retain(|record| record["FTYPE_ID"].as_i64() != Some(ftype_id));
906    }
907
908    // Finally, delete the feature itself
909    if let Some(ftype_array) = config["G2_CONFIG"]["CFG_FTYPE"].as_array_mut() {
910        ftype_array.retain(|record| record["FTYPE_ID"].as_i64() != Some(ftype_id));
911    }
912
913    serde_json::to_string(&config).map_err(|e| SzConfigError::JsonParse(e.to_string()))
914}
915
916/// Get a specific feature by code or ID
917///
918/// # Arguments
919/// * `config_json` - JSON configuration string
920/// * `feature_code_or_id` - Feature code or numeric ID
921///
922/// # Returns
923/// JSON Value representing the complete feature with elementList
924pub fn get_feature(config_json: &str, feature_code_or_id: &str) -> Result<Value> {
925    let config: Value =
926        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
927
928    // Try to parse as ID first, then as code
929    let ftype = if let Ok(id) = feature_code_or_id.trim().parse::<i64>() {
930        config["G2_CONFIG"]["CFG_FTYPE"]
931            .as_array()
932            .and_then(|arr| arr.iter().find(|f| f["FTYPE_ID"].as_i64() == Some(id)))
933            .ok_or_else(|| SzConfigError::NotFound(format!("Feature: {id}")))?
934    } else {
935        let code_upper = feature_code_or_id.to_uppercase();
936        config["G2_CONFIG"]["CFG_FTYPE"]
937            .as_array()
938            .and_then(|arr| {
939                arr.iter()
940                    .find(|f| f["FTYPE_CODE"].as_str() == Some(code_upper.as_str()))
941            })
942            .ok_or_else(|| SzConfigError::NotFound(format!("Feature: {code_upper}")))?
943    };
944
945    build_feature_json(&config, ftype)
946}
947
948/// List all features in the configuration
949///
950/// # Arguments
951/// * `config_json` - JSON configuration string
952///
953/// # Returns
954/// Vector of JSON Values representing features with elementList
955pub fn list_features(config_json: &str) -> Result<Vec<Value>> {
956    let config: Value =
957        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
958
959    let ftype_array = config["G2_CONFIG"]["CFG_FTYPE"]
960        .as_array()
961        .ok_or_else(|| SzConfigError::MissingSection("CFG_FTYPE".to_string()))?;
962
963    let mut result: Vec<Value> = ftype_array
964        .iter()
965        .map(|ftype| build_feature_json(&config, ftype))
966        .collect::<Result<Vec<_>>>()?;
967
968    // Sort by FTYPE_ID
969    result.sort_by_key(|item| item["id"].as_i64().unwrap_or(0));
970
971    Ok(result)
972}
973
974/// Set (update) a feature's properties
975///
976/// # Arguments
977/// * `config_json` - JSON configuration string
978/// * `params` - Feature parameters (feature required, updates optional)
979///
980/// # Returns
981/// Modified configuration JSON string
982///
983/// # Example
984/// ```no_run
985/// use sz_configtool_lib::features::{set_feature, SetFeatureParams};
986///
987/// let config = r#"{"G2_CONFIG":{"CFG_FTYPE":[...]}}"#;
988/// let result = set_feature(config, SetFeatureParams {
989///     feature: "NAME",
990///     candidates: Some("Yes"),
991///     behavior: Some("NAME"),
992///     version: Some(2),
993///     ..Default::default()
994/// })?;
995/// # Ok::<(), sz_configtool_lib::error::SzConfigError>(())
996/// ```
997pub fn set_feature(config_json: &str, params: SetFeatureParams) -> Result<String> {
998    let mut config: Value =
999        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
1000
1001    // Try to parse as ID first, then as code
1002    let ftype_id = if let Ok(id) = params.feature.trim().parse::<i64>() {
1003        id
1004    } else {
1005        lookup_feature_id(&config, params.feature)?
1006    };
1007
1008    let ftypes = config["G2_CONFIG"]["CFG_FTYPE"]
1009        .as_array_mut()
1010        .ok_or_else(|| SzConfigError::MissingSection("CFG_FTYPE".to_string()))?;
1011
1012    let ftype = ftypes
1013        .iter_mut()
1014        .find(|f| f["FTYPE_ID"].as_i64() == Some(ftype_id))
1015        .ok_or_else(|| SzConfigError::NotFound("Feature not found".to_string()))?;
1016
1017    // Track if any changes made (for "No changes detected")
1018    let mut changes_made = false;
1019
1020    // Update fields if provided with validation and normalization
1021    if let Some(val) = params.candidates {
1022        // Validate and normalize CANDIDATES domain (Python line 1702-1707)
1023        let normalized = validate_and_normalize_domain(val, &["Yes", "No"], "CANDIDATES")?;
1024        if ftype["USED_FOR_CAND"].as_str() != Some(normalized.as_str()) {
1025            ftype["USED_FOR_CAND"] = json!(normalized);
1026            changes_made = true;
1027        }
1028    }
1029    if let Some(val) = params.anonymize {
1030        if ftype["ANONYMIZE"].as_str() != Some(val) {
1031            ftype["ANONYMIZE"] = json!(val);
1032            changes_made = true;
1033        }
1034    }
1035    if let Some(val) = params.derived {
1036        if ftype["DERIVED"].as_str() != Some(val) {
1037            ftype["DERIVED"] = json!(val);
1038            changes_made = true;
1039        }
1040    }
1041    if let Some(val) = params.history {
1042        if ftype["PERSIST_HISTORY"].as_str() != Some(val) {
1043            ftype["PERSIST_HISTORY"] = json!(val);
1044            changes_made = true;
1045        }
1046    }
1047    if let Some(val) = params.matchkey {
1048        // Validate and normalize MATCHKEY domain (Python line 1754-1758)
1049        let normalized =
1050            validate_and_normalize_domain(val, &["Yes", "No", "Confirm", "Denial"], "MATCHKEY")?;
1051        if ftype["SHOW_IN_MATCH_KEY"].as_str() != Some(normalized.as_str()) {
1052            ftype["SHOW_IN_MATCH_KEY"] = json!(normalized);
1053            changes_made = true;
1054        }
1055    }
1056    if let Some(val) = params.version {
1057        if ftype["VERSION"].as_i64() != Some(val) {
1058            ftype["VERSION"] = json!(val);
1059            changes_made = true;
1060        }
1061    }
1062    if let Some(val) = params.rtype_id {
1063        if ftype["RTYPE_ID"].as_i64() != Some(val) {
1064            ftype["RTYPE_ID"] = json!(val);
1065            changes_made = true;
1066        }
1067    }
1068
1069    // Parse and set behavior (FTYPE_FREQ, FTYPE_EXCL, FTYPE_STAB)
1070    if let Some(behavior_code) = params.behavior {
1071        let (frequency, exclusivity, stability) = parse_behavior_code(behavior_code)?;
1072        let freq_changed = ftype["FTYPE_FREQ"].as_str() != Some(frequency);
1073        let excl_changed = ftype["FTYPE_EXCL"].as_str() != Some(exclusivity);
1074        let stab_changed = ftype["FTYPE_STAB"].as_str() != Some(stability);
1075        if freq_changed || excl_changed || stab_changed {
1076            ftype["FTYPE_FREQ"] = json!(frequency);
1077            ftype["FTYPE_EXCL"] = json!(exclusivity);
1078            ftype["FTYPE_STAB"] = json!(stability);
1079            changes_made = true;
1080        }
1081    }
1082
1083    // Lookup and set class (FCLASS_ID) - must do before modifying ftype
1084    if let Some(class_name) = params.class {
1085        // Parse config again to avoid borrow conflict
1086        let config_for_lookup: Value = serde_json::from_str(config_json)
1087            .map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
1088
1089        let fclass_array = config_for_lookup["G2_CONFIG"]["CFG_FCLASS"]
1090            .as_array()
1091            .ok_or_else(|| SzConfigError::MissingSection("CFG_FCLASS".to_string()))?;
1092
1093        let fclass_id = fclass_array
1094            .iter()
1095            .find(|c| {
1096                c["FCLASS_CODE"]
1097                    .as_str()
1098                    .map(|s| s.eq_ignore_ascii_case(class_name))
1099                    .unwrap_or(false)
1100            })
1101            .and_then(|c| c["FCLASS_ID"].as_i64())
1102            .ok_or_else(|| SzConfigError::NotFound(format!("Feature class: {class_name}")))?;
1103
1104        if ftype["FCLASS_ID"].as_i64() != Some(fclass_id) {
1105            ftype["FCLASS_ID"] = json!(fclass_id);
1106            changes_made = true;
1107        }
1108    }
1109
1110    // Check if any changes were made (Python lines 1824-1825)
1111    if !changes_made {
1112        return Err(SzConfigError::InvalidInput(
1113            "No changes detected".to_string(),
1114        ));
1115    }
1116
1117    serde_json::to_string(&config).map_err(|e| SzConfigError::JsonParse(e.to_string()))
1118}
1119
1120/// Validate value is in domain and normalize to proper case
1121fn validate_and_normalize_domain(value: &str, domain: &[&str], field_name: &str) -> Result<String> {
1122    let value_upper = value.to_uppercase();
1123    for valid_value in domain {
1124        if valid_value.to_uppercase() == value_upper {
1125            return Ok(valid_value.to_string());
1126        }
1127    }
1128    Err(SzConfigError::InvalidInput(format!(
1129        "{field_name} value must be in {domain:?}"
1130    )))
1131}
1132
1133// Helper functions
1134
1135/// Build complete feature JSON with elementList for display
1136pub fn build_feature_json(config: &Value, ftype: &Value) -> Result<Value> {
1137    let empty_array = vec![];
1138
1139    let fclass_array = config["G2_CONFIG"]["CFG_FCLASS"]
1140        .as_array()
1141        .unwrap_or(&empty_array);
1142    let sfcall_array = config["G2_CONFIG"]["CFG_SFCALL"]
1143        .as_array()
1144        .unwrap_or(&empty_array);
1145    let efcall_array = config["G2_CONFIG"]["CFG_EFCALL"]
1146        .as_array()
1147        .unwrap_or(&empty_array);
1148    let cfcall_array = config["G2_CONFIG"]["CFG_CFCALL"]
1149        .as_array()
1150        .unwrap_or(&empty_array);
1151    let sfunc_array = config["G2_CONFIG"]["CFG_SFUNC"]
1152        .as_array()
1153        .unwrap_or(&empty_array);
1154    let efunc_array = config["G2_CONFIG"]["CFG_EFUNC"]
1155        .as_array()
1156        .unwrap_or(&empty_array);
1157    let cfunc_array = config["G2_CONFIG"]["CFG_CFUNC"]
1158        .as_array()
1159        .unwrap_or(&empty_array);
1160    let felem_array = config["G2_CONFIG"]["CFG_FELEM"]
1161        .as_array()
1162        .unwrap_or(&empty_array);
1163    let fbom_array = config["G2_CONFIG"]["CFG_FBOM"]
1164        .as_array()
1165        .unwrap_or(&empty_array);
1166    let efbom_array = config["G2_CONFIG"]["CFG_EFBOM"]
1167        .as_array()
1168        .unwrap_or(&empty_array);
1169    let cfbom_array = config["G2_CONFIG"]["CFG_CFBOM"]
1170        .as_array()
1171        .unwrap_or(&empty_array);
1172
1173    let ftype_id = ftype["FTYPE_ID"].as_i64().unwrap_or(0);
1174    let fclass_id = ftype["FCLASS_ID"].as_i64().unwrap_or(0);
1175
1176    // Resolve class name
1177    let class_name = fclass_array
1178        .iter()
1179        .find(|fc| fc["FCLASS_ID"].as_i64() == Some(fclass_id))
1180        .and_then(|fc| fc["FCLASS_CODE"].as_str())
1181        .unwrap_or("OTHER")
1182        .to_string();
1183
1184    // Compute behavior
1185    let behavior = compute_behavior(ftype);
1186
1187    // Find standardize function
1188    let standardize = sfcall_array
1189        .iter()
1190        .filter(|sc| sc["FTYPE_ID"].as_i64() == Some(ftype_id))
1191        .min_by_key(|sc| sc["EXEC_ORDER"].as_i64().unwrap_or(0))
1192        .and_then(|sc| sc["SFUNC_ID"].as_i64())
1193        .and_then(|sfunc_id| {
1194            sfunc_array
1195                .iter()
1196                .find(|sf| sf["SFUNC_ID"].as_i64() == Some(sfunc_id))
1197        })
1198        .and_then(|sf| sf["SFUNC_CODE"].as_str())
1199        .unwrap_or("")
1200        .to_string();
1201
1202    // Find expression function
1203    let efcall = efcall_array
1204        .iter()
1205        .filter(|ec| ec["FTYPE_ID"].as_i64() == Some(ftype_id))
1206        .min_by_key(|ec| ec["EXEC_ORDER"].as_i64().unwrap_or(0));
1207
1208    let expression = efcall
1209        .and_then(|ec| ec["EFUNC_ID"].as_i64())
1210        .and_then(|efunc_id| {
1211            efunc_array
1212                .iter()
1213                .find(|ef| ef["EFUNC_ID"].as_i64() == Some(efunc_id))
1214        })
1215        .and_then(|ef| ef["EFUNC_CODE"].as_str())
1216        .unwrap_or("")
1217        .to_string();
1218
1219    // Find comparison function
1220    let cfcall = cfcall_array
1221        .iter()
1222        .filter(|cc| cc["FTYPE_ID"].as_i64() == Some(ftype_id))
1223        .min_by_key(|cc| cc["CFCALL_ID"].as_i64().unwrap_or(0));
1224
1225    let comparison = cfcall
1226        .and_then(|cc| cc["CFUNC_ID"].as_i64())
1227        .and_then(|cfunc_id| {
1228            cfunc_array
1229                .iter()
1230                .find(|cf| cf["CFUNC_ID"].as_i64() == Some(cfunc_id))
1231        })
1232        .and_then(|cf| cf["CFUNC_CODE"].as_str())
1233        .unwrap_or("")
1234        .to_string();
1235
1236    // Build elementList
1237    let mut element_list: Vec<(i64, Value)> = fbom_array
1238        .iter()
1239        .filter(|fbom| fbom["FTYPE_ID"].as_i64() == Some(ftype_id))
1240        .map(|fbom| {
1241            let felem_id = fbom["FELEM_ID"].as_i64().unwrap_or(0);
1242            let exec_order = fbom["EXEC_ORDER"].as_i64().unwrap_or(0);
1243
1244            let element_code = felem_array
1245                .iter()
1246                .find(|fe| fe["FELEM_ID"].as_i64() == Some(felem_id))
1247                .and_then(|fe| fe["FELEM_CODE"].as_str())
1248                .unwrap_or("")
1249                .to_string();
1250
1251            let expressed = efcall
1252                .and_then(|ec| ec["EFCALL_ID"].as_i64())
1253                .map(|efcall_id| {
1254                    efbom_array.iter().any(|efbom| {
1255                        efbom["EFCALL_ID"].as_i64() == Some(efcall_id)
1256                            && efbom["FTYPE_ID"].as_i64() == Some(ftype_id)
1257                            && efbom["FELEM_ID"].as_i64() == Some(felem_id)
1258                    })
1259                })
1260                .unwrap_or(false);
1261
1262            let compared = cfcall
1263                .and_then(|cc| cc["CFCALL_ID"].as_i64())
1264                .map(|cfcall_id| {
1265                    cfbom_array.iter().any(|cfbom| {
1266                        cfbom["CFCALL_ID"].as_i64() == Some(cfcall_id)
1267                            && cfbom["FTYPE_ID"].as_i64() == Some(ftype_id)
1268                            && cfbom["FELEM_ID"].as_i64() == Some(felem_id)
1269                    })
1270                })
1271                .unwrap_or(false);
1272
1273            let derived = fbom["DERIVED"].as_str().unwrap_or("No");
1274            let display_level = fbom["DISPLAY_LEVEL"].as_i64().unwrap_or(1);
1275            let display = if display_level == 0 { "No" } else { "Yes" };
1276
1277            (
1278                exec_order,
1279                json!({
1280                    "element": element_code,
1281                    "expressed": if expressed { "Yes" } else { "No" },
1282                    "compared": if compared { "Yes" } else { "No" },
1283                    "derived": derived,
1284                    "display": display
1285                }),
1286            )
1287        })
1288        .collect();
1289
1290    element_list.sort_by_key(|(order, _)| *order);
1291    let element_list: Vec<Value> = element_list.into_iter().map(|(_, v)| v).collect();
1292
1293    Ok(json!({
1294        "id": ftype_id,
1295        "feature": ftype["FTYPE_CODE"].as_str().unwrap_or(""),
1296        "class": class_name,
1297        "behavior": behavior,
1298        "anonymize": ftype["ANONYMIZE"].as_str().unwrap_or(""),
1299        "candidates": ftype["USED_FOR_CAND"].as_str().unwrap_or(""),
1300        "standardize": standardize,
1301        "expression": expression,
1302        "comparison": comparison,
1303        "matchKey": ftype["SHOW_IN_MATCH_KEY"].as_str().unwrap_or(""),
1304        "version": ftype["VERSION"].as_i64().unwrap_or(0),
1305        "elementList": element_list
1306    }))
1307}
1308
1309/// Parse a behavior code string into (frequency, exclusivity, stability)
1310/// Valid frequency codes: A1, F1, FF, FM, FVM, NONE, NAME
1311/// E suffix means EXCLUSIVITY = "Yes"
1312/// S suffix means STABILITY = "Yes"
1313fn parse_behavior_code(behavior: &str) -> Result<(&'static str, &'static str, &'static str)> {
1314    let mut code = behavior.to_uppercase();
1315    let mut exclusivity = "No";
1316    let mut stability = "No";
1317
1318    // Special cases that don't get E/S parsing
1319    if code != "NAME" && code != "NONE" {
1320        if code.contains('E') {
1321            exclusivity = "Yes";
1322            code = code.replace('E', "");
1323        }
1324        if code.contains('S') {
1325            stability = "Yes";
1326            code = code.replace('S', "");
1327        }
1328    }
1329
1330    // Validate frequency code
1331    let frequency: &'static str = match code.as_str() {
1332        "A1" => "A1",
1333        "F1" => "F1",
1334        "FF" => "FF",
1335        "FM" => "FM",
1336        "FVM" => "FVM",
1337        "NONE" => "NONE",
1338        "NAME" => "NAME",
1339        _ => {
1340            return Err(SzConfigError::InvalidInput(format!(
1341                "Invalid behavior code '{behavior}'. Valid codes: A1, F1, FF, FM, FVM, NONE, NAME (with optional E/S suffixes)"
1342            )));
1343        }
1344    };
1345
1346    Ok((frequency, exclusivity, stability))
1347}
1348
1349fn compute_behavior(ftype: &Value) -> String {
1350    let freq = ftype["FTYPE_FREQ"].as_str().unwrap_or("");
1351    let excl = ftype["FTYPE_EXCL"].as_str().unwrap_or("");
1352    let stab = ftype["FTYPE_STAB"].as_str().unwrap_or("");
1353
1354    let mut behavior = freq.to_string();
1355    if excl.to_uppercase() == "Y" || excl == "1" || excl.to_uppercase() == "YES" {
1356        behavior.push('E');
1357    }
1358    if stab.to_uppercase() == "Y" || stab == "1" || stab.to_uppercase() == "YES" {
1359        behavior.push('S');
1360    }
1361    behavior
1362}
1363
1364fn lookup_feature_id(config: &Value, feature_code: &str) -> Result<i64> {
1365    let code_upper = feature_code.to_uppercase();
1366    config["G2_CONFIG"]["CFG_FTYPE"]
1367        .as_array()
1368        .and_then(|arr| {
1369            arr.iter()
1370                .find(|f| f["FTYPE_CODE"].as_str() == Some(code_upper.as_str()))
1371        })
1372        .and_then(|f| f["FTYPE_ID"].as_i64())
1373        .ok_or_else(|| SzConfigError::NotFound("Feature not found".to_string()))
1374}
1375
1376#[allow(dead_code)]
1377fn lookup_sfunc_id(config: &Value, func_code: &str) -> Result<i64> {
1378    let code_upper = func_code.to_uppercase();
1379    config["G2_CONFIG"]["CFG_SFUNC"]
1380        .as_array()
1381        .and_then(|arr| {
1382            arr.iter()
1383                .find(|f| f["SFUNC_CODE"].as_str() == Some(code_upper.as_str()))
1384        })
1385        .and_then(|f| f["SFUNC_ID"].as_i64())
1386        .ok_or_else(|| SzConfigError::NotFound(format!("Standardize function: {code_upper}")))
1387}
1388
1389#[allow(dead_code)]
1390fn lookup_efunc_id(config: &Value, func_code: &str) -> Result<i64> {
1391    let code_upper = func_code.to_uppercase();
1392    config["G2_CONFIG"]["CFG_EFUNC"]
1393        .as_array()
1394        .and_then(|arr| {
1395            arr.iter()
1396                .find(|f| f["EFUNC_CODE"].as_str() == Some(code_upper.as_str()))
1397        })
1398        .and_then(|f| f["EFUNC_ID"].as_i64())
1399        .ok_or_else(|| SzConfigError::NotFound(format!("Expression function: {code_upper}")))
1400}
1401
1402#[allow(dead_code)]
1403fn lookup_cfunc_id(config: &Value, func_code: &str) -> Result<i64> {
1404    let code_upper = func_code.to_uppercase();
1405    config["G2_CONFIG"]["CFG_CFUNC"]
1406        .as_array()
1407        .and_then(|arr| {
1408            arr.iter()
1409                .find(|f| f["CFUNC_CODE"].as_str() == Some(code_upper.as_str()))
1410        })
1411        .and_then(|f| f["CFUNC_ID"].as_i64())
1412        .ok_or_else(|| SzConfigError::NotFound(format!("Comparison function: {code_upper}")))
1413}
1414
1415/// Add a feature comparison element (FBOM record)
1416///
1417/// # Arguments
1418/// * `config_json` - JSON configuration string
1419/// * `params` - Feature comparison parameters (ftype_id, felem_id required; others optional)
1420///
1421/// # Returns
1422/// Modified configuration JSON string
1423pub fn add_feature_comparison(
1424    config_json: &str,
1425    params: AddFeatureComparisonParams,
1426) -> Result<String> {
1427    let feature_code = params
1428        .feature_code
1429        .ok_or_else(|| SzConfigError::MissingField("feature_code".to_string()))?;
1430    let element_code = params
1431        .element_code
1432        .ok_or_else(|| SzConfigError::MissingField("element_code".to_string()))?;
1433
1434    let ftype_id = helpers::lookup_feature_id(config_json, feature_code)?;
1435    let felem_id = helpers::lookup_element_id(config_json, element_code)?;
1436
1437    let config: Value =
1438        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
1439
1440    // Check if already exists
1441    let fbom_array = config["G2_CONFIG"]["CFG_FBOM"]
1442        .as_array()
1443        .ok_or_else(|| SzConfigError::MissingSection("CFG_FBOM".to_string()))?;
1444
1445    if fbom_array.iter().any(|item| {
1446        item["FTYPE_ID"].as_i64() == Some(ftype_id) && item["FELEM_ID"].as_i64() == Some(felem_id)
1447    }) {
1448        return Err(SzConfigError::AlreadyExists(format!(
1449            "Feature comparison: {:?}+{:?}",
1450            params.feature_code, params.element_code
1451        )));
1452    }
1453
1454    // Build record
1455    let mut record = json!({
1456        "FTYPE_ID": ftype_id,
1457        "FELEM_ID": felem_id,
1458    });
1459
1460    record["EXEC_ORDER"] = match params.exec_order {
1461        Some(order) => json!(order),
1462        None => Value::Null,
1463    };
1464    record["DISPLAY_LEVEL"] = match params.display_level {
1465        Some(level) => json!(level),
1466        None => Value::Null,
1467    };
1468    record["DISPLAY_DELIM"] = match params.display_delim {
1469        Some(delim) => json!(delim),
1470        None => Value::Null,
1471    };
1472    record["DERIVED"] = match params.derived {
1473        Some(der) => json!(der),
1474        None => Value::Null,
1475    };
1476
1477    helpers::add_to_config_array(config_json, "CFG_FBOM", record)
1478}
1479
1480/// Delete a feature comparison element (FBOM record)
1481///
1482/// # Arguments
1483/// * `config_json` - JSON configuration string
1484/// * `ftype_id` - Feature type ID
1485/// * `felem_id` - Feature element ID
1486///
1487/// # Returns
1488/// Modified configuration JSON string
1489pub fn delete_feature_comparison(
1490    config_json: &str,
1491    feature_code: &str,
1492    element_code: &str,
1493) -> Result<String> {
1494    let ftype_id = helpers::lookup_feature_id(config_json, feature_code)?;
1495    let felem_id = helpers::lookup_element_id(config_json, element_code)?;
1496
1497    let mut config: Value =
1498        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
1499
1500    let mut found = false;
1501
1502    if let Some(fbom_array) = config["G2_CONFIG"]["CFG_FBOM"].as_array_mut() {
1503        fbom_array.retain(|item| {
1504            let matches = item["FTYPE_ID"].as_i64() == Some(ftype_id)
1505                && item["FELEM_ID"].as_i64() == Some(felem_id);
1506            if matches {
1507                found = true;
1508            }
1509            !matches
1510        });
1511    }
1512
1513    if !found {
1514        return Err(SzConfigError::NotFound(format!(
1515            "Feature comparison: FTYPE_ID={ftype_id}, FELEM_ID={felem_id}"
1516        )));
1517    }
1518
1519    serde_json::to_string(&config).map_err(|e| SzConfigError::JsonParse(e.to_string()))
1520}
1521
1522/// Get a specific feature comparison element
1523///
1524/// # Arguments
1525/// * `config_json` - JSON configuration string
1526/// * `params` - Feature comparison parameters (ftype_id and felem_id)
1527///
1528/// # Returns
1529/// JSON Value representing the feature comparison
1530pub fn get_feature_comparison(
1531    config_json: &str,
1532    params: GetFeatureComparisonParams,
1533) -> Result<Value> {
1534    let feature_code = params
1535        .feature_code
1536        .ok_or_else(|| SzConfigError::MissingField("feature_code".to_string()))?;
1537    let element_code = params
1538        .element_code
1539        .ok_or_else(|| SzConfigError::MissingField("element_code".to_string()))?;
1540
1541    let ftype_id = helpers::lookup_feature_id(config_json, feature_code)?;
1542    let felem_id = helpers::lookup_element_id(config_json, element_code)?;
1543
1544    let config: Value =
1545        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
1546
1547    let fbom_array = config["G2_CONFIG"]["CFG_FBOM"]
1548        .as_array()
1549        .ok_or_else(|| SzConfigError::MissingSection("CFG_FBOM".to_string()))?;
1550
1551    fbom_array
1552        .iter()
1553        .find(|item| {
1554            item["FTYPE_ID"].as_i64() == Some(ftype_id)
1555                && item["FELEM_ID"].as_i64() == Some(felem_id)
1556        })
1557        .cloned()
1558        .ok_or_else(|| {
1559            SzConfigError::NotFound(format!(
1560                "Feature comparison: {:?}+{:?}",
1561                params.feature_code, params.element_code
1562            ))
1563        })
1564}
1565
1566/// List all feature comparison elements (FBOM records)
1567///
1568/// # Arguments
1569/// * `config_json` - JSON configuration string
1570///
1571/// # Returns
1572/// Vector of JSON Values representing feature comparisons, sorted by FTYPE_ID and EXEC_ORDER
1573pub fn list_feature_comparisons(config_json: &str) -> Result<Vec<Value>> {
1574    let config: Value =
1575        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
1576
1577    let fbom_array = config["G2_CONFIG"]["CFG_FBOM"]
1578        .as_array()
1579        .ok_or_else(|| SzConfigError::MissingSection("CFG_FBOM".to_string()))?;
1580
1581    let mut result: Vec<Value> = fbom_array.to_vec();
1582
1583    // Sort by FTYPE_ID and EXEC_ORDER
1584    result.sort_by(|a, b| {
1585        let a_ftype = a["FTYPE_ID"].as_i64().unwrap_or(0);
1586        let b_ftype = b["FTYPE_ID"].as_i64().unwrap_or(0);
1587        let a_exec = a["EXEC_ORDER"].as_i64().unwrap_or(0);
1588        let b_exec = b["EXEC_ORDER"].as_i64().unwrap_or(0);
1589        (a_ftype, a_exec).cmp(&(b_ftype, b_exec))
1590    });
1591
1592    Ok(result)
1593}
1594
1595/// Add a feature comparison element (same as add_feature_comparison, for compatibility)
1596///
1597/// # Arguments
1598/// * `config_json` - JSON configuration string
1599/// * `params` - Feature comparison parameters (ftype_id, felem_id required; others optional)
1600///
1601/// # Returns
1602/// Modified configuration JSON string
1603pub fn add_feature_comparison_element(
1604    config_json: &str,
1605    params: AddFeatureComparisonParams,
1606) -> Result<String> {
1607    add_feature_comparison(config_json, params)
1608}
1609
1610/// Delete a feature comparison element (same as delete_feature_comparison, for compatibility)
1611///
1612/// # Arguments
1613/// * `config_json` - JSON configuration string
1614/// * `ftype_id` - Feature type ID
1615/// * `felem_id` - Feature element ID
1616///
1617/// # Returns
1618/// Modified configuration JSON string
1619pub fn delete_feature_comparison_element(
1620    config_json: &str,
1621    feature_code: &str,
1622    element_code: &str,
1623) -> Result<String> {
1624    delete_feature_comparison(config_json, feature_code, element_code)
1625}
1626
1627/// Add a feature distinct call element (CFG_DFCALL record)
1628///
1629/// # Arguments
1630/// * `config_json` - JSON configuration string
1631/// * `ftype_id` - Feature type ID
1632/// * `dfunc_id` - Distinct function ID
1633/// * `felem_id` - Optional feature element ID (default: -1)
1634/// * `exec_order` - Optional execution order
1635///
1636/// # Returns
1637/// Modified configuration JSON string
1638pub fn add_feature_distinct_call_element(
1639    config_json: &str,
1640    params: AddFeatureDistinctCallElementParams,
1641) -> Result<String> {
1642    let feature_code = params
1643        .feature_code
1644        .ok_or_else(|| SzConfigError::MissingField("feature_code".to_string()))?;
1645    let distinct_func_code = params
1646        .distinct_func_code
1647        .ok_or_else(|| SzConfigError::MissingField("distinct_func_code".to_string()))?;
1648
1649    let ftype_id = helpers::lookup_feature_id(config_json, feature_code)?;
1650    let dfunc_id = helpers::lookup_dfunc_id(config_json, distinct_func_code)?;
1651    let felem_id = if let Some(code) = params.element_code {
1652        helpers::lookup_element_id(config_json, code)?
1653    } else {
1654        -1
1655    };
1656
1657    let config: Value =
1658        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
1659
1660    // Check if already exists
1661    let dfcall_array = config["G2_CONFIG"]["CFG_DFCALL"]
1662        .as_array()
1663        .ok_or_else(|| SzConfigError::MissingSection("CFG_DFCALL".to_string()))?;
1664
1665    if dfcall_array.iter().any(|item| {
1666        item["FTYPE_ID"].as_i64() == Some(ftype_id)
1667            && item["DFUNC_ID"].as_i64() == Some(dfunc_id)
1668            && item["FELEM_ID"].as_i64() == Some(felem_id)
1669    }) {
1670        return Err(SzConfigError::AlreadyExists(format!(
1671            "Feature distinct call element: {:?}+{:?}",
1672            params.feature_code, params.distinct_func_code
1673        )));
1674    }
1675
1676    // Get next DFCALL_ID
1677    let dfcall_id = helpers::get_next_id_with_min(dfcall_array, "DFCALL_ID", 1000)?;
1678
1679    // Build record
1680    let mut record = json!({
1681        "DFCALL_ID": dfcall_id,
1682        "FTYPE_ID": ftype_id,
1683        "DFUNC_ID": dfunc_id,
1684        "FELEM_ID": felem_id,
1685    });
1686
1687    record["EXEC_ORDER"] = match params.exec_order {
1688        Some(order) => json!(order),
1689        None => Value::Null,
1690    };
1691
1692    helpers::add_to_config_array(config_json, "CFG_DFCALL", record)
1693}
1694
1695/// List all feature classes (CFG_FCLASS records)
1696///
1697/// # Arguments
1698/// * `config_json` - JSON configuration string
1699///
1700/// # Returns
1701/// Vector of JSON Values representing feature classes
1702pub fn list_feature_classes(config_json: &str) -> Result<Vec<Value>> {
1703    let config: Value =
1704        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
1705
1706    let fclass_array = config["G2_CONFIG"]["CFG_FCLASS"]
1707        .as_array()
1708        .ok_or_else(|| SzConfigError::MissingSection("CFG_FCLASS".to_string()))?;
1709
1710    let mut result: Vec<Value> = fclass_array.to_vec();
1711
1712    // Sort by FCLASS_ID
1713    result.sort_by_key(|item| item["FCLASS_ID"].as_i64().unwrap_or(0));
1714
1715    Ok(result)
1716}
1717
1718/// Get a specific feature class by ID or code
1719///
1720/// # Arguments
1721/// * `config_json` - JSON configuration string
1722/// * `fclass_id_or_code` - Feature class ID (numeric) or code (string)
1723///
1724/// # Returns
1725/// JSON Value representing the feature class
1726pub fn get_feature_class(config_json: &str, fclass_id_or_code: &str) -> Result<Value> {
1727    let config: Value =
1728        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
1729
1730    let fclass_array = config["G2_CONFIG"]["CFG_FCLASS"]
1731        .as_array()
1732        .ok_or_else(|| SzConfigError::MissingSection("CFG_FCLASS".to_string()))?;
1733
1734    // Try to parse as ID first
1735    if let Ok(id) = fclass_id_or_code.trim().parse::<i64>() {
1736        fclass_array
1737            .iter()
1738            .find(|item| item["FCLASS_ID"].as_i64() == Some(id))
1739            .cloned()
1740            .ok_or_else(|| SzConfigError::NotFound(format!("Feature class: {id}")))
1741    } else {
1742        // Try as code
1743        let code_upper = fclass_id_or_code.to_uppercase();
1744        fclass_array
1745            .iter()
1746            .find(|item| item["FCLASS_CODE"].as_str() == Some(code_upper.as_str()))
1747            .cloned()
1748            .ok_or_else(|| SzConfigError::NotFound(format!("Feature class: {code_upper}")))
1749    }
1750}
1751
1752/// Update the feature version in compatibility settings
1753///
1754/// # Arguments
1755/// * `config_json` - JSON configuration string
1756/// * `version` - New feature version string
1757///
1758/// # Returns
1759/// Modified configuration JSON string
1760pub fn update_feature_version(config_json: &str, version: &str) -> Result<String> {
1761    let mut config: Value =
1762        serde_json::from_str(config_json).map_err(|e| SzConfigError::JsonParse(e.to_string()))?;
1763
1764    // Navigate to COMPATIBILITY_VERSION
1765    let compat_version = config["G2_CONFIG"]["CONFIG_BASE_VERSION"]["COMPATIBILITY_VERSION"]
1766        .as_object_mut()
1767        .ok_or_else(|| SzConfigError::MissingSection("COMPATIBILITY_VERSION".to_string()))?;
1768
1769    compat_version.insert("FEATURE_VERSION".to_string(), json!(version));
1770
1771    serde_json::to_string(&config).map_err(|e| SzConfigError::JsonParse(e.to_string()))
1772}