Skip to main content

sz_configtool_lib/
command_processor.rs

1//! Command script processor for Senzing .gtc files
2//!
3//! Processes line-based command scripts to transform Senzing configuration JSON.
4//! Use cases include configuration upgrades, batch changes, templates, and
5//! automated testing.
6//!
7//! # Format
8//!
9//! Commands follow the pattern:
10//! ```text
11//! commandName {"param": "value", ...}
12//! commandName {"param": "value"}
13//!
14//! save
15//! ```
16//!
17//! # Example
18//!
19//! ```no_run
20//! use sz_configtool_lib::command_processor::CommandProcessor;
21//!
22//! let config = std::fs::read_to_string("g2config.json")?;
23//! let mut processor = CommandProcessor::new(config);
24//!
25//! let upgraded = processor.process_file("upgrade-10-to-11.gtc")?;
26//! std::fs::write("g2config_v11.json", upgraded)?;
27//!
28//! println!("{}", processor.summary());
29//! # Ok::<(), Box<dyn std::error::Error>>(())
30//! ```
31
32use crate::error::{Result, SzConfigError};
33use serde_json::Value;
34use std::fs;
35use std::path::Path;
36
37/// Processes Senzing command scripts (.gtc files)
38pub struct CommandProcessor {
39    config: String,
40    commands_executed: Vec<String>,
41    dry_run: bool,
42}
43
44impl CommandProcessor {
45    /// Create a new processor with initial configuration
46    ///
47    /// # Arguments
48    /// * `config_json` - Initial configuration JSON string
49    pub fn new(config_json: String) -> Self {
50        Self {
51            config: config_json,
52            commands_executed: Vec::new(),
53            dry_run: false,
54        }
55    }
56
57    /// Enable or disable dry-run mode
58    ///
59    /// In dry-run mode, commands are validated but not applied to the config.
60    ///
61    /// # Arguments
62    /// * `enabled` - true to enable dry-run mode
63    pub fn dry_run(mut self, enabled: bool) -> Self {
64        self.dry_run = enabled;
65        self
66    }
67
68    /// Process a command script from a file
69    ///
70    /// # Arguments
71    /// * `path` - Path to .gtc script file
72    ///
73    /// # Returns
74    /// Modified configuration JSON string
75    pub fn process_file<P: AsRef<Path>>(&mut self, path: P) -> Result<String> {
76        let content = fs::read_to_string(path.as_ref()).map_err(|e| {
77            SzConfigError::InvalidConfig(format!("Failed to read script file: {e}"))
78        })?;
79        self.process_script(&content)
80    }
81
82    /// Process a command script from a string
83    ///
84    /// # Arguments
85    /// * `script` - Script content with line-based commands
86    ///
87    /// # Returns
88    /// Modified configuration JSON string
89    pub fn process_script(&mut self, script: &str) -> Result<String> {
90        for (line_num, line) in script.lines().enumerate() {
91            let trimmed = line.trim();
92
93            // Skip blank lines and comments
94            if trimmed.is_empty() || trimmed.starts_with('#') {
95                continue;
96            }
97
98            // Process command
99            if let Err(e) = self.process_command(trimmed) {
100                return Err(SzConfigError::InvalidConfig(format!(
101                    "Line {}: {} - Error: {}",
102                    line_num + 1,
103                    trimmed,
104                    e
105                )));
106            }
107
108            // Track executed command (skip "save" which is a no-op)
109            if trimmed != "save" {
110                self.commands_executed
111                    .push(format!("Line {}: {}", line_num + 1, trimmed));
112            }
113        }
114
115        Ok(self.config.clone())
116    }
117
118    /// Process a single command line
119    fn process_command(&mut self, line: &str) -> Result<()> {
120        // Handle save command (no-op in library context)
121        if line == "save" {
122            return Ok(());
123        }
124
125        let (cmd, params) = parse_command_line(line)?;
126
127        // Execute command
128        let new_config = execute_command(&self.config, &cmd, &params)?;
129
130        // Update config unless dry-run
131        if !self.dry_run {
132            self.config = new_config;
133        }
134
135        Ok(())
136    }
137
138    /// Get execution summary
139    pub fn summary(&self) -> String {
140        format!(
141            "Executed {} commands{}",
142            self.commands_executed.len(),
143            if self.dry_run { " (DRY RUN)" } else { "" }
144        )
145    }
146
147    /// Get list of executed commands
148    pub fn get_executed_commands(&self) -> &[String] {
149        &self.commands_executed
150    }
151
152    /// Get current configuration
153    pub fn get_config(&self) -> &str {
154        &self.config
155    }
156}
157
158/// Parse a command line into (command_name, parameters)
159fn parse_command_line(line: &str) -> Result<(String, Value)> {
160    let parts: Vec<&str> = line.splitn(2, ' ').collect();
161
162    if parts.is_empty() {
163        return Err(SzConfigError::InvalidInput("Empty command".to_string()));
164    }
165
166    let cmd = parts[0].to_string();
167
168    let params = if parts.len() > 1 {
169        serde_json::from_str(parts[1])
170            .map_err(|e| SzConfigError::JsonParse(format!("Invalid JSON in '{cmd}': {e}")))?
171    } else {
172        Value::Null
173    };
174
175    Ok((cmd, params))
176}
177
178/// Execute a command and return updated config
179fn execute_command(config: &str, cmd: &str, params: &Value) -> Result<String> {
180    match cmd {
181        // ===== Versioning Commands =====
182        "verifyCompatibilityVersion" => {
183            let expected = get_str_param(params, "expectedVersion")?;
184            crate::versioning::verify_compatibility_version(config, expected)?;
185            Ok(config.to_string()) // Verification only, no modification
186        }
187
188        "updateCompatibilityVersion" => {
189            // Note: Function only takes new version, ignores fromVersion
190            let to = get_str_param(params, "toVersion")?;
191            crate::versioning::update_compatibility_version(config, to)
192        }
193
194        // ===== Config Section Commands =====
195        "removeConfigSection" => {
196            let section = get_str_param(params, "section")?;
197            crate::config_sections::remove_config_section(config, section)
198        }
199
200        "removeConfigSectionField" => {
201            let section = get_str_param(params, "section")?;
202            let field = get_str_param(params, "field")?;
203            crate::config_sections::remove_config_section_field(config, section, field)
204                .map(|(cfg, _)| cfg)
205        }
206
207        "addConfigSection" => {
208            let section = get_str_param(params, "section")?;
209            // add_config_section only takes section name, creates empty array
210            crate::config_sections::add_config_section(config, section)
211        }
212
213        "addConfigSectionField" => {
214            let section = get_str_param(params, "section")?;
215            let field = get_str_param(params, "field")?;
216            let value = &params["value"];
217            crate::config_sections::add_config_section_field(config, section, field, value)
218                .map(|(cfg, _)| cfg)
219        }
220
221        // ===== Attribute Commands =====
222        "addAttribute" => {
223            let attr = get_str_param(params, "attribute")?;
224            let class = get_str_param(params, "class")?;
225            let feature = get_str_param(params, "feature")?;
226            let element = get_str_param(params, "element")?;
227            let required = get_opt_str_param(params, "required");
228            let internal = get_opt_str_param(params, "internal");
229            let default_value = get_opt_str_param(params, "default");
230
231            crate::attributes::add_attribute(
232                config,
233                crate::attributes::AddAttributeParams {
234                    attribute: attr,
235                    feature,
236                    element,
237                    class,
238                    default_value,
239                    internal,
240                    required,
241                },
242            )
243            .map(|(cfg, _)| cfg)
244        }
245
246        "deleteAttribute" => {
247            let attr = get_str_param(params, "attribute")?;
248            crate::attributes::delete_attribute(config, attr)
249        }
250
251        "setAttribute" => {
252            let set_params = crate::attributes::SetAttributeParams::try_from(params)?;
253            crate::attributes::set_attribute(config, set_params)
254        }
255
256        // ===== Element Commands =====
257        "addElement" => {
258            let element = get_str_param(params, "element")?;
259            let datatype = get_opt_str_param(params, "datatype");
260
261            let add_params = crate::elements::AddElementParams {
262                code: element,
263                description: None, // Will default to code
264                data_type: datatype,
265                tokenized: None,
266            };
267
268            crate::elements::add_element(config, add_params)
269        }
270
271        "setFeatureElement" => {
272            let feature = get_str_param(params, "feature")?;
273            let element = get_str_param(params, "element")?;
274
275            // Check which property to set
276            if let Some(derived) = get_opt_str_param(params, "derived") {
277                crate::elements::set_feature_element_derived(config, feature, element, derived)
278            } else if let Some(display_level) = params.get("displayLevel").and_then(|v| v.as_i64())
279            {
280                crate::elements::set_feature_element_display_level(
281                    config,
282                    feature,
283                    element,
284                    display_level,
285                )
286            } else {
287                Err(SzConfigError::InvalidInput(
288                    "setFeatureElement requires 'derived' or 'displayLevel'".to_string(),
289                ))
290            }
291        }
292
293        // ===== Feature Commands =====
294        "addFeature" => {
295            let feature = get_str_param(params, "feature")?;
296            let element_list = params
297                .get("elementList")
298                .ok_or_else(|| SzConfigError::MissingField("elementList".to_string()))?;
299
300            crate::features::add_feature(
301                config,
302                crate::features::AddFeatureParams {
303                    feature,
304                    element_list,
305                    class: get_opt_str_param(params, "class"),
306                    behavior: get_opt_str_param(params, "behavior"),
307                    candidates: get_opt_str_param(params, "candidates"),
308                    anonymize: get_opt_str_param(params, "anonymize"),
309                    derived: get_opt_str_param(params, "derived"),
310                    history: get_opt_str_param(params, "history"),
311                    matchkey: get_opt_str_param(params, "matchKey"),
312                    standardize: get_opt_str_param(params, "standardize").filter(|s| !s.is_empty()),
313                    expression: get_opt_str_param(params, "expression").filter(|s| !s.is_empty()),
314                    comparison: get_opt_str_param(params, "comparison").filter(|s| !s.is_empty()),
315                    version: params.get("version").and_then(|v| v.as_i64()),
316                    rtype_id: params.get("rtypeId").and_then(|v| v.as_i64()),
317                },
318            )
319        }
320
321        "setFeature" => {
322            let feature = get_str_param(params, "feature")?;
323
324            crate::features::set_feature(
325                config,
326                crate::features::SetFeatureParams {
327                    feature,
328                    candidates: get_opt_str_param(params, "candidates"),
329                    anonymize: get_opt_str_param(params, "anonymize"),
330                    derived: get_opt_str_param(params, "derived"),
331                    history: get_opt_str_param(params, "history"),
332                    matchkey: get_opt_str_param(params, "matchKey"),
333                    behavior: get_opt_str_param(params, "behavior"),
334                    class: get_opt_str_param(params, "class"),
335                    version: params.get("version").and_then(|v| v.as_i64()),
336                    rtype_id: params.get("rtypeId").and_then(|v| v.as_i64()),
337                },
338            )
339        }
340
341        // ===== Behavior Override Commands =====
342        "addBehaviorOverride" => {
343            let feature = get_str_param(params, "feature")?;
344            let usage_type = get_str_param(params, "usageType")?;
345            let behavior = get_str_param(params, "behavior")?;
346
347            crate::behavior_overrides::add_behavior_override(
348                config,
349                crate::behavior_overrides::AddBehaviorOverrideParams::new(
350                    feature, usage_type, behavior,
351                ),
352            )
353        }
354
355        // ===== Fragment Commands =====
356        "deleteFragment" => {
357            let fragment = get_str_param(params, "fragment").or_else(|_| {
358                // Support old format: deleteFragment FRAGMENT_NAME
359                params
360                    .as_str()
361                    .ok_or_else(|| SzConfigError::MissingField("fragment".to_string()))
362            })?;
363            crate::fragments::delete_fragment(config, fragment)
364        }
365
366        "setFragment" => {
367            let fragment = get_str_param(params, "fragment")?;
368            let source = get_str_param(params, "source")?;
369
370            // Construct fragment config with ERFRAG_SOURCE
371            let fragment_config = serde_json::json!({
372                "ERFRAG_SOURCE": source
373            });
374
375            crate::fragments::set_fragment(config, fragment, &fragment_config)
376        }
377
378        "addFragment" => {
379            // add_fragment takes a full fragment config as Value
380            crate::fragments::add_fragment(config, params).map(|(cfg, _)| cfg)
381        }
382
383        // ===== Rule Commands =====
384        "addRule" => {
385            let id = params
386                .get("ERRULE_ID")
387                .and_then(|v| v.as_i64())
388                .unwrap_or(0);
389            crate::rules::add_rule(config, id, params).map(|(cfg, _)| cfg)
390        }
391
392        "setRule" => {
393            let set_params = crate::rules::SetRuleParams::try_from(params)?;
394            crate::rules::set_rule(config, set_params)
395        }
396
397        // ===== System Parameter Commands =====
398        "setSetting" => {
399            let name = get_str_param(params, "name")?;
400            let value = &params["value"];
401            crate::system_params::set_system_parameter(config, name, value)
402        }
403
404        // ===== Function Commands - Standardize =====
405        "removeStandardizeFunction" | "deleteStandardizeFunction" => {
406            let func = get_str_param(params, "function")?;
407            crate::functions::standardize::delete_standardize_function(config, func)
408                .map(|(cfg, _)| cfg)
409        }
410
411        "addStandardizeFunction" => {
412            let func = get_str_param(params, "function")?;
413            let connect = get_str_param(params, "connectStr")?;
414            let desc = get_opt_str_param(params, "description");
415            let language = get_opt_str_param(params, "language");
416
417            crate::functions::standardize::add_standardize_function(
418                config,
419                func,
420                crate::functions::standardize::AddStandardizeFunctionParams {
421                    connect_str: connect,
422                    description: desc,
423                    language,
424                },
425            )
426            .map(|(cfg, _)| cfg)
427        }
428
429        // ===== Function Commands - Comparison =====
430        "removeComparisonFunction" | "deleteComparisonFunction" => {
431            let func = get_str_param(params, "function")?;
432            crate::functions::comparison::delete_comparison_function(config, func)
433                .map(|(cfg, _)| cfg)
434        }
435
436        "addComparisonFunction" => {
437            let func = get_str_param(params, "function")?;
438            let connect = get_str_param(params, "connectStr")?;
439            let anon = get_opt_str_param(params, "anonSupport");
440            let desc = get_opt_str_param(params, "description");
441
442            crate::functions::comparison::add_comparison_function(
443                config,
444                func,
445                crate::functions::comparison::AddComparisonFunctionParams {
446                    connect_str: connect,
447                    description: desc,
448                    language: None,
449                    anon_support: anon,
450                },
451            )
452            .map(|(cfg, _)| cfg)
453        }
454
455        // ===== Function Commands - Expression =====
456        "addExpressionFunction" => {
457            let func = get_str_param(params, "function")?;
458            let connect = get_str_param(params, "connectStr")?;
459            let desc = get_opt_str_param(params, "description");
460            let language = get_opt_str_param(params, "language");
461
462            crate::functions::expression::add_expression_function(
463                config,
464                func,
465                crate::functions::expression::AddExpressionFunctionParams {
466                    connect_str: connect,
467                    description: desc,
468                    language,
469                },
470            )
471            .map(|(cfg, _)| cfg)
472        }
473
474        // ===== Threshold Commands =====
475        "addComparisonThreshold" => {
476            let func = get_str_param(params, "function")?;
477            let feature = get_str_param(params, "feature")?;
478            let score_name = get_str_param(params, "scoreName")?;
479            let same = params.get("sameScore").and_then(|v| v.as_i64());
480            let close = params.get("closeScore").and_then(|v| v.as_i64());
481            let likely = params.get("likelyScore").and_then(|v| v.as_i64());
482            let plausible = params.get("plausibleScore").and_then(|v| v.as_i64());
483            let unlikely = params.get("unlikelyScore").and_then(|v| v.as_i64());
484
485            crate::thresholds::add_comparison_threshold(
486                config,
487                crate::thresholds::AddComparisonThresholdParams {
488                    cfunc_code: Some(func),
489                    ftype_code: if feature.eq_ignore_ascii_case("ALL") {
490                        None
491                    } else {
492                        Some(feature)
493                    },
494                    cfunc_rtnval: Some(score_name),
495                    exec_order: None,
496                    same_score: same,
497                    close_score: close,
498                    likely_score: likely,
499                    plausible_score: plausible,
500                    un_likely_score: unlikely,
501                },
502            )
503        }
504
505        "addGenericThreshold" => {
506            let threshold_params = crate::thresholds::AddGenericThresholdParams::try_from(params)?;
507            crate::thresholds::add_generic_threshold(config, threshold_params)
508        }
509
510        // ===== Call Commands - Expression =====
511        "addExpressionCall" => {
512            let feature = get_str_param(params, "feature")?;
513            let function = get_str_param(params, "function")?;
514            let exec_order = params.get("execOrder").and_then(|v| v.as_i64());
515            let expr_feature = get_opt_str_param(params, "expressionFeature");
516            let virtual_flag = get_opt_str_param(params, "virtual").unwrap_or("No");
517            let element_list_json = params
518                .get("elementList")
519                .ok_or_else(|| SzConfigError::MissingField("elementList".to_string()))?;
520
521            // Parse elementList: [{"element": "NAME", "required": "Yes", "feature": "NAME"}, ...]
522            let element_list = parse_element_list(element_list_json)?;
523
524            let call_params = crate::calls::expression::AddExpressionCallParams {
525                efunc_code: function,
526                element_list,
527                ftype_code: Some(feature),
528                felem_code: None,
529                exec_order,
530                expression_feature: expr_feature,
531                is_virtual: virtual_flag,
532            };
533
534            let (new_config, _) =
535                crate::calls::expression::add_expression_call(config, call_params)?;
536
537            Ok(new_config)
538        }
539
540        // ===== Call Commands - Comparison =====
541        "deleteComparisonCallElement" => {
542            let feature = get_str_param(params, "feature")?;
543            let element = get_str_param(params, "element")?;
544
545            // Lookup IDs
546            let ftype_id = crate::helpers::lookup_feature_id(config, feature)?;
547            let felem_id = crate::helpers::lookup_element_id(config, element)?;
548
549            // Find the cfcall_id for this feature
550            let config_val: Value = serde_json::from_str(config)?;
551            let cfcall_array = config_val["G2_CONFIG"]["CFG_CFCALL"]
552                .as_array()
553                .ok_or_else(|| SzConfigError::MissingSection("CFG_CFCALL".to_string()))?;
554
555            // Find comparison call for this feature
556            let cfcall = cfcall_array
557                .iter()
558                .find(|call| call["FTYPE_ID"].as_i64() == Some(ftype_id))
559                .ok_or_else(|| {
560                    SzConfigError::NotFound(format!(
561                        "No comparison call found for feature {feature}"
562                    ))
563                })?;
564
565            let cfcall_id = cfcall["CFCALL_ID"]
566                .as_i64()
567                .ok_or_else(|| SzConfigError::InvalidStructure("CFCALL_ID missing".to_string()))?;
568
569            // Find exec_order from CFBOM
570            let cfbom_array = config_val["G2_CONFIG"]["CFG_CFBOM"]
571                .as_array()
572                .ok_or_else(|| SzConfigError::MissingSection("CFG_CFBOM".to_string()))?;
573
574            let cfbom = cfbom_array
575                .iter()
576                .find(|bom| {
577                    bom["CFCALL_ID"].as_i64() == Some(cfcall_id)
578                        && bom["FTYPE_ID"].as_i64() == Some(ftype_id)
579                        && bom["FELEM_ID"].as_i64() == Some(felem_id)
580                })
581                .ok_or_else(|| {
582                    SzConfigError::NotFound(format!(
583                        "Element {element} not found in comparison call for feature {feature}"
584                    ))
585                })?;
586
587            let exec_order = cfbom["EXEC_ORDER"]
588                .as_i64()
589                .ok_or_else(|| SzConfigError::InvalidStructure("EXEC_ORDER missing".to_string()))?;
590
591            // Call underlying SDK function
592            crate::calls::comparison::delete_comparison_call_element(
593                config,
594                cfcall_id,
595                crate::calls::comparison::DeleteComparisonCallElementParams {
596                    ftype_id,
597                    felem_id,
598                    exec_order,
599                },
600            )
601        }
602
603        "addComparisonCallElement" => {
604            let feature = get_str_param(params, "feature")?;
605            let element = get_str_param(params, "element")?;
606
607            // Lookup IDs
608            let ftype_id = crate::helpers::lookup_feature_id(config, feature)?;
609            let felem_id = crate::helpers::lookup_element_id(config, element)?;
610
611            // Find the cfcall_id for this feature
612            let config_val: Value = serde_json::from_str(config)?;
613            let cfcall_array = config_val["G2_CONFIG"]["CFG_CFCALL"]
614                .as_array()
615                .ok_or_else(|| SzConfigError::MissingSection("CFG_CFCALL".to_string()))?;
616
617            let cfcall = cfcall_array
618                .iter()
619                .find(|call| call["FTYPE_ID"].as_i64() == Some(ftype_id))
620                .ok_or_else(|| {
621                    SzConfigError::NotFound(format!(
622                        "No comparison call found for feature {feature}"
623                    ))
624                })?;
625
626            let cfcall_id = cfcall["CFCALL_ID"]
627                .as_i64()
628                .ok_or_else(|| SzConfigError::InvalidStructure("CFCALL_ID missing".to_string()))?;
629
630            // Determine next exec_order
631            let cfbom_array = config_val["G2_CONFIG"]["CFG_CFBOM"]
632                .as_array()
633                .ok_or_else(|| SzConfigError::MissingSection("CFG_CFBOM".to_string()))?;
634
635            let next_order = cfbom_array
636                .iter()
637                .filter(|bom| {
638                    bom["CFCALL_ID"].as_i64() == Some(cfcall_id)
639                        && bom["FTYPE_ID"].as_i64() == Some(ftype_id)
640                })
641                .filter_map(|bom| bom["EXEC_ORDER"].as_i64())
642                .max()
643                .unwrap_or(0)
644                + 1;
645
646            // Call underlying SDK function
647            crate::calls::comparison::add_comparison_call_element(
648                config,
649                crate::calls::comparison::AddComparisonCallElementParams {
650                    cfcall_id,
651                    ftype_id,
652                    felem_id,
653                    exec_order: next_order,
654                },
655            )
656            .map(|(cfg, _)| cfg)
657        }
658
659        // ===== Call Commands - Distinct =====
660        "deleteDistinctCallElement" => {
661            let feature = get_str_param(params, "feature")?;
662            let element = get_str_param(params, "element")?;
663
664            // Lookup IDs
665            let ftype_id = crate::helpers::lookup_feature_id(config, feature)?;
666            let felem_id = crate::helpers::lookup_element_id(config, element)?;
667
668            // Find the dfcall_id for this feature
669            let config_val: Value = serde_json::from_str(config)?;
670            let dfcall_array = config_val["G2_CONFIG"]["CFG_DFCALL"]
671                .as_array()
672                .ok_or_else(|| SzConfigError::MissingSection("CFG_DFCALL".to_string()))?;
673
674            let dfcall = dfcall_array
675                .iter()
676                .find(|call| call["FTYPE_ID"].as_i64() == Some(ftype_id))
677                .ok_or_else(|| {
678                    SzConfigError::NotFound(format!("No distinct call found for feature {feature}"))
679                })?;
680
681            let dfcall_id = dfcall["DFCALL_ID"]
682                .as_i64()
683                .ok_or_else(|| SzConfigError::InvalidStructure("DFCALL_ID missing".to_string()))?;
684
685            // Find exec_order from DFBOM
686            let dfbom_array = config_val["G2_CONFIG"]["CFG_DFBOM"]
687                .as_array()
688                .ok_or_else(|| SzConfigError::MissingSection("CFG_DFBOM".to_string()))?;
689
690            let dfbom = dfbom_array
691                .iter()
692                .find(|bom| {
693                    bom["DFCALL_ID"].as_i64() == Some(dfcall_id)
694                        && bom["FTYPE_ID"].as_i64() == Some(ftype_id)
695                        && bom["FELEM_ID"].as_i64() == Some(felem_id)
696                })
697                .ok_or_else(|| {
698                    SzConfigError::NotFound(format!(
699                        "Element {element} not found in distinct call for feature {feature}"
700                    ))
701                })?;
702
703            let exec_order = dfbom["EXEC_ORDER"]
704                .as_i64()
705                .ok_or_else(|| SzConfigError::InvalidStructure("EXEC_ORDER missing".to_string()))?;
706
707            // Call underlying SDK function
708            crate::calls::distinct::delete_distinct_call_element(
709                config,
710                crate::calls::distinct::DeleteDistinctCallElementParams {
711                    dfcall_id,
712                    ftype_id,
713                    felem_id,
714                    exec_order,
715                },
716            )
717        }
718
719        // ===== No-op Commands =====
720        "save" => Ok(config.to_string()),
721
722        // ===== Unknown Command =====
723        _ => Err(SzConfigError::InvalidInput(format!(
724            "Unknown command: '{cmd}'"
725        ))),
726    }
727}
728
729// ===== Helper Functions =====
730
731/// Get required string parameter
732fn get_str_param<'a>(params: &'a Value, key: &str) -> Result<&'a str> {
733    params[key]
734        .as_str()
735        .ok_or_else(|| SzConfigError::MissingField(key.to_string()))
736}
737
738/// Get optional string parameter
739fn get_opt_str_param<'a>(params: &'a Value, key: &str) -> Option<&'a str> {
740    params.get(key).and_then(|v| v.as_str())
741}
742
743/// Parse elementList from JSON into Vec<(element, required, feature)>
744fn parse_element_list(list: &Value) -> Result<Vec<(String, String, Option<String>)>> {
745    let arr = list
746        .as_array()
747        .ok_or_else(|| SzConfigError::InvalidInput("elementList must be array".to_string()))?;
748
749    arr.iter()
750        .map(|item| {
751            let element = item["element"]
752                .as_str()
753                .ok_or_else(|| SzConfigError::MissingField("element".to_string()))?
754                .to_string();
755            let required = item
756                .get("required")
757                .and_then(|v| v.as_str())
758                .unwrap_or("No")
759                .to_string();
760            let feature = item
761                .get("feature")
762                .and_then(|v| v.as_str())
763                .map(|s| s.to_string());
764
765            Ok((element, required, feature))
766        })
767        .collect()
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    const TEST_CONFIG: &str = r#"{
775  "G2_CONFIG": {
776    "CFG_DSRC": [],
777    "CFG_ATTR": [],
778    "CFG_FTYPE": [],
779    "CFG_FELEM": [],
780    "CFG_FCLASS": [
781      {"FCLASS_ID": 1, "FCLASS_CODE": "OTHER"}
782    ],
783    "CFG_FBOVR": [],
784    "CFG_ERFRAG": [],
785    "CONFIG_BASE_VERSION": {
786      "VERSION": "4.0.0",
787      "BUILD_VERSION": "4.0.0.0",
788      "BUILD_DATE": "2024-01-01",
789      "COMPATIBILITY_VERSION": {
790        "CONFIG_VERSION": "10"
791      }
792    }
793  }
794}"#;
795
796    #[test]
797    fn test_parse_command_line() {
798        let (cmd, params) =
799            parse_command_line(r#"addAttribute {"attribute": "TEST", "class": "OTHER"}"#)
800                .expect("Failed to parse");
801
802        assert_eq!(cmd, "addAttribute");
803        assert_eq!(params["attribute"], "TEST");
804        assert_eq!(params["class"], "OTHER");
805    }
806
807    #[test]
808    fn test_parse_command_line_no_params() {
809        let (cmd, params) = parse_command_line("save").expect("Failed to parse");
810
811        assert_eq!(cmd, "save");
812        assert!(params.is_null());
813    }
814
815    #[test]
816    fn test_command_processor_simple_script() {
817        let script = r#"
818verifyCompatibilityVersion {"expectedVersion": "10"}
819updateCompatibilityVersion {"fromVersion": "10", "toVersion": "11"}
820save
821"#;
822
823        let mut processor = CommandProcessor::new(TEST_CONFIG.to_string());
824        let result = processor.process_script(script);
825
826        assert!(result.is_ok());
827        assert_eq!(processor.commands_executed.len(), 2); // save is ignored
828
829        let config: Value = serde_json::from_str(&result.unwrap()).unwrap();
830        assert_eq!(
831            config["G2_CONFIG"]["CONFIG_BASE_VERSION"]["COMPATIBILITY_VERSION"]["CONFIG_VERSION"],
832            "11"
833        );
834    }
835
836    #[test]
837    fn test_command_processor_dry_run() {
838        let script = r#"updateCompatibilityVersion {"fromVersion": "10", "toVersion": "11"}"#;
839
840        let mut processor = CommandProcessor::new(TEST_CONFIG.to_string()).dry_run(true);
841        let result = processor.process_script(script);
842
843        assert!(result.is_ok());
844        assert_eq!(processor.commands_executed.len(), 1);
845
846        // Config should be unchanged in dry-run
847        let config: Value = serde_json::from_str(&result.unwrap()).unwrap();
848        assert_eq!(
849            config["G2_CONFIG"]["CONFIG_BASE_VERSION"]["COMPATIBILITY_VERSION"]["CONFIG_VERSION"],
850            "10"
851        );
852    }
853
854    #[test]
855    fn test_command_processor_invalid_command() {
856        let script = r#"unknownCommand {"param": "value"}"#;
857
858        let mut processor = CommandProcessor::new(TEST_CONFIG.to_string());
859        let result = processor.process_script(script);
860
861        assert!(result.is_err());
862        assert!(result.unwrap_err().to_string().contains("Unknown command"));
863    }
864
865    #[test]
866    fn test_command_processor_invalid_json() {
867        let script = r#"addAttribute {invalid json}"#;
868
869        let mut processor = CommandProcessor::new(TEST_CONFIG.to_string());
870        let result = processor.process_script(script);
871
872        assert!(result.is_err());
873        assert!(result.unwrap_err().to_string().contains("Invalid JSON"));
874    }
875
876    #[test]
877    fn test_execute_add_behavior_override() {
878        let config_with_feature = r#"{
879  "G2_CONFIG": {
880    "CFG_FTYPE": [
881      {"FTYPE_ID": 1, "FTYPE_CODE": "TEST"}
882    ],
883    "CFG_FBOVR": []
884  }
885}"#;
886
887        let params = serde_json::json!({
888            "feature": "TEST",
889            "usageType": "BUSINESS",
890            "behavior": "F1E"
891        });
892
893        let result = execute_command(config_with_feature, "addBehaviorOverride", &params);
894        assert!(result.is_ok());
895
896        let config: Value = serde_json::from_str(&result.unwrap()).unwrap();
897        let overrides = &config["G2_CONFIG"]["CFG_FBOVR"];
898        assert_eq!(overrides.as_array().unwrap().len(), 1);
899        assert_eq!(overrides[0]["UTYPE_CODE"], "BUSINESS");
900    }
901}