Skip to main content

konveyor_core/
rule.rs

1//! Konveyor rule definition types.
2//!
3//! These types represent the YAML rule format that Konveyor/kantra consumes.
4//! They are used by the semver-analyzer to generate migration rules and can
5//! be consumed by any tool that produces Konveyor-compatible rulesets.
6
7use crate::fix::FixStrategyEntry;
8use serde::{Deserialize, Serialize};
9
10/// Ruleset metadata (written to `ruleset.yaml`).
11#[derive(Debug, Serialize, Deserialize)]
12pub struct KonveyorRuleset {
13    pub name: String,
14    pub description: String,
15    pub labels: Vec<String>,
16}
17
18/// A single Konveyor rule.
19#[derive(Debug, Serialize, Deserialize)]
20pub struct KonveyorRule {
21    #[serde(rename = "ruleID")]
22    pub rule_id: String,
23    pub labels: Vec<String>,
24    pub effort: u32,
25    pub category: String,
26    pub description: String,
27    pub message: String,
28    #[serde(skip_serializing_if = "Vec::is_empty", default)]
29    pub links: Vec<KonveyorLink>,
30    pub when: KonveyorCondition,
31    /// Fix strategy for this rule. Not serialized to kantra YAML -- written
32    /// separately to fix-strategies.json after consolidation.
33    #[serde(skip)]
34    pub fix_strategy: Option<FixStrategyEntry>,
35}
36
37/// A hyperlink attached to a rule.
38#[derive(Debug, Serialize, Deserialize)]
39pub struct KonveyorLink {
40    pub url: String,
41    pub title: String,
42}
43
44/// A Konveyor `when` condition.
45///
46/// Supports `builtin.filecontent` (regex), `builtin.json` (xpath),
47/// `frontend.referenced` (AST-level, requires a frontend-analyzer-provider),
48/// and `or`/`and` combinators.
49#[derive(Debug, Serialize, Deserialize)]
50#[serde(untagged)]
51pub enum KonveyorCondition {
52    FileContent {
53        #[serde(rename = "builtin.filecontent")]
54        filecontent: FileContentFields,
55    },
56    Json {
57        #[serde(rename = "builtin.json")]
58        json: JsonFields,
59    },
60    FrontendReferenced {
61        #[serde(rename = "frontend.referenced")]
62        referenced: FrontendReferencedFields,
63    },
64    FrontendCssClass {
65        #[serde(rename = "frontend.cssclass")]
66        cssclass: FrontendPatternFields,
67    },
68    FrontendCssVar {
69        #[serde(rename = "frontend.cssvar")]
70        cssvar: FrontendPatternFields,
71    },
72    FrontendDependency {
73        #[serde(rename = "frontend.dependency")]
74        dependency: FrontendDependencyFields,
75    },
76    JavaReferenced {
77        #[serde(rename = "java.referenced")]
78        referenced: JavaReferencedFields,
79    },
80    JavaDependency {
81        #[serde(rename = "java.dependency")]
82        dependency: JavaDependencyFields,
83    },
84    Or {
85        or: Vec<KonveyorCondition>,
86    },
87    And {
88        and: Vec<KonveyorCondition>,
89    },
90    /// Negated `builtin.filecontent`: matches when the pattern is NOT found.
91    FileContentNegated {
92        #[serde(rename = "not")]
93        negated: bool,
94        #[serde(rename = "builtin.filecontent")]
95        filecontent: FileContentFields,
96    },
97}
98
99/// Fields for `frontend.cssclass` and `frontend.cssvar` conditions.
100#[derive(Debug, Serialize, Deserialize)]
101pub struct FrontendPatternFields {
102    pub pattern: String,
103    /// File path regex filter. Only scan files whose path matches this pattern.
104    #[serde(
105        rename = "filePattern",
106        skip_serializing_if = "Option::is_none",
107        default
108    )]
109    pub file_pattern: Option<String>,
110}
111
112/// Fields for a `frontend.dependency` condition.
113///
114/// Matches dependencies in package.json by name and optional version bounds.
115/// The provider checks `dependencies`, `devDependencies`, and `peerDependencies`.
116#[derive(Debug, Serialize, Deserialize)]
117pub struct FrontendDependencyFields {
118    /// Exact dependency name (e.g., `@patternfly/react-core`).
119    #[serde(skip_serializing_if = "Option::is_none", default)]
120    pub name: Option<String>,
121    /// Regex pattern for dependency name.
122    #[serde(skip_serializing_if = "Option::is_none", default)]
123    pub nameregex: Option<String>,
124    /// Match dependencies with version <= this bound.
125    #[serde(skip_serializing_if = "Option::is_none", default)]
126    pub upperbound: Option<String>,
127    /// Match dependencies with version >= this bound.
128    #[serde(skip_serializing_if = "Option::is_none", default)]
129    pub lowerbound: Option<String>,
130}
131
132/// Fields for a `builtin.filecontent` condition.
133#[derive(Debug, Serialize, Deserialize)]
134pub struct FileContentFields {
135    pub pattern: String,
136    #[serde(rename = "filePattern")]
137    pub file_pattern: String,
138}
139
140/// Fields for a `builtin.json` condition.
141#[derive(Debug, Serialize, Deserialize)]
142pub struct JsonFields {
143    pub xpath: String,
144    #[serde(skip_serializing_if = "Option::is_none", default)]
145    pub filepaths: Option<Vec<String>>,
146}
147
148/// Fields for a `frontend.referenced` condition.
149///
150/// This condition requires a frontend-analyzer-provider gRPC server.
151/// It performs AST-level symbol matching with location discriminators.
152#[derive(Debug, Serialize, Deserialize)]
153pub struct FrontendReferencedFields {
154    /// Regex pattern for the symbol name.
155    pub pattern: String,
156    /// Where to look: IMPORT, JSX_COMPONENT, JSX_PROP, FUNCTION_CALL, TYPE_REFERENCE.
157    pub location: String,
158    /// Filter JSX props to only those on this component (regex).
159    #[serde(skip_serializing_if = "Option::is_none", default)]
160    pub component: Option<String>,
161    /// Filter JSX components to only those inside this parent (regex).
162    #[serde(skip_serializing_if = "Option::is_none", default)]
163    pub parent: Option<String>,
164    /// Negative parent filter: only match when parent does NOT match this pattern.
165    /// Used for conformance rules (e.g., "ModalHeader must be inside Modal").
166    #[serde(rename = "notParent", skip_serializing_if = "Option::is_none", default)]
167    pub not_parent: Option<String>,
168    /// Filter by the parent component's import source (regex).
169    #[serde(
170        rename = "parentFrom",
171        skip_serializing_if = "Option::is_none",
172        default
173    )]
174    pub parent_from: Option<String>,
175    /// Positive child filter: only match the component (via `pattern`) if it
176    /// has at least one direct JSX child whose name matches this regex. The
177    /// incident is emitted on the parent component. Used for migration rules
178    /// to detect old-style children still present (e.g., `child: ^ModalBox$`
179    /// fires on `<Modal>` only when it still contains old internal components).
180    #[serde(skip_serializing_if = "Option::is_none", default)]
181    pub child: Option<String>,
182    /// Negative child filter: match the parent component (via `pattern`) and
183    /// emit incidents for each direct JSX child whose name does NOT match this
184    /// pattern. Used for "exclusive wrapper" rules (e.g., "all children of
185    /// InputGroup must be InputGroupItem or InputGroupText").
186    #[serde(rename = "notChild", skip_serializing_if = "Option::is_none", default)]
187    pub not_child: Option<String>,
188    /// Negative-existence child filter: match the component (via `pattern`)
189    /// and emit an incident if NONE of its direct JSX children match this
190    /// regex. Used for conformance rules like "AlertGroup must contain Alert."
191    ///
192    /// Inverse of `child` (which gates on existence). Complementary to
193    /// `notChild` (which fires per non-matching child).
194    #[serde(
195        rename = "requiresChild",
196        skip_serializing_if = "Option::is_none",
197        default
198    )]
199    pub requires_child: Option<String>,
200    /// Filter JSX prop values to only those matching this regex.
201    #[serde(skip_serializing_if = "Option::is_none", default)]
202    pub value: Option<String>,
203    /// Scope to imports from a specific package (e.g., `@patternfly/react-tokens`).
204    #[serde(skip_serializing_if = "Option::is_none", default)]
205    pub from: Option<String>,
206    /// File path regex filter. Only scan files whose path matches this pattern.
207    /// e.g., `".*\\.(test|spec)\\.(ts|tsx|js|jsx)$"` to scope to test files.
208    #[serde(
209        rename = "filePattern",
210        skip_serializing_if = "Option::is_none",
211        default
212    )]
213    pub file_pattern: Option<String>,
214}
215
216/// Fields for a `java.referenced` condition.
217///
218/// Uses the Konveyor Java provider (Eclipse JDTLS under the hood) for
219/// AST-level symbol matching with source code location discriminators.
220#[derive(Debug, Serialize, Deserialize)]
221pub struct JavaReferencedFields {
222    /// Regex pattern for the fully-qualified symbol (e.g., `org.springframework.boot.autoconfigure.cache*`).
223    pub pattern: String,
224    /// Source code location to search. One of: IMPORT, PACKAGE, TYPE,
225    /// ANNOTATION, METHOD_CALL, CONSTRUCTOR_CALL, INHERITANCE,
226    /// IMPLEMENTS_TYPE, ENUM_CONSTANT, RETURN_TYPE, VARIABLE_DECLARATION,
227    /// FIELD, METHOD, CLASS.
228    #[serde(skip_serializing_if = "Option::is_none", default)]
229    pub location: Option<String>,
230    /// Additional annotation inspection filter.
231    #[serde(skip_serializing_if = "Option::is_none", default)]
232    pub annotated: Option<JavaAnnotatedFields>,
233}
234
235/// Annotation inspection sub-condition for `java.referenced`.
236#[derive(Debug, Serialize, Deserialize)]
237pub struct JavaAnnotatedFields {
238    /// Regex pattern for the annotation's fully-qualified name.
239    #[serde(skip_serializing_if = "Option::is_none", default)]
240    pub pattern: Option<String>,
241    /// Annotation element constraints.
242    #[serde(skip_serializing_if = "Vec::is_empty", default)]
243    pub elements: Vec<JavaAnnotationElement>,
244}
245
246/// An annotation element constraint for `annotated`.
247#[derive(Debug, Serialize, Deserialize)]
248pub struct JavaAnnotationElement {
249    /// Exact element name.
250    pub name: String,
251    /// Regex to match the element value.
252    pub value: String,
253}
254
255/// Fields for a `java.dependency` condition.
256///
257/// Checks whether the application has a Maven/Gradle dependency matching
258/// the given name and optional version bounds.
259#[derive(Debug, Serialize, Deserialize)]
260pub struct JavaDependencyFields {
261    /// Dependency coordinate (e.g., `org.springframework.boot.spring-boot-starter-web`).
262    #[serde(skip_serializing_if = "Option::is_none", default)]
263    pub name: Option<String>,
264    /// Regex pattern for dependency name.
265    #[serde(skip_serializing_if = "Option::is_none", default)]
266    pub nameregex: Option<String>,
267    /// Match dependencies with version <= this bound.
268    #[serde(skip_serializing_if = "Option::is_none", default)]
269    pub upperbound: Option<String>,
270    /// Match dependencies with version >= this bound.
271    #[serde(skip_serializing_if = "Option::is_none", default)]
272    pub lowerbound: Option<String>,
273}
274
275/// Extract all `FrontendReferencedFields` from a `KonveyorCondition`,
276/// recursing into `Or`/`And` combinators.
277pub fn extract_frontend_refs(condition: &KonveyorCondition) -> Vec<&FrontendReferencedFields> {
278    match condition {
279        KonveyorCondition::FrontendReferenced { referenced } => vec![referenced],
280        KonveyorCondition::Or { or } => or.iter().flat_map(extract_frontend_refs).collect(),
281        KonveyorCondition::And { and } => and.iter().flat_map(extract_frontend_refs).collect(),
282        _ => vec![],
283    }
284}
285
286/// Extract the file pattern from an existing condition (for reuse in consolidated rules).
287pub fn extract_file_pattern_from_condition(condition: &KonveyorCondition) -> Option<String> {
288    match condition {
289        KonveyorCondition::FileContent { filecontent } => Some(filecontent.file_pattern.clone()),
290        KonveyorCondition::Or { or } => or.first().and_then(extract_file_pattern_from_condition),
291        _ => None,
292    }
293}
294
295/// Deduplicate conditions by their JSON representation.
296pub fn dedup_conditions(conditions: Vec<KonveyorCondition>) -> Vec<KonveyorCondition> {
297    use std::collections::BTreeSet;
298    let mut seen = BTreeSet::new();
299    let mut unique = Vec::new();
300    for cond in conditions {
301        let key = serde_json::to_string(&cond).unwrap_or_default();
302        if seen.insert(key) {
303            unique.push(cond);
304        }
305    }
306    unique
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_frontend_dependency_serializes_to_yaml() {
315        let condition = KonveyorCondition::FrontendDependency {
316            dependency: FrontendDependencyFields {
317                name: Some("@patternfly/react-core".into()),
318                nameregex: None,
319                upperbound: Some("5.99.99".into()),
320                lowerbound: None,
321            },
322        };
323        let yaml = serde_yaml::to_string(&condition).unwrap();
324        assert!(
325            yaml.contains("frontend.dependency"),
326            "Should serialize with frontend.dependency key"
327        );
328        assert!(yaml.contains("@patternfly/react-core"));
329        assert!(yaml.contains("5.99.99"));
330        // Optional None fields should not appear
331        assert!(!yaml.contains("nameregex"));
332        assert!(!yaml.contains("lowerbound"));
333    }
334
335    #[test]
336    fn test_frontend_dependency_roundtrips() {
337        let condition = KonveyorCondition::FrontendDependency {
338            dependency: FrontendDependencyFields {
339                name: Some("@patternfly/react-core".into()),
340                nameregex: None,
341                upperbound: Some("5.99.99".into()),
342                lowerbound: Some("4.0.0".into()),
343            },
344        };
345        let yaml = serde_yaml::to_string(&condition).unwrap();
346        let deserialized: KonveyorCondition = serde_yaml::from_str(&yaml).unwrap();
347        match deserialized {
348            KonveyorCondition::FrontendDependency { dependency } => {
349                assert_eq!(dependency.name, Some("@patternfly/react-core".into()));
350                assert_eq!(dependency.upperbound, Some("5.99.99".into()));
351                assert_eq!(dependency.lowerbound, Some("4.0.0".into()));
352                assert_eq!(dependency.nameregex, None);
353            }
354            _ => panic!("Should deserialize as FrontendDependency"),
355        }
356    }
357
358    #[test]
359    fn test_frontend_dependency_in_rule_yaml() {
360        let rule = KonveyorRule {
361            rule_id: "test-dep-rule".into(),
362            labels: vec!["source=test".into()],
363            effort: 1,
364            category: "mandatory".into(),
365            description: "Update dep".into(),
366            message: "Update this dependency".into(),
367            links: vec![],
368            when: KonveyorCondition::FrontendDependency {
369                dependency: FrontendDependencyFields {
370                    name: Some("@patternfly/react-core".into()),
371                    nameregex: None,
372                    upperbound: Some("5.99.99".into()),
373                    lowerbound: None,
374                },
375            },
376            fix_strategy: None,
377        };
378        let yaml = serde_yaml::to_string(&rule).unwrap();
379        assert!(yaml.contains("frontend.dependency:"));
380        assert!(
381            yaml.contains("name: '@patternfly/react-core'")
382                || yaml.contains("name: \"@patternfly/react-core\"")
383                || yaml.contains("name: '@patternfly/react-core'")
384        );
385    }
386}