Skip to main content

mbr_core/
errors.rs

1//! Centralized error types for the mbr markdown browser.
2//!
3//! This module provides typed errors using thiserror to replace
4//! `Box<dyn std::error::Error>` throughout the codebase.
5
6use std::path::PathBuf;
7use thiserror::Error;
8
9/// Boxed error from a crate layered above this one.
10///
11/// [`MbrError::Server`] and [`MbrError::Browser`] carry errors whose types live
12/// in crates that depend on this one (`mbr_server::errors::ServerError`, the
13/// binary's `BrowserError`), so this crate cannot name them. Each owning crate
14/// provides the `From` impl, which keeps `?` working into `MbrError` and the
15/// messages unchanged; `downcast_ref` recovers the concrete type.
16pub type UpstreamError = Box<dyn std::error::Error + Send + Sync>;
17
18/// Top-level error type for the mbr application.
19#[derive(Debug, Error)]
20pub enum MbrError {
21    /// The HTTP server failed (`mbr_server::errors::ServerError`).
22    #[error("Server error: {0}")]
23    Server(#[source] UpstreamError),
24
25    #[error("Configuration error: {0}")]
26    Config(#[from] ConfigError),
27
28    #[error("Configuration parsing error: {0}")]
29    ConfigParse(Box<figment::Error>),
30
31    #[error("Markdown error: {0}")]
32    Markdown(#[from] MarkdownError),
33
34    #[error("Template error: {0}")]
35    Template(#[from] TemplateError),
36
37    #[error("Repository error: {0}")]
38    Repo(#[from] RepoError),
39
40    /// The native GUI window failed (the `mbr` binary's `BrowserError`).
41    #[error("Browser error: {0}")]
42    Browser(#[source] UpstreamError),
43
44    #[error("Build error: {0}")]
45    Build(Box<BuildError>),
46
47    #[cfg(feature = "media-metadata")]
48    #[error("Video metadata error: {0}")]
49    Metadata(#[from] MetadataError),
50
51    #[cfg(feature = "media-metadata")]
52    #[error("PDF metadata error: {0}")]
53    PdfMetadata(#[from] PdfMetadataError),
54
55    #[error("IO error: {0}")]
56    Io(#[from] std::io::Error),
57
58    #[error("URL parse error: {0}")]
59    UrlParse(#[from] url::ParseError),
60
61    /// Building an HTTP response failed.
62    ///
63    /// Type-erased so this enum does not name the HTTP stack's types; the
64    /// `From<http::Error>` impl below is what the server's `?` goes through.
65    #[error("Failed to build HTTP response: {0}")]
66    Http(#[source] UpstreamError),
67
68    #[error("Background task failed: {0}")]
69    TaskJoin(#[from] tokio::task::JoinError),
70
71    #[error("Invalid media path: {0}")]
72    InvalidMediaPath(String),
73
74    #[error("Directory traversal attempt detected")]
75    DirectoryTraversal,
76}
77
78/// Lets `?` turn a failed response build (`axum::http::Error` is a re-export
79/// of `http::Error`) into [`MbrError::Http`].
80///
81/// It lives here, not beside the server's response builders, because the
82/// orphan rule forbids implementing a foreign trait for two foreign types:
83/// once the server is its own crate, only the crate owning `MbrError` may
84/// write this impl. `http` is already in this crate's graph through reqwest,
85/// so naming it adds no crate.
86impl From<http::Error> for MbrError {
87    fn from(err: http::Error) -> Self {
88        MbrError::Http(Box::new(err))
89    }
90}
91
92/// Errors related to configuration loading and parsing.
93#[derive(Debug, Error)]
94pub enum ConfigError {
95    #[error("Failed to get current directory")]
96    CurrentDirFailed(#[source] std::io::Error),
97
98    #[error("Failed to find root directory from path: {path}")]
99    RootDirNotFound { path: PathBuf },
100
101    #[error("Failed to get parent directory of: {path}")]
102    NoParentDir { path: PathBuf },
103
104    #[error("Configuration parsing failed")]
105    ParseFailed(Box<figment::Error>),
106
107    #[error("Failed to canonicalize path: {path}")]
108    CanonicalizeFailed {
109        path: PathBuf,
110        #[source]
111        source: std::io::Error,
112    },
113
114    #[error("Failed to calculate relative path from {from} to {to}")]
115    RelativePathFailed { from: PathBuf, to: PathBuf },
116
117    #[error("Template folder is not a directory: {}", path.display())]
118    TemplateFolderNotDirectory { path: PathBuf },
119
120    #[error(
121        "Invalid host address: {host}. Must be a valid IPv4 address (e.g., 127.0.0.1 or 0.0.0.0)"
122    )]
123    InvalidHost { host: String },
124
125    #[error("Invalid port: {port}. Port must be between 1 and 65535")]
126    InvalidPort { port: u16 },
127
128    #[error("Invalid sidebar_max_items: {value}. Must be greater than 0")]
129    InvalidSidebarMaxItems { value: usize },
130
131    #[error("Invalid graph_depth: {value}. Must be between 1 and 5")]
132    InvalidGraphDepth { value: usize },
133
134    #[error(
135        "Invalid flashcards_concentric_threshold: {value}. Must be greater than 0 and at most 1"
136    )]
137    InvalidFlashcardsConcentricThreshold { value: f64 },
138
139    #[error("Invalid build_concurrency: {value}. Must be greater than 0")]
140    InvalidBuildConcurrency { value: usize },
141
142    #[error(
143        "Invalid tasks_ignore_globs pattern {pattern:?}: {reason}. \
144         Patterns match a file's repository-relative path, e.g. \"templates/**\" \
145         or \"**/templates/**\""
146    )]
147    InvalidTasksIgnoreGlob { pattern: String, reason: String },
148
149    #[error(
150        "Editing is enabled on a non-loopback host but no edit_token_hash is set. \
151         Run `mbr --generate-edit-token` and add the printed edit_token_hash to \
152         .mbr/config.toml or set it in the MBR_EDIT_TOKEN_HASH environment variable \
153         (no change to the repository needed), or bind to 127.0.0.1."
154    )]
155    EditingRequiresToken,
156}
157
158/// Errors related to markdown parsing and rendering.
159#[derive(Debug, Error)]
160pub enum MarkdownError {
161    #[error("Failed to read markdown file: {path}")]
162    ReadFailed {
163        path: PathBuf,
164        #[source]
165        source: std::io::Error,
166    },
167
168    #[error("Failed to parse YAML frontmatter")]
169    YamlParseFailed,
170
171    #[error("Failed to fetch oembed data for URL: {url}")]
172    OembedFetchFailed { url: String },
173
174    #[error("HTTP request failed: {0}")]
175    HttpFailed(#[from] reqwest::Error),
176}
177
178/// Errors related to template rendering.
179#[derive(Debug, Error)]
180pub enum TemplateError {
181    #[error("Failed to initialize templates from: {path}")]
182    InitFailed {
183        path: PathBuf,
184        #[source]
185        source: tera::Error,
186    },
187
188    #[error("Failed to render template: {template_name}")]
189    RenderFailed {
190        template_name: String,
191        #[source]
192        source: tera::Error,
193    },
194
195    #[error("Template error: {0}")]
196    Tera(#[from] tera::Error),
197
198    #[error("Invalid path encoding")]
199    InvalidPathEncoding,
200}
201
202/// Errors related to repository scanning.
203#[derive(Debug, Error)]
204pub enum RepoError {
205    #[error("Failed to scan folder: {path}")]
206    ScanFailed {
207        path: PathBuf,
208        #[source]
209        source: std::io::Error,
210    },
211
212    #[error("Failed to canonicalize path: {path}")]
213    CanonicalizeFailed {
214        path: PathBuf,
215        #[source]
216        source: std::io::Error,
217    },
218
219    #[error("Failed to serialize repository to JSON")]
220    JsonSerializeFailed(#[from] serde_json::Error),
221
222    #[error("Failed to read file metadata: {path}")]
223    MetadataFailed {
224        path: PathBuf,
225        #[source]
226        source: std::io::Error,
227    },
228
229    #[error("Failed to get file times: {0}")]
230    SystemTimeFailed(#[from] std::time::SystemTimeError),
231
232    #[error("Invalid UTF-8 in path: {path}")]
233    InvalidUtf8Path { path: PathBuf },
234}
235
236/// Errors related to search functionality.
237#[derive(Debug, Error)]
238pub enum SearchError {
239    #[error("Invalid search pattern: {pattern}")]
240    PatternInvalid { pattern: String, reason: String },
241
242    #[error("Search failed: {0}")]
243    SearchFailed(String),
244
245    #[error("File read error during search: {}", path.display())]
246    FileReadError {
247        path: std::path::PathBuf,
248        #[source]
249        source: std::io::Error,
250    },
251}
252
253/// Errors related to building the lazy task index.
254///
255/// Deliberately narrow: an individual unreadable file is skipped with a debug
256/// log rather than failing the whole build, so the only way to get here is for
257/// the blocking build task itself to fail.
258#[derive(Debug, Error)]
259pub enum TaskIndexError {
260    #[error("Task index build failed: {reason}")]
261    BuildFailed { reason: String },
262}
263
264/// Why [`crate::tasks::patch_task_line`] refused to rewrite a line.
265///
266/// These are the three ways a single-line patch can be aimed at something other
267/// than the task the client thinks it is aimed at, which is exactly what the
268/// per-line optimistic-concurrency check exists to catch.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
270pub enum TaskPatchError {
271    #[error("The file has no line {line}")]
272    LineOutOfRange { line: u32 },
273
274    #[error("Line {line} is not what the client last saw; the file changed on disk")]
275    Mismatch { line: u32 },
276
277    #[error("Line {line} is not a task")]
278    NotATask { line: u32 },
279}
280
281/// Why [`crate::flashcards::parse_review_time`] refused a reviewer's clock.
282///
283/// Both are a bad request rather than a stale file, so the handler answers
284/// `422`, the same as any other malformed body.
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
286pub enum ReviewTimeError {
287    #[error("`at` must be local wall-clock time as YYYY-MM-DD HH:MM")]
288    Malformed,
289
290    #[error("`at` is further from the server's clock than any time zone")]
291    OutOfRange,
292}
293
294/// Why [`crate::flashcards::append_review`] refused to record a review.
295///
296/// The first three mean the client's picture of the file is stale — the line
297/// moved, changed, or no longer opens a top-level definition-list term. The
298/// last means the writer could not find an insertion that leaves every card
299/// intact, so it refused rather than corrupt the note. The handler answers
300/// every one of them with `409 Conflict`; nothing is written.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
302pub enum FlashcardPatchError {
303    #[error("The file has no line {line}")]
304    LineOutOfRange { line: u32 },
305
306    #[error("Line {line} is not what the client last saw; the file changed on disk")]
307    Mismatch { line: u32 },
308
309    #[error("Line {line} does not start a top-level definition-list term")]
310    NotATerm { line: u32 },
311
312    #[error("Recording a review for line {line} would change the note's other cards")]
313    UnsafeInsert { line: u32 },
314}
315
316/// Errors related to video metadata extraction.
317#[cfg(feature = "media-metadata")]
318#[derive(Debug, Error)]
319pub enum MetadataError {
320    #[error("FFmpeg initialization failed")]
321    InitFailed,
322
323    #[error("Failed to open video file: {}", path.display())]
324    OpenFailed {
325        path: PathBuf,
326        #[source]
327        source: ffmpeg_next::Error,
328    },
329
330    #[error("No video stream found in file: {}", path.display())]
331    NoVideoStream { path: PathBuf },
332
333    #[error("No subtitle stream found in file: {}", path.display())]
334    NoSubtitleStream { path: PathBuf },
335
336    #[error("No chapters found in file: {}", path.display())]
337    NoChapters { path: PathBuf },
338
339    #[error("Failed to decode video frame: {0}")]
340    DecodeFailed(String),
341
342    #[error("Failed to encode image: {0}")]
343    EncodeFailed(String),
344
345    #[error("Video too short for thumbnail (duration: {duration_secs:.1}s)")]
346    VideoTooShort { duration_secs: f64 },
347
348    #[error("IO error: {0}")]
349    Io(#[from] std::io::Error),
350}
351
352/// Errors related to PDF metadata and cover extraction.
353#[cfg(feature = "media-metadata")]
354#[derive(Debug, Error)]
355pub enum PdfMetadataError {
356    #[error("Failed to open PDF file: {}", path.display())]
357    OpenFailed {
358        path: PathBuf,
359        #[source]
360        source: lopdf::Error,
361    },
362
363    #[error("Failed to initialize PDF renderer")]
364    RendererInitFailed,
365
366    #[error("PDF has no pages: {}", path.display())]
367    NoPages { path: PathBuf },
368
369    #[error("Failed to render PDF page: {0}")]
370    RenderFailed(String),
371
372    #[error("Failed to encode image: {0}")]
373    EncodeFailed(String),
374
375    #[error("PDF is password-protected: {}", path.display())]
376    PasswordProtected { path: PathBuf },
377
378    #[error("IO error: {0}")]
379    Io(#[from] std::io::Error),
380}
381
382/// Errors related to oembed URL validation and page metadata fetching.
383///
384/// These are logged (and degraded to a plain link) rather than surfaced to
385/// users, but a typed error keeps the SSRF-check failure modes explicit.
386#[derive(Debug, Error)]
387pub enum OembedError {
388    #[error("URL has no host: {url}")]
389    NoHost { url: String },
390
391    #[error("refusing to fetch non-public address {detail}")]
392    DisallowedAddress { detail: String },
393
394    #[error("no known port for URL {url}")]
395    NoKnownPort { url: String },
396
397    #[error("failed to resolve host {domain}")]
398    DnsResolveFailed {
399        domain: String,
400        #[source]
401        source: std::io::Error,
402    },
403
404    #[error("no addresses resolved for host {domain}")]
405    NoAddressesResolved { domain: String },
406
407    #[error("refusing to fetch non-http(s) URL {url}")]
408    NonHttpScheme { url: String },
409
410    #[error("too many redirects (more than {max}) fetching {url}")]
411    TooManyRedirects { max: usize, url: String },
412
413    #[error("redirect from {url} without Location header")]
414    RedirectWithoutLocation { url: String },
415
416    #[error("invalid Location header in redirect from {url}")]
417    InvalidLocationHeader { url: String },
418
419    #[error("Blocked by Cloudflare challenge")]
420    CloudflareChallenge,
421
422    #[error("refusing to parse non-HTML content type '{content_type}' from {url}")]
423    NonHtmlContentType { content_type: String, url: String },
424
425    #[error("URL parse error: {0}")]
426    UrlParse(#[from] url::ParseError),
427
428    #[error("HTTP request failed: {0}")]
429    Http(#[from] reqwest::Error),
430}
431
432/// Errors related to static site building.
433#[derive(Debug, Error)]
434pub enum BuildError {
435    #[error("Static site generation is not supported on Windows")]
436    UnsupportedPlatform,
437
438    #[error("Failed to create output directory: {}", path.display())]
439    CreateDirFailed {
440        path: PathBuf,
441        #[source]
442        source: std::io::Error,
443    },
444
445    #[error("Failed to render markdown: {}", path.display())]
446    RenderFailed {
447        path: PathBuf,
448        #[source]
449        source: Box<MbrError>,
450    },
451
452    #[error("Failed to write output file: {}", path.display())]
453    WriteFailed {
454        path: PathBuf,
455        #[source]
456        source: std::io::Error,
457    },
458
459    #[error("Failed to create symlink: {} -> {}", link.display(), target.display())]
460    SymlinkFailed {
461        target: PathBuf,
462        link: PathBuf,
463        #[source]
464        source: std::io::Error,
465    },
466
467    #[error("Failed to copy file: {} -> {}", from.display(), to.display())]
468    CopyFailed {
469        from: PathBuf,
470        to: PathBuf,
471        #[source]
472        source: std::io::Error,
473    },
474
475    #[error("Repository scan failed")]
476    RepoScan(#[from] RepoError),
477
478    #[error("Template error")]
479    Template(#[from] TemplateError),
480
481    #[error("Markdown error")]
482    Markdown(#[from] MarkdownError),
483
484    #[error("Configuration error")]
485    Config(Box<ConfigError>),
486
487    #[error("Default Pico theme is missing from embedded assets")]
488    MissingDefaultTheme,
489}
490
491// Convenience type alias for Results using MbrError
492pub type Result<T> = std::result::Result<T, MbrError>;
493
494// Auto-box BuildError when converting to MbrError
495impl From<BuildError> for MbrError {
496    fn from(err: BuildError) -> Self {
497        MbrError::Build(Box::new(err))
498    }
499}
500
501// Auto-box ConfigError when converting to BuildError
502impl From<ConfigError> for BuildError {
503    fn from(err: ConfigError) -> Self {
504        BuildError::Config(Box::new(err))
505    }
506}
507
508// Auto-box figment::Error when converting to MbrError
509impl From<figment::Error> for MbrError {
510    fn from(err: figment::Error) -> Self {
511        MbrError::ConfigParse(Box::new(err))
512    }
513}
514
515// Auto-box figment::Error when converting to ConfigError
516impl From<figment::Error> for ConfigError {
517    fn from(err: figment::Error) -> Self {
518        ConfigError::ParseFailed(Box::new(err))
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use std::io::{Error as IoError, ErrorKind};
526
527    // ==================== Error Display Tests ====================
528
529    #[test]
530    fn test_mbr_error_display() {
531        let io_err = MbrError::Io(IoError::new(ErrorKind::NotFound, "file not found"));
532        assert!(io_err.to_string().contains("file not found"));
533
534        let config_err = MbrError::Config(ConfigError::RootDirNotFound {
535            path: PathBuf::from("/test/path"),
536        });
537        assert!(config_err.to_string().contains("Configuration error"));
538    }
539
540    #[test]
541    fn test_config_error_display() {
542        let err = ConfigError::RootDirNotFound {
543            path: PathBuf::from("/missing/root"),
544        };
545        assert!(err.to_string().contains("/missing/root"));
546
547        let err = ConfigError::InvalidHost {
548            host: "invalid-host".to_string(),
549        };
550        assert!(err.to_string().contains("invalid-host"));
551        assert!(err.to_string().contains("IPv4"));
552
553        let err = ConfigError::InvalidPort { port: 0 };
554        assert!(err.to_string().contains("0"));
555        assert!(err.to_string().contains("Port must be between 1 and 65535"));
556
557        let err = ConfigError::InvalidSidebarMaxItems { value: 0 };
558        assert!(err.to_string().contains("sidebar_max_items"));
559        assert!(err.to_string().contains("greater than 0"));
560
561        let err = ConfigError::InvalidGraphDepth { value: 6 };
562        assert!(err.to_string().contains("graph_depth"));
563        assert!(err.to_string().contains("6"));
564        assert!(err.to_string().contains("between 1 and 5"));
565
566        let err = ConfigError::InvalidBuildConcurrency { value: 0 };
567        assert!(err.to_string().contains("build_concurrency"));
568        assert!(err.to_string().contains("greater than 0"));
569    }
570
571    #[test]
572    fn test_markdown_error_display() {
573        let err = MarkdownError::ReadFailed {
574            path: PathBuf::from("/test/doc.md"),
575            source: IoError::new(ErrorKind::NotFound, "not found"),
576        };
577        assert!(err.to_string().contains("/test/doc.md"));
578
579        let err = MarkdownError::OembedFetchFailed {
580            url: "https://example.com".to_string(),
581        };
582        assert!(err.to_string().contains("https://example.com"));
583    }
584
585    #[test]
586    fn test_template_error_display() {
587        let err = TemplateError::RenderFailed {
588            template_name: "index.html".to_string(),
589            source: tera::Error::msg("missing variable"),
590        };
591        assert!(err.to_string().contains("index.html"));
592    }
593
594    #[test]
595    fn test_build_error_display() {
596        let err = BuildError::UnsupportedPlatform;
597        assert!(err.to_string().contains("Windows"));
598
599        let err = BuildError::SymlinkFailed {
600            target: PathBuf::from("/target"),
601            link: PathBuf::from("/link"),
602            source: IoError::new(ErrorKind::PermissionDenied, "permission denied"),
603        };
604        assert!(err.to_string().contains("/target"));
605        assert!(err.to_string().contains("/link"));
606    }
607
608    // ==================== Error Conversion Tests ====================
609
610    #[test]
611    fn test_build_error_to_mbr_error() {
612        let build_err = BuildError::UnsupportedPlatform;
613        let mbr_err: MbrError = build_err.into();
614
615        match mbr_err {
616            MbrError::Build(boxed) => {
617                assert!(matches!(*boxed, BuildError::UnsupportedPlatform));
618            }
619            _ => panic!("Expected MbrError::Build, got {:?}", mbr_err),
620        }
621    }
622
623    #[test]
624    fn test_config_error_to_build_error() {
625        let config_err = ConfigError::RootDirNotFound {
626            path: PathBuf::from("/test"),
627        };
628        let build_err: BuildError = config_err.into();
629
630        match build_err {
631            BuildError::Config(boxed) => {
632                assert!(matches!(*boxed, ConfigError::RootDirNotFound { .. }));
633            }
634            _ => panic!("Expected BuildError::Config, got {:?}", build_err),
635        }
636    }
637
638    #[test]
639    fn test_figment_error_to_mbr_error() {
640        // Create a figment error by trying to parse invalid TOML
641        let figment_err = figment::Error::from("test figment error".to_string());
642        let mbr_err: MbrError = figment_err.into();
643
644        match mbr_err {
645            MbrError::ConfigParse(boxed) => {
646                assert!(boxed.to_string().contains("test figment error"));
647            }
648            _ => panic!("Expected MbrError::ConfigParse, got {:?}", mbr_err),
649        }
650    }
651
652    #[test]
653    fn test_figment_error_to_config_error() {
654        let figment_err = figment::Error::from("parse failed".to_string());
655        let config_err: ConfigError = figment_err.into();
656
657        match config_err {
658            ConfigError::ParseFailed(boxed) => {
659                assert!(boxed.to_string().contains("parse failed"));
660            }
661            _ => panic!("Expected ConfigError::ParseFailed, got {:?}", config_err),
662        }
663    }
664
665    // ==================== Auto From Derive Tests ====================
666
667    #[test]
668    fn test_io_error_to_mbr_error() {
669        let io_err = IoError::new(ErrorKind::NotFound, "file missing");
670        let mbr_err: MbrError = io_err.into();
671
672        match mbr_err {
673            MbrError::Io(err) => {
674                assert_eq!(err.kind(), ErrorKind::NotFound);
675                assert!(err.to_string().contains("file missing"));
676            }
677            _ => panic!("Expected MbrError::Io, got {:?}", mbr_err),
678        }
679    }
680
681    #[test]
682    fn test_markdown_error_to_mbr_error() {
683        let md_err = MarkdownError::YamlParseFailed;
684        let mbr_err: MbrError = md_err.into();
685
686        match mbr_err {
687            MbrError::Markdown(MarkdownError::YamlParseFailed) => {}
688            _ => panic!(
689                "Expected MbrError::Markdown(YamlParseFailed), got {:?}",
690                mbr_err
691            ),
692        }
693    }
694
695    #[test]
696    fn test_template_error_to_mbr_error() {
697        let tpl_err = TemplateError::InvalidPathEncoding;
698        let mbr_err: MbrError = tpl_err.into();
699
700        match mbr_err {
701            MbrError::Template(TemplateError::InvalidPathEncoding) => {}
702            _ => panic!(
703                "Expected MbrError::Template(InvalidPathEncoding), got {:?}",
704                mbr_err
705            ),
706        }
707    }
708
709    #[test]
710    fn test_repo_error_to_mbr_error() {
711        let repo_err = RepoError::InvalidUtf8Path {
712            path: PathBuf::from("/bad/path"),
713        };
714        let mbr_err: MbrError = repo_err.into();
715
716        match mbr_err {
717            MbrError::Repo(RepoError::InvalidUtf8Path { path }) => {
718                assert_eq!(path, PathBuf::from("/bad/path"));
719            }
720            _ => panic!(
721                "Expected MbrError::Repo(InvalidUtf8Path), got {:?}",
722                mbr_err
723            ),
724        }
725    }
726
727    // ==================== Error Chain Tests ====================
728
729    #[test]
730    fn test_error_chain_preserves_source() {
731        use std::error::Error;
732
733        let io_err = IoError::new(ErrorKind::PermissionDenied, "access denied");
734        let config_err = ConfigError::CanonicalizeFailed {
735            path: PathBuf::from("/test"),
736            source: io_err,
737        };
738
739        // Check that source() returns the underlying IO error
740        let source = config_err.source();
741        assert!(source.is_some());
742        assert!(source.unwrap().to_string().contains("access denied"));
743    }
744
745    #[test]
746    fn test_nested_error_chain() {
747        use std::error::Error;
748
749        let io_err = IoError::new(ErrorKind::NotFound, "file not found");
750        let md_err = MarkdownError::ReadFailed {
751            path: PathBuf::from("/test.md"),
752            source: io_err,
753        };
754        let mbr_err: MbrError = md_err.into();
755
756        // MbrError -> MarkdownError -> IoError
757        let source1 = mbr_err.source();
758        assert!(source1.is_some());
759
760        let source2 = source1.unwrap().source();
761        assert!(source2.is_some());
762        assert!(source2.unwrap().to_string().contains("file not found"));
763    }
764
765    // ==================== Question Mark Operator Tests ====================
766
767    fn fallible_operation() -> std::result::Result<(), IoError> {
768        Err(IoError::other("operation failed"))
769    }
770
771    fn uses_question_mark() -> Result<()> {
772        fallible_operation()?; // Should auto-convert IoError to MbrError
773        Ok(())
774    }
775
776    #[test]
777    fn test_question_mark_conversion() {
778        let result = uses_question_mark();
779        assert!(result.is_err());
780
781        match result {
782            Err(MbrError::Io(io_err)) => {
783                assert!(io_err.to_string().contains("operation failed"));
784            }
785            Err(other) => panic!("Expected MbrError::Io, got {:?}", other),
786            Ok(_) => panic!("Expected error, got Ok"),
787        }
788    }
789
790    // ==================== Edge Case Tests ====================
791
792    #[test]
793    fn test_empty_error_message() {
794        let converted: MbrError = IoError::other("").into();
795
796        // Should not panic on empty message
797        let _ = converted.to_string();
798    }
799
800    #[test]
801    fn test_unicode_in_error_message() {
802        let converted: MbrError = IoError::other("文件未找到 🔍").into();
803
804        match converted {
805            MbrError::Io(io_err) => {
806                assert!(io_err.to_string().contains("文件未找到"));
807                assert!(io_err.to_string().contains("🔍"));
808            }
809            _ => panic!("Expected MbrError::Io"),
810        }
811    }
812}