diff --git a/Cargo.lock b/Cargo.lock index 94f08b417..672f0eb4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3964,6 +3964,7 @@ dependencies = [ "oakcore-rs", "oakffmpeg-link", "portaudio", + "thiserror 2.0.20", ] [[package]] @@ -3973,6 +3974,7 @@ dependencies = [ "ffmpeg-next", "oakcore-rs", "oakffmpeg-link", + "thiserror 2.0.20", ] [[package]] @@ -3984,6 +3986,7 @@ dependencies = [ "oakcore-rs", "ocio-rs", "quick-xml 0.41.0", + "thiserror 2.0.20", ] [[package]] @@ -4007,6 +4010,7 @@ dependencies = [ "oakundo", "serde", "serde_json", + "thiserror 2.0.20", ] [[package]] @@ -4021,6 +4025,7 @@ dependencies = [ "oakcommon", "oakcore-rs", "oakundo", + "thiserror 2.0.20", ] [[package]] @@ -4031,6 +4036,7 @@ dependencies = [ "quick-xml 0.41.0", "serde", "serde_json", + "thiserror 2.0.20", ] [[package]] @@ -4042,6 +4048,7 @@ dependencies = [ "oaknode", "oakrender", "oakundo", + "thiserror 2.0.20", ] [[package]] @@ -4053,6 +4060,7 @@ dependencies = [ "oakcore-rs", "oaknode", "ocio-rs", + "thiserror 2.0.20", "wgpu 25.0.2", ] @@ -4068,6 +4076,7 @@ dependencies = [ "oakrender", "oaktimeline", "oakundo", + "thiserror 2.0.20", ] [[package]] @@ -4078,6 +4087,7 @@ dependencies = [ "oakcore-rs", "oaknode", "oakundo", + "thiserror 2.0.20", ] [[package]] @@ -4085,6 +4095,7 @@ name = "oakundo" version = "0.1.0" dependencies = [ "oakcore-rs", + "thiserror 2.0.20", ] [[package]] diff --git a/crates/oakaudio/Cargo.toml b/crates/oakaudio/Cargo.toml index 067ccea6e..719b7ede4 100644 --- a/crates/oakaudio/Cargo.toml +++ b/crates/oakaudio/Cargo.toml @@ -21,3 +21,5 @@ ffmpeg-next = "9" # Real audio output (M12 P1): the preview playback stream. The PortAudio # C library must be installed (macOS: brew install portaudio). portaudio = "0.8" +# `std::error::Error` impls for the crate-internal error enum. +thiserror = "2" diff --git a/crates/oakaudio/src/error.rs b/crates/oakaudio/src/error.rs index a2782acb4..fccc727c3 100644 --- a/crates/oakaudio/src/error.rs +++ b/crates/oakaudio/src/error.rs @@ -36,17 +36,22 @@ pub const OAKAUDIO_E_NOMEM: i32 = -60005; pub type Result = std::result::Result; /// Crate-internal error. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum Error { /// Null handle or invalid argument. + #[error("audio: invalid argument")] Invalid, /// Wrong state. + #[error("audio: invalid state")] State, /// Operation failed (context string is log-only). + #[error("audio: operation failed: {0}")] Failed(String), /// Not found. + #[error("audio: not found")] NotFound, /// Out of memory. + #[error("audio: out of memory")] NoMem, } @@ -62,3 +67,57 @@ impl Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every variant must produce a non-empty `Display` message; the + /// `Failed` variant must surface its context string. + #[test] + fn display_is_non_empty() { + for msg in [ + Error::Invalid.to_string(), + Error::State.to_string(), + Error::Failed("context".into()).to_string(), + Error::NotFound.to_string(), + Error::NoMem.to_string(), + ] { + assert!(!msg.trim().is_empty(), "empty Display message"); + } + assert!( + Error::Failed("context".into()) + .to_string() + .contains("context") + ); + } + + /// `Error` must be usable behind a trait object. + #[test] + fn error_is_object_safe() { + let errs: Vec> = vec![ + Box::new(Error::Invalid), + Box::new(Error::State), + Box::new(Error::Failed("context".into())), + Box::new(Error::NotFound), + Box::new(Error::NoMem), + ]; + assert_eq!(errs.len(), 5); + } + + /// No variant wraps a downstream error, so `source()` stays `None`. + #[test] + fn source_is_none() { + assert!(std::error::Error::source(&Error::Failed("context".into())).is_none()); + } + + /// The `code()` mapping must be unchanged by the `Error` trait impl. + #[test] + fn code_mapping_unchanged() { + assert_eq!(Error::Invalid.code(), OAKAUDIO_E_INVALID); + assert_eq!(Error::State.code(), OAKAUDIO_E_STATE); + assert_eq!(Error::Failed("context".into()).code(), OAKAUDIO_E_FAILED); + assert_eq!(Error::NotFound.code(), OAKAUDIO_E_NOT_FOUND); + assert_eq!(Error::NoMem.code(), OAKAUDIO_E_NOMEM); + } +} diff --git a/crates/oakcodec/Cargo.toml b/crates/oakcodec/Cargo.toml index 81d6f80a4..9f1488d00 100644 --- a/crates/oakcodec/Cargo.toml +++ b/crates/oakcodec/Cargo.toml @@ -32,3 +32,5 @@ oakcore-rs = { path = "../oakcore" } # (9.0.0 -> FFmpeg 9.0 removed AVCodec fields; 8.1.0 -> FFmpeg 8.1 added # enum variants; 8.0.0 -> FFmpeg 8.0 renamed FF_PROFILE_*). ffmpeg-next = { version = "9.0", features = ["static"] } +# `std::error::Error` impls for the crate-internal error enum. +thiserror = "2" diff --git a/crates/oakcodec/src/error.rs b/crates/oakcodec/src/error.rs index 64b670e8a..47abb4f57 100644 --- a/crates/oakcodec/src/error.rs +++ b/crates/oakcodec/src/error.rs @@ -39,19 +39,25 @@ pub const OAKCODEC_ABI_VERSION: u32 = 1; pub type Result = std::result::Result; /// Crate-internal error. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum Error { /// Null handle or invalid argument. + #[error("codec: invalid argument")] Invalid, /// Wrong state. + #[error("codec: invalid state")] State, /// Operation failed (context string is log-only). + #[error("codec: operation failed: {0}")] Failed(String), /// Not found. + #[error("codec: not found")] NotFound, /// Out of memory. + #[error("codec: out of memory")] NoMem, /// The operation was cancelled. + #[error("codec: operation cancelled")] Cancelled, } @@ -68,3 +74,60 @@ impl Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every variant must produce a non-empty `Display` message; the + /// `Failed` variant must surface its context string. + #[test] + fn display_is_non_empty() { + for msg in [ + Error::Invalid.to_string(), + Error::State.to_string(), + Error::Failed("context".into()).to_string(), + Error::NotFound.to_string(), + Error::NoMem.to_string(), + Error::Cancelled.to_string(), + ] { + assert!(!msg.trim().is_empty(), "empty Display message"); + } + assert!( + Error::Failed("context".into()) + .to_string() + .contains("context") + ); + } + + /// `Error` must be usable behind a trait object. + #[test] + fn error_is_object_safe() { + let errs: Vec> = vec![ + Box::new(Error::Invalid), + Box::new(Error::State), + Box::new(Error::Failed("context".into())), + Box::new(Error::NotFound), + Box::new(Error::NoMem), + Box::new(Error::Cancelled), + ]; + assert_eq!(errs.len(), 6); + } + + /// No variant wraps a downstream error, so `source()` stays `None`. + #[test] + fn source_is_none() { + assert!(std::error::Error::source(&Error::Failed("context".into())).is_none()); + } + + /// The `code()` mapping must be unchanged by the `Error` trait impl. + #[test] + fn code_mapping_unchanged() { + assert_eq!(Error::Invalid.code(), OAKCODEC_E_INVALID); + assert_eq!(Error::State.code(), OAKCODEC_E_STATE); + assert_eq!(Error::Failed("context".into()).code(), OAKCODEC_E_FAILED); + assert_eq!(Error::NotFound.code(), OAKCODEC_E_NOT_FOUND); + assert_eq!(Error::NoMem.code(), OAKCODEC_E_NOMEM); + assert_eq!(Error::Cancelled.code(), OAKCODEC_E_CANCELLED); + } +} diff --git a/crates/oakcommon/Cargo.toml b/crates/oakcommon/Cargo.toml index dc8959365..b40febe39 100644 --- a/crates/oakcommon/Cargo.toml +++ b/crates/oakcommon/Cargo.toml @@ -30,3 +30,4 @@ ocio-rs = "0.2.1" # derives per-channel bit depths from its color-type tables and does float # image I/O through it. See README.md. image = { version = "0.25", default-features = false, features = ["tiff"] } +thiserror = "2" diff --git a/crates/oakcommon/src/error.rs b/crates/oakcommon/src/error.rs index 100c7f4c0..414e3382f 100644 --- a/crates/oakcommon/src/error.rs +++ b/crates/oakcommon/src/error.rs @@ -17,6 +17,8 @@ //! Error codes, mirroring `include/common/error.h`; project-wide //! -MMCCCC scheme (module 01), pass-through untranslated. +use thiserror::Error; + /// Success. pub const OAKCOMMON_OK: i32 = 0; /// Empty handle or invalid argument. @@ -34,17 +36,22 @@ pub const OAKCOMMON_E_NOMEM: i32 = -10005; pub type Result = std::result::Result; /// Crate-internal error. -#[derive(Debug)] +#[derive(Debug, Error)] pub enum Error { /// Empty handle or invalid argument. + #[error("common: empty handle or invalid argument")] Invalid, /// Wrong state. + #[error("common: call not valid in current state")] State, /// Operation failed (context string is log-only). + #[error("common: operation failed: {0}")] Failed(String), /// Not found. + #[error("common: entry not found")] NotFound, /// Out of memory. + #[error("common: allocation failed")] NoMem, } @@ -153,4 +160,30 @@ mod tests { assert_eq!(e.code(), OAKCOMMON_E_FAILED); assert!(format!("{e:?}").contains("bad colorspace")); } + + #[test] + fn display_is_non_empty_for_each_variant() { + let variants = [ + Error::Invalid, + Error::State, + Error::Failed("context".to_string()), + Error::NotFound, + Error::NoMem, + ]; + for e in &variants { + assert!(!e.to_string().is_empty()); + } + } + + #[test] + fn failed_display_includes_context() { + let e = Error::Failed("context info".to_string()); + assert!(e.to_string().contains("context info")); + } + + #[test] + fn error_is_object_safe() { + let e: Box = Box::new(Error::NoMem); + assert!(!e.to_string().is_empty()); + } } diff --git a/crates/oakengine/Cargo.toml b/crates/oakengine/Cargo.toml index eb72d0d58..c999e661a 100644 --- a/crates/oakengine/Cargo.toml +++ b/crates/oakengine/Cargo.toml @@ -16,6 +16,9 @@ serde_json = "1" # POSIX shm_open/mmap/munmap/shm_unlink constants + syscalls for the # shared-memory frame-slot transport (src/ipc.rs). libc = "0.2" +# Error derive (Display + std::error::Error) for the crate error enum +# (src/error.rs). Same major version the other modules use (oakotio, ...). +thiserror = "2" # Every module call crosses the module C ABI as an `extern "C"` import # (src/bridge/). The module crates below are REAL dependencies so their diff --git a/crates/oakengine/README.md b/crates/oakengine/README.md index 88774e5e3..4ffb45431 100644 --- a/crates/oakengine/README.md +++ b/crates/oakengine/README.md @@ -40,8 +40,10 @@ tests/ ### Dependencies The facade's regular dependencies are `serde`/`serde_json` (the worker's -NDJSON control-plane protocol, `src/worker.rs`) and `libc` (POSIX -`shm_open`/`mmap`/`munmap`/`shm_unlink` for `src/ipc.rs`). Every module +NDJSON control-plane protocol, `src/worker.rs`), `libc` (POSIX +`shm_open`/`mmap`/`munmap`/`shm_unlink` for `src/ipc.rs`) and `thiserror` +(the `Display` + `std::error::Error` impl for the facade error enum, +`src/error.rs`). Every module call crosses the module C ABI as an `extern "C"` import (`src/bridge/`), and the module crates themselves are real dependencies: [`linkage`](src/linkage.rs) anchors them so their `#[no_mangle]` exports are linked into the diff --git a/crates/oakengine/src/error.rs b/crates/oakengine/src/error.rs index 98bb54ed1..c321c42a0 100644 --- a/crates/oakengine/src/error.rs +++ b/crates/oakengine/src/error.rs @@ -23,6 +23,8 @@ //! (e.g. -20004 is oakundo's NOT_FOUND, -30001 oaknode's INVALID) and the //! facade never rewrites them. +use thiserror::Error; + /// Success. pub const OAKENGINE_OK: i32 = 0; /// Empty handle or invalid argument. @@ -43,21 +45,28 @@ pub const OAKENGINE_E_CANCELLED: i32 = -6; pub type Result = std::result::Result; /// Crate-internal error. -#[derive(Debug)] +#[derive(Debug, Error)] pub enum Error { /// Empty handle or invalid argument. + #[error("engine: invalid argument")] Invalid, /// Wrong state. + #[error("engine: call not valid in the current state")] State, /// The underlying operation failed (context string is log-only). + #[error("engine: operation failed: {0}")] Failed(String), /// Not found. + #[error("engine: not found")] NotFound, /// Out of memory. + #[error("engine: out of memory")] NoMem, /// Cancelled. + #[error("engine: cancelled")] Cancelled, /// A module error code that must pass through untranslated. + #[error("engine: module error code {0}")] Module(i32), } @@ -85,3 +94,62 @@ impl Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// One instance of every variant (data-carrying ones get a sample + /// payload). + fn all_errors() -> Vec { + vec![ + Error::Invalid, + Error::State, + Error::Failed("boom".to_string()), + Error::NotFound, + Error::NoMem, + Error::Cancelled, + Error::Module(-20004), + ] + } + + #[test] + fn display_is_non_empty_for_every_variant() { + for e in all_errors() { + let s = e.to_string(); + assert!(!s.is_empty(), "Display produced an empty message for {e:?}"); + } + } + + #[test] + fn error_is_object_safe() { + // `Box` must be constructible for every + // variant; `source()` stays None (no wrapped downstream error). + let errors: Vec> = all_errors() + .into_iter() + .map(|e| Box::new(e) as Box) + .collect(); + for e in &errors { + assert!(!e.to_string().is_empty()); + assert!(e.source().is_none()); + } + } + + #[test] + fn code_is_unaffected_by_trait_impl() { + assert_eq!(Error::Invalid.code(), OAKENGINE_E_INVALID); + assert_eq!(Error::State.code(), OAKENGINE_E_STATE); + assert_eq!(Error::Failed("boom".to_string()).code(), OAKENGINE_E_FAILED); + assert_eq!(Error::NotFound.code(), OAKENGINE_E_NOT_FOUND); + assert_eq!(Error::NoMem.code(), OAKENGINE_E_NOMEM); + assert_eq!(Error::Cancelled.code(), OAKENGINE_E_CANCELLED); + // Module codes pass through verbatim, untranslated. + assert_eq!(Error::Module(-20004).code(), -20004); + } + + #[test] + fn from_module_wraps_negative_and_accepts_ok() { + assert!(Error::from_module(0).is_ok()); + assert_eq!(Error::from_module(-20004).unwrap_err().code(), -20004); + } +} diff --git a/crates/oaknode/Cargo.toml b/crates/oaknode/Cargo.toml index 02ad41045..e4c9c7af8 100644 --- a/crates/oaknode/Cargo.toml +++ b/crates/oaknode/Cargo.toml @@ -13,6 +13,7 @@ oakcore-rs = { path = "../oakcore" } oakcommon = { path = "../oakcommon" } oakundo = { path = "../oakundo" } oakcodec = { path = "../oakcodec" } +thiserror = "2" [features] # In-crate stubs for the oakundo C ABI (and other module bridges) so diff --git a/crates/oaknode/src/error.rs b/crates/oaknode/src/error.rs index 742b0b226..944701c1d 100644 --- a/crates/oaknode/src/error.rs +++ b/crates/oaknode/src/error.rs @@ -17,6 +17,8 @@ //! Error codes, mirroring `include/node/error.h` verbatim; project-wide //! -MMCCCC scheme (module registry in include/common/error.h), pass-through untranslated. +use thiserror::Error; + /// Success. pub const OAKNODE_OK: i32 = 0; /// Null handle or invalid argument. @@ -34,17 +36,22 @@ pub const OAKNODE_E_NOMEM: i32 = -30005; pub type Result = std::result::Result; /// Crate-internal error. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Error)] pub enum Error { /// Null handle or invalid argument. + #[error("node: empty handle or invalid argument")] Invalid, /// Wrong state. + #[error("node: call not valid in current state")] State, /// Operation failed (context string is log-only). + #[error("node: operation failed: {0}")] Failed(String), /// Not found. + #[error("node: entry not found")] NotFound, /// Out of memory. + #[error("node: allocation failed")] NoMem, } @@ -60,3 +67,55 @@ impl Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_codes_match_header_values() { + // Load-bearing values from include/node/error.h (module 03). + assert_eq!(OAKNODE_OK, 0); + assert_eq!(OAKNODE_E_INVALID, -30001); + assert_eq!(OAKNODE_E_STATE, -30002); + assert_eq!(OAKNODE_E_FAILED, -30003); + assert_eq!(OAKNODE_E_NOT_FOUND, -30004); + assert_eq!(OAKNODE_E_NOMEM, -30005); + } + + #[test] + fn code_maps_each_variant() { + assert_eq!(Error::Invalid.code(), OAKNODE_E_INVALID); + assert_eq!(Error::State.code(), OAKNODE_E_STATE); + assert_eq!(Error::Failed("boom".to_string()).code(), OAKNODE_E_FAILED); + assert_eq!(Error::NotFound.code(), OAKNODE_E_NOT_FOUND); + assert_eq!(Error::NoMem.code(), OAKNODE_E_NOMEM); + } + + #[test] + fn display_is_non_empty_for_each_variant() { + let variants = [ + Error::Invalid, + Error::State, + Error::Failed("context".to_string()), + Error::NotFound, + Error::NoMem, + ]; + for e in &variants { + assert!(!e.to_string().is_empty()); + } + } + + #[test] + fn failed_display_includes_context() { + let e = Error::Failed("context info".to_string()); + assert!(e.to_string().contains("context info")); + } + + #[test] + fn error_is_object_safe() { + let e: Box = Box::new(Error::NoMem); + assert!(!e.to_string().is_empty()); + } +} + diff --git a/crates/oakotio/Cargo.toml b/crates/oakotio/Cargo.toml index 32026e897..30a764dfe 100644 --- a/crates/oakotio/Cargo.toml +++ b/crates/oakotio/Cargo.toml @@ -42,6 +42,9 @@ oakcore-rs = { path = "../oakcore" } # (src/common/rust/Cargo.toml). quick-xml = "0.41.0" +# Error trait derive for the crate error enum. +thiserror = "2" + [dev-dependencies] # oakcore-rs: exact Rational comparisons in the integration tests diff --git a/crates/oakotio/src/error.rs b/crates/oakotio/src/error.rs index 1ebde4734..b7dc0d531 100644 --- a/crates/oakotio/src/error.rs +++ b/crates/oakotio/src/error.rs @@ -16,47 +16,75 @@ //! Error type for the oakotio binding. -use std::fmt; - /// Errors produced by loading or saving OpenTimelineIO JSON. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum OtioError { /// The document could not be parsed (or a value could not be /// serialized) as JSON. - Json(serde_json::Error), + #[error("OpenTimelineIO JSON error: {0}")] + Json(#[from] serde_json::Error), /// The underlying file could not be read or written. - Io(std::io::Error), -} - -impl fmt::Display for OtioError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - OtioError::Json(e) => write!(f, "OpenTimelineIO JSON error: {e}"), - OtioError::Io(e) => write!(f, "OpenTimelineIO file error: {e}"), - } - } -} - -impl std::error::Error for OtioError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - OtioError::Json(e) => Some(e), - OtioError::Io(e) => Some(e), - } - } -} - -impl From for OtioError { - fn from(e: serde_json::Error) -> OtioError { - OtioError::Json(e) - } -} - -impl From for OtioError { - fn from(e: std::io::Error) -> OtioError { - OtioError::Io(e) - } + #[error("OpenTimelineIO file error: {0}")] + Io(#[from] std::io::Error), } /// Convenience alias used by the binding API. pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + use std::error::Error as _; + + /// Every variant renders a non-empty, module-prefixed message. + #[test] + fn display_is_non_empty() { + let json_err = serde_json::from_str::<()>("not json").unwrap_err(); + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"); + let cases = [ + ( + OtioError::Json(json_err), + "OpenTimelineIO JSON error: ", + ), + (OtioError::Io(io_err), "OpenTimelineIO file error: "), + ]; + for (err, prefix) in cases { + let msg = err.to_string(); + assert!(!msg.is_empty(), "Display for {err:?} is empty"); + assert!(msg.starts_with(prefix), "{msg:?} lacks module prefix"); + } + } + + /// The wrapped error is reachable through `source()` (downstream error + /// wrapping relationship). + #[test] + fn source_forwards() { + let json_err = serde_json::from_str::<()>("not json").unwrap_err(); + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"); + assert!(OtioError::Json(json_err).source().is_some()); + assert!(OtioError::Io(io_err).source().is_some()); + } + + /// The `From` conversions (used by `?`) still work. + #[test] + fn from_conversions() { + let json_err = serde_json::from_str::<()>("not json").unwrap_err(); + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"); + assert!(matches!(OtioError::from(json_err), OtioError::Json(_))); + assert!(matches!(OtioError::from(io_err), OtioError::Io(_))); + } + + /// The error is object-safe: every variant boxes into + /// `Box`. + #[test] + fn object_safe() { + let json_err = serde_json::from_str::<()>("not json").unwrap_err(); + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"); + let errs: Vec> = + vec![Box::new(OtioError::Json(json_err)), Box::new(OtioError::Io(io_err))]; + assert_eq!(errs.len(), 2); + for err in &errs { + assert!(!err.to_string().is_empty()); + } + } +} diff --git a/crates/oakplugin/Cargo.toml b/crates/oakplugin/Cargo.toml index 2a1eab802..549936f25 100644 --- a/crates/oakplugin/Cargo.toml +++ b/crates/oakplugin/Cargo.toml @@ -19,6 +19,8 @@ oakcore-rs = { path = "../oakcore" } oakundo = { path = "../oakundo" } oaknode = { path = "../oaknode" } oakrender = { path = "../oakrender" } +# Error derive(Display + std::error::Error,见 src/error.rs);理由已登记 README。 +thiserror = "2" [features] # 测试桩(库内状态访问器):cargo test --features test-stubs 时桥直连 diff --git a/crates/oakplugin/README.md b/crates/oakplugin/README.md index b9ceb6871..83cb1b388 100644 --- a/crates/oakplugin/README.md +++ b/crates/oakplugin/README.md @@ -180,6 +180,9 @@ src/ - **依赖登记**:`cc`(build-dependencies)——编译 C shim 的唯一 稳妥方式(手写 `Command::new("cc")` 无法处理跨平台 flag/交叉编译; 零运行时依赖不变)。M11 第 2 期未新增依赖。 +- **依赖登记**:`thiserror`(dependencies)——为 crate 错误枚举 + (src/error.rs)派生 `Display` + `std::error::Error` 的惯例实现; + 项目内其他模块(oakotio 等)已采用同版本("2"),避免手写样板。 - suite 函数表经 `suite_v1()` 等 accessor 暴露(`static` 初始化 放 lazy/OnceLock 里),`fetch_suite` 只查表。 - **GL 上下文归属**:oakplugin 不持有 GL 上下文(C ABI 无 diff --git a/crates/oakplugin/src/error.rs b/crates/oakplugin/src/error.rs index 1909bfbd2..7ab4fbdeb 100644 --- a/crates/oakplugin/src/error.rs +++ b/crates/oakplugin/src/error.rs @@ -17,6 +17,8 @@ //! 错误码。与 `include/*/error.h` 逐字对应;项目统一 -MMCCCC 方案 //! (模块号注册表见 include/common/error.h),跨模块透传不翻译。 +use thiserror::Error; + /// 成功。 pub const OAKPLUGIN_OK: i32 = 0; /// 空句柄或非法参数。 @@ -34,17 +36,22 @@ pub const OAKPLUGIN_E_NOMEM: i32 = -90005; pub type Result = std::result::Result; /// crate 内部错误。`code()` 给出对外错误码。 -#[derive(Debug)] +#[derive(Debug, Error)] pub enum Error { /// 空句柄或非法参数。 + #[error("plugin: invalid argument")] Invalid, /// 状态不允许。 + #[error("plugin: call not valid in the current state")] State, /// 底层失败,附人类可读上下文(仅日志,不出界)。 + #[error("plugin: operation failed: {0}")] Failed(String), /// 未找到。 + #[error("plugin: not found")] NotFound, /// 分配失败。 + #[error("plugin: out of memory")] NoMem, } @@ -60,3 +67,51 @@ impl Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// One instance of every variant (data-carrying ones get a sample + /// payload). + fn all_errors() -> Vec { + vec![ + Error::Invalid, + Error::State, + Error::Failed("boom".to_string()), + Error::NotFound, + Error::NoMem, + ] + } + + #[test] + fn display_is_non_empty_for_every_variant() { + for e in all_errors() { + let s = e.to_string(); + assert!(!s.is_empty(), "Display produced an empty message for {e:?}"); + } + } + + #[test] + fn error_is_object_safe() { + // `Box` must be constructible for every + // variant; `source()` stays None (no wrapped downstream error). + let errors: Vec> = all_errors() + .into_iter() + .map(|e| Box::new(e) as Box) + .collect(); + for e in &errors { + assert!(!e.to_string().is_empty()); + assert!(e.source().is_none()); + } + } + + #[test] + fn code_is_unaffected_by_trait_impl() { + assert_eq!(Error::Invalid.code(), OAKPLUGIN_E_INVALID); + assert_eq!(Error::State.code(), OAKPLUGIN_E_STATE); + assert_eq!(Error::Failed("boom".to_string()).code(), OAKPLUGIN_E_FAILED); + assert_eq!(Error::NotFound.code(), OAKPLUGIN_E_NOT_FOUND); + assert_eq!(Error::NoMem.code(), OAKPLUGIN_E_NOMEM); + } +} diff --git a/crates/oakrender/Cargo.toml b/crates/oakrender/Cargo.toml index 68896f679..362941a76 100644 --- a/crates/oakrender/Cargo.toml +++ b/crates/oakrender/Cargo.toml @@ -25,6 +25,8 @@ wgpu = "25" # the vendored OpenColorIO C++ sources (cmake/ninja required); without it # ocio-sys builds a stub (all color tests then early-return). ocio-rs = { version = "0.2", features = ["bundled"] } +# `std::error::Error` impls for the crate-internal error enum. +thiserror = "2" [dev-dependencies] # Test binaries that pull oakcodec's decoder (the direct-call bridge) link diff --git a/crates/oakrender/src/error.rs b/crates/oakrender/src/error.rs index dcab4aafd..ef366d6ed 100644 --- a/crates/oakrender/src/error.rs +++ b/crates/oakrender/src/error.rs @@ -34,17 +34,22 @@ pub const OAKRENDER_E_NOMEM: i32 = -70005; pub type Result = std::result::Result; /// Crate-internal error. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, thiserror::Error)] pub enum Error { /// Null handle or invalid argument. + #[error("render: invalid argument")] Invalid, /// Wrong state. + #[error("render: invalid state")] State, /// Operation failed (context string is log-only). + #[error("render: operation failed: {0}")] Failed(String), /// Not found. + #[error("render: not found")] NotFound, /// Out of memory. + #[error("render: out of memory")] NoMem, } @@ -60,3 +65,57 @@ impl Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every variant must produce a non-empty `Display` message; the + /// `Failed` variant must surface its context string. + #[test] + fn display_is_non_empty() { + for msg in [ + Error::Invalid.to_string(), + Error::State.to_string(), + Error::Failed("context".into()).to_string(), + Error::NotFound.to_string(), + Error::NoMem.to_string(), + ] { + assert!(!msg.trim().is_empty(), "empty Display message"); + } + assert!( + Error::Failed("context".into()) + .to_string() + .contains("context") + ); + } + + /// `Error` must be usable behind a trait object. + #[test] + fn error_is_object_safe() { + let errs: Vec> = vec![ + Box::new(Error::Invalid), + Box::new(Error::State), + Box::new(Error::Failed("context".into())), + Box::new(Error::NotFound), + Box::new(Error::NoMem), + ]; + assert_eq!(errs.len(), 5); + } + + /// No variant wraps a downstream error, so `source()` stays `None`. + #[test] + fn source_is_none() { + assert!(std::error::Error::source(&Error::Failed("context".into())).is_none()); + } + + /// The `code()` mapping must be unchanged by the `Error` trait impl. + #[test] + fn code_mapping_unchanged() { + assert_eq!(Error::Invalid.code(), OAKRENDER_E_INVALID); + assert_eq!(Error::State.code(), OAKRENDER_E_STATE); + assert_eq!(Error::Failed("context".into()).code(), OAKRENDER_E_FAILED); + assert_eq!(Error::NotFound.code(), OAKRENDER_E_NOT_FOUND); + assert_eq!(Error::NoMem.code(), OAKRENDER_E_NOMEM); + } +} diff --git a/crates/oakstorage/Cargo.lock b/crates/oakstorage/Cargo.lock index d83c45198..baba84fbd 100644 --- a/crates/oakstorage/Cargo.lock +++ b/crates/oakstorage/Cargo.lock @@ -1336,8 +1336,10 @@ name = "oakotio" version = "0.1.0" dependencies = [ "oakcore-rs", + "quick-xml", "serde", "serde_json", + "thiserror", ] [[package]] @@ -1349,6 +1351,7 @@ dependencies = [ "sea-orm", "serde", "serde_json", + "thiserror", "tokio", ] @@ -1532,6 +1535,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" diff --git a/crates/oakstorage/Cargo.toml b/crates/oakstorage/Cargo.toml index 9aec5d8dc..d3f1aa847 100644 --- a/crates/oakstorage/Cargo.toml +++ b/crates/oakstorage/Cargo.toml @@ -15,6 +15,9 @@ panic = "unwind" oakcore-rs = { path = "../oakcore" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } +# Error derive (Display + std::error::Error) for the crate error enum +# (src/error.rs). Same major version the other modules use (oakotio, ...). +thiserror = "2" # Native OTIO JSON model (project-local; see src/bindings/oakotio). oakotio = { path = "../oakotio" } # Database backends via SeaORM (sync facade: a private current-thread diff --git a/crates/oakstorage/src/error.rs b/crates/oakstorage/src/error.rs index 3688d1b22..1c1fce1a6 100644 --- a/crates/oakstorage/src/error.rs +++ b/crates/oakstorage/src/error.rs @@ -16,6 +16,8 @@ //! Error and info codes (M10 §2.1; -MMCCCC scheme, module 10). +use thiserror::Error; + /// Success. pub const OAKSTORAGE_OK: i32 = 0; /// Project version too old (info code, positive). @@ -45,23 +47,31 @@ pub const OAKSTORAGE_E_NOMEM: i32 = -100008; pub type Result = std::result::Result; /// Crate-internal error. -#[derive(Debug)] +#[derive(Debug, Error)] pub enum Error { /// Null handle or invalid argument. + #[error("storage: invalid argument")] Invalid, /// Wrong state. + #[error("storage: call not valid in the current state")] State, /// Not found. + #[error("storage: not found")] NotFound, /// Operation failed (context string is log-only). + #[error("storage: operation failed: {0}")] Failed(String), /// No backend claimed the URI. + #[error("storage: no backend claimed the URI")] NoBackend, /// Format error. + #[error("storage: format error: {0}")] Format(String), /// I/O error. + #[error("storage: I/O error: {0}")] Io(String), /// Out of memory. + #[error("storage: out of memory")] NoMem, } @@ -80,3 +90,57 @@ impl Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// One instance of every variant (data-carrying ones get a sample + /// payload). + fn all_errors() -> Vec { + vec![ + Error::Invalid, + Error::State, + Error::NotFound, + Error::Failed("boom".to_string()), + Error::NoBackend, + Error::Format("bad xml".to_string()), + Error::Io("disk full".to_string()), + Error::NoMem, + ] + } + + #[test] + fn display_is_non_empty_for_every_variant() { + for e in all_errors() { + let s = e.to_string(); + assert!(!s.is_empty(), "Display produced an empty message for {e:?}"); + } + } + + #[test] + fn error_is_object_safe() { + // `Box` must be constructible for every + // variant; `source()` stays None (no wrapped downstream error). + let errors: Vec> = all_errors() + .into_iter() + .map(|e| Box::new(e) as Box) + .collect(); + for e in &errors { + assert!(!e.to_string().is_empty()); + assert!(e.source().is_none()); + } + } + + #[test] + fn code_is_unaffected_by_trait_impl() { + assert_eq!(Error::Invalid.code(), OAKSTORAGE_E_INVALID); + assert_eq!(Error::State.code(), OAKSTORAGE_E_STATE); + assert_eq!(Error::NotFound.code(), OAKSTORAGE_E_NOT_FOUND); + assert_eq!(Error::Failed("boom".to_string()).code(), OAKSTORAGE_E_FAILED); + assert_eq!(Error::NoBackend.code(), OAKSTORAGE_E_NO_BACKEND); + assert_eq!(Error::Format("bad xml".to_string()).code(), OAKSTORAGE_E_FORMAT); + assert_eq!(Error::Io("disk full".to_string()).code(), OAKSTORAGE_E_IO); + assert_eq!(Error::NoMem.code(), OAKSTORAGE_E_NOMEM); + } +} diff --git a/crates/oaktask/Cargo.toml b/crates/oaktask/Cargo.toml index e3b435918..dfc8c6c51 100644 --- a/crates/oaktask/Cargo.toml +++ b/crates/oaktask/Cargo.toml @@ -42,3 +42,5 @@ oakotio = { path = "../oakotio" } # render-real integration test's cargo invocation working (it links the # real oakrender — now always — and skips the ticket stubs). oakrender = { path = "../oakrender" } +# Error trait derive for the crate-internal error enum. +thiserror = "2" diff --git a/crates/oaktask/src/error.rs b/crates/oaktask/src/error.rs index 1ea44f940..449b2a69d 100644 --- a/crates/oaktask/src/error.rs +++ b/crates/oaktask/src/error.rs @@ -38,19 +38,25 @@ pub const OAKTASK_E_CANCELLED: i32 = -80006; pub type Result = std::result::Result; /// Crate-internal error. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum Error { /// Null handle or invalid argument. + #[error("task: invalid argument")] Invalid, /// Wrong state. + #[error("task: wrong state")] State, /// Operation failed (context string is log-only). + #[error("task: operation failed: {0}")] Failed(String), /// Not found. + #[error("task: not found")] NotFound, /// Out of memory. + #[error("task: out of memory")] NoMem, /// The operation was cancelled. + #[error("task: cancelled")] Cancelled, } @@ -67,3 +73,62 @@ impl Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every variant renders a non-empty, module-prefixed message. + #[test] + fn display_is_non_empty() { + let cases = [ + (Error::Invalid, "task: invalid argument"), + (Error::State, "task: wrong state"), + (Error::Failed("boom".into()), "task: operation failed: boom"), + (Error::NotFound, "task: not found"), + (Error::NoMem, "task: out of memory"), + (Error::Cancelled, "task: cancelled"), + ]; + for (err, expect) in cases { + let msg = err.to_string(); + assert!(!msg.is_empty(), "Display for {err:?} is empty"); + assert_eq!(msg, expect, "Display for {err:?}"); + } + } + + /// `Failed` carries the context string in its message. + #[test] + fn failed_includes_context() { + let msg = Error::Failed("disk full".into()).to_string(); + assert!(msg.contains("disk full")); + } + + /// The error is object-safe: every variant boxes into + /// `Box`. + #[test] + fn object_safe() { + let errs: Vec> = vec![ + Box::new(Error::Invalid), + Box::new(Error::State), + Box::new(Error::Failed("boom".into())), + Box::new(Error::NotFound), + Box::new(Error::NoMem), + Box::new(Error::Cancelled), + ]; + assert_eq!(errs.len(), 6); + for err in &errs { + assert!(!err.to_string().is_empty()); + } + } + + /// Implementing `std::error::Error` must not change the FFI code mapping. + #[test] + fn codes_unchanged() { + assert_eq!(Error::Invalid.code(), OAKTASK_E_INVALID); + assert_eq!(Error::State.code(), OAKTASK_E_STATE); + assert_eq!(Error::Failed("x".into()).code(), OAKTASK_E_FAILED); + assert_eq!(Error::NotFound.code(), OAKTASK_E_NOT_FOUND); + assert_eq!(Error::NoMem.code(), OAKTASK_E_NOMEM); + assert_eq!(Error::Cancelled.code(), OAKTASK_E_CANCELLED); + } +} diff --git a/crates/oaktimeline/Cargo.toml b/crates/oaktimeline/Cargo.toml index da54d9a2b..436f60544 100644 --- a/crates/oaktimeline/Cargo.toml +++ b/crates/oaktimeline/Cargo.toml @@ -21,3 +21,5 @@ oakcore-rs = { path = "../oakcore" } oakundo = { path = "../oakundo" } oaknode = { path = "../oaknode" } oakcommon = { path = "../oakcommon" } +# Error trait derive for the crate-internal error enum. +thiserror = "2" diff --git a/crates/oaktimeline/src/error.rs b/crates/oaktimeline/src/error.rs index 6791f0bd0..4875a7b81 100644 --- a/crates/oaktimeline/src/error.rs +++ b/crates/oaktimeline/src/error.rs @@ -38,17 +38,22 @@ pub const OAKTIMELINE_E_NOMEM: i32 = -40005; pub type Result = std::result::Result; /// Crate-internal error. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum Error { /// Null handle or invalid argument. + #[error("timeline: invalid argument")] Invalid, /// Wrong state. + #[error("timeline: wrong state")] State, /// Operation failed (context string is log-only). + #[error("timeline: operation failed: {0}")] Failed(String), /// Index out of range / entry not found. + #[error("timeline: not found")] NotFound, /// Out of memory. + #[error("timeline: out of memory")] NoMem, } @@ -64,3 +69,59 @@ impl Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every variant renders a non-empty, module-prefixed message. + #[test] + fn display_is_non_empty() { + let cases = [ + (Error::Invalid, "timeline: invalid argument"), + (Error::State, "timeline: wrong state"), + (Error::Failed("boom".into()), "timeline: operation failed: boom"), + (Error::NotFound, "timeline: not found"), + (Error::NoMem, "timeline: out of memory"), + ]; + for (err, expect) in cases { + let msg = err.to_string(); + assert!(!msg.is_empty(), "Display for {err:?} is empty"); + assert_eq!(msg, expect, "Display for {err:?}"); + } + } + + /// `Failed` carries the context string in its message. + #[test] + fn failed_includes_context() { + let msg = Error::Failed("disk full".into()).to_string(); + assert!(msg.contains("disk full")); + } + + /// The error is object-safe: every variant boxes into + /// `Box`. + #[test] + fn object_safe() { + let errs: Vec> = vec![ + Box::new(Error::Invalid), + Box::new(Error::State), + Box::new(Error::Failed("boom".into())), + Box::new(Error::NotFound), + Box::new(Error::NoMem), + ]; + assert_eq!(errs.len(), 5); + for err in &errs { + assert!(!err.to_string().is_empty()); + } + } + + /// Implementing `std::error::Error` must not change the FFI code mapping. + #[test] + fn codes_unchanged() { + assert_eq!(Error::Invalid.code(), OAKTIMELINE_E_INVALID); + assert_eq!(Error::State.code(), OAKTIMELINE_E_STATE); + assert_eq!(Error::Failed("x".into()).code(), OAKTIMELINE_E_FAILED); + assert_eq!(Error::NotFound.code(), OAKTIMELINE_E_NOT_FOUND); + assert_eq!(Error::NoMem.code(), OAKTIMELINE_E_NOMEM); + } +} diff --git a/crates/oakundo/Cargo.toml b/crates/oakundo/Cargo.toml index 93d14cd58..9ab4f8084 100644 --- a/crates/oakundo/Cargo.toml +++ b/crates/oakundo/Cargo.toml @@ -10,3 +10,4 @@ crate-type = ["staticlib", "rlib"] [dependencies] oakcore-rs = { path = "../oakcore" } +thiserror = "2" diff --git a/crates/oakundo/src/error.rs b/crates/oakundo/src/error.rs index 828595683..2896ae773 100644 --- a/crates/oakundo/src/error.rs +++ b/crates/oakundo/src/error.rs @@ -17,6 +17,8 @@ //! Error codes, mirroring `include/undo/error.h`; project-wide //! -MMCCCC scheme (module 02), pass-through untranslated. +use thiserror::Error; + /// Success. pub const OAKUNDO_OK: i32 = 0; /// Empty handle or invalid argument. @@ -34,17 +36,22 @@ pub const OAKUNDO_E_NOMEM: i32 = -20005; pub type Result = std::result::Result; /// Crate-internal error. -#[derive(Debug)] +#[derive(Debug, Error)] pub enum Error { /// Empty handle or invalid argument. + #[error("undo: empty handle or invalid argument")] Invalid, /// Wrong state. + #[error("undo: call not valid in current state")] State, /// Operation failed (context string is log-only). + #[error("undo: operation failed: {0}")] Failed(String), /// Not found. + #[error("undo: entry not found")] NotFound, /// Out of memory. + #[error("undo: allocation failed")] NoMem, } @@ -60,3 +67,55 @@ impl Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_codes_match_header_values() { + // Load-bearing values from include/undo/error.h (module 02). + assert_eq!(OAKUNDO_OK, 0); + assert_eq!(OAKUNDO_E_INVALID, -20001); + assert_eq!(OAKUNDO_E_STATE, -20002); + assert_eq!(OAKUNDO_E_FAILED, -20003); + assert_eq!(OAKUNDO_E_NOT_FOUND, -20004); + assert_eq!(OAKUNDO_E_NOMEM, -20005); + } + + #[test] + fn code_maps_each_variant() { + assert_eq!(Error::Invalid.code(), OAKUNDO_E_INVALID); + assert_eq!(Error::State.code(), OAKUNDO_E_STATE); + assert_eq!(Error::Failed("boom".to_string()).code(), OAKUNDO_E_FAILED); + assert_eq!(Error::NotFound.code(), OAKUNDO_E_NOT_FOUND); + assert_eq!(Error::NoMem.code(), OAKUNDO_E_NOMEM); + } + + #[test] + fn display_is_non_empty_for_each_variant() { + let variants = [ + Error::Invalid, + Error::State, + Error::Failed("context".to_string()), + Error::NotFound, + Error::NoMem, + ]; + for e in &variants { + assert!(!e.to_string().is_empty()); + } + } + + #[test] + fn failed_display_includes_context() { + let e = Error::Failed("context info".to_string()); + assert!(e.to_string().contains("context info")); + } + + #[test] + fn error_is_object_safe() { + let e: Box = Box::new(Error::NoMem); + assert!(!e.to_string().is_empty()); + } +} + diff --git a/src/oakui/waveform.rs b/src/oakui/waveform.rs index 158623bb1..402c7d61f 100644 --- a/src/oakui/waveform.rs +++ b/src/oakui/waveform.rs @@ -87,15 +87,12 @@ impl WaveformCache { /// when missing. Failures (missing media, no audio stream) leave the /// clip silent (no waveform drawn). pub fn refresh(&self, clip: u64, filename: &str, duration_frames: i64) { - eprintln!("DBG-WFC: refresh clip={clip}"); if self.get(clip).is_some() { - eprintln!("DBG-WFC: cache hit"); return; } let Some(waveform) = extract(filename, duration_frames, self.fps) else { return; }; - eprintln!("DBG-WFC: inserting"); let mut map = self.map.lock().unwrap_or_else(|e| e.into_inner()); if !map.contains_key(&clip) { map.insert(clip, Arc::new(waveform));