Skip to main content

ferro_hgvs/
error.rs

1//! Error types for ferro-hgvs
2//!
3//! This module provides comprehensive error handling with:
4//! - Error codes for categorization
5//! - Source span tracking for error location
6//! - Helpful diagnostic messages
7//! - "Did you mean?" suggestions where applicable
8
9use std::fmt;
10use thiserror::Error;
11
12/// Error codes for categorizing errors
13///
14/// These codes can be used for programmatic error handling
15/// and for documentation lookup.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17#[repr(u16)]
18pub enum ErrorCode {
19    // Parse errors (E1xxx)
20    /// Invalid accession format
21    InvalidAccession = 1001,
22    /// Unknown variant type prefix
23    UnknownVariantType = 1002,
24    /// Invalid position format
25    InvalidPosition = 1003,
26    /// Invalid edit format
27    InvalidEdit = 1004,
28    /// Unexpected end of input
29    UnexpectedEnd = 1005,
30    /// Unexpected characters
31    UnexpectedChar = 1006,
32    /// Invalid base/nucleotide
33    InvalidBase = 1007,
34    /// Invalid amino acid
35    InvalidAminoAcid = 1008,
36
37    // Reference errors (E2xxx)
38    /// Reference/transcript not found
39    ReferenceNotFound = 2001,
40    /// Sequence not available
41    SequenceNotFound = 2002,
42    /// Chromosome/contig not found
43    ChromosomeNotFound = 2003,
44
45    // Validation errors (E3xxx)
46    /// Position out of bounds
47    PositionOutOfBounds = 3001,
48    /// Reference sequence mismatch
49    ReferenceMismatch = 3002,
50    /// Invalid coordinate range
51    InvalidRange = 3003,
52    /// Exon-intron boundary crossing
53    ExonIntronBoundary = 3004,
54    /// UTR-CDS boundary crossing
55    UtrCdsBoundary = 3005,
56
57    // Normalization errors (E4xxx)
58    /// Intronic variant (cannot normalize)
59    IntronicVariant = 4001,
60    /// Unsupported variant type
61    UnsupportedVariant = 4002,
62
63    // Conversion errors (E5xxx)
64    /// Coordinate conversion failed
65    ConversionFailed = 5001,
66    /// No overlapping transcript
67    NoOverlappingTranscript = 5002,
68
69    // IO errors (E9xxx)
70    /// File IO error
71    IoError = 9001,
72    /// JSON parsing error
73    JsonError = 9002,
74}
75
76impl ErrorCode {
77    /// Get the error code as a string (e.g., "E1001")
78    pub fn as_str(&self) -> String {
79        format!("E{:04}", *self as u16)
80    }
81
82    /// Get a brief description of this error code
83    pub fn description(&self) -> &'static str {
84        match self {
85            ErrorCode::InvalidAccession => "invalid accession format",
86            ErrorCode::UnknownVariantType => "unknown variant type prefix",
87            ErrorCode::InvalidPosition => "invalid position format",
88            ErrorCode::InvalidEdit => "invalid edit format",
89            ErrorCode::UnexpectedEnd => "unexpected end of input",
90            ErrorCode::UnexpectedChar => "unexpected character",
91            ErrorCode::InvalidBase => "invalid nucleotide base",
92            ErrorCode::InvalidAminoAcid => "invalid amino acid",
93            ErrorCode::ReferenceNotFound => "reference not found",
94            ErrorCode::SequenceNotFound => "sequence not available",
95            ErrorCode::ChromosomeNotFound => "chromosome not found",
96            ErrorCode::PositionOutOfBounds => "position out of bounds",
97            ErrorCode::ReferenceMismatch => "reference sequence mismatch",
98            ErrorCode::InvalidRange => "invalid coordinate range",
99            ErrorCode::ExonIntronBoundary => "variant crosses exon-intron boundary",
100            ErrorCode::UtrCdsBoundary => "variant crosses UTR-CDS boundary",
101            ErrorCode::IntronicVariant => "intronic variant not supported",
102            ErrorCode::UnsupportedVariant => "unsupported variant type",
103            ErrorCode::ConversionFailed => "coordinate conversion failed",
104            ErrorCode::NoOverlappingTranscript => "no overlapping transcript",
105            ErrorCode::IoError => "file I/O error",
106            ErrorCode::JsonError => "JSON parsing error",
107        }
108    }
109}
110
111impl fmt::Display for ErrorCode {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        write!(f, "{}", self.as_str())
114    }
115}
116
117/// A span in the source input indicating error location
118#[derive(Debug, Clone, PartialEq, Eq, Default)]
119pub struct SourceSpan {
120    /// Starting byte offset (0-indexed)
121    pub start: usize,
122    /// Ending byte offset (exclusive)
123    pub end: usize,
124}
125
126impl SourceSpan {
127    /// Create a new source span
128    pub fn new(start: usize, end: usize) -> Self {
129        Self { start, end }
130    }
131
132    /// Create a span for a single position
133    pub fn point(pos: usize) -> Self {
134        Self {
135            start: pos,
136            end: pos + 1,
137        }
138    }
139
140    /// Format the source with the error highlighted
141    ///
142    /// Returns a string like:
143    /// ```text
144    /// NM_000088.3:c.459A>G
145    ///                ^~~~
146    /// ```
147    pub fn highlight(&self, source: &str) -> String {
148        if source.is_empty() {
149            return String::new();
150        }
151
152        let safe_start = self.start.min(source.len());
153        let safe_end = self.end.min(source.len()).max(safe_start);
154
155        // Build the pointer line
156        let mut pointer = String::with_capacity(source.len() + 4);
157        for _ in 0..safe_start {
158            pointer.push(' ');
159        }
160        if safe_start < safe_end {
161            pointer.push('^');
162            for _ in (safe_start + 1)..safe_end {
163                pointer.push('~');
164            }
165        } else {
166            pointer.push('^');
167        }
168
169        format!("{}\n{}", source, pointer)
170    }
171}
172
173/// Diagnostic information for an error
174#[derive(Debug, Clone, PartialEq, Eq, Default)]
175pub struct Diagnostic {
176    /// Error code
177    pub code: Option<ErrorCode>,
178    /// Source span for highlighting
179    pub span: Option<SourceSpan>,
180    /// The original input (for error display)
181    pub source: Option<String>,
182    /// Helpful hint or suggestion
183    pub hint: Option<String>,
184    /// "Did you mean?" suggestion
185    pub suggestion: Option<String>,
186}
187
188impl Diagnostic {
189    /// Create a new empty diagnostic
190    pub fn new() -> Self {
191        Self::default()
192    }
193
194    /// Add an error code
195    pub fn with_code(mut self, code: ErrorCode) -> Self {
196        self.code = Some(code);
197        self
198    }
199
200    /// Add a source span
201    pub fn with_span(mut self, span: SourceSpan) -> Self {
202        self.span = Some(span);
203        self
204    }
205
206    /// Add the original source
207    pub fn with_source(mut self, source: impl Into<String>) -> Self {
208        self.source = Some(source.into());
209        self
210    }
211
212    /// Add a hint
213    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
214        self.hint = Some(hint.into());
215        self
216    }
217
218    /// Add a suggestion
219    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
220        self.suggestion = Some(suggestion.into());
221        self
222    }
223
224    /// Format the diagnostic as a detailed error message
225    pub fn format(&self, primary_message: &str) -> String {
226        let mut result = String::new();
227
228        // Error code prefix
229        if let Some(code) = &self.code {
230            result.push_str(&format!("[{}] ", code));
231        }
232
233        // Primary message
234        result.push_str(primary_message);
235
236        // Source span highlight
237        if let (Some(span), Some(source)) = (&self.span, &self.source) {
238            result.push_str("\n\n");
239            result.push_str(&span.highlight(source));
240        }
241
242        // Hint
243        if let Some(hint) = &self.hint {
244            result.push_str("\n\nHint: ");
245            result.push_str(hint);
246        }
247
248        // Suggestion
249        if let Some(suggestion) = &self.suggestion {
250            result.push_str("\n\nDid you mean: ");
251            result.push_str(suggestion);
252        }
253
254        result
255    }
256}
257
258/// Main error type for ferro-hgvs operations
259#[derive(Error, Debug, Clone, PartialEq)]
260pub enum FerroError {
261    /// Parse error with position and message
262    #[error("Parse error at position {pos}: {msg}")]
263    Parse {
264        pos: usize,
265        msg: String,
266        /// Optional diagnostic with additional context
267        diagnostic: Option<Box<Diagnostic>>,
268    },
269
270    /// Reference sequence or transcript not found
271    #[error("Reference not found: {id}")]
272    ReferenceNotFound { id: String },
273
274    /// Variant spans an exon-intron boundary
275    #[error("Variant spans exon-intron boundary at exon {exon}: {variant}")]
276    ExonIntronBoundary { exon: u32, variant: String },
277
278    /// Variant spans a UTR-CDS boundary
279    #[error("Variant spans UTR-CDS boundary: {variant}")]
280    UtrCdsBoundary { variant: String },
281
282    /// Invalid coordinates provided
283    #[error("Invalid coordinates: {msg}")]
284    InvalidCoordinates { msg: String },
285
286    /// Unsupported variant type
287    #[error("Unsupported variant type: {variant_type}")]
288    UnsupportedVariant { variant_type: String },
289
290    /// Intronic variant cannot be normalized (no genomic data)
291    #[error("Intronic variant normalization not supported: {variant}")]
292    IntronicVariant { variant: String },
293
294    /// Genomic reference data is not available
295    #[error("Genomic reference not available for {contig}:{start}-{end}")]
296    GenomicReferenceNotAvailable {
297        contig: String,
298        start: u64,
299        end: u64,
300    },
301
302    /// Protein reference data is not available
303    #[error("Protein reference not available for {accession}:{start}-{end}")]
304    ProteinReferenceNotAvailable {
305        accession: String,
306        start: u64,
307        end: u64,
308    },
309
310    /// Amino acid mismatch with reference
311    #[error("Amino acid mismatch at position {position}: expected {expected}, found {found} in {accession}")]
312    AminoAcidMismatch {
313        accession: String,
314        position: u64,
315        expected: String,
316        found: String,
317    },
318
319    /// Reference sequence mismatch
320    #[error("Reference mismatch at {location}: expected {expected}, found {found}")]
321    ReferenceMismatch {
322        location: String,
323        expected: String,
324        found: String,
325    },
326
327    /// Coordinate conversion error
328    #[error("Coordinate conversion error: {msg}")]
329    ConversionError { msg: String },
330
331    /// IO error (for file operations)
332    #[error("IO error: {msg}")]
333    Io { msg: String },
334
335    /// JSON parsing error
336    #[error("JSON error: {msg}")]
337    Json { msg: String },
338}
339
340impl FerroError {
341    /// Create a parse error with diagnostic information
342    pub fn parse_with_diagnostic(
343        pos: usize,
344        msg: impl Into<String>,
345        diagnostic: Diagnostic,
346    ) -> Self {
347        FerroError::Parse {
348            pos,
349            msg: msg.into(),
350            diagnostic: Some(Box::new(diagnostic)),
351        }
352    }
353
354    /// Create a simple parse error without diagnostic
355    pub fn parse(pos: usize, msg: impl Into<String>) -> Self {
356        FerroError::Parse {
357            pos,
358            msg: msg.into(),
359            diagnostic: None,
360        }
361    }
362
363    /// Get the error code if available
364    pub fn code(&self) -> Option<ErrorCode> {
365        match self {
366            FerroError::Parse {
367                diagnostic: Some(d),
368                ..
369            } => d.code,
370            FerroError::ReferenceNotFound { .. } => Some(ErrorCode::ReferenceNotFound),
371            FerroError::ExonIntronBoundary { .. } => Some(ErrorCode::ExonIntronBoundary),
372            FerroError::UtrCdsBoundary { .. } => Some(ErrorCode::UtrCdsBoundary),
373            FerroError::InvalidCoordinates { .. } => Some(ErrorCode::InvalidRange),
374            FerroError::UnsupportedVariant { .. } => Some(ErrorCode::UnsupportedVariant),
375            FerroError::IntronicVariant { .. } => Some(ErrorCode::IntronicVariant),
376            FerroError::ReferenceMismatch { .. } => Some(ErrorCode::ReferenceMismatch),
377            FerroError::ConversionError { .. } => Some(ErrorCode::ConversionFailed),
378            FerroError::Io { .. } => Some(ErrorCode::IoError),
379            FerroError::Json { .. } => Some(ErrorCode::JsonError),
380            _ => None,
381        }
382    }
383
384    /// Get a formatted error with full diagnostic output
385    pub fn detailed_message(&self) -> String {
386        match self {
387            FerroError::Parse {
388                pos,
389                msg,
390                diagnostic: Some(d),
391            } => d.format(&format!("Parse error at position {}: {}", pos, msg)),
392            _ => self.to_string(),
393        }
394    }
395}
396
397/// Helper to suggest similar variant type prefixes
398pub fn suggest_variant_type(found: &str) -> Option<&'static str> {
399    let found_lower = found.to_lowercase();
400    let prefixes = [
401        ("c", "c."),
402        ("g", "g."),
403        ("p", "p."),
404        ("n", "n."),
405        ("r", "r."),
406        ("m", "m."),
407        ("c:", "c."),
408        ("g:", "g."),
409        ("p:", "p."),
410    ];
411
412    for (pattern, suggestion) in prefixes {
413        if found_lower.starts_with(pattern) {
414            return Some(suggestion);
415        }
416    }
417    None
418}
419
420/// Helper to suggest similar amino acids
421pub fn suggest_amino_acid(found: &str) -> Option<&'static str> {
422    let suggestions = [
423        ("ale", "Ala"),
424        ("arg", "Arg"),
425        ("asg", "Asn"),
426        ("asp", "Asp"),
427        ("cis", "Cys"),
428        ("cys", "Cys"),
429        ("gln", "Gln"),
430        ("glu", "Glu"),
431        ("gly", "Gly"),
432        ("his", "His"),
433        ("ile", "Ile"),
434        ("leu", "Leu"),
435        ("lys", "Lys"),
436        ("met", "Met"),
437        ("phe", "Phe"),
438        ("pro", "Pro"),
439        ("sec", "Sec"),
440        ("sel", "Sec"),
441        ("ser", "Ser"),
442        ("thr", "Thr"),
443        ("trp", "Trp"),
444        ("tyr", "Tyr"),
445        ("val", "Val"),
446        ("ter", "Ter"),
447        ("stop", "Ter"),
448        ("end", "Ter"),
449        ("xaa", "Xaa"),
450        ("unk", "Xaa"),
451    ];
452
453    let found_lower = found.to_lowercase();
454    for (pattern, suggestion) in suggestions {
455        if found_lower.starts_with(pattern) || pattern.starts_with(&found_lower) {
456            return Some(suggestion);
457        }
458    }
459    None
460}
461
462impl From<std::io::Error> for FerroError {
463    fn from(err: std::io::Error) -> Self {
464        FerroError::Io {
465            msg: err.to_string(),
466        }
467    }
468}
469
470impl From<serde_json::Error> for FerroError {
471    fn from(err: serde_json::Error) -> Self {
472        FerroError::Json {
473            msg: err.to_string(),
474        }
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    // ErrorCode tests
483    #[test]
484    fn test_error_code_as_str() {
485        assert_eq!(ErrorCode::InvalidAccession.as_str(), "E1001");
486        assert_eq!(ErrorCode::UnknownVariantType.as_str(), "E1002");
487        assert_eq!(ErrorCode::ReferenceNotFound.as_str(), "E2001");
488        assert_eq!(ErrorCode::PositionOutOfBounds.as_str(), "E3001");
489        assert_eq!(ErrorCode::IntronicVariant.as_str(), "E4001");
490        assert_eq!(ErrorCode::ConversionFailed.as_str(), "E5001");
491        assert_eq!(ErrorCode::IoError.as_str(), "E9001");
492    }
493
494    #[test]
495    fn test_error_code_description() {
496        assert_eq!(
497            ErrorCode::InvalidAccession.description(),
498            "invalid accession format"
499        );
500        assert_eq!(
501            ErrorCode::InvalidPosition.description(),
502            "invalid position format"
503        );
504        assert_eq!(ErrorCode::InvalidEdit.description(), "invalid edit format");
505        assert_eq!(
506            ErrorCode::UnexpectedEnd.description(),
507            "unexpected end of input"
508        );
509        assert_eq!(
510            ErrorCode::UnexpectedChar.description(),
511            "unexpected character"
512        );
513        assert_eq!(
514            ErrorCode::InvalidBase.description(),
515            "invalid nucleotide base"
516        );
517        assert_eq!(
518            ErrorCode::InvalidAminoAcid.description(),
519            "invalid amino acid"
520        );
521        assert_eq!(
522            ErrorCode::SequenceNotFound.description(),
523            "sequence not available"
524        );
525        assert_eq!(
526            ErrorCode::ChromosomeNotFound.description(),
527            "chromosome not found"
528        );
529        assert_eq!(
530            ErrorCode::ReferenceMismatch.description(),
531            "reference sequence mismatch"
532        );
533        assert_eq!(
534            ErrorCode::InvalidRange.description(),
535            "invalid coordinate range"
536        );
537        assert_eq!(
538            ErrorCode::ExonIntronBoundary.description(),
539            "variant crosses exon-intron boundary"
540        );
541        assert_eq!(
542            ErrorCode::UtrCdsBoundary.description(),
543            "variant crosses UTR-CDS boundary"
544        );
545        assert_eq!(
546            ErrorCode::UnsupportedVariant.description(),
547            "unsupported variant type"
548        );
549        assert_eq!(
550            ErrorCode::NoOverlappingTranscript.description(),
551            "no overlapping transcript"
552        );
553        assert_eq!(ErrorCode::JsonError.description(), "JSON parsing error");
554    }
555
556    #[test]
557    fn test_error_code_display() {
558        assert_eq!(format!("{}", ErrorCode::InvalidAccession), "E1001");
559        assert_eq!(format!("{}", ErrorCode::IoError), "E9001");
560    }
561
562    // SourceSpan tests
563    #[test]
564    fn test_source_span_new() {
565        let span = SourceSpan::new(5, 10);
566        assert_eq!(span.start, 5);
567        assert_eq!(span.end, 10);
568    }
569
570    #[test]
571    fn test_source_span_point() {
572        let span = SourceSpan::point(7);
573        assert_eq!(span.start, 7);
574        assert_eq!(span.end, 8);
575    }
576
577    #[test]
578    fn test_source_span_highlight() {
579        let span = SourceSpan::new(12, 15);
580        let result = span.highlight("NM_000088.3:c.459A>G");
581        assert!(result.contains("NM_000088.3:c.459A>G"));
582        assert!(result.contains("^"));
583    }
584
585    #[test]
586    fn test_source_span_highlight_empty_source() {
587        let span = SourceSpan::new(0, 5);
588        let result = span.highlight("");
589        assert_eq!(result, "");
590    }
591
592    #[test]
593    fn test_source_span_highlight_single_position() {
594        let span = SourceSpan::new(0, 1);
595        let result = span.highlight("ABC");
596        assert!(result.contains("ABC"));
597        assert!(result.contains("^"));
598    }
599
600    #[test]
601    fn test_source_span_highlight_out_of_bounds() {
602        let span = SourceSpan::new(100, 200);
603        let result = span.highlight("short");
604        assert!(result.contains("short"));
605    }
606
607    // Diagnostic tests
608    #[test]
609    fn test_diagnostic_new() {
610        let diag = Diagnostic::new();
611        assert!(diag.code.is_none());
612        assert!(diag.span.is_none());
613        assert!(diag.source.is_none());
614        assert!(diag.hint.is_none());
615        assert!(diag.suggestion.is_none());
616    }
617
618    #[test]
619    fn test_diagnostic_builder() {
620        let diag = Diagnostic::new()
621            .with_code(ErrorCode::InvalidAccession)
622            .with_span(SourceSpan::new(0, 5))
623            .with_source("NM_invalid:c.100A>G")
624            .with_hint("Check accession format")
625            .with_suggestion("NM_000088.3:c.100A>G");
626
627        assert_eq!(diag.code, Some(ErrorCode::InvalidAccession));
628        assert!(diag.span.is_some());
629        assert_eq!(diag.source, Some("NM_invalid:c.100A>G".to_string()));
630        assert_eq!(diag.hint, Some("Check accession format".to_string()));
631        assert_eq!(diag.suggestion, Some("NM_000088.3:c.100A>G".to_string()));
632    }
633
634    #[test]
635    fn test_diagnostic_format_simple() {
636        let diag = Diagnostic::new();
637        let result = diag.format("Simple error");
638        assert_eq!(result, "Simple error");
639    }
640
641    #[test]
642    fn test_diagnostic_format_with_code() {
643        let diag = Diagnostic::new().with_code(ErrorCode::InvalidAccession);
644        let result = diag.format("Invalid format");
645        assert!(result.starts_with("[E1001]"));
646    }
647
648    #[test]
649    fn test_diagnostic_format_full() {
650        let diag = Diagnostic::new()
651            .with_code(ErrorCode::InvalidAccession)
652            .with_span(SourceSpan::new(0, 5))
653            .with_source("error_input")
654            .with_hint("Try this")
655            .with_suggestion("correct_input");
656
657        let result = diag.format("Test message");
658        assert!(result.contains("[E1001]"));
659        assert!(result.contains("Test message"));
660        assert!(result.contains("Hint: Try this"));
661        assert!(result.contains("Did you mean: correct_input"));
662    }
663
664    // FerroError tests
665    #[test]
666    fn test_ferro_error_parse() {
667        let err = FerroError::parse(10, "unexpected token");
668        assert!(matches!(err, FerroError::Parse { pos: 10, .. }));
669    }
670
671    #[test]
672    fn test_ferro_error_parse_with_diagnostic() {
673        let diag = Diagnostic::new().with_code(ErrorCode::InvalidEdit);
674        let err = FerroError::parse_with_diagnostic(5, "bad edit", diag);
675        assert!(matches!(err, FerroError::Parse { pos: 5, .. }));
676    }
677
678    #[test]
679    fn test_ferro_error_code() {
680        let err = FerroError::ReferenceNotFound {
681            id: "NM_123".to_string(),
682        };
683        assert_eq!(err.code(), Some(ErrorCode::ReferenceNotFound));
684
685        let err = FerroError::ExonIntronBoundary {
686            exon: 3,
687            variant: "c.100del".to_string(),
688        };
689        assert_eq!(err.code(), Some(ErrorCode::ExonIntronBoundary));
690
691        let err = FerroError::UtrCdsBoundary {
692            variant: "c.1-100del".to_string(),
693        };
694        assert_eq!(err.code(), Some(ErrorCode::UtrCdsBoundary));
695
696        let err = FerroError::InvalidCoordinates {
697            msg: "bad coords".to_string(),
698        };
699        assert_eq!(err.code(), Some(ErrorCode::InvalidRange));
700
701        let err = FerroError::UnsupportedVariant {
702            variant_type: "xyz".to_string(),
703        };
704        assert_eq!(err.code(), Some(ErrorCode::UnsupportedVariant));
705
706        let err = FerroError::IntronicVariant {
707            variant: "c.100+5del".to_string(),
708        };
709        assert_eq!(err.code(), Some(ErrorCode::IntronicVariant));
710
711        let err = FerroError::ReferenceMismatch {
712            location: "c.100".to_string(),
713            expected: "A".to_string(),
714            found: "G".to_string(),
715        };
716        assert_eq!(err.code(), Some(ErrorCode::ReferenceMismatch));
717
718        let err = FerroError::ConversionError {
719            msg: "conversion failed".to_string(),
720        };
721        assert_eq!(err.code(), Some(ErrorCode::ConversionFailed));
722
723        let err = FerroError::Io {
724            msg: "file error".to_string(),
725        };
726        assert_eq!(err.code(), Some(ErrorCode::IoError));
727
728        let err = FerroError::Json {
729            msg: "json error".to_string(),
730        };
731        assert_eq!(err.code(), Some(ErrorCode::JsonError));
732    }
733
734    #[test]
735    fn test_ferro_error_code_from_diagnostic() {
736        let diag = Diagnostic::new().with_code(ErrorCode::InvalidPosition);
737        let err = FerroError::parse_with_diagnostic(0, "test", diag);
738        assert_eq!(err.code(), Some(ErrorCode::InvalidPosition));
739    }
740
741    #[test]
742    fn test_ferro_error_code_none() {
743        let err = FerroError::parse(0, "simple error");
744        assert_eq!(err.code(), None);
745    }
746
747    #[test]
748    fn test_ferro_error_detailed_message() {
749        let diag = Diagnostic::new()
750            .with_code(ErrorCode::InvalidAccession)
751            .with_source("bad_input")
752            .with_span(SourceSpan::new(0, 3));
753        let err = FerroError::parse_with_diagnostic(0, "invalid", diag);
754        let msg = err.detailed_message();
755        assert!(msg.contains("[E1001]"));
756        assert!(msg.contains("bad_input"));
757    }
758
759    #[test]
760    fn test_ferro_error_detailed_message_simple() {
761        let err = FerroError::ReferenceNotFound {
762            id: "NM_test".to_string(),
763        };
764        let msg = err.detailed_message();
765        assert!(msg.contains("NM_test"));
766    }
767
768    #[test]
769    fn test_ferro_error_display() {
770        let err = FerroError::parse(10, "unexpected");
771        let display = format!("{}", err);
772        assert!(display.contains("10"));
773        assert!(display.contains("unexpected"));
774
775        let err = FerroError::ReferenceNotFound {
776            id: "test_id".to_string(),
777        };
778        let display = format!("{}", err);
779        assert!(display.contains("test_id"));
780    }
781
782    // Suggestion helper tests
783    #[test]
784    fn test_suggest_variant_type() {
785        assert_eq!(suggest_variant_type("c:100A>G"), Some("c."));
786        assert_eq!(suggest_variant_type("g:123A>G"), Some("g."));
787        assert_eq!(suggest_variant_type("p:Val600Glu"), Some("p."));
788        assert_eq!(suggest_variant_type("c100A>G"), Some("c."));
789        assert_eq!(suggest_variant_type("C.100A>G"), Some("c."));
790        assert_eq!(suggest_variant_type("xyz"), None);
791    }
792
793    #[test]
794    fn test_suggest_amino_acid() {
795        assert_eq!(suggest_amino_acid("Ale"), Some("Ala"));
796        assert_eq!(suggest_amino_acid("arg"), Some("Arg"));
797        assert_eq!(suggest_amino_acid("asg"), Some("Asn"));
798        assert_eq!(suggest_amino_acid("cis"), Some("Cys"));
799        assert_eq!(suggest_amino_acid("stop"), Some("Ter"));
800        assert_eq!(suggest_amino_acid("end"), Some("Ter"));
801        assert_eq!(suggest_amino_acid("unk"), Some("Xaa"));
802        assert_eq!(suggest_amino_acid("sel"), Some("Sec"));
803        assert_eq!(suggest_amino_acid("xyz"), None);
804    }
805
806    // From impl tests
807    #[test]
808    fn test_from_io_error() {
809        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
810        let ferro_err: FerroError = io_err.into();
811        assert!(matches!(ferro_err, FerroError::Io { .. }));
812        assert!(ferro_err.to_string().contains("not found"));
813    }
814
815    #[test]
816    fn test_ferro_error_equality() {
817        let err1 = FerroError::parse(10, "test");
818        let err2 = FerroError::parse(10, "test");
819        assert_eq!(err1, err2);
820
821        let err3 = FerroError::parse(11, "test");
822        assert_ne!(err1, err3);
823    }
824
825    #[test]
826    fn test_source_span_equality() {
827        let span1 = SourceSpan::new(5, 10);
828        let span2 = SourceSpan::new(5, 10);
829        assert_eq!(span1, span2);
830
831        let span3 = SourceSpan::new(5, 11);
832        assert_ne!(span1, span3);
833    }
834
835    #[test]
836    fn test_diagnostic_equality() {
837        let diag1 = Diagnostic::new().with_code(ErrorCode::InvalidAccession);
838        let diag2 = Diagnostic::new().with_code(ErrorCode::InvalidAccession);
839        assert_eq!(diag1, diag2);
840
841        let diag3 = Diagnostic::new().with_code(ErrorCode::InvalidEdit);
842        assert_ne!(diag1, diag3);
843    }
844
845    #[test]
846    fn test_error_code_hash() {
847        use std::collections::HashSet;
848        let mut set = HashSet::new();
849        set.insert(ErrorCode::InvalidAccession);
850        set.insert(ErrorCode::InvalidEdit);
851        assert!(set.contains(&ErrorCode::InvalidAccession));
852        assert!(set.contains(&ErrorCode::InvalidEdit));
853        assert!(!set.contains(&ErrorCode::InvalidPosition));
854    }
855}