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