Skip to main content

sz_configtool_lib/
rules.rs

1//! Rule (CFG_ERRULE) operations
2//!
3//! Functions for managing entity resolution rules in the configuration.
4//! Rules define matching and relationship logic based on fragments.
5
6use crate::error::{Result, SzConfigError};
7use crate::helpers;
8use serde_json::{Value, json};
9
10// ============================================================================
11// Parameter Structs
12// ============================================================================
13
14/// Parameters for setting (updating) a rule
15#[derive(Debug, Clone)]
16pub struct SetRuleParams<'a> {
17    pub code: &'a str,
18    pub resolve: Option<&'a str>,
19    pub relate: Option<&'a str>,
20    pub rtype_id: Option<i64>,
21    pub fragment: Option<&'a str>,
22    pub disqualifier: Option<&'a str>,
23    pub tier: Option<i64>,
24}
25
26impl<'a> TryFrom<&'a Value> for SetRuleParams<'a> {
27    type Error = SzConfigError;
28
29    fn try_from(json: &'a Value) -> Result<Self> {
30        let code = json
31            .get("code")
32            .and_then(|v| v.as_str())
33            .or_else(|| json.get("rule").and_then(|v| v.as_str()))
34            .ok_or_else(|| SzConfigError::MissingField("code or rule".to_string()))?;
35
36        Ok(Self {
37            code,
38            resolve: json
39                .get("resolve")
40                .and_then(|v| v.as_str())
41                .or_else(|| json.get("RESOLVE").and_then(|v| v.as_str())),
42            relate: json
43                .get("relate")
44                .and_then(|v| v.as_str())
45                .or_else(|| json.get("RELATE").and_then(|v| v.as_str())),
46            rtype_id: json
47                .get("rtypeId")
48                .and_then(|v| v.as_i64())
49                .or_else(|| json.get("RTYPE_ID").and_then(|v| v.as_i64())),
50            fragment: json
51                .get("fragment")
52                .and_then(|v| v.as_str())
53                .or_else(|| json.get("FRAGMENT").and_then(|v| v.as_str())),
54            disqualifier: json
55                .get("disqualifier")
56                .and_then(|v| v.as_str())
57                .or_else(|| json.get("DISQUALIFIER").and_then(|v| v.as_str())),
58            tier: json
59                .get("tier")
60                .and_then(|v| v.as_i64())
61                .or_else(|| json.get("TIER").and_then(|v| v.as_i64())),
62        })
63    }
64}
65
66/// Add a new rule to the configuration
67///
68/// # Arguments
69///
70/// * `config_json` - Configuration JSON string
71/// * `rule_config` - JSON configuration for the rule (must include ERRULE_CODE)
72///
73/// # Returns
74///
75/// Returns `(modified_config, new_rule_id)` tuple on success
76///
77/// # Example
78///
79/// ```
80/// use sz_configtool_lib::rules;
81/// use serde_json::json;
82///
83/// let config = r#"{"G2_CONFIG": {"CFG_ERRULE": []}}"#;
84/// let rule_config = json!({
85///     "ERRULE_CODE": "CUSTOM_RULE",
86///     "RESOLVE": "Yes",
87///     "RELATE": "No",
88///     "RTYPE_ID": 1
89/// });
90/// // ID parameter is required (0 for auto-assign, >0 for specific ID)
91/// let (_modified, _rule_id) = rules::add_rule(config, 0, &rule_config).unwrap();
92/// ```
93pub fn add_rule(config_json: &str, id: i64, rule_config: &Value) -> Result<(String, i64)> {
94    let code = rule_config
95        .get("ERRULE_CODE")
96        .and_then(|v| v.as_str())
97        .ok_or_else(|| SzConfigError::MissingField("ERRULE_CODE".to_string()))?;
98
99    // Validate ID not already taken
100    let config_data: Value = serde_json::from_str(config_json)?;
101    if let Some(errule_array) = config_data
102        .get("G2_CONFIG")
103        .and_then(|g| g.get("CFG_ERRULE"))
104        .and_then(|v| v.as_array())
105    {
106        if errule_array
107            .iter()
108            .any(|item| item.get("ERRULE_ID").and_then(|v| v.as_i64()) == Some(id))
109        {
110            return Err(SzConfigError::AlreadyExists(
111                "The specified ID is already taken".to_string(),
112            ));
113        }
114    }
115
116    // Create new item with provided config plus ID
117    let mut new_item = rule_config.clone();
118    if let Some(obj) = new_item.as_object_mut() {
119        obj.insert("ERRULE_ID".to_string(), json!(id));
120        obj.insert("ERRULE_CODE".to_string(), json!(code.to_uppercase()));
121    }
122
123    // Add to config
124    let modified_json = helpers::add_to_config_array(config_json, "CFG_ERRULE", new_item)?;
125
126    Ok((modified_json, id))
127}
128
129/// Delete a rule from the configuration
130///
131/// # Arguments
132///
133/// * `config_json` - Configuration JSON string
134/// * `rule_code` - Rule code to delete
135///
136/// # Returns
137///
138/// Returns modified configuration JSON on success
139///
140/// # Example
141///
142/// ```
143/// use sz_configtool_lib::rules;
144///
145/// let config = r#"{"G2_CONFIG": {"CFG_ERRULE": [{"ERRULE_ID": 1, "ERRULE_CODE": "TEST"}]}}"#;
146/// let modified = rules::delete_rule(config, "TEST").unwrap();
147/// ```
148pub fn delete_rule(config_json: &str, rule_code: &str) -> Result<String> {
149    let rule_code = rule_code.to_uppercase();
150
151    // Verify rule exists before deletion
152    let _ = helpers::find_in_config_array(config_json, "CFG_ERRULE", "ERRULE_CODE", &rule_code)?
153        .ok_or_else(|| SzConfigError::NotFound(format!("Rule not found: {rule_code}")))?;
154
155    // Remove from config
156    helpers::remove_from_config_array(config_json, "CFG_ERRULE", "ERRULE_CODE", &rule_code)
157}
158
159/// Get a rule by code or ID
160///
161/// # Arguments
162///
163/// * `config_json` - Configuration JSON string
164/// * `code_or_id` - Rule code or ID to search for
165///
166/// # Returns
167///
168/// Returns the rule JSON object on success
169///
170/// # Example
171///
172/// ```
173/// use sz_configtool_lib::rules;
174///
175/// let config = r#"{"G2_CONFIG": {"CFG_ERRULE": [{"ERRULE_ID": 1, "ERRULE_CODE": "TEST"}]}}"#;
176/// let rule = rules::get_rule(config, "TEST").unwrap();
177/// ```
178pub fn get_rule(config_json: &str, code_or_id: &str) -> Result<Value> {
179    let search_value = code_or_id.to_uppercase();
180
181    // Try to find by CODE first, then by ID
182    let item = if let Some(item) =
183        helpers::find_in_config_array(config_json, "CFG_ERRULE", "ERRULE_CODE", &search_value)?
184    {
185        item
186    } else if let Some(item) =
187        helpers::find_in_config_array(config_json, "CFG_ERRULE", "ERRULE_ID", &search_value)?
188    {
189        item
190    } else {
191        return Err(SzConfigError::NotFound(format!(
192            "Rule not found: {search_value}"
193        )));
194    };
195
196    // Transform to lowercase format (matching list_rules for consistency)
197    let resolve = item.get("RESOLVE").and_then(|v| v.as_str()).unwrap_or("");
198    let tier = if resolve == "Yes" {
199        item.get("ERRULE_TIER").and_then(|v| v.as_i64())
200    } else {
201        None
202    };
203
204    Ok(json!({
205        "id": item.get("ERRULE_ID").and_then(|v| v.as_i64()).unwrap_or(0),
206        "rule": item.get("ERRULE_CODE").and_then(|v| v.as_str()).unwrap_or(""),
207        "resolve": resolve,
208        "relate": item.get("RELATE").and_then(|v| v.as_str()).unwrap_or(""),
209        "rtype_id": item.get("RTYPE_ID").and_then(|v| v.as_i64()).unwrap_or(0),
210        "fragment": item.get("QUAL_ERFRAG_CODE").and_then(|v| v.as_str()).unwrap_or(""),
211        "disqualifier": item.get("DISQ_ERFRAG_CODE").and_then(|v| v.as_str()).unwrap_or(""),
212        "tier": tier
213    }))
214}
215
216/// List all rules in the configuration
217///
218/// # Arguments
219///
220/// * `config_json` - Configuration JSON string
221///
222/// # Returns
223///
224/// Returns a vector of rule objects in Python sz_configtool format
225///
226/// # Example
227///
228/// ```
229/// use sz_configtool_lib::rules;
230///
231/// let config = r#"{"G2_CONFIG": {"CFG_ERRULE": [{"ERRULE_ID": 1, "ERRULE_CODE": "TEST", "RESOLVE": "Yes", "RELATE": "No", "RTYPE_ID": 1, "QUAL_ERFRAG_CODE": "", "DISQ_ERFRAG_CODE": "", "ERRULE_TIER": 10}]}}"#;
232/// let rules = rules::list_rules(config).unwrap();
233/// assert_eq!(rules.len(), 1);
234/// ```
235pub fn list_rules(config_json: &str) -> Result<Vec<Value>> {
236    let config_data: Value = serde_json::from_str(config_json)?;
237
238    // Extract rules and transform to Python format
239    let items: Vec<Value> = if let Some(g2_config) = config_data.get("G2_CONFIG") {
240        if let Some(array) = g2_config.get("CFG_ERRULE").and_then(|v| v.as_array()) {
241            array
242                .iter()
243                .map(|item| {
244                    let resolve = item.get("RESOLVE").and_then(|v| v.as_str()).unwrap_or("");
245                    let tier = if resolve == "Yes" {
246                        item.get("ERRULE_TIER").and_then(|v| v.as_i64())
247                    } else {
248                        None
249                    };
250
251                    json!({
252                        "id": item.get("ERRULE_ID").and_then(|v| v.as_i64()).unwrap_or(0),
253                        "rule": item.get("ERRULE_CODE").and_then(|v| v.as_str()).unwrap_or(""),
254                        "resolve": resolve,
255                        "relate": item.get("RELATE").and_then(|v| v.as_str()).unwrap_or(""),
256                        "rtype_id": item.get("RTYPE_ID").and_then(|v| v.as_i64()).unwrap_or(0),
257                        "fragment": item.get("QUAL_ERFRAG_CODE").and_then(|v| v.as_str()).unwrap_or(""),
258                        "disqualifier": item.get("DISQ_ERFRAG_CODE").and_then(|v| v.as_str()).unwrap_or(""),
259                        "tier": tier
260                    })
261                })
262                .collect()
263        } else {
264            Vec::new()
265        }
266    } else {
267        Vec::new()
268    };
269
270    Ok(items)
271}
272
273/// Update an existing rule in the configuration
274///
275/// # Arguments
276///
277/// * `config_json` - Configuration JSON string
278/// * `rule_code` - Rule code to update
279/// * `rule_config` - New configuration for the rule
280///
281/// # Returns
282///
283/// Returns modified configuration JSON on success
284///
285/// # Example
286///
287/// ```
288/// use sz_configtool_lib::rules;
289///
290/// let config = r#"{"G2_CONFIG": {"CFG_ERRULE": [{"ERRULE_ID": 1, "ERRULE_CODE": "TEST", "RESOLVE": "No"}], "CFG_ERFRAG": []}}"#;
291/// let params = rules::SetRuleParams {
292///     code: "TEST",
293///     resolve: Some("Yes"),
294///     relate: Some("No"),
295///     rtype_id: None,
296///     fragment: None,
297///     disqualifier: None,
298///     tier: None,
299/// };
300/// let modified = rules::set_rule(config, params).unwrap();
301/// ```
302pub fn set_rule(config_json: &str, params: SetRuleParams) -> Result<String> {
303    let code = params.code.to_uppercase();
304
305    // Get existing rule to validate and merge updates
306    let existing_rule =
307        helpers::find_in_config_array(config_json, "CFG_ERRULE", "ERRULE_CODE", &code)?
308            .ok_or_else(|| SzConfigError::NotFound(format!("Rule not found: {code}")))?;
309
310    // Validate NEW fragment if being updated (line 4683-4686)
311    if let Some(frag) = params.fragment {
312        let frag_upper = frag.to_uppercase();
313        helpers::find_in_config_array(config_json, "CFG_ERFRAG", "ERFRAG_CODE", &frag_upper)?
314            .ok_or_else(|| SzConfigError::NotFound(format!("Fragment '{frag_upper}' not found")))?;
315    }
316
317    // Validate NEW disqualifier if being updated (line 4688-4692)
318    if let Some(disq) = params.disqualifier {
319        let disq_upper = disq.to_uppercase();
320        helpers::find_in_config_array(config_json, "CFG_ERFRAG", "ERFRAG_CODE", &disq_upper)?
321            .ok_or_else(|| SzConfigError::NotFound(format!("Fragment '{disq_upper}' not found")))?;
322    }
323
324    // Determine final RESOLVE value (from params or existing)
325    let resolve_value = params
326        .resolve
327        .or_else(|| existing_rule.get("RESOLVE").and_then(|v| v.as_str()))
328        .unwrap_or("No");
329
330    // CHECK 3: RESOLVE domain validation (line 4694-4697)
331    let resolve_upper = resolve_value.to_uppercase();
332    if resolve_upper != "YES" && resolve_upper != "NO" {
333        return Err(SzConfigError::InvalidInput(
334            "resolve value must be in [\"Yes\", \"No\"]".to_string(),
335        ));
336    }
337    let final_resolve = if resolve_upper == "YES" { "Yes" } else { "No" };
338
339    // Determine final RELATE value (from params or existing)
340    let relate_value = params
341        .relate
342        .or_else(|| existing_rule.get("RELATE").and_then(|v| v.as_str()))
343        .unwrap_or("No");
344
345    // CHECK 4: RELATE domain validation (line 4699-4702)
346    let relate_upper = relate_value.to_uppercase();
347    if relate_upper != "YES" && relate_upper != "NO" {
348        return Err(SzConfigError::InvalidInput(
349            "relate value must be in [\"Yes\", \"No\"]".to_string(),
350        ));
351    }
352    let final_relate = if relate_upper == "YES" { "Yes" } else { "No" };
353
354    // CHECK 5: Can't have both RESOLVE=Yes AND RELATE=Yes (line 4704-4709)
355    if final_resolve == "Yes" && final_relate == "Yes" {
356        return Err(SzConfigError::InvalidInput(
357            "A rule must either resolve or relate, please set the other to No".to_string(),
358        ));
359    }
360
361    // Determine final RTYPE_ID (from params or existing)
362    let mut final_rtype_id = params
363        .rtype_id
364        .or_else(|| existing_rule.get("RTYPE_ID").and_then(|v| v.as_i64()))
365        .unwrap_or(1);
366
367    // AUTO-CORRECT: RESOLVE=Yes forces RTYPE_ID to 1 (Python line 4722-4725)
368    // "just do it without making them wonder"
369    if final_resolve == "Yes" && final_rtype_id != 1 {
370        final_rtype_id = 1;
371    }
372
373    // CHECK 8: RELATE=Yes requires RTYPE_ID in [2, 3, 4] (line 4731-4736)
374    if final_relate == "Yes" && ![2, 3, 4].contains(&final_rtype_id) {
375        return Err(SzConfigError::InvalidInput(
376            "Relationship type (RTYPE_ID) must be set to either 2=Possible match or 3=Possibly related".to_string(),
377        ));
378    }
379
380    // Extract ERRULE_ID from existing rule to preserve it
381    let errule_id = existing_rule
382        .get("ERRULE_ID")
383        .and_then(|v| v.as_i64())
384        .unwrap_or(0);
385
386    // Build update object from params with validated values
387    let mut updated_item = json!({
388        "ERRULE_ID": errule_id,
389        "ERRULE_CODE": code.clone()
390    });
391
392    if let Some(obj) = updated_item.as_object_mut() {
393        if params.resolve.is_some() {
394            obj.insert("RESOLVE".to_string(), json!(final_resolve));
395        }
396        if params.relate.is_some() {
397            obj.insert("RELATE".to_string(), json!(final_relate));
398        }
399        // Insert RTYPE_ID if explicitly provided OR if resolve was updated (auto-correction may have occurred)
400        if params.rtype_id.is_some() || params.resolve.is_some() {
401            obj.insert("RTYPE_ID".to_string(), json!(final_rtype_id));
402        }
403        if let Some(frag) = params.fragment {
404            obj.insert("QUAL_ERFRAG_CODE".to_string(), json!(frag.to_uppercase()));
405        }
406        if let Some(disq) = params.disqualifier {
407            obj.insert("DISQ_ERFRAG_CODE".to_string(), json!(disq.to_uppercase()));
408        }
409        if let Some(tier) = params.tier {
410            obj.insert("ERRULE_TIER".to_string(), json!(tier));
411        }
412    }
413
414    // Update the item in the config
415    helpers::update_in_config_array(
416        config_json,
417        "CFG_ERRULE",
418        "ERRULE_CODE",
419        &code,
420        updated_item,
421    )
422}