1use std::path::PathBuf;
7use thiserror::Error;
8
9pub type UpstreamError = Box<dyn std::error::Error + Send + Sync>;
17
18#[derive(Debug, Error)]
20pub enum MbrError {
21 #[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 #[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 #[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
78impl From<http::Error> for MbrError {
87 fn from(err: http::Error) -> Self {
88 MbrError::Http(Box::new(err))
89 }
90}
91
92#[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#[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#[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#[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#[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#[derive(Debug, Error)]
259pub enum TaskIndexError {
260 #[error("Task index build failed: {reason}")]
261 BuildFailed { reason: String },
262}
263
264#[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#[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#[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#[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#[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#[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#[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
491pub type Result<T> = std::result::Result<T, MbrError>;
493
494impl From<BuildError> for MbrError {
496 fn from(err: BuildError) -> Self {
497 MbrError::Build(Box::new(err))
498 }
499}
500
501impl From<ConfigError> for BuildError {
503 fn from(err: ConfigError) -> Self {
504 BuildError::Config(Box::new(err))
505 }
506}
507
508impl From<figment::Error> for MbrError {
510 fn from(err: figment::Error) -> Self {
511 MbrError::ConfigParse(Box::new(err))
512 }
513}
514
515impl 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 #[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 #[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 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 #[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 #[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 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 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 fn fallible_operation() -> std::result::Result<(), IoError> {
768 Err(IoError::other("operation failed"))
769 }
770
771 fn uses_question_mark() -> Result<()> {
772 fallible_operation()?; 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 #[test]
793 fn test_empty_error_message() {
794 let converted: MbrError = IoError::other("").into();
795
796 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}