1#[cfg(feature = "filesystem")]
4use rust_i18n::t;
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7use thiserror::Error;
8
9pub type LintResult<T> = Result<T, LintError>;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Fix {
14 pub start_byte: usize,
16 pub end_byte: usize,
18 pub replacement: String,
20 pub description: String,
22 pub safe: bool,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub confidence: Option<f32>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub group: Option<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub depends_on: Option<String>,
41}
42
43pub const FIX_CONFIDENCE_HIGH_THRESHOLD: f32 = 0.95;
44pub const FIX_CONFIDENCE_MEDIUM_THRESHOLD: f32 = 0.75;
45const LEGACY_UNSAFE_CONFIDENCE: f32 = 0.80;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48pub enum FixConfidenceTier {
49 High,
50 Medium,
51 Low,
52}
53
54impl Fix {
55 pub fn replace(
57 start: usize,
58 end: usize,
59 replacement: impl Into<String>,
60 description: impl Into<String>,
61 safe: bool,
62 ) -> Self {
63 debug_assert!(
64 start <= end,
65 "Fix::replace: start_byte ({start}) must be <= end_byte ({end})"
66 );
67 let confidence = if safe { 1.0 } else { LEGACY_UNSAFE_CONFIDENCE };
68 Self {
69 start_byte: start,
70 end_byte: end,
71 replacement: replacement.into(),
72 description: description.into(),
73 safe,
74 confidence: Some(confidence),
75 group: None,
76 depends_on: None,
77 }
78 }
79
80 pub fn replace_with_confidence(
82 start: usize,
83 end: usize,
84 replacement: impl Into<String>,
85 description: impl Into<String>,
86 confidence: f32,
87 ) -> Self {
88 debug_assert!(
89 start <= end,
90 "Fix::replace_with_confidence: start_byte ({start}) must be <= end_byte ({end})"
91 );
92 Self {
93 start_byte: start,
94 end_byte: end,
95 replacement: replacement.into(),
96 description: description.into(),
97 safe: confidence >= FIX_CONFIDENCE_HIGH_THRESHOLD,
98 confidence: Some(clamp_confidence(confidence)),
99 group: None,
100 depends_on: None,
101 }
102 }
103
104 pub fn insert(
106 position: usize,
107 text: impl Into<String>,
108 description: impl Into<String>,
109 safe: bool,
110 ) -> Self {
111 let confidence = if safe { 1.0 } else { LEGACY_UNSAFE_CONFIDENCE };
112 Self {
113 start_byte: position,
114 end_byte: position,
115 replacement: text.into(),
116 description: description.into(),
117 safe,
118 confidence: Some(confidence),
119 group: None,
120 depends_on: None,
121 }
122 }
123
124 pub fn insert_with_confidence(
126 position: usize,
127 text: impl Into<String>,
128 description: impl Into<String>,
129 confidence: f32,
130 ) -> Self {
131 Self {
132 start_byte: position,
133 end_byte: position,
134 replacement: text.into(),
135 description: description.into(),
136 safe: confidence >= FIX_CONFIDENCE_HIGH_THRESHOLD,
137 confidence: Some(clamp_confidence(confidence)),
138 group: None,
139 depends_on: None,
140 }
141 }
142
143 pub fn delete(start: usize, end: usize, description: impl Into<String>, safe: bool) -> Self {
145 debug_assert!(
146 start <= end,
147 "Fix::delete: start_byte ({start}) must be <= end_byte ({end})"
148 );
149 let confidence = if safe { 1.0 } else { LEGACY_UNSAFE_CONFIDENCE };
150 Self {
151 start_byte: start,
152 end_byte: end,
153 replacement: String::new(),
154 description: description.into(),
155 safe,
156 confidence: Some(confidence),
157 group: None,
158 depends_on: None,
159 }
160 }
161
162 pub fn delete_with_confidence(
164 start: usize,
165 end: usize,
166 description: impl Into<String>,
167 confidence: f32,
168 ) -> Self {
169 debug_assert!(
170 start <= end,
171 "Fix::delete_with_confidence: start_byte ({start}) must be <= end_byte ({end})"
172 );
173 Self {
174 start_byte: start,
175 end_byte: end,
176 replacement: String::new(),
177 description: description.into(),
178 safe: confidence >= FIX_CONFIDENCE_HIGH_THRESHOLD,
179 confidence: Some(clamp_confidence(confidence)),
180 group: None,
181 depends_on: None,
182 }
183 }
184
185 fn debug_assert_valid_range(content: &str, start: usize, end: usize, context: &'static str) {
190 debug_assert!(
191 start <= end,
192 "{context}: start_byte ({start}) must be <= end_byte ({end})"
193 );
194 debug_assert!(
195 start <= content.len(),
196 "{context}: start_byte ({start}) is out of bounds (len={})",
197 content.len()
198 );
199 debug_assert!(
200 content.is_char_boundary(start),
201 "{context}: start_byte ({start}) is not on a UTF-8 char boundary"
202 );
203 debug_assert!(
204 end <= content.len(),
205 "{context}: end_byte ({end}) is out of bounds (len={})",
206 content.len()
207 );
208 debug_assert!(
209 content.is_char_boundary(end),
210 "{context}: end_byte ({end}) is not on a UTF-8 char boundary"
211 );
212 }
213
214 fn debug_assert_valid_position(content: &str, position: usize, context: &'static str) {
218 debug_assert!(
219 position <= content.len(),
220 "{context}: position ({position}) is out of bounds (len={})",
221 content.len()
222 );
223 debug_assert!(
224 content.is_char_boundary(position),
225 "{context}: position ({position}) is not on a UTF-8 char boundary"
226 );
227 }
228
229 pub fn replace_checked(
234 content: &str,
235 start: usize,
236 end: usize,
237 replacement: impl Into<String>,
238 description: impl Into<String>,
239 safe: bool,
240 ) -> Self {
241 Self::debug_assert_valid_range(content, start, end, "Fix::replace_checked");
242 Self::replace(start, end, replacement, description, safe)
243 }
244
245 pub fn replace_with_confidence_checked(
252 content: &str,
253 start: usize,
254 end: usize,
255 replacement: impl Into<String>,
256 description: impl Into<String>,
257 confidence: f32,
258 ) -> Self {
259 Self::debug_assert_valid_range(content, start, end, "Fix::replace_with_confidence_checked");
260 Self::replace_with_confidence(start, end, replacement, description, confidence)
261 }
262
263 pub fn insert_checked(
268 content: &str,
269 position: usize,
270 text: impl Into<String>,
271 description: impl Into<String>,
272 safe: bool,
273 ) -> Self {
274 Self::debug_assert_valid_position(content, position, "Fix::insert_checked");
275 Self::insert(position, text, description, safe)
276 }
277
278 pub fn insert_with_confidence_checked(
285 content: &str,
286 position: usize,
287 text: impl Into<String>,
288 description: impl Into<String>,
289 confidence: f32,
290 ) -> Self {
291 Self::debug_assert_valid_position(content, position, "Fix::insert_with_confidence_checked");
292 Self::insert_with_confidence(position, text, description, confidence)
293 }
294
295 pub fn delete_checked(
300 content: &str,
301 start: usize,
302 end: usize,
303 description: impl Into<String>,
304 safe: bool,
305 ) -> Self {
306 Self::debug_assert_valid_range(content, start, end, "Fix::delete_checked");
307 Self::delete(start, end, description, safe)
308 }
309
310 pub fn delete_with_confidence_checked(
317 content: &str,
318 start: usize,
319 end: usize,
320 description: impl Into<String>,
321 confidence: f32,
322 ) -> Self {
323 Self::debug_assert_valid_range(content, start, end, "Fix::delete_with_confidence_checked");
324 Self::delete_with_confidence(start, end, description, confidence)
325 }
326
327 pub fn with_confidence(mut self, confidence: f32) -> Self {
329 let clamped = clamp_confidence(confidence);
330 self.confidence = Some(clamped);
331 self.safe = clamped >= FIX_CONFIDENCE_HIGH_THRESHOLD;
332 self
333 }
334
335 pub fn with_group(mut self, group: impl Into<String>) -> Self {
337 self.group = Some(group.into());
338 self
339 }
340
341 pub fn with_dependency(mut self, depends_on: impl Into<String>) -> Self {
343 self.depends_on = Some(depends_on.into());
344 self
345 }
346
347 pub fn confidence_score(&self) -> f32 {
349 self.confidence.unwrap_or({
350 if self.safe {
351 1.0
352 } else {
353 LEGACY_UNSAFE_CONFIDENCE
354 }
355 })
356 }
357
358 pub fn is_safe(&self) -> bool {
360 self.confidence_score() >= FIX_CONFIDENCE_HIGH_THRESHOLD
361 }
362
363 pub fn confidence_tier(&self) -> FixConfidenceTier {
365 let confidence = self.confidence_score();
366 if confidence >= FIX_CONFIDENCE_HIGH_THRESHOLD {
367 FixConfidenceTier::High
368 } else if confidence >= FIX_CONFIDENCE_MEDIUM_THRESHOLD {
369 FixConfidenceTier::Medium
370 } else {
371 FixConfidenceTier::Low
372 }
373 }
374
375 pub fn is_insertion(&self) -> bool {
377 self.start_byte == self.end_byte && !self.replacement.is_empty()
378 }
379
380 pub fn is_deletion(&self) -> bool {
382 self.replacement.is_empty() && self.start_byte < self.end_byte
383 }
384}
385
386impl PartialEq for Fix {
387 fn eq(&self, other: &Self) -> bool {
388 self.start_byte == other.start_byte
389 && self.end_byte == other.end_byte
390 && self.replacement == other.replacement
391 && self.description == other.description
392 && self.safe == other.safe
393 && confidence_option_eq(self.confidence, other.confidence)
394 && self.group == other.group
395 && self.depends_on == other.depends_on
396 }
397}
398
399impl Eq for Fix {}
400
401fn clamp_confidence(confidence: f32) -> f32 {
402 confidence.clamp(0.0, 1.0)
403}
404
405fn confidence_option_eq(a: Option<f32>, b: Option<f32>) -> bool {
406 match (a, b) {
407 (Some(left), Some(right)) => left.to_bits() == right.to_bits(),
408 (None, None) => true,
409 _ => false,
410 }
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
419pub struct RuleMetadata {
420 pub category: String,
422 pub severity: String,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub applies_to_tool: Option<String>,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct Diagnostic {
433 pub level: DiagnosticLevel,
434 pub message: String,
435 pub file: PathBuf,
436 pub line: usize,
437 pub column: usize,
438 pub rule: String,
439 pub suggestion: Option<String>,
440 #[serde(default)]
442 pub fixes: Vec<Fix>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub assumption: Option<String>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub metadata: Option<RuleMetadata>,
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
461pub enum DiagnosticLevel {
462 Error,
463 Warning,
464 Info,
465}
466
467fn lookup_rule_metadata(rule_id: &str) -> Option<RuleMetadata> {
469 agnix_rules::get_rule_metadata(rule_id).map(|(category, severity, tool)| RuleMetadata {
470 category: category.to_string(),
471 severity: severity.to_string(),
472 applies_to_tool: (!tool.is_empty()).then_some(tool.to_string()),
473 })
474}
475
476impl Diagnostic {
477 pub fn error(
478 file: PathBuf,
479 line: usize,
480 column: usize,
481 rule: &str,
482 message: impl Into<String>,
483 ) -> Self {
484 let metadata = lookup_rule_metadata(rule);
485 Self {
486 level: DiagnosticLevel::Error,
487 message: message.into(),
488 file,
489 line,
490 column,
491 rule: rule.to_string(),
492 suggestion: None,
493 fixes: Vec::new(),
494 assumption: None,
495 metadata,
496 }
497 }
498
499 pub fn warning(
500 file: PathBuf,
501 line: usize,
502 column: usize,
503 rule: &str,
504 message: impl Into<String>,
505 ) -> Self {
506 let metadata = lookup_rule_metadata(rule);
507 Self {
508 level: DiagnosticLevel::Warning,
509 message: message.into(),
510 file,
511 line,
512 column,
513 rule: rule.to_string(),
514 suggestion: None,
515 fixes: Vec::new(),
516 assumption: None,
517 metadata,
518 }
519 }
520
521 pub fn info(
522 file: PathBuf,
523 line: usize,
524 column: usize,
525 rule: &str,
526 message: impl Into<String>,
527 ) -> Self {
528 let metadata = lookup_rule_metadata(rule);
529 Self {
530 level: DiagnosticLevel::Info,
531 message: message.into(),
532 file,
533 line,
534 column,
535 rule: rule.to_string(),
536 suggestion: None,
537 fixes: Vec::new(),
538 assumption: None,
539 metadata,
540 }
541 }
542
543 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
544 self.suggestion = Some(suggestion.into());
545 self
546 }
547
548 pub fn with_assumption(mut self, assumption: impl Into<String>) -> Self {
553 self.assumption = Some(assumption.into());
554 self
555 }
556
557 pub fn with_fix(mut self, fix: Fix) -> Self {
559 self.fixes.push(fix);
560 self
561 }
562
563 pub fn with_fixes(mut self, fixes: impl IntoIterator<Item = Fix>) -> Self {
565 self.fixes.extend(fixes);
566 self
567 }
568
569 pub fn with_metadata(mut self, metadata: RuleMetadata) -> Self {
571 self.metadata = Some(metadata);
572 self
573 }
574
575 pub fn has_fixes(&self) -> bool {
577 !self.fixes.is_empty()
578 }
579
580 pub fn has_safe_fixes(&self) -> bool {
582 self.fixes.iter().any(Fix::is_safe)
583 }
584}
585
586#[derive(Error, Debug)]
588pub enum FileError {
589 #[error("Failed to read file: {path}")]
590 Read {
591 path: PathBuf,
592 #[source]
593 source: std::io::Error,
594 },
595
596 #[error("Failed to write file: {path}")]
597 Write {
598 path: PathBuf,
599 #[source]
600 source: std::io::Error,
601 },
602
603 #[error("Refusing to read symlink: {path}")]
604 Symlink { path: PathBuf },
605
606 #[error("File too large: {path} ({size} bytes, limit {limit} bytes)")]
607 TooBig {
608 path: PathBuf,
609 size: u64,
610 limit: u64,
611 },
612
613 #[error("Not a regular file: {path}")]
614 NotRegular { path: PathBuf },
615}
616
617#[derive(Error, Debug)]
619pub enum ValidationError {
620 #[error("Too many files to validate: {count} files found, limit is {limit}")]
621 TooManyFiles { count: usize, limit: usize },
622
623 #[error("Validation root not found: {path}")]
624 RootNotFound { path: PathBuf },
625
626 #[error(transparent)]
627 Other(#[from] anyhow::Error),
628}
629
630#[derive(Error, Debug)]
632pub enum ConfigError {
633 #[error("Invalid exclude pattern: {pattern} ({message})")]
634 InvalidExcludePattern { pattern: String, message: String },
635
636 #[error("Failed to parse configuration")]
637 ParseError(#[from] anyhow::Error),
638}
639
640#[derive(Error, Debug)]
642pub enum CoreError {
643 #[error(transparent)]
644 File(#[from] FileError),
645
646 #[error(transparent)]
647 Validation(#[from] ValidationError),
648
649 #[error(transparent)]
650 Config(#[from] ConfigError),
651}
652
653impl CoreError {
654 pub fn source_diagnostics(&self) -> Vec<&FileError> {
659 match self {
660 CoreError::File(e) => vec![e],
661 _ => vec![],
662 }
663 }
664
665 pub fn path(&self) -> Option<&PathBuf> {
667 match self {
668 CoreError::File(FileError::Read { path, .. })
669 | CoreError::File(FileError::Write { path, .. })
670 | CoreError::File(FileError::Symlink { path })
671 | CoreError::File(FileError::TooBig { path, .. })
672 | CoreError::File(FileError::NotRegular { path }) => Some(path),
673 CoreError::Validation(ValidationError::RootNotFound { path }) => Some(path),
674 _ => None,
675 }
676 }
677}
678
679pub type LintError = CoreError;
685
686#[derive(Debug)]
697#[non_exhaustive]
698pub enum ValidationOutcome {
699 Success(Vec<Diagnostic>),
702
703 #[cfg(feature = "filesystem")]
705 IoError(FileError),
706
707 Skipped,
709}
710
711impl ValidationOutcome {
712 pub fn is_success(&self) -> bool {
714 matches!(self, ValidationOutcome::Success(_))
715 }
716
717 #[cfg(feature = "filesystem")]
719 pub fn is_io_error(&self) -> bool {
720 matches!(self, ValidationOutcome::IoError(_))
721 }
722
723 pub fn is_skipped(&self) -> bool {
725 matches!(self, ValidationOutcome::Skipped)
726 }
727
728 pub fn diagnostics(&self) -> &[Diagnostic] {
732 match self {
733 ValidationOutcome::Success(diags) => diags,
734 #[cfg(feature = "filesystem")]
735 ValidationOutcome::IoError(_) => &[],
736 ValidationOutcome::Skipped => &[],
737 }
738 }
739
740 pub fn into_diagnostics(self) -> Vec<Diagnostic> {
746 match self {
747 ValidationOutcome::Success(diags) => diags,
748 #[cfg(feature = "filesystem")]
749 ValidationOutcome::IoError(file_error) => {
750 let error_msg = file_error.to_string();
751 let path = match file_error {
752 FileError::Read { path, .. }
753 | FileError::Write { path, .. }
754 | FileError::Symlink { path }
755 | FileError::TooBig { path, .. }
756 | FileError::NotRegular { path } => path,
757 };
758 vec![
759 Diagnostic::error(
760 path,
761 0,
762 0,
763 "file::read",
764 t!("rules.file_read_error", error = error_msg),
765 )
766 .with_suggestion(t!("rules.file_read_error_suggestion")),
767 ]
768 }
769 ValidationOutcome::Skipped => vec![],
770 }
771 }
772
773 #[cfg(feature = "filesystem")]
775 pub fn io_error(&self) -> Option<&FileError> {
776 match self {
777 ValidationOutcome::IoError(e) => Some(e),
778 _ => None,
779 }
780 }
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786
787 #[test]
790 fn test_error_auto_populates_metadata_for_known_rule() {
791 let diag = Diagnostic::error(PathBuf::from("test.md"), 1, 1, "AS-001", "Test");
792 assert!(
793 diag.metadata.is_some(),
794 "Metadata should be auto-populated for known rule AS-001"
795 );
796 let meta = diag.metadata.unwrap();
797 assert_eq!(meta.category, "agent-skills");
798 assert_eq!(meta.severity, "HIGH");
799 assert!(
800 meta.applies_to_tool.is_none(),
801 "AS-001 is generic, should have no tool"
802 );
803 }
804
805 #[test]
806 fn test_warning_auto_populates_metadata() {
807 let diag = Diagnostic::warning(PathBuf::from("test.md"), 1, 1, "CC-HK-001", "Test");
808 assert!(diag.metadata.is_some());
809 let meta = diag.metadata.unwrap();
810 assert_eq!(meta.applies_to_tool, Some("claude-code".to_string()));
811 }
812
813 #[test]
814 fn test_info_auto_populates_metadata() {
815 let diag = Diagnostic::info(PathBuf::from("test.md"), 1, 1, "AS-001", "Test");
816 assert!(diag.metadata.is_some());
817 }
818
819 #[test]
820 fn test_unknown_rule_has_no_metadata() {
821 let diag = Diagnostic::error(PathBuf::from("test.md"), 1, 1, "UNKNOWN-999", "Test");
822 assert!(
823 diag.metadata.is_none(),
824 "Unknown rules should not have metadata"
825 );
826 }
827
828 #[test]
829 fn test_lookup_rule_metadata_empty_string() {
830 let meta = lookup_rule_metadata("");
831 assert!(meta.is_none(), "Empty string should return None");
832 }
833
834 #[test]
835 fn test_lookup_rule_metadata_special_characters() {
836 let meta = lookup_rule_metadata("@#$%^&*()");
837 assert!(
838 meta.is_none(),
839 "Rule ID with special characters should return None"
840 );
841 }
842
843 #[test]
846 fn test_with_metadata_builder() {
847 let meta = RuleMetadata {
848 category: "custom".to_string(),
849 severity: "LOW".to_string(),
850 applies_to_tool: Some("my-tool".to_string()),
851 };
852 let diag = Diagnostic::error(PathBuf::from("test.md"), 1, 1, "UNKNOWN-999", "Test")
853 .with_metadata(meta.clone());
854 assert_eq!(diag.metadata, Some(meta));
855 }
856
857 #[test]
858 fn test_with_metadata_overrides_auto_populated() {
859 let diag = Diagnostic::error(PathBuf::from("test.md"), 1, 1, "AS-001", "Test");
860 assert!(diag.metadata.is_some());
861
862 let custom_meta = RuleMetadata {
863 category: "custom".to_string(),
864 severity: "LOW".to_string(),
865 applies_to_tool: None,
866 };
867 let diag = diag.with_metadata(custom_meta.clone());
868 assert_eq!(diag.metadata, Some(custom_meta));
869 }
870
871 #[test]
874 fn test_rule_metadata_serde_roundtrip() {
875 let meta = RuleMetadata {
876 category: "agent-skills".to_string(),
877 severity: "HIGH".to_string(),
878 applies_to_tool: Some("claude-code".to_string()),
879 };
880 let json = serde_json::to_string(&meta).unwrap();
881 let deserialized: RuleMetadata = serde_json::from_str(&json).unwrap();
882 assert_eq!(meta, deserialized);
883 }
884
885 #[test]
886 fn test_rule_metadata_serde_none_tool_omitted() {
887 let meta = RuleMetadata {
888 category: "agent-skills".to_string(),
889 severity: "HIGH".to_string(),
890 applies_to_tool: None,
891 };
892 let json = serde_json::to_string(&meta).unwrap();
893 assert!(
894 !json.contains("applies_to_tool"),
895 "None tool should be omitted via skip_serializing_if"
896 );
897 }
898
899 #[test]
900 fn test_diagnostic_serde_roundtrip_with_metadata() {
901 let diag = Diagnostic::error(PathBuf::from("test.md"), 10, 5, "AS-001", "Test error");
902 let json = serde_json::to_string(&diag).unwrap();
903 let deserialized: Diagnostic = serde_json::from_str(&json).unwrap();
904 assert_eq!(deserialized.metadata, diag.metadata);
905 assert_eq!(deserialized.rule, "AS-001");
906 }
907
908 #[test]
909 fn test_diagnostic_serde_roundtrip_without_metadata() {
910 let diag = Diagnostic {
911 level: DiagnosticLevel::Error,
912 message: "Test".to_string(),
913 file: PathBuf::from("test.md"),
914 line: 1,
915 column: 1,
916 rule: "UNKNOWN".to_string(),
917 suggestion: None,
918 fixes: Vec::new(),
919 assumption: None,
920 metadata: None,
921 };
922 let json = serde_json::to_string(&diag).unwrap();
923 assert!(
924 !json.contains("metadata"),
925 "None metadata should be omitted"
926 );
927 let deserialized: Diagnostic = serde_json::from_str(&json).unwrap();
928 assert!(deserialized.metadata.is_none());
929 }
930
931 #[test]
932 fn test_diagnostic_deserialize_without_metadata_field() {
933 let json = r#"{
935 "level": "Error",
936 "message": "Test",
937 "file": "test.md",
938 "line": 1,
939 "column": 1,
940 "rule": "AS-001",
941 "fixes": []
942 }"#;
943 let diag: Diagnostic = serde_json::from_str(json).unwrap();
944 assert!(
945 diag.metadata.is_none(),
946 "Missing metadata field should deserialize as None"
947 );
948 }
949
950 #[test]
951 fn test_diagnostic_manual_metadata_serde_roundtrip() {
952 let manual_metadata = RuleMetadata {
953 category: "custom-category".to_string(),
954 severity: "MEDIUM".to_string(),
955 applies_to_tool: Some("custom-tool".to_string()),
956 };
957
958 let diag = Diagnostic::error(
959 PathBuf::from("test.md"),
960 5,
961 10,
962 "CUSTOM-001",
963 "Custom error",
964 )
965 .with_metadata(manual_metadata.clone());
966
967 let json = serde_json::to_string(&diag).unwrap();
969
970 let deserialized: Diagnostic = serde_json::from_str(&json).unwrap();
972
973 assert_eq!(deserialized.metadata, Some(manual_metadata));
975 assert_eq!(deserialized.rule, "CUSTOM-001");
976 assert_eq!(deserialized.message, "Custom error");
977 }
978
979 #[test]
982 fn test_fix_is_insertion_true_when_start_equals_end() {
983 let fix = Fix::insert(10, "inserted text", "insert something", true);
984 assert!(fix.is_insertion());
985 }
986
987 #[test]
988 fn test_fix_is_insertion_false_when_replacement_empty() {
989 let fix = Fix {
991 start_byte: 5,
992 end_byte: 5,
993 replacement: String::new(),
994 description: "no-op".to_string(),
995 safe: true,
996 confidence: Some(1.0),
997 group: None,
998 depends_on: None,
999 };
1000 assert!(!fix.is_insertion());
1001 }
1002
1003 #[test]
1004 fn test_fix_is_insertion_false_when_range_differs() {
1005 let fix = Fix::replace(0, 10, "replacement", "replace", true);
1006 assert!(!fix.is_insertion());
1007 }
1008
1009 #[test]
1010 fn test_fix_is_insertion_at_zero() {
1011 let fix = Fix::insert(0, "prepend", "prepend text", true);
1012 assert!(fix.is_insertion());
1013 }
1014
1015 #[test]
1018 fn test_fix_is_deletion_true_when_replacement_empty() {
1019 let fix = Fix::delete(5, 15, "remove text", true);
1020 assert!(fix.is_deletion());
1021 }
1022
1023 #[test]
1024 fn test_fix_is_deletion_false_when_replacement_nonempty() {
1025 let fix = Fix::replace(5, 15, "new text", "replace", true);
1026 assert!(!fix.is_deletion());
1027 }
1028
1029 #[test]
1030 fn test_fix_is_deletion_false_when_start_equals_end() {
1031 let fix = Fix {
1033 start_byte: 5,
1034 end_byte: 5,
1035 replacement: String::new(),
1036 description: "no-op".to_string(),
1037 safe: true,
1038 confidence: Some(1.0),
1039 group: None,
1040 depends_on: None,
1041 };
1042 assert!(!fix.is_deletion());
1043 }
1044
1045 #[test]
1046 fn test_fix_is_deletion_single_byte() {
1047 let fix = Fix::delete(10, 11, "delete one byte", false);
1048 assert!(fix.is_deletion());
1049 }
1050
1051 #[test]
1054 fn test_fix_replace_fields() {
1055 let fix = Fix::replace(2, 8, "new", "replace old", false);
1056 assert_eq!(fix.start_byte, 2);
1057 assert_eq!(fix.end_byte, 8);
1058 assert_eq!(fix.replacement, "new");
1059 assert_eq!(fix.description, "replace old");
1060 assert!(!fix.safe);
1061 assert_eq!(fix.confidence_tier(), FixConfidenceTier::Medium);
1062 assert!(!fix.is_insertion());
1063 assert!(!fix.is_deletion());
1064 }
1065
1066 #[test]
1067 fn test_fix_insert_fields() {
1068 let fix = Fix::insert(42, "text", "insert", true);
1069 assert_eq!(fix.start_byte, 42);
1070 assert_eq!(fix.end_byte, 42);
1071 assert_eq!(fix.replacement, "text");
1072 assert!(fix.safe);
1073 assert_eq!(fix.confidence_tier(), FixConfidenceTier::High);
1074 }
1075
1076 #[test]
1077 fn test_fix_delete_fields() {
1078 let fix = Fix::delete(0, 100, "remove block", true);
1079 assert_eq!(fix.start_byte, 0);
1080 assert_eq!(fix.end_byte, 100);
1081 assert!(fix.replacement.is_empty());
1082 assert!(fix.safe);
1083 assert_eq!(fix.confidence_tier(), FixConfidenceTier::High);
1084 }
1085
1086 #[test]
1087 fn test_fix_explicit_confidence_fields() {
1088 let fix = Fix::replace_with_confidence(0, 4, "NAME", "normalize", 0.42)
1089 .with_group("name-normalization")
1090 .with_dependency("fix-prefix");
1091
1092 assert_eq!(fix.confidence_score(), 0.42);
1093 assert_eq!(fix.confidence_tier(), FixConfidenceTier::Low);
1094 assert!(!fix.is_safe());
1095 assert_eq!(fix.group.as_deref(), Some("name-normalization"));
1096 assert_eq!(fix.depends_on.as_deref(), Some("fix-prefix"));
1097 }
1098
1099 #[test]
1100 fn test_fix_with_confidence_updates_safe_compat_flag() {
1101 let fix = Fix::replace(0, 4, "NAME", "normalize", true).with_confidence(0.80);
1102 assert!(!fix.safe);
1103 assert_eq!(fix.confidence_tier(), FixConfidenceTier::Medium);
1104 }
1105
1106 #[test]
1109 fn test_diagnostic_with_suggestion() {
1110 let diag = Diagnostic::warning(PathBuf::from("test.md"), 1, 0, "AS-001", "test message")
1111 .with_suggestion("try this instead");
1112
1113 assert_eq!(diag.suggestion, Some("try this instead".to_string()));
1114 assert_eq!(diag.level, DiagnosticLevel::Warning);
1115 assert_eq!(diag.message, "test message");
1116 }
1117
1118 #[test]
1119 fn test_diagnostic_with_fix() {
1120 let fix = Fix::insert(0, "added", "add prefix", true);
1121 let diag = Diagnostic::error(PathBuf::from("a.md"), 5, 3, "CC-AG-001", "missing prefix")
1122 .with_fix(fix);
1123
1124 assert!(diag.has_fixes());
1125 assert!(diag.has_safe_fixes());
1126 assert_eq!(diag.fixes.len(), 1);
1127 assert_eq!(diag.fixes[0].replacement, "added");
1128 }
1129
1130 #[test]
1131 fn test_diagnostic_with_fixes_multiple() {
1132 let fixes = vec![
1133 Fix::insert(0, "a", "fix a", true),
1134 Fix::delete(10, 20, "fix b", false),
1135 ];
1136 let diag =
1137 Diagnostic::info(PathBuf::from("b.md"), 1, 0, "XML-001", "xml issue").with_fixes(fixes);
1138
1139 assert_eq!(diag.fixes.len(), 2);
1140 assert!(diag.has_fixes());
1141 assert!(diag.has_safe_fixes());
1143 }
1144
1145 #[test]
1146 fn test_diagnostic_with_assumption() {
1147 let diag = Diagnostic::warning(PathBuf::from("c.md"), 2, 0, "CC-HK-001", "hook issue")
1148 .with_assumption("Assuming Claude Code >= 1.0.0");
1149
1150 assert_eq!(
1151 diag.assumption,
1152 Some("Assuming Claude Code >= 1.0.0".to_string())
1153 );
1154 }
1155
1156 #[test]
1157 fn test_diagnostic_builder_chaining() {
1158 let diag = Diagnostic::error(PathBuf::from("d.md"), 10, 5, "MCP-001", "mcp error")
1159 .with_suggestion("fix it")
1160 .with_fix(Fix::replace(0, 5, "fixed", "auto fix", true))
1161 .with_assumption("Assuming MCP protocol 2025-11-25");
1162
1163 assert_eq!(diag.suggestion, Some("fix it".to_string()));
1164 assert_eq!(diag.fixes.len(), 1);
1165 assert!(diag.assumption.is_some());
1166 assert_eq!(diag.level, DiagnosticLevel::Error);
1167 assert_eq!(diag.rule, "MCP-001");
1168 }
1169
1170 #[test]
1171 fn test_diagnostic_no_fixes_by_default() {
1172 let diag = Diagnostic::warning(PathBuf::from("e.md"), 1, 0, "AS-005", "something wrong");
1173
1174 assert!(!diag.has_fixes());
1175 assert!(!diag.has_safe_fixes());
1176 assert!(diag.fixes.is_empty());
1177 assert!(diag.suggestion.is_none());
1178 assert!(diag.assumption.is_none());
1179 }
1180
1181 #[test]
1182 fn test_diagnostic_has_safe_fixes_false_when_all_unsafe() {
1183 let fixes = vec![
1184 Fix::delete(0, 5, "remove a", false),
1185 Fix::delete(10, 15, "remove b", false),
1186 ];
1187 let diag = Diagnostic::error(PathBuf::from("f.md"), 1, 0, "CC-AG-002", "agent error")
1188 .with_fixes(fixes);
1189
1190 assert!(diag.has_fixes());
1191 assert!(!diag.has_safe_fixes());
1192 }
1193
1194 #[test]
1197 fn test_diagnostic_error_level() {
1198 let diag = Diagnostic::error(PathBuf::from("x.md"), 1, 0, "R-001", "err");
1199 assert_eq!(diag.level, DiagnosticLevel::Error);
1200 }
1201
1202 #[test]
1203 fn test_diagnostic_warning_level() {
1204 let diag = Diagnostic::warning(PathBuf::from("x.md"), 1, 0, "R-002", "warn");
1205 assert_eq!(diag.level, DiagnosticLevel::Warning);
1206 }
1207
1208 #[test]
1209 fn test_diagnostic_info_level() {
1210 let diag = Diagnostic::info(PathBuf::from("x.md"), 1, 0, "R-003", "info");
1211 assert_eq!(diag.level, DiagnosticLevel::Info);
1212 }
1213
1214 #[test]
1217 fn test_diagnostic_serialization_roundtrip() {
1218 let original = Diagnostic::error(
1219 PathBuf::from("project/CLAUDE.md"),
1220 42,
1221 7,
1222 "CC-AG-003",
1223 "Agent configuration issue",
1224 )
1225 .with_suggestion("Add the required field")
1226 .with_fix(Fix::insert(100, "new_field: true\n", "add field", true))
1227 .with_fix(Fix::delete(200, 250, "remove deprecated", false))
1228 .with_assumption("Assuming Claude Code >= 1.0.0");
1229
1230 let json = serde_json::to_string(&original).expect("serialization should succeed");
1231 let deserialized: Diagnostic =
1232 serde_json::from_str(&json).expect("deserialization should succeed");
1233
1234 assert_eq!(deserialized.level, original.level);
1235 assert_eq!(deserialized.message, original.message);
1236 assert_eq!(deserialized.file, original.file);
1237 assert_eq!(deserialized.line, original.line);
1238 assert_eq!(deserialized.column, original.column);
1239 assert_eq!(deserialized.rule, original.rule);
1240 assert_eq!(deserialized.suggestion, original.suggestion);
1241 assert_eq!(deserialized.assumption, original.assumption);
1242 assert_eq!(deserialized.fixes.len(), 2);
1243 assert_eq!(deserialized.fixes[0].replacement, "new_field: true\n");
1244 assert!(deserialized.fixes[0].safe);
1245 assert!(deserialized.fixes[1].replacement.is_empty());
1246 assert!(!deserialized.fixes[1].safe);
1247 }
1248
1249 #[test]
1250 fn test_fix_serialization_roundtrip() {
1251 let original = Fix::replace(10, 20, "replaced", "test fix", true);
1252 let json = serde_json::to_string(&original).expect("serialization should succeed");
1253 let deserialized: Fix =
1254 serde_json::from_str(&json).expect("deserialization should succeed");
1255
1256 assert_eq!(deserialized.start_byte, original.start_byte);
1257 assert_eq!(deserialized.end_byte, original.end_byte);
1258 assert_eq!(deserialized.replacement, original.replacement);
1259 assert_eq!(deserialized.description, original.description);
1260 assert_eq!(deserialized.safe, original.safe);
1261 }
1262
1263 #[test]
1264 fn test_diagnostic_without_optional_fields_roundtrip() {
1265 let original =
1266 Diagnostic::info(PathBuf::from("simple.md"), 1, 0, "AS-001", "simple message");
1267
1268 let json = serde_json::to_string(&original).expect("serialization should succeed");
1269 let deserialized: Diagnostic =
1270 serde_json::from_str(&json).expect("deserialization should succeed");
1271
1272 assert_eq!(deserialized.suggestion, None);
1273 assert_eq!(deserialized.assumption, None);
1274 assert!(deserialized.fixes.is_empty());
1275 }
1276
1277 #[test]
1280 fn test_diagnostic_level_ordering() {
1281 assert!(DiagnosticLevel::Error < DiagnosticLevel::Warning);
1282 assert!(DiagnosticLevel::Warning < DiagnosticLevel::Info);
1283 assert!(DiagnosticLevel::Error < DiagnosticLevel::Info);
1284 }
1285
1286 #[cfg(debug_assertions)]
1289 mod fix_debug_assert_tests {
1290 use super::*;
1291 use std::panic;
1292
1293 #[test]
1294 fn test_fix_replace_reversed_range_panics() {
1295 assert!(panic::catch_unwind(|| Fix::replace(10, 5, "x", "bad", true)).is_err());
1296 }
1297
1298 #[test]
1299 fn test_fix_replace_with_confidence_reversed_range_panics() {
1300 assert!(
1301 panic::catch_unwind(|| Fix::replace_with_confidence(10, 5, "x", "bad", 0.9))
1302 .is_err()
1303 );
1304 }
1305
1306 #[test]
1307 fn test_fix_delete_reversed_range_panics() {
1308 assert!(panic::catch_unwind(|| Fix::delete(20, 10, "bad", true)).is_err());
1309 }
1310
1311 #[test]
1312 fn test_fix_delete_with_confidence_reversed_range_panics() {
1313 assert!(
1314 panic::catch_unwind(|| Fix::delete_with_confidence(20, 10, "bad", 0.9)).is_err()
1315 );
1316 }
1317
1318 #[test]
1319 fn test_fix_replace_equal_start_end_ok() {
1320 let fix = Fix::replace(5, 5, "x", "ok", true);
1322 assert_eq!(fix.start_byte, 5);
1323 assert_eq!(fix.end_byte, 5);
1324 }
1325 }
1326
1327 mod fix_checked_tests {
1330 use super::*;
1331
1332 const CONTENT_2BYTE: &str = "hel\u{00e9}lo";
1335
1336 #[test]
1337 fn test_fix_replace_checked_valid_boundaries() {
1338 let fix = Fix::replace_checked(CONTENT_2BYTE, 0, 5, "x", "ok", true);
1339 assert_eq!(fix.start_byte, 0);
1340 assert_eq!(fix.end_byte, 5);
1341 }
1342
1343 #[test]
1344 fn test_fix_insert_checked_valid_boundary() {
1345 let fix = Fix::insert_checked(CONTENT_2BYTE, 3, "x", "ok", true);
1347 assert_eq!(fix.start_byte, 3);
1348 }
1349
1350 #[test]
1351 fn test_fix_checked_at_content_end() {
1352 let fix = Fix::insert_checked(CONTENT_2BYTE, CONTENT_2BYTE.len(), "x", "ok", true);
1353 assert_eq!(fix.start_byte, CONTENT_2BYTE.len());
1354 }
1355
1356 #[test]
1357 fn test_fix_replace_with_confidence_checked_valid() {
1358 let fix = Fix::replace_with_confidence_checked(CONTENT_2BYTE, 0, 3, "x", "ok", 0.9);
1359 assert_eq!(fix.start_byte, 0);
1360 assert_eq!(fix.end_byte, 3);
1361 assert!((fix.confidence_score() - 0.9).abs() < 1e-6);
1362 }
1363
1364 #[test]
1365 fn test_fix_delete_checked_valid() {
1366 let fix = Fix::delete_checked(CONTENT_2BYTE, 0, 3, "ok", true);
1367 assert_eq!(fix.start_byte, 0);
1368 assert_eq!(fix.end_byte, 3);
1369 }
1370
1371 #[test]
1372 fn test_fix_insert_with_confidence_checked_valid() {
1373 let fix = Fix::insert_with_confidence_checked(CONTENT_2BYTE, 3, "x", "ok", 0.9);
1375 assert_eq!(fix.start_byte, 3);
1376 assert_eq!(fix.end_byte, 3);
1377 assert!((fix.confidence_score() - 0.9).abs() < 1e-6);
1378 }
1379
1380 #[test]
1381 fn test_fix_delete_with_confidence_checked_valid() {
1382 let fix = Fix::delete_with_confidence_checked(CONTENT_2BYTE, 0, 3, "ok", 0.9);
1383 assert_eq!(fix.start_byte, 0);
1384 assert_eq!(fix.end_byte, 3);
1385 assert!((fix.confidence_score() - 0.9).abs() < 1e-6);
1386 }
1387
1388 const CONTENT_4BYTE: &str = "a\u{1f600}b";
1391
1392 #[test]
1393 fn test_fix_replace_checked_four_byte_valid() {
1394 let fix = Fix::replace_checked(CONTENT_4BYTE, 1, 5, "x", "ok", true);
1396 assert_eq!(fix.start_byte, 1);
1397 assert_eq!(fix.end_byte, 5);
1398 }
1399
1400 #[test]
1401 fn test_fix_replace_checked_zero_width_at_valid_boundary() {
1402 let fix = Fix::replace_checked(CONTENT_2BYTE, 3, 3, "x", "ok", true);
1404 assert_eq!(fix.start_byte, 3);
1405 assert_eq!(fix.end_byte, 3);
1406 }
1407
1408 #[test]
1409 fn test_fix_replace_checked_ascii_content() {
1410 let content = "hello";
1412 let fix = Fix::replace_checked(content, 1, 3, "i", "ok", true);
1413 assert_eq!(fix.start_byte, 1);
1414 assert_eq!(fix.end_byte, 3);
1415 }
1416
1417 #[cfg(debug_assertions)]
1419 mod fix_checked_panic_tests {
1420 use super::*;
1421 use std::panic;
1422
1423 #[test]
1424 fn test_fix_replace_checked_mid_codepoint_start_panics() {
1425 assert!(
1427 panic::catch_unwind(|| {
1428 Fix::replace_checked(CONTENT_2BYTE, 4, 5, "x", "bad", true)
1429 })
1430 .is_err()
1431 );
1432 }
1433
1434 #[test]
1435 fn test_fix_replace_checked_mid_codepoint_end_panics() {
1436 assert!(
1437 panic::catch_unwind(|| {
1438 Fix::replace_checked(CONTENT_2BYTE, 0, 4, "x", "bad", true)
1439 })
1440 .is_err()
1441 );
1442 }
1443
1444 #[test]
1445 fn test_fix_insert_checked_mid_codepoint_panics() {
1446 assert!(
1447 panic::catch_unwind(|| {
1448 Fix::insert_checked(CONTENT_2BYTE, 4, "x", "bad", true)
1449 })
1450 .is_err()
1451 );
1452 }
1453
1454 #[test]
1455 fn test_fix_delete_checked_mid_codepoint_panics() {
1456 assert!(
1457 panic::catch_unwind(|| {
1458 Fix::delete_checked(CONTENT_2BYTE, 3, 4, "bad", true)
1459 })
1460 .is_err()
1461 );
1462 }
1463
1464 #[test]
1465 fn test_fix_replace_with_confidence_checked_mid_codepoint_panics() {
1466 assert!(
1467 panic::catch_unwind(|| {
1468 Fix::replace_with_confidence_checked(CONTENT_2BYTE, 4, 5, "x", "bad", 0.9)
1469 })
1470 .is_err()
1471 );
1472 }
1473
1474 #[test]
1475 fn test_fix_insert_with_confidence_checked_mid_codepoint_panics() {
1476 assert!(
1477 panic::catch_unwind(|| {
1478 Fix::insert_with_confidence_checked(CONTENT_2BYTE, 4, "x", "bad", 0.9)
1479 })
1480 .is_err()
1481 );
1482 }
1483
1484 #[test]
1485 fn test_fix_delete_with_confidence_checked_mid_codepoint_panics() {
1486 assert!(
1487 panic::catch_unwind(|| {
1488 Fix::delete_with_confidence_checked(CONTENT_2BYTE, 3, 4, "bad", 0.9)
1489 })
1490 .is_err()
1491 );
1492 }
1493
1494 #[test]
1495 fn test_fix_delete_checked_mid_codepoint_start_panics() {
1496 assert!(
1498 panic::catch_unwind(|| Fix::delete_checked(CONTENT_2BYTE, 4, 5, "bad", true))
1499 .is_err()
1500 );
1501 }
1502
1503 #[test]
1504 fn test_fix_delete_with_confidence_checked_mid_codepoint_start_panics() {
1505 assert!(
1507 panic::catch_unwind(|| Fix::delete_with_confidence_checked(
1508 CONTENT_2BYTE,
1509 4,
1510 5,
1511 "bad",
1512 0.9
1513 ))
1514 .is_err()
1515 );
1516 }
1517
1518 #[test]
1519 fn test_fix_replace_with_confidence_checked_mid_codepoint_end_panics() {
1520 assert!(
1522 panic::catch_unwind(|| Fix::replace_with_confidence_checked(
1523 CONTENT_2BYTE,
1524 0,
1525 4,
1526 "x",
1527 "bad",
1528 0.9
1529 ))
1530 .is_err()
1531 );
1532 }
1533
1534 #[test]
1535 fn test_fix_insert_with_confidence_checked_out_of_bounds_panics() {
1536 assert!(
1537 panic::catch_unwind(|| Fix::insert_with_confidence_checked(
1538 CONTENT_2BYTE,
1539 CONTENT_2BYTE.len() + 1,
1540 "x",
1541 "bad",
1542 0.9
1543 ))
1544 .is_err()
1545 );
1546 }
1547
1548 #[test]
1549 fn test_fix_replace_checked_reversed_range_panics() {
1550 assert!(
1552 panic::catch_unwind(|| Fix::replace_checked(
1553 CONTENT_2BYTE,
1554 5,
1555 3,
1556 "x",
1557 "bad",
1558 true
1559 ))
1560 .is_err()
1561 );
1562 }
1563
1564 #[test]
1565 fn test_fix_delete_checked_reversed_range_panics() {
1566 assert!(
1567 panic::catch_unwind(|| Fix::delete_checked(CONTENT_2BYTE, 5, 3, "bad", true))
1568 .is_err()
1569 );
1570 }
1571
1572 #[test]
1573 fn test_fix_replace_with_confidence_checked_reversed_range_panics() {
1574 assert!(
1575 panic::catch_unwind(|| Fix::replace_with_confidence_checked(
1576 CONTENT_2BYTE,
1577 5,
1578 3,
1579 "x",
1580 "bad",
1581 0.9
1582 ))
1583 .is_err()
1584 );
1585 }
1586
1587 #[test]
1588 fn test_fix_delete_with_confidence_checked_reversed_range_panics() {
1589 assert!(
1590 panic::catch_unwind(|| Fix::delete_with_confidence_checked(
1591 CONTENT_2BYTE,
1592 5,
1593 3,
1594 "bad",
1595 0.9
1596 ))
1597 .is_err()
1598 );
1599 }
1600
1601 #[test]
1602 fn test_fix_checked_out_of_bounds_panics() {
1603 assert!(
1604 panic::catch_unwind(|| {
1605 Fix::insert_checked(
1606 CONTENT_2BYTE,
1607 CONTENT_2BYTE.len() + 1,
1608 "x",
1609 "bad",
1610 true,
1611 )
1612 })
1613 .is_err()
1614 );
1615 }
1616
1617 #[test]
1618 fn test_fix_replace_checked_four_byte_mid_emoji_panics() {
1619 assert!(
1621 panic::catch_unwind(|| {
1622 Fix::replace_checked(CONTENT_4BYTE, 1, 3, "x", "bad", true)
1623 })
1624 .is_err()
1625 );
1626 }
1627
1628 #[test]
1629 fn test_fix_replace_checked_end_out_of_bounds_panics() {
1630 assert!(
1631 panic::catch_unwind(|| Fix::replace_checked(
1632 CONTENT_2BYTE,
1633 0,
1634 CONTENT_2BYTE.len() + 1,
1635 "x",
1636 "bad",
1637 true
1638 ))
1639 .is_err()
1640 );
1641 }
1642
1643 #[test]
1644 fn test_fix_delete_checked_end_out_of_bounds_panics() {
1645 assert!(
1646 panic::catch_unwind(|| Fix::delete_checked(
1647 CONTENT_2BYTE,
1648 0,
1649 CONTENT_2BYTE.len() + 1,
1650 "bad",
1651 true
1652 ))
1653 .is_err()
1654 );
1655 }
1656 }
1657 }
1658
1659 #[test]
1662 fn test_validation_outcome_success_empty() {
1663 let outcome = ValidationOutcome::Success(vec![]);
1664 assert!(outcome.is_success());
1665 assert!(!outcome.is_skipped());
1666 assert!(outcome.diagnostics().is_empty());
1667 assert!(outcome.into_diagnostics().is_empty());
1668 }
1669
1670 #[test]
1671 fn test_validation_outcome_success_with_diagnostics() {
1672 let diag = Diagnostic::warning(PathBuf::from("test.md"), 1, 0, "AS-001", "test");
1673 let outcome = ValidationOutcome::Success(vec![diag]);
1674 assert!(outcome.is_success());
1675 assert_eq!(outcome.diagnostics().len(), 1);
1676 assert_eq!(outcome.diagnostics()[0].rule, "AS-001");
1677 let diags = outcome.into_diagnostics();
1678 assert_eq!(diags.len(), 1);
1679 assert_eq!(diags[0].rule, "AS-001");
1680 }
1681
1682 #[test]
1683 fn test_validation_outcome_skipped() {
1684 let outcome = ValidationOutcome::Skipped;
1685 assert!(outcome.is_skipped());
1686 assert!(!outcome.is_success());
1687 assert!(outcome.diagnostics().is_empty());
1688 assert!(outcome.into_diagnostics().is_empty());
1689 }
1690
1691 #[cfg(feature = "filesystem")]
1692 #[test]
1693 fn test_validation_outcome_io_error() {
1694 let file_error = FileError::Read {
1695 path: PathBuf::from("/tmp/missing.md"),
1696 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
1697 };
1698 let outcome = ValidationOutcome::IoError(file_error);
1699 assert!(outcome.is_io_error());
1700 assert!(!outcome.is_success());
1701 assert!(!outcome.is_skipped());
1702 assert!(outcome.diagnostics().is_empty());
1704 }
1705
1706 #[cfg(feature = "filesystem")]
1707 #[test]
1708 fn test_validation_outcome_io_error_ref() {
1709 let file_error = FileError::Read {
1710 path: PathBuf::from("/tmp/missing.md"),
1711 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
1712 };
1713 let outcome = ValidationOutcome::IoError(file_error);
1714 let err = outcome.io_error().expect("should be Some for IoError");
1715 match err {
1716 FileError::Read { path, .. } => {
1717 assert_eq!(path, &PathBuf::from("/tmp/missing.md"));
1718 }
1719 _ => panic!("expected FileError::Read"),
1720 }
1721 }
1722
1723 #[cfg(feature = "filesystem")]
1724 #[test]
1725 fn test_validation_outcome_io_error_into_diagnostics() {
1726 let file_error = FileError::Read {
1727 path: PathBuf::from("/tmp/missing.md"),
1728 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
1729 };
1730 let outcome = ValidationOutcome::IoError(file_error);
1731 let diags = outcome.into_diagnostics();
1732 assert_eq!(diags.len(), 1);
1733 assert_eq!(diags[0].rule, "file::read");
1734 assert_eq!(diags[0].file, PathBuf::from("/tmp/missing.md"));
1735 assert_eq!(diags[0].level, DiagnosticLevel::Error);
1736 assert!(!diags[0].message.is_empty());
1739 assert!(diags[0].suggestion.is_some());
1740 }
1741
1742 #[cfg(feature = "filesystem")]
1743 #[test]
1744 fn test_validation_outcome_io_error_symlink() {
1745 let file_error = FileError::Symlink {
1746 path: PathBuf::from("/tmp/link.md"),
1747 };
1748 let outcome = ValidationOutcome::IoError(file_error);
1749 let diags = outcome.into_diagnostics();
1750 assert_eq!(diags.len(), 1);
1751 assert_eq!(diags[0].rule, "file::read");
1752 assert_eq!(diags[0].level, DiagnosticLevel::Error);
1753 assert!(diags[0].suggestion.is_some());
1754 }
1755
1756 #[cfg(feature = "filesystem")]
1757 #[test]
1758 fn test_validation_outcome_io_error_too_big() {
1759 let file_error = FileError::TooBig {
1760 path: PathBuf::from("/tmp/huge.md"),
1761 size: 5_000_000,
1762 limit: 1_048_576,
1763 };
1764 let outcome = ValidationOutcome::IoError(file_error);
1765 let diags = outcome.into_diagnostics();
1766 assert_eq!(diags.len(), 1);
1767 assert_eq!(diags[0].rule, "file::read");
1768 assert_eq!(diags[0].level, DiagnosticLevel::Error);
1769 assert!(diags[0].suggestion.is_some());
1770 }
1771
1772 #[test]
1773 fn test_validation_outcome_success_io_error_ref_is_none() {
1774 let outcome = ValidationOutcome::Success(vec![]);
1775 #[cfg(feature = "filesystem")]
1776 assert!(outcome.io_error().is_none());
1777 let _ = outcome;
1778 }
1779
1780 #[test]
1781 fn test_validation_outcome_skipped_io_error_ref_is_none() {
1782 let outcome = ValidationOutcome::Skipped;
1783 #[cfg(feature = "filesystem")]
1784 assert!(outcome.io_error().is_none());
1785 let _ = outcome;
1786 }
1787
1788 #[test]
1791 fn test_core_error_path_root_not_found() {
1792 let path = PathBuf::from("/some/nonexistent/path");
1793 let err = CoreError::Validation(ValidationError::RootNotFound { path: path.clone() });
1794 assert_eq!(err.path(), Some(&path));
1795 }
1796}