syncable_cli/analyzer/helmlint/rules/
hl1xxx.rs

1//! HL1xxx - Chart Structure Rules
2//!
3//! Rules for validating Helm chart structure, Chart.yaml, and file organization.
4
5use crate::analyzer::helmlint::parser::chart::ApiVersion;
6use crate::analyzer::helmlint::rules::{LintContext, Rule};
7use crate::analyzer::helmlint::types::{CheckFailure, RuleCategory, Severity};
8
9/// Get all HL1xxx rules.
10pub fn rules() -> Vec<Box<dyn Rule>> {
11    vec![
12        Box::new(HL1001),
13        Box::new(HL1002),
14        Box::new(HL1003),
15        Box::new(HL1004),
16        Box::new(HL1005),
17        Box::new(HL1006),
18        Box::new(HL1007),
19        Box::new(HL1008),
20        Box::new(HL1009),
21        Box::new(HL1010),
22        Box::new(HL1011),
23        Box::new(HL1012),
24        Box::new(HL1013),
25        Box::new(HL1014),
26        Box::new(HL1015),
27        Box::new(HL1016),
28        Box::new(HL1017),
29    ]
30}
31
32/// HL1001: Missing Chart.yaml
33pub struct HL1001;
34
35impl Rule for HL1001 {
36    fn code(&self) -> &'static str {
37        "HL1001"
38    }
39
40    fn severity(&self) -> Severity {
41        Severity::Error
42    }
43
44    fn name(&self) -> &'static str {
45        "missing-chart-yaml"
46    }
47
48    fn description(&self) -> &'static str {
49        "Chart.yaml is required for all Helm charts"
50    }
51
52    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
53        if ctx.chart_metadata.is_none() && !ctx.has_file("Chart.yaml") {
54            vec![CheckFailure::new(
55                "HL1001",
56                Severity::Error,
57                "Missing Chart.yaml file",
58                "Chart.yaml",
59                1,
60                RuleCategory::Structure,
61            )]
62        } else {
63            vec![]
64        }
65    }
66}
67
68/// HL1002: Invalid apiVersion
69pub struct HL1002;
70
71impl Rule for HL1002 {
72    fn code(&self) -> &'static str {
73        "HL1002"
74    }
75
76    fn severity(&self) -> Severity {
77        Severity::Error
78    }
79
80    fn name(&self) -> &'static str {
81        "invalid-api-version"
82    }
83
84    fn description(&self) -> &'static str {
85        "Chart apiVersion must be v1 or v2"
86    }
87
88    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
89        if let Some(chart) = ctx.chart_metadata
90            && !chart.has_valid_api_version()
91        {
92            let version = match &chart.api_version {
93                ApiVersion::Unknown(v) => v.clone(),
94                _ => "unknown".to_string(),
95            };
96            return vec![CheckFailure::new(
97                "HL1002",
98                Severity::Error,
99                format!("Invalid apiVersion '{}'. Must be v1 or v2", version),
100                "Chart.yaml",
101                1,
102                RuleCategory::Structure,
103            )];
104        }
105        vec![]
106    }
107}
108
109/// HL1003: Missing required field 'name'
110pub struct HL1003;
111
112impl Rule for HL1003 {
113    fn code(&self) -> &'static str {
114        "HL1003"
115    }
116
117    fn severity(&self) -> Severity {
118        Severity::Error
119    }
120
121    fn name(&self) -> &'static str {
122        "missing-name"
123    }
124
125    fn description(&self) -> &'static str {
126        "Chart.yaml must have a 'name' field"
127    }
128
129    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
130        if let Some(chart) = ctx.chart_metadata
131            && chart.name.is_empty()
132        {
133            return vec![CheckFailure::new(
134                "HL1003",
135                Severity::Error,
136                "Missing required field 'name' in Chart.yaml",
137                "Chart.yaml",
138                1,
139                RuleCategory::Structure,
140            )];
141        }
142        vec![]
143    }
144}
145
146/// HL1004: Missing required field 'version'
147pub struct HL1004;
148
149impl Rule for HL1004 {
150    fn code(&self) -> &'static str {
151        "HL1004"
152    }
153
154    fn severity(&self) -> Severity {
155        Severity::Error
156    }
157
158    fn name(&self) -> &'static str {
159        "missing-version"
160    }
161
162    fn description(&self) -> &'static str {
163        "Chart.yaml must have a 'version' field"
164    }
165
166    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
167        if let Some(chart) = ctx.chart_metadata
168            && chart.version.is_empty()
169        {
170            return vec![CheckFailure::new(
171                "HL1004",
172                Severity::Error,
173                "Missing required field 'version' in Chart.yaml",
174                "Chart.yaml",
175                1,
176                RuleCategory::Structure,
177            )];
178        }
179        vec![]
180    }
181}
182
183/// HL1005: Version not valid SemVer
184pub struct HL1005;
185
186impl Rule for HL1005 {
187    fn code(&self) -> &'static str {
188        "HL1005"
189    }
190
191    fn severity(&self) -> Severity {
192        Severity::Warning
193    }
194
195    fn name(&self) -> &'static str {
196        "invalid-semver"
197    }
198
199    fn description(&self) -> &'static str {
200        "Chart version should be valid SemVer"
201    }
202
203    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
204        if let Some(chart) = ctx.chart_metadata
205            && !chart.version.is_empty()
206            && !is_valid_semver(&chart.version)
207        {
208            return vec![CheckFailure::new(
209                "HL1005",
210                Severity::Warning,
211                format!(
212                    "Version '{}' is not valid SemVer (expected X.Y.Z format)",
213                    chart.version
214                ),
215                "Chart.yaml",
216                1,
217                RuleCategory::Structure,
218            )];
219        }
220        vec![]
221    }
222}
223
224/// HL1006: Missing description
225pub struct HL1006;
226
227impl Rule for HL1006 {
228    fn code(&self) -> &'static str {
229        "HL1006"
230    }
231
232    fn severity(&self) -> Severity {
233        Severity::Info
234    }
235
236    fn name(&self) -> &'static str {
237        "missing-description"
238    }
239
240    fn description(&self) -> &'static str {
241        "Chart should have a description"
242    }
243
244    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
245        if let Some(chart) = ctx.chart_metadata
246            && (chart.description.is_none()
247                || chart
248                    .description
249                    .as_ref()
250                    .map(|d| d.is_empty())
251                    .unwrap_or(true))
252        {
253            return vec![CheckFailure::new(
254                "HL1006",
255                Severity::Info,
256                "Chart.yaml is missing a description",
257                "Chart.yaml",
258                1,
259                RuleCategory::Structure,
260            )];
261        }
262        vec![]
263    }
264}
265
266/// HL1007: Missing maintainers
267pub struct HL1007;
268
269impl Rule for HL1007 {
270    fn code(&self) -> &'static str {
271        "HL1007"
272    }
273
274    fn severity(&self) -> Severity {
275        Severity::Info
276    }
277
278    fn name(&self) -> &'static str {
279        "missing-maintainers"
280    }
281
282    fn description(&self) -> &'static str {
283        "Chart should have maintainers listed"
284    }
285
286    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
287        if let Some(chart) = ctx.chart_metadata
288            && chart.maintainers.is_empty()
289        {
290            return vec![CheckFailure::new(
291                "HL1007",
292                Severity::Info,
293                "Chart.yaml has no maintainers listed",
294                "Chart.yaml",
295                1,
296                RuleCategory::Structure,
297            )];
298        }
299        vec![]
300    }
301}
302
303/// HL1008: Chart is deprecated
304pub struct HL1008;
305
306impl Rule for HL1008 {
307    fn code(&self) -> &'static str {
308        "HL1008"
309    }
310
311    fn severity(&self) -> Severity {
312        Severity::Warning
313    }
314
315    fn name(&self) -> &'static str {
316        "chart-deprecated"
317    }
318
319    fn description(&self) -> &'static str {
320        "Chart is marked as deprecated"
321    }
322
323    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
324        if let Some(chart) = ctx.chart_metadata
325            && chart.is_deprecated()
326        {
327            return vec![CheckFailure::new(
328                "HL1008",
329                Severity::Warning,
330                "Chart is marked as deprecated",
331                "Chart.yaml",
332                1,
333                RuleCategory::Structure,
334            )];
335        }
336        vec![]
337    }
338}
339
340/// HL1009: Missing templates directory
341pub struct HL1009;
342
343impl Rule for HL1009 {
344    fn code(&self) -> &'static str {
345        "HL1009"
346    }
347
348    fn severity(&self) -> Severity {
349        Severity::Warning
350    }
351
352    fn name(&self) -> &'static str {
353        "missing-templates"
354    }
355
356    fn description(&self) -> &'static str {
357        "Chart should have a templates directory"
358    }
359
360    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
361        // Skip for library charts
362        if let Some(chart) = ctx.chart_metadata
363            && chart.is_library()
364        {
365            return vec![];
366        }
367
368        let has_templates = ctx
369            .files
370            .iter()
371            .any(|f| f.starts_with("templates/") || f.contains("/templates/"));
372        if !has_templates && ctx.templates.is_empty() {
373            return vec![CheckFailure::new(
374                "HL1009",
375                Severity::Warning,
376                "Chart has no templates directory",
377                ".",
378                1,
379                RuleCategory::Structure,
380            )];
381        }
382        vec![]
383    }
384}
385
386/// HL1010: Invalid chart type
387pub struct HL1010;
388
389impl Rule for HL1010 {
390    fn code(&self) -> &'static str {
391        "HL1010"
392    }
393
394    fn severity(&self) -> Severity {
395        Severity::Error
396    }
397
398    fn name(&self) -> &'static str {
399        "invalid-chart-type"
400    }
401
402    fn description(&self) -> &'static str {
403        "Chart type must be 'application' or 'library'"
404    }
405
406    fn check(&self, _ctx: &LintContext) -> Vec<CheckFailure> {
407        // This is handled during parsing - if type is invalid, serde will fail
408        // or produce Unknown variant which we handle elsewhere
409        vec![]
410    }
411}
412
413/// HL1011: Missing values.yaml
414pub struct HL1011;
415
416impl Rule for HL1011 {
417    fn code(&self) -> &'static str {
418        "HL1011"
419    }
420
421    fn severity(&self) -> Severity {
422        Severity::Warning
423    }
424
425    fn name(&self) -> &'static str {
426        "missing-values-yaml"
427    }
428
429    fn description(&self) -> &'static str {
430        "Chart should have a values.yaml file"
431    }
432
433    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
434        if ctx.values.is_none() && !ctx.has_file("values.yaml") {
435            return vec![CheckFailure::new(
436                "HL1011",
437                Severity::Warning,
438                "Missing values.yaml file",
439                "values.yaml",
440                1,
441                RuleCategory::Structure,
442            )];
443        }
444        vec![]
445    }
446}
447
448/// HL1012: Chart name contains invalid characters
449pub struct HL1012;
450
451impl Rule for HL1012 {
452    fn code(&self) -> &'static str {
453        "HL1012"
454    }
455
456    fn severity(&self) -> Severity {
457        Severity::Error
458    }
459
460    fn name(&self) -> &'static str {
461        "invalid-chart-name"
462    }
463
464    fn description(&self) -> &'static str {
465        "Chart name must contain only lowercase alphanumeric characters and hyphens"
466    }
467
468    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
469        if let Some(chart) = ctx.chart_metadata
470            && !is_valid_chart_name(&chart.name)
471        {
472            return vec![CheckFailure::new(
473                "HL1012",
474                Severity::Error,
475                format!(
476                    "Chart name '{}' contains invalid characters. Use only lowercase letters, numbers, and hyphens",
477                    chart.name
478                ),
479                "Chart.yaml",
480                1,
481                RuleCategory::Structure,
482            )];
483        }
484        vec![]
485    }
486}
487
488/// HL1013: Icon URL not HTTPS
489pub struct HL1013;
490
491impl Rule for HL1013 {
492    fn code(&self) -> &'static str {
493        "HL1013"
494    }
495
496    fn severity(&self) -> Severity {
497        Severity::Warning
498    }
499
500    fn name(&self) -> &'static str {
501        "icon-not-https"
502    }
503
504    fn description(&self) -> &'static str {
505        "Icon URL should use HTTPS"
506    }
507
508    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
509        if let Some(chart) = ctx.chart_metadata
510            && let Some(icon) = &chart.icon
511            && icon.starts_with("http://")
512        {
513            return vec![CheckFailure::new(
514                "HL1013",
515                Severity::Warning,
516                "Icon URL should use HTTPS instead of HTTP",
517                "Chart.yaml",
518                1,
519                RuleCategory::Structure,
520            )];
521        }
522        vec![]
523    }
524}
525
526/// HL1014: Home URL not HTTPS
527pub struct HL1014;
528
529impl Rule for HL1014 {
530    fn code(&self) -> &'static str {
531        "HL1014"
532    }
533
534    fn severity(&self) -> Severity {
535        Severity::Warning
536    }
537
538    fn name(&self) -> &'static str {
539        "home-not-https"
540    }
541
542    fn description(&self) -> &'static str {
543        "Home URL should use HTTPS"
544    }
545
546    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
547        if let Some(chart) = ctx.chart_metadata
548            && let Some(home) = &chart.home
549            && home.starts_with("http://")
550        {
551            return vec![CheckFailure::new(
552                "HL1014",
553                Severity::Warning,
554                "Home URL should use HTTPS instead of HTTP",
555                "Chart.yaml",
556                1,
557                RuleCategory::Structure,
558            )];
559        }
560        vec![]
561    }
562}
563
564/// HL1015: Duplicate dependency names
565pub struct HL1015;
566
567impl Rule for HL1015 {
568    fn code(&self) -> &'static str {
569        "HL1015"
570    }
571
572    fn severity(&self) -> Severity {
573        Severity::Error
574    }
575
576    fn name(&self) -> &'static str {
577        "duplicate-dependencies"
578    }
579
580    fn description(&self) -> &'static str {
581        "Chart has duplicate dependency names"
582    }
583
584    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
585        if let Some(chart) = ctx.chart_metadata {
586            let duplicates = chart.has_duplicate_dependencies();
587            if !duplicates.is_empty() {
588                return vec![CheckFailure::new(
589                    "HL1015",
590                    Severity::Error,
591                    format!("Duplicate dependency names: {}", duplicates.join(", ")),
592                    "Chart.yaml",
593                    1,
594                    RuleCategory::Structure,
595                )];
596            }
597        }
598        vec![]
599    }
600}
601
602/// HL1016: Dependency missing version
603pub struct HL1016;
604
605impl Rule for HL1016 {
606    fn code(&self) -> &'static str {
607        "HL1016"
608    }
609
610    fn severity(&self) -> Severity {
611        Severity::Warning
612    }
613
614    fn name(&self) -> &'static str {
615        "dependency-missing-version"
616    }
617
618    fn description(&self) -> &'static str {
619        "Chart dependency is missing a version"
620    }
621
622    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
623        let mut failures = Vec::new();
624        if let Some(chart) = ctx.chart_metadata {
625            for dep in &chart.dependencies {
626                if dep.version.is_none()
627                    || dep.version.as_ref().map(|v| v.is_empty()).unwrap_or(true)
628                {
629                    failures.push(CheckFailure::new(
630                        "HL1016",
631                        Severity::Warning,
632                        format!("Dependency '{}' is missing a version", dep.name),
633                        "Chart.yaml",
634                        1,
635                        RuleCategory::Structure,
636                    ));
637                }
638            }
639        }
640        failures
641    }
642}
643
644/// HL1017: Dependency missing repository
645pub struct HL1017;
646
647impl Rule for HL1017 {
648    fn code(&self) -> &'static str {
649        "HL1017"
650    }
651
652    fn severity(&self) -> Severity {
653        Severity::Error
654    }
655
656    fn name(&self) -> &'static str {
657        "dependency-missing-repository"
658    }
659
660    fn description(&self) -> &'static str {
661        "Chart dependency is missing a repository"
662    }
663
664    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
665        let mut failures = Vec::new();
666        if let Some(chart) = ctx.chart_metadata {
667            for dep in &chart.dependencies {
668                if dep.repository.is_none()
669                    || dep
670                        .repository
671                        .as_ref()
672                        .map(|r| r.is_empty())
673                        .unwrap_or(true)
674                {
675                    // Skip if it's a file:// reference (local dependency)
676                    failures.push(CheckFailure::new(
677                        "HL1017",
678                        Severity::Error,
679                        format!("Dependency '{}' is missing a repository", dep.name),
680                        "Chart.yaml",
681                        1,
682                        RuleCategory::Structure,
683                    ));
684                }
685            }
686        }
687        failures
688    }
689}
690
691/// Check if a version string is valid SemVer.
692fn is_valid_semver(version: &str) -> bool {
693    let parts: Vec<&str> = version.split('.').collect();
694    if parts.len() < 2 || parts.len() > 3 {
695        return false;
696    }
697
698    // Check major and minor are numeric
699    for (i, part) in parts.iter().enumerate() {
700        // Allow pre-release and build metadata on the last part
701        let numeric_part = if i == parts.len() - 1 {
702            part.split(['-', '+']).next().unwrap_or(part)
703        } else {
704            part
705        };
706
707        if numeric_part.parse::<u64>().is_err() {
708            return false;
709        }
710    }
711
712    true
713}
714
715/// Check if a chart name is valid.
716fn is_valid_chart_name(name: &str) -> bool {
717    if name.is_empty() {
718        return false;
719    }
720
721    // Must start with a letter
722    if !name
723        .chars()
724        .next()
725        .map(|c| c.is_ascii_lowercase())
726        .unwrap_or(false)
727    {
728        return false;
729    }
730
731    // Must contain only lowercase letters, numbers, and hyphens
732    name.chars()
733        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    #[test]
741    fn test_valid_semver() {
742        assert!(is_valid_semver("1.0.0"));
743        assert!(is_valid_semver("0.1.0"));
744        assert!(is_valid_semver("10.20.30"));
745        assert!(is_valid_semver("1.0.0-alpha"));
746        assert!(is_valid_semver("1.0.0+build"));
747        assert!(is_valid_semver("1.0"));
748        assert!(!is_valid_semver("1"));
749        assert!(!is_valid_semver("v1.0.0"));
750        assert!(!is_valid_semver("1.0.0.0"));
751        assert!(!is_valid_semver(""));
752    }
753
754    #[test]
755    fn test_valid_chart_name() {
756        assert!(is_valid_chart_name("my-chart"));
757        assert!(is_valid_chart_name("mychart"));
758        assert!(is_valid_chart_name("my-chart-123"));
759        assert!(!is_valid_chart_name("My-Chart"));
760        assert!(!is_valid_chart_name("my_chart"));
761        assert!(!is_valid_chart_name("123-chart"));
762        assert!(!is_valid_chart_name(""));
763    }
764}