1#![warn(rust_2024_compatibility, clippy::all)]
2
3use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
6use dictator_supreme::SupremeConfig;
7use memchr::memchr_iter;
8
9#[derive(Debug, Clone)]
11pub struct RustConfig {
12 pub max_lines: usize,
13 pub min_edition: Option<String>,
15 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#[must_use]
31pub fn lint_source(source: &str) -> Diagnostics {
32 lint_source_with_config(source, &RustConfig::default())
33}
34
35#[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
46fn check_mod_rs_structure(path: &str, diags: &mut Diagnostics) {
48 let path = std::path::Path::new(path);
49
50 if path.file_name().and_then(|n| n.to_str()) != Some("mod.rs") {
52 return;
53 }
54
55 let Some(parent) = path.parent() else {
57 return;
58 };
59
60 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 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#[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
107fn check_cargo_edition(source: &str, min_edition: &str, diags: &mut Diagnostics) {
109 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 if trimmed.starts_with("edition.workspace") {
116 return; }
118 if trimmed.starts_with("edition") && !trimmed.contains(".workspace") {
119 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
154fn 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
165fn 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 if trimmed.starts_with("rust-version.workspace") {
173 return; }
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
210fn 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
227fn 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 if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
239 code_lines += 1;
240 }
241
242 line_start = nl + 1;
243 }
244
245 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
266fn is_comment_only_line(trimmed: &str) -> bool {
268 trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
269}
270
271fn 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 if !in_raw_string && (trimmed.contains("= r\"") || trimmed.contains("= r#\"")) {
287 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 !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 if in_raw_string {
305 line_start = nl + 1;
306 continue;
307 }
308
309 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 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
347fn is_struct_field_or_impl_item(trimmed: &str) -> bool {
349 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 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 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 !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 if filename == "Cargo.toml" {
420 return lint_cargo_toml(source, &self.config);
421 }
422
423 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(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#[must_use]
463pub fn init_decree_with_config(config: RustConfig) -> BoxDecree {
464 Box::new(RustDecree::new(config, SupremeConfig::default()))
465}
466
467#[must_use]
469pub fn init_decree_with_configs(config: RustConfig, supreme: SupremeConfig) -> BoxDecree {
470 Box::new(RustDecree::new(config, supreme))
471}
472
473#[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 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 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 #[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 #[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 #[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}