diff --git a/.dprint.jsonc b/.dprint.jsonc index c3e8a2e..790dece 100644 --- a/.dprint.jsonc +++ b/.dprint.jsonc @@ -3,6 +3,12 @@ }, "json": { }, + // Match the repo-wide 120 line-length set in .editorconfig and ruff.toml, + // otherwise dprint's bundled ruff would reformat Python files to its + // default and fight with `mise run ruff-fmt`. + "ruff": { + "lineLength": 120, + }, "malva": { }, "markdown": { diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index bf9ec1a..d30e7ec 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -38,6 +38,10 @@ jobs: mise install env: GITHUB_TOKEN: ${{ github.token }} + # GitHub release downloads occasionally take longer than mise's + # default 30s HTTP timeout; bump it so transient network slowness + # doesn't fail the whole `mise install` step. + MISE_HTTP_TIMEOUT: "120" - name: Run checkers run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0d7859..a697eef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,9 @@ jobs: mise install env: GITHUB_TOKEN: ${{ github.token }} + # Match check.yml: bump mise's 30s HTTP timeout so GitHub-release + # downloads don't fail the install on transient slowness. + MISE_HTTP_TIMEOUT: "120" - name: Fetch MNIST model run: mise run fetch-mnist-rclone diff --git a/.mise.toml b/.mise.toml index 6b7f24d..8a17572 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,22 +1,18 @@ [tools] action-validator = "latest" -"cargo:ast-grep" = "latest" cargo-binstall = "latest" +"cargo:ast-grep" = "latest" "cargo:aube" = "latest" "cargo:taplo-cli" = "latest" "cargo:wasm-pack" = "latest" "chromedriver" = "146" -claude = "latest" cmake = "latest" -codex = "latest" dart = { version = "latest", url = "https://storage.googleapis.com/dart-archive/channels/stable/release/{{ version }}/sdk/dartsdk-{{ os() }}-{{ arch() }}-release.zip", version_expr = 'fromJSON(body).prefixes | filter({ # matches "^channels/stable/release/(\\d+\\.\\d+\\.\\d+)/$" }) | map({split(#, "/")[3]}) | sortVersions()', version_list_url = "https://storage.googleapis.com/storage/v1/b/dart-archive/o?prefix=channels/stable/release/&delimiter=/" } dotnet = "latest" dotnet-core = "latest" "dotnet:roslynator.dotnet.cli" = "latest" dprint = "latest" editorconfig-checker = "latest" -gemini-cli = "latest" -"github:block/goose" = "latest" "github:grok-rs/waitup" = "latest" "github:wasm-bindgen/wasm-bindgen" = "0.2.114" java = "latest" @@ -25,7 +21,6 @@ mprocs = "latest" node = "22" "npm:onnxruntime-web" = "latest" "npm:pyodide" = "0.29.3" -ollama = "latest" osv-scanner = "latest" pipx = "latest" "pipx:cmake" = "latest" @@ -129,6 +124,20 @@ run = "typos" depends = ["cargo-check"] run = "osv-scanner --lockfile Cargo.lock" +[tasks.prefetch] +depends = ["download-models"] +description = "Pre-download all dependencies and models (Rust crates, Dart packages, Python envs, Java/Maven, .NET, Node, WIT)" +run = """ +cargo check --workspace +dart pub get --directory services/ws-modules/dart-comm1 +uv sync --directory services/ws-modules/pydata1 +uv sync --directory services/ws-modules/pyface1 +mvn dependency:resolve --quiet +dotnet restore +npm install --prefix services/ws-server/static +zig build --fetch --build-file services/ws-modules/zig-data1/build.zig +""" + [tasks.regenerate-verification] alias = "regen-verification" description = "Regenerate checked-in verification output files" diff --git a/Cargo.lock b/Cargo.lock index 4bd8cd7..fca4bdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1430,13 +1430,13 @@ dependencies = [ name = "et-cli" version = "0.1.0" dependencies = [ - "anyhow", "clap", "edge-toolkit", "serde", "serde_json", "serde_yaml", "tempfile", + "thiserror 2.0.18", "toml 0.8.23", ] diff --git a/Cargo.toml b/Cargo.toml index 4258e6f..fe06ece 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,6 @@ resolver = "2" actix = "0.13" actix-rt = "2" actix-web = { version = "4", features = ["rustls-0_23"] } -anyhow = "1.0" base64 = "0.22.1" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.4", features = ["derive"] } diff --git a/config/ast-grep/rules/no-anyhow.yml b/config/ast-grep/rules/no-anyhow.yml new file mode 100644 index 0000000..338e63e --- /dev/null +++ b/config/ast-grep/rules/no-anyhow.yml @@ -0,0 +1,21 @@ +id: no-anyhow +language: Rust +severity: error +message: | + The `anyhow` crate is forbidden. + Define an error enum with `thiserror`. +rule: + any: + - all: + - kind: use_declaration + - regex: '\banyhow\b' + - all: + - kind: scoped_identifier + - regex: "^anyhow::" + - all: + - kind: scoped_use_list + - regex: '\banyhow\b' + - kind: macro_invocation + pattern: anyhow!($$$ARGS) +ignores: + - generated/** diff --git a/config/ast-grep/rules/no-inline-mod.yml b/config/ast-grep/rules/no-inline-mod.yml new file mode 100644 index 0000000..2d67213 --- /dev/null +++ b/config/ast-grep/rules/no-inline-mod.yml @@ -0,0 +1,12 @@ +id: no-inline-mod +language: Rust +severity: error +message: | + Inline `mod X { ... }` blocks are forbidden. Move the module body into a + separate file (`X.rs` or `X/mod.rs`) and declare it with `mod X;` instead. +rule: + kind: mod_item + has: + kind: declaration_list +ignores: + - generated/** diff --git a/config/ast-grep/rules/no-rust-line-continuation.yml b/config/ast-grep/rules/no-rust-line-continuation.yml index a347e71..ff19077 100644 --- a/config/ast-grep/rules/no-rust-line-continuation.yml +++ b/config/ast-grep/rules/no-rust-line-continuation.yml @@ -7,3 +7,5 @@ message: | rule: kind: string_literal regex: '\\\n' +ignores: + - generated/** diff --git a/config/ast-grep/rules/no-shadow-result.yml b/config/ast-grep/rules/no-shadow-result.yml new file mode 100644 index 0000000..e9a4a51 --- /dev/null +++ b/config/ast-grep/rules/no-shadow-result.yml @@ -0,0 +1,23 @@ +id: no-shadow-result +language: Rust +severity: error +message: | + Don't shadow `std::result::Result` at file scope — every bare `Result` + then becomes ambiguous to readers. Either spell the error type at each + call site (`Result`), reference the local alias by its + qualified path (`crate::Result`, `et_int_gen::Result`), or alias + the import (`use foo::Result as FooResult;`). +rule: + any: + - all: + - kind: type_item + - has: + kind: type_identifier + regex: "^Result$" + - all: + - kind: use_declaration + - regex: '\bResult\b' + - not: + regex: '\bResult\s+as\s' +ignores: + - generated/** diff --git a/services/ws-modules/pydata1/pydata1/__init__.py b/services/ws-modules/pydata1/pydata1/__init__.py index 3c6c617..3ac1f7f 100644 --- a/services/ws-modules/pydata1/pydata1/__init__.py +++ b/services/ws-modules/pydata1/pydata1/__init__.py @@ -4,9 +4,7 @@ from datetime import datetime, timezone -async def run( - ws_send, wait_for_response, put_file, get_file, sleep_ms, log, set_status -) -> None: +async def run(ws_send, wait_for_response, put_file, get_file, sleep_ms, log, set_status) -> None: """Execute the data1 workflow: connect, store, fetch, verify.""" log("pydata1: entered run()") diff --git a/services/ws-modules/pyface1/pyface1/face_detection.py b/services/ws-modules/pyface1/pyface1/face_detection.py index cf9b637..50c96eb 100644 --- a/services/ws-modules/pyface1/pyface1/face_detection.py +++ b/services/ws-modules/pyface1/pyface1/face_detection.py @@ -163,12 +163,8 @@ def preprocess_geometry(source_width: float, source_height: float) -> dict[str, ) return { "resize_ratio": resize_ratio, - "resized_width": float( - int(clamp(round(source_width * resize_ratio), 1, FACE_INPUT_WIDTH)) - ), - "resized_height": float( - int(clamp(round(source_height * resize_ratio), 1, FACE_INPUT_HEIGHT)) - ), + "resized_width": float(int(clamp(round(source_width * resize_ratio), 1, FACE_INPUT_WIDTH))), + "resized_height": float(int(clamp(round(source_height * resize_ratio), 1, FACE_INPUT_HEIGHT))), } @@ -205,11 +201,7 @@ def decode_outputs( landm = output_values(landm_values, "landm", 10) prior_count = len(loc) // 4 - if ( - prior_count == 0 - or len(conf) != prior_count * 2 - or len(landm) != prior_count * 10 - ): + if prior_count == 0 or len(conf) != prior_count * 2 or len(landm) != prior_count * 10: raise ValueError("RetinaFace outputs had unexpected shapes") priors = model_priors() @@ -256,9 +248,7 @@ def decode_outputs( } -def status_text( - input_name: str, output_names: Iterable[object], summary: DetectionSummary -) -> str: +def status_text(input_name: str, output_names: Iterable[object], summary: DetectionSummary) -> str: """Render the browser status text used by the face detection demo.""" outputs = ", ".join(str(name) for name in output_names) lines = [ diff --git a/services/ws-modules/wasi-graphics-info/wasi_graphics_info/__init__.py b/services/ws-modules/wasi-graphics-info/wasi_graphics_info/__init__.py index 230bba1..aeb91b6 100644 --- a/services/ws-modules/wasi-graphics-info/wasi_graphics_info/__init__.py +++ b/services/ws-modules/wasi-graphics-info/wasi_graphics_info/__init__.py @@ -178,9 +178,7 @@ def _entry(binding: int, read_only: bool) -> GpuBindGroupLayoutEntry: binding=binding, visibility=GpuShaderStage.compute(), buffer=GpuBufferBindingLayout( - type=GpuBufferBindingType.READ_ONLY_STORAGE - if read_only - else GpuBufferBindingType.STORAGE, + type=GpuBufferBindingType.READ_ONLY_STORAGE if read_only else GpuBufferBindingType.STORAGE, has_dynamic_offset=False, min_binding_size=None, ), @@ -280,15 +278,11 @@ def _run_matmul() -> dict: label="matmul-bgl", ) ) - pl = device.create_pipeline_layout( - GpuPipelineLayoutDescriptor(bind_group_layouts=[bgl], label="matmul-pl") - ) + pl = device.create_pipeline_layout(GpuPipelineLayoutDescriptor(bind_group_layouts=[bgl], label="matmul-pl")) pipeline = device.create_compute_pipeline( GpuComputePipelineDescriptor( - compute=GpuProgrammableStage( - module=shader, entry_point="main", constants=None - ), + compute=GpuProgrammableStage(module=shader, entry_point="main", constants=None), layout=GpuLayoutMode_Specific(value=pl), label="matmul-pipeline", ) @@ -325,9 +319,7 @@ def _run_matmul() -> dict: result_c00 = struct.unpack(" 1e-4: - raise RuntimeError( - f"wasi-webgpu: matmul produced C[0][0]={result_c00}, expected {EXPECTED_C00}" - ) + raise RuntimeError(f"wasi-webgpu: matmul produced C[0][0]={result_c00}, expected {EXPECTED_C00}") _log(f"wasi-webgpu matmul: C[0][0]={result_c00:.4f} in {elapsed_ms:.2f}ms") return { @@ -380,9 +372,7 @@ def _mnist_inference() -> dict: out_name, out_tensor = outputs[0] if out_name != MNIST_OUTPUT_NAME: - _log( - f"warning: output name {out_name!r} differs from expected {MNIST_OUTPUT_NAME!r}" - ) + _log(f"warning: output name {out_name!r} differs from expected {MNIST_OUTPUT_NAME!r}") raw = out_tensor.data() arr = array.array("f") @@ -396,9 +386,7 @@ def _mnist_inference() -> dict: _log(f"predicted class: {predicted}, logits: {[round(v, 3) for v in logits]}") if predicted != EXPECTED_MNIST_CLASS: - raise RuntimeError( - f"MNIST verification FAILED: predicted {predicted}, expected {EXPECTED_MNIST_CLASS}" - ) + raise RuntimeError(f"MNIST verification FAILED: predicted {predicted}, expected {EXPECTED_MNIST_CLASS}") _log("MNIST verification: ok") return { diff --git a/services/ws-wasi-runner/src/bindings.rs b/services/ws-wasi-runner/src/bindings.rs new file mode 100644 index 0000000..95e29d7 --- /dev/null +++ b/services/ws-wasi-runner/src/bindings.rs @@ -0,0 +1,42 @@ +//! `wasmtime::component::bindgen!` output for the runner world. +//! +//! Lives in its own file so `no-inline-mod` can stay enforced on the +//! crate root: the macro generates a `mod`-shaped tree of types, which +//! would otherwise have to be wrapped in `pub mod bindings { ... }` at +//! the `lib.rs` top level. + +wasmtime::component::bindgen!({ + path: "wit", + world: "runner", + imports: { default: async }, + exports: { default: async }, + // Map every wasi-webgpu resource to a payload type owned by us so + // resource_table operations work on real wgpu objects rather than + // bindgen-generated marker structs. The types live in + // `host::wasi_webgpu` and are wgpu-backed for the matmul subset. + with: { + "wasi:keyvalue/store.bucket": super::host::wasi_keyvalue::Bucket, + "wasi:webgpu/webgpu.gpu": super::host::wasi_webgpu::Gpu, + "wasi:webgpu/webgpu.gpu-adapter": super::host::wasi_webgpu::GpuAdapter, + "wasi:webgpu/webgpu.gpu-adapter-info": super::host::wasi_webgpu::GpuAdapterInfo, + "wasi:webgpu/webgpu.gpu-supported-features": super::host::wasi_webgpu::GpuSupportedFeatures, + "wasi:webgpu/webgpu.gpu-supported-limits": super::host::wasi_webgpu::GpuSupportedLimits, + "wasi:webgpu/webgpu.gpu-device": super::host::wasi_webgpu::GpuDevice, + "wasi:webgpu/webgpu.gpu-queue": super::host::wasi_webgpu::GpuQueue, + "wasi:webgpu/webgpu.gpu-buffer": super::host::wasi_webgpu::GpuBuffer, + "wasi:webgpu/webgpu.gpu-buffer-usage": super::host::wasi_webgpu::GpuBufferUsage, + "wasi:webgpu/webgpu.gpu-map-mode": super::host::wasi_webgpu::GpuMapMode, + "wasi:webgpu/webgpu.gpu-shader-stage": super::host::wasi_webgpu::GpuShaderStage, + "wasi:webgpu/webgpu.gpu-bind-group-layout": super::host::wasi_webgpu::GpuBindGroupLayout, + "wasi:webgpu/webgpu.gpu-bind-group": super::host::wasi_webgpu::GpuBindGroup, + "wasi:webgpu/webgpu.gpu-pipeline-layout": super::host::wasi_webgpu::GpuPipelineLayout, + "wasi:webgpu/webgpu.gpu-shader-module": super::host::wasi_webgpu::GpuShaderModule, + "wasi:webgpu/webgpu.gpu-compute-pipeline": super::host::wasi_webgpu::GpuComputePipeline, + "wasi:webgpu/webgpu.gpu-command-encoder": super::host::wasi_webgpu::GpuCommandEncoder, + "wasi:webgpu/webgpu.gpu-compute-pass-encoder": super::host::wasi_webgpu::GpuComputePassEncoder, + "wasi:webgpu/webgpu.gpu-command-buffer": super::host::wasi_webgpu::GpuCommandBuffer, + "wasi:webgpu/webgpu.record-option-gpu-size64": super::host::wasi_webgpu::RecordOptionGpuSize64, + "wasi:webgpu/webgpu.record-gpu-pipeline-constant-value": + super::host::wasi_webgpu::RecordGpuPipelineConstantValue, + }, +}); diff --git a/services/ws-wasi-runner/src/host/wasi_webgpu.rs b/services/ws-wasi-runner/src/host/wasi_webgpu.rs index 0cba6c3..1eb46b2 100644 --- a/services/ws-wasi-runner/src/host/wasi_webgpu.rs +++ b/services/ws-wasi-runner/src/host/wasi_webgpu.rs @@ -38,30 +38,33 @@ use crate::bindings::wasi::webgpu::webgpu::{ /// `gpu-buffer-usage.STORAGE()` style accessors return these constants and /// the guest ORs them into `gpu-buffer-descriptor.usage`. Matches the /// WebGPU spec values so we can hand them directly to `wgpu::BufferUsages`. -mod usage { - pub const MAP_READ: u32 = 0x0001; - pub const MAP_WRITE: u32 = 0x0002; - pub const COPY_SRC: u32 = 0x0004; - pub const COPY_DST: u32 = 0x0008; - pub const INDEX: u32 = 0x0010; - pub const VERTEX: u32 = 0x0020; - pub const UNIFORM: u32 = 0x0040; - pub const STORAGE: u32 = 0x0080; - pub const INDIRECT: u32 = 0x0100; - pub const QUERY_RESOLVE: u32 = 0x0200; +struct Usage; +impl Usage { + const MAP_READ: u32 = 0x0001; + const MAP_WRITE: u32 = 0x0002; + const COPY_SRC: u32 = 0x0004; + const COPY_DST: u32 = 0x0008; + const INDEX: u32 = 0x0010; + const VERTEX: u32 = 0x0020; + const UNIFORM: u32 = 0x0040; + const STORAGE: u32 = 0x0080; + const INDIRECT: u32 = 0x0100; + const QUERY_RESOLVE: u32 = 0x0200; } /// gpu-map-mode flag bits (WebGPU spec values). -mod map_mode { - pub const READ: u32 = 0x0001; - pub const WRITE: u32 = 0x0002; +struct MapMode; +impl MapMode { + const READ: u32 = 0x0001; + const WRITE: u32 = 0x0002; } /// gpu-shader-stage flag bits (WebGPU spec values). -mod shader_stage { - pub const VERTEX: u32 = 0x1; - pub const FRAGMENT: u32 = 0x2; - pub const COMPUTE: u32 = 0x4; +struct ShaderStage; +impl ShaderStage { + const VERTEX: u32 = 0x1; + const FRAGMENT: u32 = 0x2; + const COMPUTE: u32 = 0x4; } /// Top-level handle: no per-instance state — `request-adapter` constructs a @@ -200,34 +203,34 @@ async fn request_adapter_inner(_options: Option) -> Op fn buffer_usage_from_flags(flags: u32) -> wgpu::BufferUsages { let mut out = wgpu::BufferUsages::empty(); - if flags & usage::MAP_READ != 0 { + if flags & Usage::MAP_READ != 0 { out |= wgpu::BufferUsages::MAP_READ; } - if flags & usage::MAP_WRITE != 0 { + if flags & Usage::MAP_WRITE != 0 { out |= wgpu::BufferUsages::MAP_WRITE; } - if flags & usage::COPY_SRC != 0 { + if flags & Usage::COPY_SRC != 0 { out |= wgpu::BufferUsages::COPY_SRC; } - if flags & usage::COPY_DST != 0 { + if flags & Usage::COPY_DST != 0 { out |= wgpu::BufferUsages::COPY_DST; } - if flags & usage::INDEX != 0 { + if flags & Usage::INDEX != 0 { out |= wgpu::BufferUsages::INDEX; } - if flags & usage::VERTEX != 0 { + if flags & Usage::VERTEX != 0 { out |= wgpu::BufferUsages::VERTEX; } - if flags & usage::UNIFORM != 0 { + if flags & Usage::UNIFORM != 0 { out |= wgpu::BufferUsages::UNIFORM; } - if flags & usage::STORAGE != 0 { + if flags & Usage::STORAGE != 0 { out |= wgpu::BufferUsages::STORAGE; } - if flags & usage::INDIRECT != 0 { + if flags & Usage::INDIRECT != 0 { out |= wgpu::BufferUsages::INDIRECT; } - if flags & usage::QUERY_RESOLVE != 0 { + if flags & Usage::QUERY_RESOLVE != 0 { out |= wgpu::BufferUsages::QUERY_RESOLVE; } out @@ -235,13 +238,13 @@ fn buffer_usage_from_flags(flags: u32) -> wgpu::BufferUsages { fn shader_stages_from_flags(flags: u32) -> wgpu::ShaderStages { let mut out = wgpu::ShaderStages::empty(); - if flags & shader_stage::VERTEX != 0 { + if flags & ShaderStage::VERTEX != 0 { out |= wgpu::ShaderStages::VERTEX; } - if flags & shader_stage::FRAGMENT != 0 { + if flags & ShaderStage::FRAGMENT != 0 { out |= wgpu::ShaderStages::FRAGMENT; } - if flags & shader_stage::COMPUTE != 0 { + if flags & ShaderStage::COMPUTE != 0 { out |= wgpu::ShaderStages::COMPUTE; } out @@ -913,34 +916,34 @@ impl HostGpuBuffer for HostState { impl HostGpuBufferUsage for HostState { async fn map_read(&mut self) -> u32 { - usage::MAP_READ + Usage::MAP_READ } async fn map_write(&mut self) -> u32 { - usage::MAP_WRITE + Usage::MAP_WRITE } async fn copy_src(&mut self) -> u32 { - usage::COPY_SRC + Usage::COPY_SRC } async fn copy_dst(&mut self) -> u32 { - usage::COPY_DST + Usage::COPY_DST } async fn index(&mut self) -> u32 { - usage::INDEX + Usage::INDEX } async fn vertex(&mut self) -> u32 { - usage::VERTEX + Usage::VERTEX } async fn uniform(&mut self) -> u32 { - usage::UNIFORM + Usage::UNIFORM } async fn storage(&mut self) -> u32 { - usage::STORAGE + Usage::STORAGE } async fn indirect(&mut self) -> u32 { - usage::INDIRECT + Usage::INDIRECT } async fn query_resolve(&mut self) -> u32 { - usage::QUERY_RESOLVE + Usage::QUERY_RESOLVE } async fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { @@ -951,10 +954,10 @@ impl HostGpuBufferUsage for HostState { impl HostGpuMapMode for HostState { async fn read(&mut self) -> u32 { - map_mode::READ + MapMode::READ } async fn write(&mut self) -> u32 { - map_mode::WRITE + MapMode::WRITE } async fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { @@ -965,13 +968,13 @@ impl HostGpuMapMode for HostState { impl HostGpuShaderStage for HostState { async fn vertex(&mut self) -> u32 { - shader_stage::VERTEX + ShaderStage::VERTEX } async fn fragment(&mut self) -> u32 { - shader_stage::FRAGMENT + ShaderStage::FRAGMENT } async fn compute(&mut self) -> u32 { - shader_stage::COMPUTE + ShaderStage::COMPUTE } async fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { diff --git a/services/ws-wasi-runner/src/lib.rs b/services/ws-wasi-runner/src/lib.rs index f3dd0d2..c271eeb 100644 --- a/services/ws-wasi-runner/src/lib.rs +++ b/services/ws-wasi-runner/src/lib.rs @@ -15,11 +15,10 @@ use tracing_opentelemetry::OpenTelemetrySpanExt; use wasmtime::component::{Component, HasSelf, Linker}; use wasmtime::{Config, Engine, Store}; -/// Errors `run_module` can fail with. `reqwest::Error` is forwarded -/// transparently — it already carries the URL it failed on. wasmtime's -/// `Error` (an alias for `anyhow::Error` upstream) doesn't nest cleanly -/// through `std::error::Error`, so the `From` impl flattens it to its -/// formatted chain via `{err:#}`. +/// Errors `run_module` can fail with. Both `reqwest::Error` and +/// `wasmtime::Error` already carry enough context (the failing URL, +/// the wasmtime error chain) to be useful on their own, so they're +/// forwarded transparently. #[derive(Debug, Error)] pub enum RunnerError { #[error("could not derive HTTP base from WS_SERVER_URL={ws_url}")] @@ -31,56 +30,14 @@ pub enum RunnerError { #[error("module {module} package.json missing `main` field")] PackageJsonMissingMain { module: String }, - #[error("wasm component model: {0}")] - Wasm(String), + #[error(transparent)] + Wasm(#[from] wasmtime::Error), #[error("module run() returned err: {0}")] Guest(String), } -impl From for RunnerError { - fn from(err: wasmtime::Error) -> Self { - RunnerError::Wasm(format!("{err:#}")) - } -} - -pub mod bindings { - wasmtime::component::bindgen!({ - path: "wit", - world: "runner", - imports: { default: async }, - exports: { default: async }, - // Map every wasi-webgpu resource to a payload type owned by us so - // resource_table operations work on real wgpu objects rather than - // bindgen-generated marker structs. The types live in - // `host::wasi_webgpu` and are wgpu-backed for the matmul subset. - with: { - "wasi:keyvalue/store.bucket": super::host::wasi_keyvalue::Bucket, - "wasi:webgpu/webgpu.gpu": super::host::wasi_webgpu::Gpu, - "wasi:webgpu/webgpu.gpu-adapter": super::host::wasi_webgpu::GpuAdapter, - "wasi:webgpu/webgpu.gpu-adapter-info": super::host::wasi_webgpu::GpuAdapterInfo, - "wasi:webgpu/webgpu.gpu-supported-features": super::host::wasi_webgpu::GpuSupportedFeatures, - "wasi:webgpu/webgpu.gpu-supported-limits": super::host::wasi_webgpu::GpuSupportedLimits, - "wasi:webgpu/webgpu.gpu-device": super::host::wasi_webgpu::GpuDevice, - "wasi:webgpu/webgpu.gpu-queue": super::host::wasi_webgpu::GpuQueue, - "wasi:webgpu/webgpu.gpu-buffer": super::host::wasi_webgpu::GpuBuffer, - "wasi:webgpu/webgpu.gpu-buffer-usage": super::host::wasi_webgpu::GpuBufferUsage, - "wasi:webgpu/webgpu.gpu-map-mode": super::host::wasi_webgpu::GpuMapMode, - "wasi:webgpu/webgpu.gpu-shader-stage": super::host::wasi_webgpu::GpuShaderStage, - "wasi:webgpu/webgpu.gpu-bind-group-layout": super::host::wasi_webgpu::GpuBindGroupLayout, - "wasi:webgpu/webgpu.gpu-bind-group": super::host::wasi_webgpu::GpuBindGroup, - "wasi:webgpu/webgpu.gpu-pipeline-layout": super::host::wasi_webgpu::GpuPipelineLayout, - "wasi:webgpu/webgpu.gpu-shader-module": super::host::wasi_webgpu::GpuShaderModule, - "wasi:webgpu/webgpu.gpu-compute-pipeline": super::host::wasi_webgpu::GpuComputePipeline, - "wasi:webgpu/webgpu.gpu-command-encoder": super::host::wasi_webgpu::GpuCommandEncoder, - "wasi:webgpu/webgpu.gpu-compute-pass-encoder": super::host::wasi_webgpu::GpuComputePassEncoder, - "wasi:webgpu/webgpu.gpu-command-buffer": super::host::wasi_webgpu::GpuCommandBuffer, - "wasi:webgpu/webgpu.record-option-gpu-size64": super::host::wasi_webgpu::RecordOptionGpuSize64, - "wasi:webgpu/webgpu.record-gpu-pipeline-constant-value": - super::host::wasi_webgpu::RecordGpuPipelineConstantValue, - }, - }); -} +pub mod bindings; pub mod host; diff --git a/utilities/cli/Cargo.toml b/utilities/cli/Cargo.toml index 7676cf9..6f4cf58 100644 --- a/utilities/cli/Cargo.toml +++ b/utilities/cli/Cargo.toml @@ -7,12 +7,12 @@ license.workspace = true repository.workspace = true [dependencies] -anyhow.workspace = true clap.workspace = true edge-toolkit.workspace = true serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true +thiserror.workspace = true toml.workspace = true [dev-dependencies] diff --git a/utilities/cli/src/deployment_types/docker_compose.rs b/utilities/cli/src/deployment_types/docker_compose.rs index bdcf73b..f3fefcd 100644 --- a/utilities/cli/src/deployment_types/docker_compose.rs +++ b/utilities/cli/src/deployment_types/docker_compose.rs @@ -1,17 +1,19 @@ use std::fs; use std::path::Path; -use anyhow::{Context, Result}; use edge_toolkit::input::ClusterInput; +use crate::error::CliError; use crate::{ OutputType, absolute_from, cluster_module_names, module_registry, relative_path_from, resolve_module_paths, }; -pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &Path) -> Result<()> { +pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &Path) -> Result<(), CliError> { let output_path = output_dir.join(OutputType::DockerCompose.output_file_name()); - let workspace_root = - std::env::current_dir().with_context(|| "Failed to resolve current working directory for compose services")?; + let workspace_root = std::env::current_dir().map_err(|source| CliError::CurrentDir { + context: "compose services", + source, + })?; let output_abs = absolute_from(&workspace_root, output_dir); let workspace_rel = relative_path_from(&output_abs, &workspace_root).display().to_string(); let openobserve_env_file_rel = relative_path_from(&output_abs, &workspace_root.join("config/o2.env")) @@ -91,12 +93,15 @@ pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &P ], }; let content = render_compose_yaml(&compose); - fs::write(&output_path, content).with_context(|| format!("Failed to write output file: {:?}", output_path))?; + fs::write(&output_path, content).map_err(|source| CliError::WriteOutput { + path: output_path.clone(), + source, + })?; Ok(()) } -pub fn docker_image_module_paths(module_names: &[String]) -> Result> { +pub fn docker_image_module_paths(module_names: &[String]) -> Result, CliError> { let project_root = edge_toolkit::config::get_project_root(); let ws_server_dir = project_root.join("services/ws-server"); let mut paths = Vec::with_capacity(module_names.len() + 2); diff --git a/utilities/cli/src/deployment_types/mise.rs b/utilities/cli/src/deployment_types/mise.rs index 4009327..49fe9de 100644 --- a/utilities/cli/src/deployment_types/mise.rs +++ b/utilities/cli/src/deployment_types/mise.rs @@ -1,16 +1,18 @@ use std::fs; use std::path::Path; -use anyhow::{Context, Result}; use edge_toolkit::input::ClusterInput; use toml::{Table, Value}; +use crate::error::CliError; use crate::{absolute_from, cluster_module_names, module_registry, relative_path_from, resolve_module_paths}; -pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Result<()> { +pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Result<(), CliError> { let output_path = output_dir.join("mise.toml"); - let workspace_root = - std::env::current_dir().with_context(|| "Failed to resolve current working directory for mise tasks")?; + let workspace_root = std::env::current_dir().map_err(|source| CliError::CurrentDir { + context: "mise tasks", + source, + })?; let output_abs = absolute_from(&workspace_root, output_dir); let ws_server_dir = workspace_root.join("services/ws-server"); let workspace_rel = relative_path_from(&output_abs, &workspace_root).display().to_string(); @@ -79,15 +81,18 @@ pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Re root.insert("tasks".to_string(), Value::Table(tasks)); let content = format_mise_toml( - toml::to_string(&Value::Table(root)).context("Failed to serialize mise TOML")?, + toml::to_string(&Value::Table(root)).map_err(CliError::SerializeToml)?, openobserve_env_file_rel, ); - fs::write(&output_path, content).with_context(|| format!("Failed to write output file: {:?}", output_path))?; + fs::write(&output_path, content).map_err(|source| CliError::WriteOutput { + path: output_path.clone(), + source, + })?; Ok(()) } -pub fn scenario_module_paths(ws_server_dir: &Path, module_names: &[String]) -> Result> { +pub fn scenario_module_paths(ws_server_dir: &Path, module_names: &[String]) -> Result, CliError> { let project_root = edge_toolkit::config::get_project_root(); let mut paths = vec![ relative_path_from(ws_server_dir, &project_root.join("services/ws-server/static")) diff --git a/utilities/cli/src/error.rs b/utilities/cli/src/error.rs new file mode 100644 index 0000000..86dcc35 --- /dev/null +++ b/utilities/cli/src/error.rs @@ -0,0 +1,145 @@ +use std::path::PathBuf; + +use thiserror::Error; + +/// Errors returned by `et-cli` operations. Variants carry the path or +/// value they failed on so users can see *what* went wrong, not just the +/// underlying `io::Error` text. +#[derive(Debug, Error)] +pub enum CliError { + #[error("Failed to read input file: {path:?}")] + ReadInput { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to read {path}")] + ReadFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to write {path}")] + WriteFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to write output file: {path:?}")] + WriteOutput { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to create output directory: {path:?}")] + CreateOutputDir { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to read verification root directory: {path:?}")] + ReadVerificationRoot { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to read verification input directory: {path:?}")] + ReadVerificationInputDir { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to read entry from {path:?}")] + ReadDirEntry { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to read file type for {path:?}")] + ReadFileType { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to resolve current working directory for {context}")] + CurrentDir { + context: &'static str, + #[source] + source: std::io::Error, + }, + + #[error("Failed to parse cluster input YAML")] + ParseClusterYaml(#[source] serde_yaml::Error), + + #[error("Failed to parse {path}")] + ParseToml { + path: PathBuf, + #[source] + source: toml::de::Error, + }, + + #[error("Failed to parse {path}")] + ParseJson { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + + #[error("Failed to serialize package JSON")] + SerializeJson(#[source] serde_json::Error), + + #[error("Failed to serialize mise TOML")] + SerializeToml(#[source] toml::ser::Error), + + #[error("Expected pyproject.toml or Cargo.toml in module directory {0:?}")] + MissingManifest(PathBuf), + + #[error("Output path {0:?} has no parent directory")] + NoParentDir(PathBuf), + + #[error("{0} has no [package] section")] + MissingPackageSection(PathBuf), + + #[error("{0} contains a non-object dependencies field")] + NonObjectDependencies(PathBuf), + + #[error("main = {main:?} does not exist in {dir}")] + MissingMainFile { main: String, dir: PathBuf }, + + #[error( + "No main file in {dir}; expected {underscored}.{ext} or {hyphenated}.{ext} (override with [ws-module] main)" + )] + UnresolvedMainFile { + dir: PathBuf, + underscored: String, + hyphenated: String, + ext: &'static str, + }, + + #[error("{0} must contain a JSON object")] + NonObjectPackageJson(PathBuf), + + #[error("Verification root {root:?} maps multiple scenario inputs to the same output directory {output:?}")] + DuplicateScenarioOutput { root: PathBuf, output: PathBuf }, + + #[error("Verification input file {0:?} has no file stem")] + MissingFileStem(PathBuf), + + #[error("Verification root {0:?} does not contain any scenario files under */input/*.yaml or */input/*.yml")] + NoScenarios(PathBuf), + + #[error("Unsupported deployment_type {0:?}. Supported values are currently: mise, docker-compose")] + UnsupportedDeploymentType(String), + + #[error("No local module or runtime package found for dependency {0:?}")] + UnknownDependency(String), +} diff --git a/utilities/cli/src/lib.rs b/utilities/cli/src/lib.rs index 23615db..76660d9 100644 --- a/utilities/cli/src/lib.rs +++ b/utilities/cli/src/lib.rs @@ -3,17 +3,18 @@ use std::ffi::OsString; use std::fs; use std::path::{Component, Path, PathBuf}; -use anyhow::{Context, Result, anyhow}; use clap::ValueEnum; use edge_toolkit::input::ClusterInput; use serde::Deserialize; mod deployment_types; +mod error; mod module_package_json; pub use deployment_types::{ docker_image_module_paths, generate_docker_compose_deployment, generate_mise_deployment, scenario_module_paths, }; +pub use error::CliError; pub use module_package_json::generate_module_package_json; #[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq, ValueEnum)] @@ -122,7 +123,7 @@ pub fn generate_deployment( input_file: &Path, output_dir: &Path, output_type: Option, -) -> Result { +) -> Result { let cluster = load_cluster_input(input_file)?; let output_type = output_type .map(Ok) @@ -139,28 +140,29 @@ pub fn generate_deployment( )) } -pub fn load_cluster_input(input_file: &Path) -> Result { - let content = - fs::read_to_string(input_file).with_context(|| format!("Failed to read input file: {:?}", input_file))?; +pub fn load_cluster_input(input_file: &Path) -> Result { + let content = fs::read_to_string(input_file).map_err(|source| CliError::ReadInput { + path: input_file.to_path_buf(), + source, + })?; - serde_yaml::from_str(&content).with_context(|| "Failed to parse cluster input YAML") + serde_yaml::from_str(&content).map_err(CliError::ParseClusterYaml) } pub fn regenerate_verification( verification_root: &Path, output_type: Option, -) -> Result> { +) -> Result, CliError> { let scenarios = discover_verification_scenarios(verification_root)?; let mut regenerated = Vec::with_capacity(scenarios.len()); let mut seen_output_dirs = BTreeSet::new(); for (input_file, output_dir) in scenarios { if !seen_output_dirs.insert(output_dir.clone()) { - return Err(anyhow!( - "Verification root {:?} maps multiple scenario inputs to the same output directory {:?}", - verification_root, - output_dir - )); + return Err(CliError::DuplicateScenarioOutput { + root: verification_root.to_path_buf(), + output: output_dir, + }); } let cluster = load_cluster_input(&input_file)?; let module_names = cluster_module_names(&cluster); @@ -181,16 +183,13 @@ pub fn regenerate_verification( Ok(regenerated) } -pub fn output_type_from_input(value: &str) -> Result { +pub fn output_type_from_input(value: &str) -> Result { if value.eq_ignore_ascii_case("mise") { Ok(OutputType::Mise) } else if matches!(value.to_ascii_lowercase().as_str(), "docker-compose" | "docker_compose") { Ok(OutputType::DockerCompose) } else { - Err(anyhow!( - "Unsupported deployment_type {:?}. Supported values are currently: mise, docker-compose", - value - )) + Err(CliError::UnsupportedDeploymentType(value.to_string())) } } @@ -202,10 +201,16 @@ fn deployment_summary(cluster_name: String, agent_templates: usize, module_names } } -fn generate_deployment_outputs(cluster: &ClusterInput, output_dir: &Path, output_types: &[OutputType]) -> Result<()> { +fn generate_deployment_outputs( + cluster: &ClusterInput, + output_dir: &Path, + output_types: &[OutputType], +) -> Result<(), CliError> { if !output_dir.exists() { - fs::create_dir_all(output_dir) - .with_context(|| format!("Failed to create output directory: {:?}", output_dir))?; + fs::create_dir_all(output_dir).map_err(|source| CliError::CreateOutputDir { + path: output_dir.to_path_buf(), + source, + })?; } for output_type in output_types { @@ -217,23 +222,35 @@ fn generate_deployment_outputs(cluster: &ClusterInput, output_dir: &Path, output let readme_path = output_dir.join("README.md"); let module_names = cluster_module_names(cluster); - fs::write(&readme_path, generated_readme(cluster, &module_names, output_types)) - .with_context(|| format!("Failed to write output file: {:?}", readme_path))?; + fs::write(&readme_path, generated_readme(cluster, &module_names, output_types)).map_err(|source| { + CliError::WriteOutput { + path: readme_path.clone(), + source, + } + })?; Ok(()) } -fn discover_verification_scenarios(verification_root: &Path) -> Result> { +fn discover_verification_scenarios(verification_root: &Path) -> Result, CliError> { let mut scenarios = Vec::new(); - let verification_sets = fs::read_dir(verification_root) - .with_context(|| format!("Failed to read verification root directory: {:?}", verification_root))?; + let verification_sets = fs::read_dir(verification_root).map_err(|source| CliError::ReadVerificationRoot { + path: verification_root.to_path_buf(), + source, + })?; for entry in verification_sets { - let entry = entry.with_context(|| format!("Failed to read entry from {:?}", verification_root))?; + let entry = entry.map_err(|source| CliError::ReadDirEntry { + path: verification_root.to_path_buf(), + source, + })?; let set_root = entry.path(); if !entry .file_type() - .with_context(|| format!("Failed to read file type for {:?}", set_root))? + .map_err(|source| CliError::ReadFileType { + path: set_root.clone(), + source, + })? .is_dir() { continue; @@ -245,14 +262,22 @@ fn discover_verification_scenarios(verification_root: &Path) -> Result Result( registry: &BTreeMap, module_names: &[String], path_for: F, -) -> Result> +) -> Result, CliError> where F: Fn(&ModuleRegistryEntry) -> String, { @@ -526,7 +548,7 @@ where let entry = registry .get(&module_name) - .ok_or_else(|| anyhow!("No local module or runtime package found for dependency {module_name:?}"))?; + .ok_or_else(|| CliError::UnknownDependency(module_name.clone()))?; let path = path_for(entry); if seen_paths.insert(path.clone()) { paths.push(path); diff --git a/utilities/cli/src/main.rs b/utilities/cli/src/main.rs index 082c1bc..2b64028 100644 --- a/utilities/cli/src/main.rs +++ b/utilities/cli/src/main.rs @@ -1,8 +1,7 @@ use std::path::PathBuf; -use anyhow::Result; use clap::{Parser, Subcommand}; -use et_cli::{OutputType, generate_deployment, generate_module_package_json, regenerate_verification}; +use et_cli::{CliError, OutputType, generate_deployment, generate_module_package_json, regenerate_verification}; #[derive(Parser)] struct Cli { @@ -33,7 +32,7 @@ enum Commands { }, } -fn main() -> Result<()> { +fn main() -> Result<(), CliError> { let cli = Cli::parse(); match &cli.command { diff --git a/utilities/cli/src/module_package_json/mod.rs b/utilities/cli/src/module_package_json/mod.rs index fc3c1e4..c51c604 100644 --- a/utilities/cli/src/module_package_json/mod.rs +++ b/utilities/cli/src/module_package_json/mod.rs @@ -2,10 +2,11 @@ use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result, anyhow}; use serde::Deserialize; use serde_json::{Map, Value, json}; +use crate::error::CliError; + #[derive(Deserialize)] struct Project { name: String, @@ -86,31 +87,34 @@ enum MaybeInherited { }, } -pub fn generate_module_package_json(module_dir: &Path) -> Result { +pub fn generate_module_package_json(module_dir: &Path) -> Result { let out_path = module_dir.join("pkg/package.json"); let package_json = if module_dir.join("pyproject.toml").is_file() { package_json_from_pyproject(module_dir)? } else if module_dir.join("Cargo.toml").is_file() { package_json_from_cargo(module_dir, &out_path)? } else { - return Err(anyhow!( - "Expected pyproject.toml or Cargo.toml in module directory {:?}", - module_dir - )); + return Err(CliError::MissingManifest(module_dir.to_path_buf())); }; let parent = out_path .parent() - .ok_or_else(|| anyhow!("Output path {:?} has no parent directory", out_path))?; - fs::create_dir_all(parent).with_context(|| format!("Failed to create output directory: {:?}", parent))?; - let mut out = serde_json::to_string_pretty(&package_json).context("Failed to serialize package JSON")?; + .ok_or_else(|| CliError::NoParentDir(out_path.clone()))?; + fs::create_dir_all(parent).map_err(|source| CliError::CreateOutputDir { + path: parent.to_path_buf(), + source, + })?; + let mut out = serde_json::to_string_pretty(&package_json).map_err(CliError::SerializeJson)?; out.push('\n'); - fs::write(&out_path, &out).with_context(|| format!("Failed to write {}", out_path.display()))?; + fs::write(&out_path, &out).map_err(|source| CliError::WriteFile { + path: out_path.clone(), + source, + })?; Ok(out_path) } -fn package_json_from_pyproject(module_dir: &Path) -> Result { +fn package_json_from_pyproject(module_dir: &Path) -> Result { let pyproject_path = module_dir.join("pyproject.toml"); let pyproject: Pyproject = read_toml(&pyproject_path)?; let p = &pyproject.project; @@ -147,15 +151,19 @@ fn project_repository(urls: &BTreeMap) -> Option<&str> { .map(String::as_str) } -fn package_json_from_cargo(module_dir: &Path, out_path: &Path) -> Result { +fn package_json_from_cargo(module_dir: &Path, out_path: &Path) -> Result { let cargo_toml_path = module_dir.join("Cargo.toml"); - let cargo_toml_src = fs::read_to_string(&cargo_toml_path) - .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?; - let cargo_toml: CargoToml = - toml::from_str(&cargo_toml_src).with_context(|| format!("Failed to parse {}", cargo_toml_path.display()))?; + let cargo_toml_src = fs::read_to_string(&cargo_toml_path).map_err(|source| CliError::ReadFile { + path: cargo_toml_path.clone(), + source, + })?; + let cargo_toml: CargoToml = toml::from_str(&cargo_toml_src).map_err(|source| CliError::ParseToml { + path: cargo_toml_path.clone(), + source, + })?; let package = cargo_toml .package - .ok_or_else(|| anyhow!("{} has no [package] section", cargo_toml_path.display()))?; + .ok_or_else(|| CliError::MissingPackageSection(cargo_toml_path.clone()))?; let crate_name = package.name; let kind = detect_cargo_kind(&cargo_toml_src); let workspace = find_workspace_package(module_dir)?; @@ -200,7 +208,7 @@ fn package_json_from_cargo(module_dir: &Path, out_path: &Path) -> Result .or_insert_with(|| Value::Object(Map::new())); let dependency_map = dependencies .as_object_mut() - .ok_or_else(|| anyhow!("{} contains a non-object dependencies field", out_path.display()))?; + .ok_or_else(|| CliError::NonObjectDependencies(out_path.to_path_buf()))?; for (name, version) in ws_module.dependencies { dependency_map.insert(name, json!(version)); } @@ -224,7 +232,7 @@ fn resolve_inherited(direct: Option<&MaybeInherited>, workspace: Option<&str>) - /// Walk parents of `start` looking for a Cargo.toml containing a /// `[workspace]` table; return its `[workspace.package]` if present. -fn find_workspace_package(start: &Path) -> Result> { +fn find_workspace_package(start: &Path) -> Result, CliError> { for dir in start.ancestors().skip(1) { let cargo = dir.join("Cargo.toml"); if !cargo.is_file() { @@ -291,10 +299,13 @@ fn detect_cargo_kind(cargo_toml_src: &str) -> ModuleKind { /// is derived from `name` by trying both its `_` and `-` variants with the /// extension dictated by `kind` (`.wasm` for WASI, `.js` for browser/Pyodide). /// The resolved file must exist in `pkg_dir`; this errors otherwise. -fn resolve_main(pkg_dir: &Path, name: &str, kind: ModuleKind, main_override: Option<&str>) -> Result { +fn resolve_main(pkg_dir: &Path, name: &str, kind: ModuleKind, main_override: Option<&str>) -> Result { if let Some(main) = main_override { if !pkg_dir.join(main).is_file() { - return Err(anyhow!("main = {:?} does not exist in {}", main, pkg_dir.display())); + return Err(CliError::MissingMainFile { + main: main.to_string(), + dir: pkg_dir.to_path_buf(), + }); } return Ok(main.to_string()); } @@ -312,30 +323,45 @@ fn resolve_main(pkg_dir: &Path, name: &str, kind: ModuleKind, main_override: Opt return Ok(candidate); } } - Err(anyhow!( - "No main file in {}; expected {underscored}.{ext} or {hyphenated}.{ext} (override with [ws-module] main)", - pkg_dir.display() - )) + Err(CliError::UnresolvedMainFile { + dir: pkg_dir.to_path_buf(), + underscored, + hyphenated, + ext, + }) } -fn read_toml(path: &Path) -> Result +fn read_toml(path: &Path) -> Result where T: for<'de> Deserialize<'de>, { - let src = fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; - toml::from_str(&src).with_context(|| format!("Failed to parse {}", path.display())) + let src = fs::read_to_string(path).map_err(|source| CliError::ReadFile { + path: path.to_path_buf(), + source, + })?; + toml::from_str(&src).map_err(|source| CliError::ParseToml { + path: path.to_path_buf(), + source, + }) } -fn read_package_json(path: &Path) -> Result>> { +fn read_package_json(path: &Path) -> Result>, CliError> { let src = match fs::read_to_string(path) { Ok(src) => src, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error).with_context(|| format!("Failed to read {}", path.display())), + Err(source) => { + return Err(CliError::ReadFile { + path: path.to_path_buf(), + source, + }); + } }; - let Value::Object(pkg) = - serde_json::from_str(&src).with_context(|| format!("Failed to parse {}", path.display()))? + let Value::Object(pkg) = serde_json::from_str(&src).map_err(|source| CliError::ParseJson { + path: path.to_path_buf(), + source, + })? else { - return Err(anyhow!("{} must contain a JSON object", path.display())); + return Err(CliError::NonObjectPackageJson(path.to_path_buf())); }; Ok(Some(pkg)) }