dictator_rust/
lib.rs

1#![warn(rust_2024_compatibility, clippy::all)]
2
3//! decree.rust - Rust structural rules.
4
5use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
6use dictator_supreme::SupremeConfig;
7use memchr::memchr_iter;
8
9/// Configuration for rust decree
10#[derive(Debug, Clone)]
11pub struct RustConfig {
12    pub max_lines: usize,
13    /// Minimum required Rust edition (e.g., "2024"). None = disabled.
14    pub min_edition: Option<String>,
15    /// Minimum required rust-version/MSRV (e.g., "1.83"). None = disabled.
16    pub min_rust_version: Option<String>,
17}
18
19impl Default for RustConfig {
20    fn default() -> Self {
21        Self {
22            max_lines: 400,
23            min_edition: None,
24            min_rust_version: None,
25        }
26    }
27}
28
29/// Lint Rust source for structural violations.
30#[must_use]
31pub fn lint_source(source: &str) -> Diagnostics {
32    lint_source_with_config(source, &RustConfig::default())
33}
34
35/// Lint with custom configuration
36#[must_use]
37pub fn lint_source_with_config(source: &str, config: &RustConfig) -> Diagnostics {
38    let mut diags = Diagnostics::new();
39
40    check_file_line_count(source, config.max_lines, &mut diags);
41    check_visibility_ordering(source, &mut diags);
42
43    diags
44}
45
46/// Lint Cargo.toml for edition and rust-version compliance
47#[must_use]
48pub fn lint_cargo_toml(source: &str, config: &RustConfig) -> Diagnostics {
49    let mut diags = Diagnostics::new();
50
51    if let Some(ref min_edition) = config.min_edition {
52        check_cargo_edition(source, min_edition, &mut diags);
53    }
54
55    if let Some(ref min_rust_version) = config.min_rust_version {
56        check_rust_version(source, min_rust_version, &mut diags);
57    }
58
59    diags
60}
61
62/// Check Cargo.toml edition against minimum required
63fn check_cargo_edition(source: &str, min_edition: &str, diags: &mut Diagnostics) {
64    // Simple line-based parsing to find edition
65    let mut found_edition: Option<(String, usize, usize)> = None;
66
67    for (line_idx, line) in source.lines().enumerate() {
68        let trimmed = line.trim();
69        // Skip workspace inheritance: edition.workspace = true
70        if trimmed.starts_with("edition.workspace") {
71            return; // Can't validate without parsing workspace Cargo.toml
72        }
73        if trimmed.starts_with("edition") && !trimmed.contains(".workspace") {
74            // Parse: edition = "2021" or edition="2021"
75            if let Some(eq_pos) = trimmed.find('=') {
76                let value_part = trimmed[eq_pos + 1..].trim();
77                let edition = value_part.trim_matches('"').trim_matches('\'').trim();
78                let line_start: usize = source.lines().take(line_idx).map(|l| l.len() + 1).sum();
79                found_edition = Some((edition.to_string(), line_start, line_start + line.len()));
80                break;
81            }
82        }
83    }
84
85    match found_edition {
86        Some((edition, start, end)) => {
87            if edition_ord(&edition) < edition_ord(min_edition) {
88                diags.push(Diagnostic {
89                    rule: "rust/fossil-edition".to_string(),
90                    message: format!(
91                        "edition {edition} is fossilized, the Dictator demands {min_edition}"
92                    ),
93                    enforced: true,
94                    span: Span::new(start, end),
95                });
96            }
97        }
98        None => {
99            diags.push(Diagnostic {
100                rule: "rust/missing-edition".to_string(),
101                message: format!("no edition declared, the Dictator demands {min_edition}"),
102                enforced: false,
103                span: Span::new(0, source.len().min(50)),
104            });
105        }
106    }
107}
108
109/// Convert edition string to comparable ordinal
110fn edition_ord(edition: &str) -> u32 {
111    match edition {
112        "2015" => 1,
113        "2018" => 2,
114        "2021" => 3,
115        "2024" => 4,
116        _ => 0,
117    }
118}
119
120/// Check Cargo.toml rust-version against minimum required
121fn check_rust_version(source: &str, min_version: &str, diags: &mut Diagnostics) {
122    let mut found_version: Option<(String, usize, usize)> = None;
123
124    for (line_idx, line) in source.lines().enumerate() {
125        let trimmed = line.trim();
126        // Skip workspace inheritance: rust-version.workspace = true
127        if trimmed.starts_with("rust-version.workspace") {
128            return; // Can't validate without parsing workspace Cargo.toml
129        }
130        if trimmed.starts_with("rust-version") && !trimmed.contains(".workspace") {
131            if let Some(eq_pos) = trimmed.find('=') {
132                let value_part = trimmed[eq_pos + 1..].trim();
133                let version = value_part.trim_matches('"').trim_matches('\'').trim();
134                let line_start: usize = source.lines().take(line_idx).map(|l| l.len() + 1).sum();
135                found_version = Some((version.to_string(), line_start, line_start + line.len()));
136                break;
137            }
138        }
139    }
140
141    match found_version {
142        Some((version, start, end)) => {
143            if version_cmp(&version, min_version) == std::cmp::Ordering::Less {
144                diags.push(Diagnostic {
145                    rule: "rust/fossil-rust-version".to_string(),
146                    message: format!(
147                        "rust-version {version} is prehistoric, the Dictator demands {min_version}+"
148                    ),
149                    enforced: true,
150                    span: Span::new(start, end),
151                });
152            }
153        }
154        None => {
155            diags.push(Diagnostic {
156                rule: "rust/missing-rust-version".to_string(),
157                message: format!("no rust-version declared, the Dictator demands {min_version}+"),
158                enforced: false,
159                span: Span::new(0, source.len().min(50)),
160            });
161        }
162    }
163}
164
165/// Compare semver-like versions (1.70 vs 1.83, 1.70.0 vs 1.70.1)
166fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
167    let parse = |v: &str| -> Vec<u32> { v.split('.').filter_map(|p| p.parse().ok()).collect() };
168    let a_parts = parse(a);
169    let b_parts = parse(b);
170
171    for i in 0..3 {
172        let a_val = a_parts.get(i).copied().unwrap_or(0);
173        let b_val = b_parts.get(i).copied().unwrap_or(0);
174        match a_val.cmp(&b_val) {
175            std::cmp::Ordering::Equal => {}
176            other => return other,
177        }
178    }
179    std::cmp::Ordering::Equal
180}
181
182/// Rule 1: File line count (ignoring comments and blank lines)
183fn check_file_line_count(source: &str, max_lines: usize, diags: &mut Diagnostics) {
184    let mut code_lines = 0;
185    let bytes = source.as_bytes();
186    let mut line_start = 0;
187
188    for nl in memchr_iter(b'\n', bytes) {
189        let line = &source[line_start..nl];
190        let trimmed = line.trim();
191
192        // Count line if it's not blank and not a comment-only line
193        if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
194            code_lines += 1;
195        }
196
197        line_start = nl + 1;
198    }
199
200    // Handle last line without newline
201    if line_start < bytes.len() {
202        let line = &source[line_start..];
203        let trimmed = line.trim();
204        if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
205            code_lines += 1;
206        }
207    }
208
209    if code_lines > max_lines {
210        diags.push(Diagnostic {
211            rule: "rust/file-too-long".to_string(),
212            message: format!(
213                "File has {code_lines} code lines (max {max_lines}, excl. comments/blanks)"
214            ),
215            enforced: false,
216            span: Span::new(0, source.len().min(100)),
217        });
218    }
219}
220
221/// Check if a line is comment-only (// or /* */ style)
222fn is_comment_only_line(trimmed: &str) -> bool {
223    trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
224}
225
226/// Rule 2: Visibility ordering - pub items should come before private items
227fn check_visibility_ordering(source: &str, diags: &mut Diagnostics) {
228    let bytes = source.as_bytes();
229    let mut line_start = 0;
230    let mut in_struct = false;
231    let mut in_impl = false;
232    let mut has_private = false;
233    let mut in_raw_string = false;
234
235    for nl in memchr_iter(b'\n', bytes) {
236        let line = &source[line_start..nl];
237        let trimmed = line.trim();
238
239        // Track raw string literals (r" or r#")
240        // Simple heuristic: line starting with `let ... = r"` or ending with `";` for multiline
241        if !in_raw_string && (trimmed.contains("= r\"") || trimmed.contains("= r#\"")) {
242            // Check if the raw string closes on the same line
243            let after_open = trimmed.find("= r\"").map_or_else(
244                || trimmed.find("= r#\"").map_or("", |pos| &trimmed[pos + 5..]),
245                |pos| &trimmed[pos + 4..],
246            );
247            // If there's no closing quote on this line, we're in a multiline raw string
248            if !after_open.contains('"') {
249                in_raw_string = true;
250            }
251        } else if in_raw_string && (trimmed.ends_with("\";") || trimmed == "\";" || trimmed == "\"")
252        {
253            in_raw_string = false;
254            line_start = nl + 1;
255            continue;
256        }
257
258        // Skip lines inside raw string literals
259        if in_raw_string {
260            line_start = nl + 1;
261            continue;
262        }
263
264        // Track struct/impl blocks
265        if trimmed.contains("struct ") && trimmed.contains('{') {
266            in_struct = true;
267            has_private = false;
268        } else if trimmed.contains("impl ") && trimmed.contains('{') {
269            in_impl = true;
270            has_private = false;
271        } else if trimmed == "}" || trimmed.starts_with("}\n") {
272            in_struct = false;
273            in_impl = false;
274            has_private = false;
275        }
276
277        // Check visibility within struct/impl
278        if (in_struct || in_impl) && !trimmed.is_empty() && !is_comment_only_line(trimmed) {
279            let is_pub = trimmed.starts_with("pub ");
280            let is_field_or_method = is_struct_field_or_impl_item(trimmed);
281
282            if is_field_or_method {
283                if !is_pub && !has_private {
284                    has_private = true;
285                } else if is_pub && has_private {
286                    diags.push(Diagnostic {
287                        rule: "rust/visibility-order".to_string(),
288                        message:
289                            "Public item found after private item. Expected all public items first"
290                                .to_string(),
291                        enforced: false,
292                        span: Span::new(line_start, nl),
293                    });
294                }
295            }
296        }
297
298        line_start = nl + 1;
299    }
300}
301
302/// Check if line is a struct field or impl method/associated function
303fn is_struct_field_or_impl_item(trimmed: &str) -> bool {
304    // Struct fields typically have pattern: [pub] name: Type [,]
305    // Impl items typically have pattern: [pub] fn name(...) or [pub] const/type
306    // Exclude closing braces, empty lines, attributes, and comments
307    if trimmed.is_empty()
308        || trimmed == "}"
309        || trimmed.starts_with('}')
310        || trimmed.starts_with('#')
311        || trimmed.starts_with("//")
312    {
313        return false;
314    }
315
316    // Check for impl items (fn, const, type, unsafe, etc.)
317    // These are more specific patterns, check them first
318    if trimmed.starts_with("fn ")
319        || trimmed.starts_with("pub fn ")
320        || trimmed.starts_with("const ")
321        || trimmed.starts_with("pub const ")
322        || trimmed.starts_with("type ")
323        || trimmed.starts_with("pub type ")
324        || trimmed.starts_with("unsafe fn ")
325        || trimmed.starts_with("pub unsafe fn ")
326        || trimmed.starts_with("async fn ")
327        || trimmed.starts_with("pub async fn ")
328    {
329        return true;
330    }
331
332    // Check for struct field pattern: identifier followed by colon and type
333    // Must start with a valid Rust identifier (letter or underscore, optionally prefixed with pub)
334    let field_part = trimmed.strip_prefix("pub ").unwrap_or(trimmed);
335    field_part.find(':').is_some_and(|colon_pos| {
336        let before_colon = field_part[..colon_pos].trim();
337        // Valid field name: starts with letter/underscore, contains only alphanumeric/underscore
338        !before_colon.is_empty()
339            && before_colon
340                .chars()
341                .next()
342                .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
343            && before_colon
344                .chars()
345                .all(|c| c.is_ascii_alphanumeric() || c == '_')
346    })
347}
348
349#[derive(Default)]
350pub struct RustDecree {
351    config: RustConfig,
352    supreme: SupremeConfig,
353}
354
355impl RustDecree {
356    #[must_use]
357    pub const fn new(config: RustConfig, supreme: SupremeConfig) -> Self {
358        Self { config, supreme }
359    }
360}
361
362impl Decree for RustDecree {
363    fn name(&self) -> &'static str {
364        "rust"
365    }
366
367    fn lint(&self, path: &str, source: &str) -> Diagnostics {
368        let filename = std::path::Path::new(path)
369            .file_name()
370            .and_then(|f| f.to_str())
371            .unwrap_or("");
372
373        // Cargo.toml gets edition check only (no supreme formatting rules)
374        if filename == "Cargo.toml" {
375            return lint_cargo_toml(source, &self.config);
376        }
377
378        // Regular Rust files get full treatment
379        let mut diags = dictator_supreme::lint_source_with_owner(source, &self.supreme, "rust");
380        diags.extend(lint_source_with_config(source, &self.config));
381        diags
382    }
383
384    fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
385        dictator_decree_abi::DecreeMetadata {
386            abi_version: dictator_decree_abi::ABI_VERSION.to_string(),
387            decree_version: env!("CARGO_PKG_VERSION").to_string(),
388            description: "Rust structural rules".to_string(),
389            dectauthors: Some(env!("CARGO_PKG_AUTHORS").to_string()),
390            supported_extensions: vec!["rs".to_string()],
391            supported_filenames: vec![
392                "Cargo.toml".to_string(),
393                "build.rs".to_string(),
394                "rust-toolchain".to_string(),
395                "rust-toolchain.toml".to_string(),
396                ".rustfmt.toml".to_string(),
397                "rustfmt.toml".to_string(),
398                "clippy.toml".to_string(),
399                ".clippy.toml".to_string(),
400            ],
401            skip_filenames: vec!["Cargo.lock".to_string()],
402            capabilities: vec![dictator_decree_abi::Capability::Lint],
403        }
404    }
405}
406
407#[must_use]
408pub fn init_decree() -> BoxDecree {
409    Box::new(RustDecree::default())
410}
411
412/// Create decree with custom config
413#[must_use]
414pub fn init_decree_with_config(config: RustConfig) -> BoxDecree {
415    Box::new(RustDecree::new(config, SupremeConfig::default()))
416}
417
418/// Create decree with custom config + supreme config (merged from decree.supreme + decree.rust)
419#[must_use]
420pub fn init_decree_with_configs(config: RustConfig, supreme: SupremeConfig) -> BoxDecree {
421    Box::new(RustDecree::new(config, supreme))
422}
423
424/// Convert `DecreeSettings` to `RustConfig`
425#[must_use]
426pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> RustConfig {
427    RustConfig {
428        max_lines: settings.max_lines.unwrap_or(400),
429        min_edition: settings.min_edition.clone(),
430        min_rust_version: settings.min_rust_version.clone(),
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn detects_file_too_long() {
440        use std::fmt::Write;
441        let mut src = String::new();
442        for i in 0..410 {
443            let _ = writeln!(src, "let x{i} = {i};");
444        }
445        let diags = lint_source(&src);
446        assert!(
447            diags.iter().any(|d| d.rule == "rust/file-too-long"),
448            "Should detect file with >400 code lines"
449        );
450    }
451
452    #[test]
453    fn ignores_comments_in_line_count() {
454        use std::fmt::Write;
455        // 390 code lines + 60 comment lines = 450 total, but only 390 counted
456        let mut src = String::new();
457        for i in 0..390 {
458            let _ = writeln!(src, "let x{i} = {i};");
459        }
460        for i in 0..60 {
461            let _ = writeln!(src, "// Comment {i}");
462        }
463        let diags = lint_source(&src);
464        assert!(
465            !diags.iter().any(|d| d.rule == "rust/file-too-long"),
466            "Should not count comment-only lines"
467        );
468    }
469
470    #[test]
471    fn ignores_blank_lines_in_count() {
472        use std::fmt::Write;
473        // 390 code lines + 60 blank lines = 450 total, but only 390 counted
474        let mut src = String::new();
475        for i in 0..390 {
476            let _ = writeln!(src, "let x{i} = {i};");
477        }
478        for _ in 0..60 {
479            src.push('\n');
480        }
481        let diags = lint_source(&src);
482        assert!(
483            !diags.iter().any(|d| d.rule == "rust/file-too-long"),
484            "Should not count blank lines"
485        );
486    }
487
488    #[test]
489    fn detects_pub_after_private_in_struct() {
490        let src = r"
491struct User {
492    name: String,
493    age: u32,
494    pub email: String,
495}
496";
497        let diags = lint_source(src);
498        assert!(
499            diags.iter().any(|d| d.rule == "rust/visibility-order"),
500            "Should detect pub field after private fields in struct"
501        );
502    }
503
504    #[test]
505    fn detects_pub_after_private_in_impl() {
506        let src = r"
507impl User {
508    fn private_method(&self) {}
509    pub fn public_method(&self) {}
510}
511";
512        let diags = lint_source(src);
513        assert!(
514            diags.iter().any(|d| d.rule == "rust/visibility-order"),
515            "Should detect pub method after private method in impl"
516        );
517    }
518
519    #[test]
520    fn accepts_pub_before_private() {
521        let src = r"
522struct User {
523    pub id: u32,
524    pub name: String,
525    email: String,
526}
527";
528        let diags = lint_source(src);
529        assert!(
530            !diags.iter().any(|d| d.rule == "rust/visibility-order"),
531            "Should accept public fields before private fields"
532        );
533    }
534
535    #[test]
536    fn accepts_impl_with_correct_order() {
537        let src = r"
538impl User {
539    pub fn new(name: String) -> Self {
540        User { name }
541    }
542
543    pub fn get_name(&self) -> &str {
544        &self.name
545    }
546
547    fn validate(&self) -> bool {
548        true
549    }
550}
551";
552        let diags = lint_source(src);
553        assert!(
554            !diags.iter().any(|d| d.rule == "rust/visibility-order"),
555            "Should accept impl with public methods before private"
556        );
557    }
558
559    #[test]
560    fn handles_empty_file() {
561        let src = "";
562        let diags = lint_source(src);
563        assert!(diags.is_empty(), "Empty file should have no violations");
564    }
565
566    #[test]
567    fn handles_file_with_only_comments() {
568        let src = "// Comment 1\n// Comment 2\n/* Block comment */\n";
569        let diags = lint_source(src);
570        assert!(
571            !diags.iter().any(|d| d.rule == "rust/file-too-long"),
572            "File with only comments should not trigger line count"
573        );
574    }
575
576    // ========== Edition check tests ==========
577
578    #[test]
579    fn detects_edition_too_old() {
580        let cargo_toml = r#"[package]
581name = "test"
582version = "0.1.0"
583edition = "2021"
584"#;
585        let config = RustConfig {
586            min_edition: Some("2024".to_string()),
587            ..Default::default()
588        };
589        let diags = lint_cargo_toml(cargo_toml, &config);
590        assert!(
591            diags.iter().any(|d| d.rule == "rust/fossil-edition"),
592            "Should detect edition 2021 < 2024"
593        );
594    }
595
596    #[test]
597    fn accepts_edition_meeting_minimum() {
598        let cargo_toml = r#"[package]
599name = "test"
600version = "0.1.0"
601edition = "2024"
602"#;
603        let config = RustConfig {
604            min_edition: Some("2024".to_string()),
605            ..Default::default()
606        };
607        let diags = lint_cargo_toml(cargo_toml, &config);
608        assert!(
609            !diags.iter().any(|d| d.rule == "rust/fossil-edition"),
610            "Should accept edition matching minimum"
611        );
612    }
613
614    #[test]
615    fn accepts_edition_exceeding_minimum() {
616        let cargo_toml = r#"[package]
617name = "test"
618version = "0.1.0"
619edition = "2024"
620"#;
621        let config = RustConfig {
622            min_edition: Some("2021".to_string()),
623            ..Default::default()
624        };
625        let diags = lint_cargo_toml(cargo_toml, &config);
626        assert!(
627            !diags.iter().any(|d| d.rule == "rust/fossil-edition"),
628            "Should accept edition exceeding minimum"
629        );
630    }
631
632    #[test]
633    fn detects_missing_edition() {
634        let cargo_toml = r#"[package]
635name = "test"
636version = "0.1.0"
637"#;
638        let config = RustConfig {
639            min_edition: Some("2024".to_string()),
640            ..Default::default()
641        };
642        let diags = lint_cargo_toml(cargo_toml, &config);
643        assert!(
644            diags.iter().any(|d| d.rule == "rust/missing-edition"),
645            "Should detect missing edition field"
646        );
647    }
648
649    #[test]
650    fn skips_edition_check_when_disabled() {
651        let cargo_toml = r#"[package]
652name = "test"
653version = "0.1.0"
654edition = "2015"
655"#;
656        let config = RustConfig {
657            min_edition: None,
658            ..Default::default()
659        };
660        let diags = lint_cargo_toml(cargo_toml, &config);
661        assert!(
662            diags.is_empty(),
663            "Should skip edition check when min_edition is None"
664        );
665    }
666
667    #[test]
668    fn handles_edition_without_spaces() {
669        let cargo_toml = r#"[package]
670name="test"
671edition="2021"
672"#;
673        let config = RustConfig {
674            min_edition: Some("2024".to_string()),
675            ..Default::default()
676        };
677        let diags = lint_cargo_toml(cargo_toml, &config);
678        assert!(
679            diags.iter().any(|d| d.rule == "rust/fossil-edition"),
680            "Should parse edition without spaces around ="
681        );
682    }
683
684    // ========== Rust-version check tests ==========
685
686    #[test]
687    fn detects_rust_version_too_old() {
688        let cargo_toml = r#"[package]
689name = "test"
690version = "0.1.0"
691rust-version = "1.70"
692"#;
693        let config = RustConfig {
694            min_rust_version: Some("1.83".to_string()),
695            ..Default::default()
696        };
697        let diags = lint_cargo_toml(cargo_toml, &config);
698        assert!(
699            diags.iter().any(|d| d.rule == "rust/fossil-rust-version"),
700            "Should detect rust-version 1.70 < 1.83"
701        );
702    }
703
704    #[test]
705    fn accepts_rust_version_meeting_minimum() {
706        let cargo_toml = r#"[package]
707name = "test"
708rust-version = "1.83"
709"#;
710        let config = RustConfig {
711            min_rust_version: Some("1.83".to_string()),
712            ..Default::default()
713        };
714        let diags = lint_cargo_toml(cargo_toml, &config);
715        assert!(
716            !diags.iter().any(|d| d.rule == "rust/fossil-rust-version"),
717            "Should accept rust-version matching minimum"
718        );
719    }
720
721    #[test]
722    fn accepts_rust_version_exceeding_minimum() {
723        let cargo_toml = r#"[package]
724name = "test"
725rust-version = "1.85"
726"#;
727        let config = RustConfig {
728            min_rust_version: Some("1.83".to_string()),
729            ..Default::default()
730        };
731        let diags = lint_cargo_toml(cargo_toml, &config);
732        assert!(
733            !diags.iter().any(|d| d.rule == "rust/fossil-rust-version"),
734            "Should accept rust-version exceeding minimum"
735        );
736    }
737
738    #[test]
739    fn accepts_rust_version_with_patch() {
740        let cargo_toml = r#"[package]
741name = "test"
742rust-version = "1.83.1"
743"#;
744        let config = RustConfig {
745            min_rust_version: Some("1.83.0".to_string()),
746            ..Default::default()
747        };
748        let diags = lint_cargo_toml(cargo_toml, &config);
749        assert!(
750            !diags.iter().any(|d| d.rule == "rust/fossil-rust-version"),
751            "Should accept 1.83.1 >= 1.83.0"
752        );
753    }
754
755    #[test]
756    fn detects_missing_rust_version() {
757        let cargo_toml = r#"[package]
758name = "test"
759version = "0.1.0"
760"#;
761        let config = RustConfig {
762            min_rust_version: Some("1.83".to_string()),
763            ..Default::default()
764        };
765        let diags = lint_cargo_toml(cargo_toml, &config);
766        assert!(
767            diags.iter().any(|d| d.rule == "rust/missing-rust-version"),
768            "Should detect missing rust-version field"
769        );
770    }
771
772    #[test]
773    fn skips_rust_version_check_when_disabled() {
774        let cargo_toml = r#"[package]
775name = "test"
776rust-version = "1.50"
777"#;
778        let config = RustConfig {
779            min_rust_version: None,
780            ..Default::default()
781        };
782        let diags = lint_cargo_toml(cargo_toml, &config);
783        assert!(
784            !diags.iter().any(|d| d.rule.contains("rust-version")),
785            "Should skip rust-version check when disabled"
786        );
787    }
788
789    #[test]
790    fn version_comparison_works() {
791        use std::cmp::Ordering;
792        assert_eq!(version_cmp("1.70", "1.83"), Ordering::Less);
793        assert_eq!(version_cmp("1.83", "1.83"), Ordering::Equal);
794        assert_eq!(version_cmp("1.84", "1.83"), Ordering::Greater);
795        assert_eq!(version_cmp("1.83.0", "1.83"), Ordering::Equal);
796        assert_eq!(version_cmp("1.83.1", "1.83.0"), Ordering::Greater);
797        assert_eq!(version_cmp("2.0", "1.99"), Ordering::Greater);
798    }
799}