refactor(crates): implement std::error::Error for all module error enums

thiserror derive across oakundo/oakcommon/oaknode/oaktimeline/oaktask/
oakotio/oakcodec/oakaudio/oakrender/oakplugin/oakengine/oakstorage;
Display carries the module prefix and the Failed context, source()
stays default except oakotio's #[from] forwarding. code() mappings and
variants unchanged; each error.rs gains Display/object-safety/code
regression tests.
This commit is contained in:
2026-08-14 05:45:05 +08:00
parent d460d57805
commit 4240df1d71
29 changed files with 773 additions and 51 deletions
Generated
+11
View File
@@ -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]]
+2
View File
@@ -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"
+60 -1
View File
@@ -36,17 +36,22 @@ pub const OAKAUDIO_E_NOMEM: i32 = -60005;
pub type Result<T> = std::result::Result<T, Error>;
/// 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<Box<dyn std::error::Error>> = 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);
}
}
+2
View File
@@ -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"
+64 -1
View File
@@ -39,19 +39,25 @@ pub const OAKCODEC_ABI_VERSION: u32 = 1;
pub type Result<T> = std::result::Result<T, Error>;
/// 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<Box<dyn std::error::Error>> = 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);
}
}
+1
View File
@@ -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"
+34 -1
View File
@@ -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<T> = std::result::Result<T, Error>;
/// 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<dyn std::error::Error> = Box::new(Error::NoMem);
assert!(!e.to_string().is_empty());
}
}
+3
View File
@@ -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
+4 -2
View File
@@ -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
+69 -1
View File
@@ -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<T> = std::result::Result<T, Error>;
/// 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<Error> {
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<dyn std::error::Error>` must be constructible for every
// variant; `source()` stays None (no wrapped downstream error).
let errors: Vec<Box<dyn std::error::Error>> = all_errors()
.into_iter()
.map(|e| Box::new(e) as Box<dyn std::error::Error>)
.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);
}
}
+1
View File
@@ -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
+60 -1
View File
@@ -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<T> = std::result::Result<T, Error>;
/// 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<dyn std::error::Error> = Box::new(Error::NoMem);
assert!(!e.to_string().is_empty());
}
}
+3
View File
@@ -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
+63 -35
View File
@@ -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<serde_json::Error> for OtioError {
fn from(e: serde_json::Error) -> OtioError {
OtioError::Json(e)
}
}
impl From<std::io::Error> 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<T> = std::result::Result<T, OtioError>;
#[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<dyn std::error::Error>`.
#[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<Box<dyn std::error::Error>> =
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());
}
}
}
+2
View File
@@ -19,6 +19,8 @@ oakcore-rs = { path = "../oakcore" }
oakundo = { path = "../oakundo" }
oaknode = { path = "../oaknode" }
oakrender = { path = "../oakrender" }
# Error deriveDisplay + std::error::Error,见 src/error.rs);理由已登记 README。
thiserror = "2"
[features]
# 测试桩(库内状态访问器):cargo test --features test-stubs 时桥直连
+3
View File
@@ -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 无
+56 -1
View File
@@ -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<T> = std::result::Result<T, Error>;
/// 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<Error> {
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<dyn std::error::Error>` must be constructible for every
// variant; `source()` stays None (no wrapped downstream error).
let errors: Vec<Box<dyn std::error::Error>> = all_errors()
.into_iter()
.map(|e| Box::new(e) as Box<dyn std::error::Error>)
.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);
}
}
+2
View File
@@ -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
+60 -1
View File
@@ -34,17 +34,22 @@ pub const OAKRENDER_E_NOMEM: i32 = -70005;
pub type Result<T> = std::result::Result<T, Error>;
/// 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<Box<dyn std::error::Error>> = 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);
}
}
+12
View File
@@ -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"
+3
View File
@@ -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
+65 -1
View File
@@ -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<T> = std::result::Result<T, Error>;
/// 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<Error> {
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<dyn std::error::Error>` must be constructible for every
// variant; `source()` stays None (no wrapped downstream error).
let errors: Vec<Box<dyn std::error::Error>> = all_errors()
.into_iter()
.map(|e| Box::new(e) as Box<dyn std::error::Error>)
.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);
}
}
+2
View File
@@ -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"
+66 -1
View File
@@ -38,19 +38,25 @@ pub const OAKTASK_E_CANCELLED: i32 = -80006;
pub type Result<T> = std::result::Result<T, Error>;
/// 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<dyn std::error::Error>`.
#[test]
fn object_safe() {
let errs: Vec<Box<dyn std::error::Error>> = 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);
}
}
+2
View File
@@ -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"
+62 -1
View File
@@ -38,17 +38,22 @@ pub const OAKTIMELINE_E_NOMEM: i32 = -40005;
pub type Result<T> = std::result::Result<T, Error>;
/// 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<dyn std::error::Error>`.
#[test]
fn object_safe() {
let errs: Vec<Box<dyn std::error::Error>> = 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);
}
}
+1
View File
@@ -10,3 +10,4 @@ crate-type = ["staticlib", "rlib"]
[dependencies]
oakcore-rs = { path = "../oakcore" }
thiserror = "2"
+60 -1
View File
@@ -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<T> = std::result::Result<T, Error>;
/// 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<dyn std::error::Error> = Box::new(Error::NoMem);
assert!(!e.to_string().is_empty());
}
}
-3
View File
@@ -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));