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
46#[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
62fn check_cargo_edition(source: &str, min_edition: &str, diags: &mut Diagnostics) {
64 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 if trimmed.starts_with("edition.workspace") {
71 return; }
73 if trimmed.starts_with("edition") && !trimmed.contains(".workspace") {
74 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
109fn 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
120fn 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 if trimmed.starts_with("rust-version.workspace") {
128 return; }
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
165fn 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
182fn 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 if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
194 code_lines += 1;
195 }
196
197 line_start = nl + 1;
198 }
199
200 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
221fn is_comment_only_line(trimmed: &str) -> bool {
223 trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
224}
225
226fn 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 if !in_raw_string && (trimmed.contains("= r\"") || trimmed.contains("= r#\"")) {
242 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 !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 if in_raw_string {
260 line_start = nl + 1;
261 continue;
262 }
263
264 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 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
302fn is_struct_field_or_impl_item(trimmed: &str) -> bool {
304 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 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 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 !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 if filename == "Cargo.toml" {
375 return lint_cargo_toml(source, &self.config);
376 }
377
378 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#[must_use]
414pub fn init_decree_with_config(config: RustConfig) -> BoxDecree {
415 Box::new(RustDecree::new(config, SupremeConfig::default()))
416}
417
418#[must_use]
420pub fn init_decree_with_configs(config: RustConfig, supreme: SupremeConfig) -> BoxDecree {
421 Box::new(RustDecree::new(config, supreme))
422}
423
424#[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 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 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 #[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 #[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}