yamllint_rs/
lib.rs

1use anyhow::Result;
2use ignore::WalkBuilder;
3use rayon::prelude::*;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicUsize, Ordering};
7use std::sync::Arc;
8
9pub mod analysis;
10pub mod config;
11pub mod directives;
12pub mod formatter;
13pub mod rule_pool;
14pub mod rules;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum OutputFormat {
18    Standard,
19    Colored,
20}
21
22#[derive(Debug, Clone)]
23pub struct ProcessingOptions {
24    pub recursive: bool,
25    pub verbose: bool,
26    pub output_format: OutputFormat,
27    pub show_progress: bool,
28}
29
30impl Default for ProcessingOptions {
31    fn default() -> Self {
32        Self {
33            recursive: false,
34            verbose: false,
35            output_format: OutputFormat::Colored,
36            show_progress: true,
37        }
38    }
39}
40
41pub fn detect_output_format(format_str: &str) -> OutputFormat {
42    match format_str {
43        "standard" => OutputFormat::Standard,
44        "colored" => OutputFormat::Colored,
45        "auto" | _ => {
46            if std::env::var("NO_COLOR").is_ok() {
47                return OutputFormat::Standard;
48            }
49
50            if !atty::is(atty::Stream::Stdout) {
51                return OutputFormat::Standard;
52            }
53
54            OutputFormat::Colored
55        }
56    }
57}
58
59pub struct FileProcessor {
60    options: ProcessingOptions,
61    rules: Arc<Vec<Box<dyn rules::Rule>>>,
62    fix_mode: bool,
63    config: Option<Arc<config::Config>>,
64    formatter: Box<dyn formatter::Formatter>,
65}
66
67impl FileProcessor {
68    fn should_run_rule_for_file(
69        rule_id: &str,
70        file_path: &str,
71        config: &Option<Arc<config::Config>>,
72    ) -> bool {
73        if let Some(config) = config {
74            if let Some(rule_config) = config.get_rule_config(rule_id) {
75                if let Some(ignore_val) = rule_config.other.get("ignore") {
76                    if let Some(ignore_str) = ignore_val.as_str() {
77                        let patterns: Vec<&str> = ignore_str
78                            .lines()
79                            .map(|line| line.trim())
80                            .filter(|line| !line.is_empty())
81                            .collect();
82
83                        for pattern in patterns {
84                            if file_path.contains(pattern) {
85                                return false;
86                            }
87                        }
88                    }
89                }
90            }
91        }
92        true
93    }
94
95    pub fn new(options: ProcessingOptions) -> Self {
96        let formatter = formatter::create_formatter(options.output_format);
97        Self {
98            options,
99            rules: Arc::new(Vec::new()),
100            fix_mode: false,
101            config: None,
102            formatter,
103        }
104    }
105
106    pub fn with_default_rules(options: ProcessingOptions) -> Self {
107        let factory = rules::factory::RuleFactory::new();
108        let config = config::Config::default();
109        let enabled_rules = config.get_enabled_rules();
110        let mut rules = factory.create_rules_by_ids_with_config(&enabled_rules, &config);
111        let config_arc = Arc::new(config);
112
113        for rule in &mut rules {
114            let severity = config_arc.get_rule_severity(rule.rule_id());
115            rule.set_severity(severity);
116        }
117
118        let formatter = formatter::create_formatter(options.output_format);
119        Self {
120            options,
121            rules: Arc::new(rules),
122            fix_mode: false,
123            config: Some(config_arc),
124            formatter,
125        }
126    }
127
128    pub fn with_fix_mode(options: ProcessingOptions) -> Self {
129        let mut processor = Self::with_default_rules(options);
130        processor.fix_mode = true;
131        processor
132    }
133
134    pub fn with_config(options: ProcessingOptions, config: config::Config) -> Self {
135        let factory = rules::factory::RuleFactory::new();
136        let enabled_rules = config.get_enabled_rules();
137
138        let config_arc = Arc::new(config);
139        let mut rules = factory.create_rules_by_ids_with_config(&enabled_rules, &config_arc);
140
141        for rule in &mut rules {
142            let severity = config_arc.get_rule_severity(rule.rule_id());
143            rule.set_severity(severity);
144        }
145
146        let formatter = formatter::create_formatter(options.output_format);
147        Self {
148            options,
149            rules: Arc::new(rules),
150            fix_mode: false,
151            config: Some(config_arc),
152            formatter,
153        }
154    }
155
156    pub fn with_config_and_fix_mode(options: ProcessingOptions, config: config::Config) -> Self {
157        let mut processor = Self::with_config(options, config);
158        processor.fix_mode = true;
159        processor
160    }
161
162    pub fn add_rule(&mut self, rule: Box<dyn rules::Rule>) {
163        Arc::get_mut(&mut self.rules)
164            .expect("Cannot add rule when rules are shared")
165            .push(rule);
166    }
167
168    pub fn process_file<P: AsRef<Path>>(&self, file_path: P) -> Result<LintResult> {
169        let path = file_path.as_ref();
170
171        if let Some(config) = &self.config {
172            let cwd = std::env::current_dir().ok();
173            let config_dir = cwd.as_deref();
174            if config.is_file_ignored(path, config_dir) {
175                return Ok(LintResult {
176                    file: self.get_relative_path(path),
177                    issues: vec![],
178                });
179            }
180        }
181
182        let relative_path = self.get_relative_path(path);
183
184        if self.options.verbose {
185            println!("Processing file: {}", relative_path);
186        }
187
188        let content = std::fs::read_to_string(path)?;
189
190        if self.fix_mode {
191            self.process_file_with_fixes(path, &content, &relative_path)
192        } else {
193            self.process_file_check_only(&content, &relative_path)
194        }
195    }
196
197    fn check_file_content(
198        rules: &[Box<dyn rules::Rule>],
199        content: &str,
200        relative_path: &str,
201        config: &Option<Arc<config::Config>>,
202    ) -> LintResult {
203        let all_rule_ids: std::collections::HashSet<String> =
204            rules.iter().map(|r| r.rule_id().to_string()).collect();
205        let mut directive_state = directives::DirectiveState::new(all_rule_ids);
206        directive_state.parse_from_content(content);
207
208        let analysis = analysis::ContentAnalysis::analyze(content);
209
210        let estimated_issues = rules.len() * 3;
211        let mut all_issues = Vec::with_capacity(estimated_issues);
212        for rule in rules {
213            let rule_id = rule.rule_id();
214            if !Self::should_run_rule_for_file(rule_id, relative_path, config) {
215                continue;
216            }
217            let issues = rule.check_with_analysis(content, relative_path, &analysis);
218            for issue in issues {
219                all_issues.push((issue, rule_id.to_string()));
220            }
221        }
222
223        let filtered_issues = directive_state.filter_issues(all_issues);
224        let mut sorted_issues = filtered_issues;
225        sorted_issues.sort_by(|a, b| a.0.line.cmp(&b.0.line).then(a.0.column.cmp(&b.0.column)));
226
227        LintResult {
228            file: relative_path.to_string(),
229            issues: sorted_issues,
230        }
231    }
232
233    fn process_file_check_only(&self, content: &str, relative_path: &str) -> Result<LintResult> {
234        let result =
235            Self::check_file_content(self.rules.as_slice(), content, relative_path, &self.config);
236
237        if result.issues.is_empty() {
238            if self.options.verbose {
239                println!("✓ No issues found in {}", result.file);
240            }
241        } else {
242            println!("{}", self.formatter.format_filename(&result.file));
243
244            let mut output = String::with_capacity(result.issues.len() * 120);
245
246            for (issue, rule_name) in &result.issues {
247                let formatted = self.formatter.format_issue(issue, rule_name);
248                output.push_str(&formatted);
249            }
250
251            print!("{}", output);
252        }
253
254        Ok(result)
255    }
256
257    fn apply_fixes_and_check(
258        rules: &[Box<dyn rules::Rule>],
259        content: &str,
260        relative_path: &str,
261        config: &Option<Arc<config::Config>>,
262    ) -> (String, usize, usize, Vec<(LintIssue, String)>) {
263        let registry = rules::registry::RuleRegistry::new();
264        let mut fixed_content = String::with_capacity(content.len());
265        fixed_content.push_str(content);
266        let mut total_fixes = 0;
267        let mut fixable_issues = 0;
268
269        let mut fixable_rules: Vec<(usize, usize)> = rules
270            .iter()
271            .enumerate()
272            .filter_map(|(idx, rule)| {
273                let rule_id = rule.rule_id();
274                if !Self::should_run_rule_for_file(rule_id, relative_path, config) {
275                    return None;
276                }
277                if !rule.can_fix() {
278                    return None;
279                }
280                let metadata = registry.get_rule_metadata(rule_id)?;
281                let order = metadata.fix_order.unwrap_or(999);
282                Some((idx, order))
283            })
284            .collect();
285
286        fixable_rules.sort_by_key(|(_, order)| *order);
287
288        for (idx, _) in fixable_rules {
289            let rule = &rules[idx];
290            let fix_result = rule.fix(&fixed_content, relative_path);
291            if fix_result.changed || fix_result.fixes_applied > 0 {
292                fixed_content = fix_result.content;
293                total_fixes += fix_result.fixes_applied;
294                fixable_issues += fix_result.fixes_applied;
295            }
296        }
297
298        let analysis = analysis::ContentAnalysis::analyze(&fixed_content);
299        let estimated_issues = rules.len() * 3;
300        let mut all_issues = Vec::with_capacity(estimated_issues);
301        for rule in rules {
302            let rule_id = rule.rule_id();
303            if !Self::should_run_rule_for_file(rule_id, relative_path, config) {
304                continue;
305            }
306            let issues = rule.check_with_analysis(&fixed_content, relative_path, &analysis);
307            for issue in issues {
308                all_issues.push((issue, rule_id.to_string()));
309            }
310        }
311
312        all_issues.sort_by(|a, b| a.0.line.cmp(&b.0.line).then(a.0.column.cmp(&b.0.column)));
313
314        (fixed_content, total_fixes, fixable_issues, all_issues)
315    }
316
317    fn process_file_with_fixes<P: AsRef<Path>>(
318        &self,
319        path: P,
320        content: &str,
321        relative_path: &str,
322    ) -> Result<LintResult> {
323        let (fixed_content, total_fixes, fixable_issues, all_issues) = Self::apply_fixes_and_check(
324            self.rules.as_slice(),
325            content,
326            relative_path,
327            &self.config,
328        );
329
330        let _non_fixable_issues = all_issues.len();
331
332        if fixed_content != content {
333            std::fs::write(path, &fixed_content)?;
334            if total_fixes > 0 {
335                println!(
336                    "Fixed {} issues in {} ({} fixable, {} remaining)",
337                    total_fixes, relative_path, fixable_issues, _non_fixable_issues
338                );
339            }
340        } else if _non_fixable_issues > 0 {
341            println!(
342                "Found {} non-fixable issues in {}:",
343                _non_fixable_issues, relative_path
344            );
345            for (issue, _rule_name) in &all_issues {
346                println!(
347                    "  {}:{}: {}: {}",
348                    issue.line,
349                    issue.column,
350                    format!("{:?}", issue.severity).to_lowercase(),
351                    issue.message
352                );
353            }
354        } else {
355            if self.options.verbose {
356                println!("✓ No issues found in {}", relative_path);
357            }
358        }
359
360        Ok(LintResult {
361            file: relative_path.to_string(),
362            issues: all_issues,
363        })
364    }
365
366    pub fn process_directory<P: AsRef<Path>>(&self, dir_path: P) -> Result<usize> {
367        let path = dir_path.as_ref();
368
369        if !path.is_dir() {
370            return Err(anyhow::anyhow!(
371                "Path is not a directory: {}",
372                path.display()
373            ));
374        }
375
376        if self.options.verbose {
377            println!("Processing directory: {}", path.display());
378        }
379
380        let mut yaml_files = Vec::with_capacity(100);
381
382        let walker = WalkBuilder::new(path).follow_links(false).build();
383
384        for result in walker {
385            let entry = result?;
386            let file_path = entry.path();
387            if file_path.is_file() && self.is_yaml_file(file_path) {
388                if let Some(config) = &self.config {
389                    let config_dir = Some(path);
390                    if config.is_file_ignored(file_path, config_dir) {
391                        continue;
392                    }
393                }
394                yaml_files.push(file_path.to_path_buf());
395            }
396        }
397
398        if yaml_files.is_empty() {
399            if self.options.verbose {
400                println!("No YAML files found in directory");
401            }
402            return Ok(0);
403        }
404
405        if self.options.verbose {
406            println!(
407                "Found {} YAML files, processing in parallel...",
408                yaml_files.len()
409            );
410        }
411
412        let options = self.options.clone();
413        let fix_mode = self.fix_mode;
414        let shared_rules = self.rules.clone();
415
416        let results = if options.show_progress {
417            let total = yaml_files.len();
418            let counter = Arc::new(AtomicUsize::new(0));
419            Self::process_files_list(
420                &yaml_files,
421                shared_rules,
422                &options,
423                fix_mode,
424                &self.config,
425                Some(counter),
426                Some(total),
427            )?
428        } else {
429            Self::process_files_list(
430                &yaml_files,
431                shared_rules,
432                &options,
433                fix_mode,
434                &self.config,
435                None,
436                None,
437            )?
438        };
439
440        let formatter = formatter::create_formatter(options.output_format);
441        let mut stdout = std::io::stdout().lock();
442        let mut total_issues = 0;
443        for result in &results {
444            if !result.issues.is_empty() {
445                total_issues += result.issues.len();
446                writeln!(stdout, "{}", formatter.format_filename(&result.file))?;
447
448                let mut output = String::with_capacity(result.issues.len() * 120);
449
450                for (issue, rule_name) in &result.issues {
451                    let formatted = formatter.format_issue(issue, rule_name);
452                    output.push_str(&formatted);
453                }
454
455                write!(stdout, "{}", output)?;
456            }
457        }
458
459        if self.options.verbose {
460            writeln!(stdout, "Successfully processed {} files", results.len())?;
461        }
462
463        if self.options.verbose {
464            writeln!(stdout, "Completed processing {} files", yaml_files.len())?;
465        }
466
467        Ok(total_issues)
468    }
469
470    fn is_yaml_file(&self, path: &Path) -> bool {
471        if let Some(ext) = path.extension() {
472            matches!(
473                ext.to_string_lossy().to_lowercase().as_str(),
474                "yaml" | "yml"
475            )
476        } else {
477            false
478        }
479    }
480
481    fn get_relative_path(&self, path: &Path) -> String {
482        Self::get_relative_path_static(path)
483    }
484
485    fn get_relative_path_static(path: &Path) -> String {
486        if let Ok(cwd) = std::env::current_dir() {
487            if let Ok(relative) = path.strip_prefix(&cwd) {
488                return relative.to_string_lossy().to_string();
489            }
490        }
491        path.to_string_lossy().to_string()
492    }
493
494    fn process_files_list(
495        files: &[PathBuf],
496        rules: Arc<Vec<Box<dyn rules::Rule>>>,
497        options: &ProcessingOptions,
498        fix_mode: bool,
499        config: &Option<Arc<config::Config>>,
500        counter: Option<Arc<AtomicUsize>>,
501        total: Option<usize>,
502    ) -> Result<Vec<LintResult>> {
503        if files.len() > 3 {
504            files
505                .par_iter()
506                .map(|file| {
507                    Self::process_single_file(
508                        rules.clone(),
509                        file,
510                        options,
511                        fix_mode,
512                        config,
513                        counter.as_ref().map(Arc::clone),
514                        total,
515                    )
516                })
517                .collect()
518        } else {
519            files
520                .iter()
521                .map(|file| {
522                    Self::process_single_file(
523                        rules.clone(),
524                        file,
525                        options,
526                        fix_mode,
527                        config,
528                        counter.as_ref().map(Arc::clone),
529                        total,
530                    )
531                })
532                .collect()
533        }
534    }
535
536    fn process_single_file(
537        rules: Arc<Vec<Box<dyn rules::Rule>>>,
538        file_path: &Path,
539        options: &ProcessingOptions,
540        fix_mode: bool,
541        config: &Option<Arc<config::Config>>,
542        counter: Option<Arc<AtomicUsize>>,
543        total: Option<usize>,
544    ) -> Result<LintResult> {
545        let relative_path = Self::get_relative_path_static(file_path);
546
547        if options.verbose {
548            eprintln!("Processing file: {}", relative_path);
549        }
550
551        let content = std::fs::read_to_string(file_path)?;
552
553        let result = if fix_mode {
554            Self::process_file_with_fixes_static(
555                &rules,
556                file_path,
557                &content,
558                &relative_path,
559                config,
560            )
561        } else {
562            Self::process_file_check_only_static(&rules, &content, &relative_path, config)
563        }?;
564
565        if let (Some(counter), Some(total)) = (counter, total) {
566            let count = counter.fetch_add(1, Ordering::Relaxed) + 1;
567            if count % 1000 == 0 || count == total {
568                let percent = (count * 100) / total;
569                eprintln!(
570                    "[Progress] Processed {}/{} files ({}%)",
571                    count, total, percent
572                );
573            }
574        }
575
576        Ok(result)
577    }
578
579    fn process_file_check_only_static(
580        rules: &[Box<dyn rules::Rule>],
581        content: &str,
582        relative_path: &str,
583        config: &Option<Arc<config::Config>>,
584    ) -> Result<LintResult> {
585        let result = Self::check_file_content(rules, content, relative_path, config);
586        Ok(result)
587    }
588
589    fn process_file_with_fixes_static(
590        rules: &[Box<dyn rules::Rule>],
591        path: &Path,
592        content: &str,
593        relative_path: &str,
594        config: &Option<Arc<config::Config>>,
595    ) -> Result<LintResult> {
596        let (fixed_content, total_fixes, fixable_issues, all_issues) =
597            Self::apply_fixes_and_check(rules, content, relative_path, config);
598
599        let _non_fixable_issues = all_issues.len();
600
601        if total_fixes > 0 {
602            std::fs::write(path, &fixed_content)?;
603            println!(
604                "Fixed {} issues in {} ({} fixable, {} remaining)",
605                total_fixes, relative_path, fixable_issues, _non_fixable_issues
606            );
607        } else if !all_issues.is_empty() {
608            println!(
609                "Found {} non-fixable issues in {}:",
610                _non_fixable_issues, relative_path
611            );
612            for (issue, rule_name) in &all_issues {
613                let level = match issue.severity {
614                    crate::Severity::Error => "error",
615                    crate::Severity::Warning => "warning",
616                    crate::Severity::Info => "info",
617                };
618                println!(
619                    "  {}:{}:{}: {} {} ({})",
620                    relative_path, issue.line, issue.column, level, issue.message, rule_name
621                );
622            }
623        }
624
625        Ok(LintResult {
626            file: relative_path.to_string(),
627            issues: all_issues,
628        })
629    }
630}
631
632pub fn load_config<P: AsRef<Path>>(path: P) -> Result<config::Config> {
633    let content = std::fs::read_to_string(path)?;
634
635    match parse_original_yamllint_format(&content) {
636        Ok(original_config) => return Ok(original_config),
637        Err(e) => {
638            if !e.to_string().contains("Not original yamllint format") {
639                return Err(e);
640            }
641        }
642    }
643
644    let config: config::Config = serde_yaml::from_str(&content)?;
645    Ok(config)
646}
647
648fn yaml_value_to_json(yaml_val: &serde_yaml::Value) -> serde_json::Value {
649    match yaml_val {
650        serde_yaml::Value::Null => serde_json::Value::Null,
651        serde_yaml::Value::Bool(b) => serde_json::Value::Bool(*b),
652        serde_yaml::Value::Number(n) => {
653            if let Some(u) = n.as_u64() {
654                serde_json::Value::Number(u.into())
655            } else if let Some(i) = n.as_i64() {
656                serde_json::Value::Number(i.into())
657            } else if let Some(f) = n.as_f64() {
658                serde_json::Number::from_f64(f)
659                    .map(serde_json::Value::Number)
660                    .unwrap_or(serde_json::Value::Null)
661            } else {
662                serde_json::Value::Null
663            }
664        }
665        serde_yaml::Value::String(s) => serde_json::Value::String(s.clone()),
666        serde_yaml::Value::Sequence(seq) => {
667            serde_json::Value::Array(seq.iter().map(yaml_value_to_json).collect())
668        }
669        serde_yaml::Value::Mapping(map) => serde_json::Value::Object(
670            map.iter()
671                .filter_map(|(k, v)| {
672                    k.as_str()
673                        .map(|key| (key.to_string(), yaml_value_to_json(v)))
674                })
675                .collect(),
676        ),
677        serde_yaml::Value::Tagged(_) => serde_json::Value::Null,
678    }
679}
680
681fn parse_original_yamllint_format(content: &str) -> Result<config::Config> {
682    use serde_yaml::Value;
683
684    let yaml_value: Value = serde_yaml::from_str(content)?;
685
686    let has_extends = yaml_value.get("extends").is_some();
687    let has_rules_simple_format = yaml_value
688        .get("rules")
689        .and_then(|r| r.as_mapping())
690        .map(|rules_map| {
691            rules_map
692                .values()
693                .any(|v| v.is_string() || (v.is_mapping() && v.get("level").is_some()))
694        })
695        .unwrap_or(false);
696
697    if has_extends {
698        return convert_original_yamllint_config(yaml_value);
699    }
700
701    if has_rules_simple_format {
702        if let Some(rules) = yaml_value.get("rules") {
703            if let Some(rules_map) = rules.as_mapping() {
704                let has_simple_values = rules_map
705                    .values()
706                    .any(|v| v.is_string() || (v.is_mapping() && v.get("level").is_some()));
707
708                if has_simple_values {
709                    return convert_original_yamllint_config(yaml_value);
710                }
711            }
712        }
713    }
714
715    Err(anyhow::anyhow!("Not original yamllint format"))
716}
717
718fn convert_original_yamllint_config(yaml_value: serde_yaml::Value) -> Result<config::Config> {
719    let mut config = config::Config::new();
720
721    if let Some(ignore_val) = yaml_value.get("ignore") {
722        if let Some(ignore_str) = ignore_val.as_str() {
723            config.ignore = Some(ignore_str.to_string());
724        } else if let Some(ignore_seq) = ignore_val.as_sequence() {
725            let patterns: Vec<String> = ignore_seq
726                .iter()
727                .filter_map(|v| v.as_str().map(|s| s.to_string()))
728                .collect();
729            config.ignore = Some(patterns.join("\n"));
730        }
731    }
732
733    if let Some(ignore_from_file_val) = yaml_value.get("ignore-from-file") {
734        if let Some(ignore_file_str) = ignore_from_file_val.as_str() {
735            config.ignore_from_file = Some(ignore_file_str.to_string());
736        } else if let Some(ignore_file_seq) = ignore_from_file_val.as_sequence() {
737            if let Some(first_file) = ignore_file_seq.first().and_then(|v| v.as_str()) {
738                config.ignore_from_file = Some(first_file.to_string());
739            }
740        }
741    }
742
743    if let Some(rules) = yaml_value.get("rules").and_then(|r| r.as_mapping()) {
744        for (rule_name, rule_config) in rules {
745            let rule_name = rule_name.as_str().unwrap_or("");
746
747            if let Some(rule_str) = rule_config.as_str() {
748                match rule_str {
749                    "disable" => {
750                        config.set_rule_enabled(rule_name, false);
751                    }
752                    "enable" => {
753                        config.set_rule_enabled(rule_name, true);
754                    }
755                    _ => {
756                        config.set_rule_enabled(rule_name, true);
757                    }
758                }
759            } else if let Some(rule_map) = rule_config.as_mapping() {
760                let mut enabled = None;
761                let mut severity = None;
762                let mut settings: Option<serde_json::Value> = None;
763
764                if let Some(enable_val) = rule_map.get("enable") {
765                    enabled = enable_val.as_bool();
766                }
767                if let Some(disable_val) = rule_map.get("disable") {
768                    if let Some(disable_bool) = disable_val.as_bool() {
769                        enabled = Some(!disable_bool);
770                    }
771                }
772
773                if let Some(level_val) = rule_map.get("level") {
774                    if let Some(level_str) = level_val.as_str() {
775                        match level_str {
776                            "error" => severity = Some(crate::Severity::Error),
777                            "warning" => severity = Some(crate::Severity::Warning),
778                            "info" => severity = Some(crate::Severity::Info),
779                            "disable" => enabled = Some(false),
780                            _ => {}
781                        }
782                    }
783                }
784
785                match rule_name {
786                    "line-length" => {
787                        let mut max_length = 80;
788                        let mut allow_non_breakable_words = true;
789
790                        if let Some(max_val) = rule_map.get("max").and_then(|v| v.as_u64()) {
791                            max_length = max_val as usize;
792                        }
793                        if let Some(allow_val) = rule_map.get("allow-non-breakable-words") {
794                            if let Some(allow_bool) = allow_val.as_bool() {
795                                allow_non_breakable_words = allow_bool;
796                            }
797                        }
798
799                        let mut allow_non_breakable_inline_mappings = false;
800                        if let Some(allow_val) = rule_map.get("allow-non-breakable-inline-mappings")
801                        {
802                            if let Some(allow_bool) = allow_val.as_bool() {
803                                allow_non_breakable_inline_mappings = allow_bool;
804                            }
805                        }
806
807                        let rule_settings = serde_json::to_value(config::LineLengthConfig {
808                            max_length,
809                            allow_non_breakable_words,
810                            allow_non_breakable_inline_mappings,
811                        })
812                        .unwrap();
813                        settings = Some(rule_settings);
814                    }
815                    "document-start" => {
816                        if let Some(present_val) = rule_map.get("present") {
817                            if let Some(present_bool) = present_val.as_bool() {
818                                let rule_settings =
819                                    serde_json::to_value(config::DocumentStartConfig {
820                                        present: Some(present_bool),
821                                    })
822                                    .unwrap();
823                                settings = Some(rule_settings);
824                            }
825                        }
826                    }
827                    "indentation" => {
828                        let mut spaces = Some(2);
829                        let mut indent_sequences = Some(true);
830                        let check_multi_line_strings = Some(false);
831                        let mut ignore = None;
832
833                        if let Some(spaces_val) = rule_map.get("spaces").and_then(|v| v.as_u64()) {
834                            spaces = Some(spaces_val as usize);
835                        }
836                        if let Some(indent_val) = rule_map.get("indent-sequences") {
837                            if let Some(indent_bool) = indent_val.as_bool() {
838                                indent_sequences = Some(indent_bool);
839                            } else {
840                                enabled = Some(false);
841                            }
842                        }
843
844                        if let Some(ignore_val) = rule_map.get("ignore") {
845                            if let Some(s) = ignore_val.as_str() {
846                                ignore = Some(s.to_string());
847                            } else {
848                                ignore = serde_yaml::to_string(ignore_val)
849                                    .ok()
850                                    .map(|s| s.trim_matches('"').to_string());
851                            }
852                        }
853                        let rule_settings = serde_json::to_value(config::IndentationConfig {
854                            spaces,
855                            indent_sequences,
856                            check_multi_line_strings,
857                            ignore,
858                        })
859                        .unwrap();
860                        settings = Some(rule_settings);
861                    }
862                    "comments" => {
863                        if let Some(min_spaces_val) = rule_map
864                            .get("min-spaces-from-content")
865                            .and_then(|v| v.as_u64())
866                        {
867                            let rule_settings = serde_json::to_value(config::CommentsConfig {
868                                min_spaces_from_content: Some(min_spaces_val as usize),
869                            })
870                            .unwrap();
871                            settings = Some(rule_settings);
872                        }
873                    }
874                    "truthy" => {
875                        let mut allowed_values = vec!["false".to_string(), "true".to_string()];
876                        if let Some(allowed_vals) =
877                            rule_map.get("allowed-values").and_then(|v| v.as_sequence())
878                        {
879                            allowed_values = allowed_vals
880                                .iter()
881                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
882                                .collect();
883                        }
884                        let rule_settings =
885                            serde_json::to_value(config::TruthyConfig { allowed_values }).unwrap();
886                        settings = Some(rule_settings);
887                    }
888                    "empty-lines" => {
889                        let mut max = None;
890                        let mut max_start = None;
891                        let mut max_end = None;
892
893                        if let Some(max_val) = rule_map.get("max").and_then(|v| v.as_u64()) {
894                            max = Some(max_val as usize);
895                        }
896                        if let Some(start_val) = rule_map.get("max-start").and_then(|v| v.as_u64())
897                        {
898                            max_start = Some(start_val as usize);
899                        }
900                        if let Some(end_val) = rule_map.get("max-end").and_then(|v| v.as_u64()) {
901                            max_end = Some(end_val as usize);
902                        }
903
904                        let rule_settings = serde_json::to_value(config::EmptyLinesConfig {
905                            max,
906                            max_start,
907                            max_end,
908                        })
909                        .unwrap();
910                        settings = Some(rule_settings);
911                    }
912                    "trailing-spaces" => {
913                        let allow = rule_map
914                            .get("allow")
915                            .and_then(|v| v.as_bool())
916                            .unwrap_or(false);
917                        let rule_settings =
918                            serde_json::to_value(config::TrailingSpacesConfig { allow }).unwrap();
919                        settings = Some(rule_settings);
920                    }
921                    "document-end" => {
922                        if let Some(present_val) = rule_map.get("present") {
923                            if let Some(present_bool) = present_val.as_bool() {
924                                let rule_settings =
925                                    serde_json::to_value(config::DocumentEndConfig {
926                                        present: Some(present_bool),
927                                    })
928                                    .unwrap();
929                                settings = Some(rule_settings);
930                            }
931                        }
932                    }
933                    "key-ordering" => {
934                        if let Some(order_vals) =
935                            rule_map.get("order").and_then(|v| v.as_sequence())
936                        {
937                            let order: Vec<String> = order_vals
938                                .iter()
939                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
940                                .collect();
941                            let rule_settings = serde_json::to_value(config::KeyOrderingConfig {
942                                order: Some(order),
943                            })
944                            .unwrap();
945                            settings = Some(rule_settings);
946                        }
947                    }
948                    "anchors" => {
949                        if let Some(max_len_val) =
950                            rule_map.get("max-length").and_then(|v| v.as_u64())
951                        {
952                            let rule_settings = serde_json::to_value(config::AnchorsConfig {
953                                max_length: Some(max_len_val as usize),
954                            })
955                            .unwrap();
956                            settings = Some(rule_settings);
957                        }
958                    }
959                    "new-lines" => {
960                        if let Some(type_val) = rule_map.get("type").and_then(|v| v.as_str()) {
961                            let type_str = type_val.to_string();
962                            let rule_settings = serde_json::to_value(config::NewLinesConfig {
963                                type_: Some(type_str),
964                            })
965                            .unwrap();
966                            settings = Some(rule_settings);
967                        }
968                    }
969                    _ => {}
970                }
971
972                let existing = config.rules.get(rule_name).cloned();
973                let final_enabled = if let Some(ref existing_config) = existing {
974                    enabled.or(existing_config.enabled)
975                } else {
976                    enabled
977                };
978
979                let final_severity =
980                    severity.or_else(|| existing.as_ref().and_then(|c| c.severity));
981                let final_settings = settings.or_else(|| existing.clone().and_then(|c| c.settings));
982
983                let mut final_other = existing.map(|c| c.other).unwrap_or_default();
984
985                for (key, value) in rule_map {
986                    if let Some(key_str) = key.as_str() {
987                        let json_val = yaml_value_to_json(value);
988                        final_other.insert(key_str.to_string(), json_val);
989                    }
990                }
991
992                config.rules.insert(
993                    rule_name.to_string(),
994                    config::RuleConfig {
995                        enabled: final_enabled,
996                        severity: final_severity,
997                        settings: final_settings,
998                        other: final_other,
999                    },
1000                );
1001            }
1002        }
1003    }
1004
1005    Ok(config)
1006}
1007
1008pub fn discover_config_file() -> Option<PathBuf> {
1009    discover_config_file_from_dir(std::env::current_dir().ok()?)
1010}
1011
1012pub fn discover_config_file_from_dir(start_dir: PathBuf) -> Option<PathBuf> {
1013    let mut dir = start_dir.as_path();
1014    loop {
1015        let config_path = dir.join(".yamllint");
1016        if config_path.exists() {
1017            return Some(config_path);
1018        }
1019
1020        if let Some(parent) = dir.parent() {
1021            dir = parent;
1022        } else {
1023            break;
1024        }
1025    }
1026
1027    None
1028}
1029
1030#[derive(Debug, Clone)]
1031pub struct LintResult {
1032    pub file: String,
1033    pub issues: Vec<(LintIssue, String)>,
1034}
1035
1036#[derive(Debug, Clone)]
1037pub struct LintIssue {
1038    pub line: usize,
1039    pub column: usize,
1040    pub message: String,
1041    pub severity: Severity,
1042}
1043
1044#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1045pub enum Severity {
1046    Error,
1047    Warning,
1048    Info,
1049}
1050
1051impl Severity {
1052    pub fn from_str(s: &str) -> anyhow::Result<Self> {
1053        match s.to_lowercase().as_str() {
1054            "error" => Ok(Severity::Error),
1055            "warning" => Ok(Severity::Warning),
1056            "info" => Ok(Severity::Info),
1057            _ => Err(anyhow::anyhow!("Invalid severity: {}", s)),
1058        }
1059    }
1060
1061    pub fn to_string(&self) -> String {
1062        match self {
1063            Severity::Error => "error".to_string(),
1064            Severity::Warning => "warning".to_string(),
1065            Severity::Info => "info".to_string(),
1066        }
1067    }
1068}
1069
1070pub fn lint_yaml<P: AsRef<Path>>(file_path: P) -> Result<LintResult> {
1071    let path = file_path.as_ref();
1072    let _content = std::fs::read_to_string(path)?;
1073
1074    let result = LintResult {
1075        file: path.to_string_lossy().to_string(),
1076        issues: vec![],
1077    };
1078
1079    Ok(result)
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use std::io::Write;
1086    use tempfile::NamedTempFile;
1087
1088    #[test]
1089    fn test_lint_valid_yaml() {
1090        let mut file = NamedTempFile::new().expect("Failed to create temp file");
1091        writeln!(file, "key: value").expect("Failed to write to temp file");
1092        writeln!(file, "nested:").expect("Failed to write to temp file");
1093        writeln!(file, "  subkey: subvalue").expect("Failed to write to temp file");
1094
1095        let result = lint_yaml(file.path()).expect("Failed to lint YAML");
1096        assert_eq!(result.issues.len(), 0);
1097    }
1098
1099    #[test]
1100    fn test_default_config() {
1101        let config = config::Config::default();
1102        assert!(config.rules.contains_key("line-length"));
1103        assert!(config.rules.contains_key("indentation"));
1104    }
1105}