read-only 0.1.0

Read-only field exposure and safe composition helpers via proc macros
Documentation

read-only

Crates.io docs.rs License: MIT OR Apache-2.0

Read-only field exposure and safe composition helpers via proc macros.

This crate intentionally combines two patterns:

  • #[cast] is the classic cast-based readonly-field pattern, re-exported from readonly::make.
  • #[embed] is this crate's safe composition-based alternative that builds a dedicated ReadOnly* view struct and dereferences into it without unsafe code.

Installation

Add this to your Cargo.toml:

[dependencies]
read-only = "0.1"

Usage

Two attribute macros are provided.

#[cast]

[!NOTE] #[cast] is a re-export of readonly::make. You can use either read_only::cast or readonly::make — they are the same proc macro.

Implements Deref via an unsafe pointer cast — wrapped safely so callers never write unsafe themselves. Fields remain writable inside the defining module but become read-only externally.

All fields read-only (no #[readonly] annotation needed):

use read_only::cast;

#[cast]
pub struct Config {
    pub timeout: u64,
    pub retries: u32,
}

// Inside this module:  config.timeout = 5;        // ✅ write
// Outside:             config.timeout = 5;        // ❌ compile error
//                      let t = config.timeout;    // ✅ read

Selective read-only (annotate specific fields with #[readonly]):

use read_only::cast;

#[cast]
pub struct Device {
    #[readonly]
    pub serial: u64,
    pub mutable_count: i32,
    internal: f64, // stays private in both views
}

// Inside this module:  dev.mutable_count = 1;     // ✅ write
//                      dev.serial = 2;             // ✅ write (inside module)
// Outside:             dev.mutable_count = 1;      // ✅ write (not readonly)
//                      let s = dev.serial;         // ✅ read only
//                      dev.serial = 2;             // ❌ compile error

When no #[readonly] attribute appears on any field, the entire struct becomes read-only through a generated view struct. When any field is annotated, only annotated fields are lifted into the read-only view; unannotated fields keep their original mutability.

#[embed]

Moves #[readonly] annotated fields into a ReadOnly* struct that is embedded inside the original struct. A safe Deref implementation is generated — no unsafe code is involved.

use read_only::embed;

#[embed]
pub struct Device {
    #[readonly]
    pub handle: u64,
    #[readonly]
    pub label: String,
    pub mutable: i32,
}

impl Device {
    pub fn new(handle: u64, label: &str, mutable: i32) -> Self {
        Self {
            read_only: ReadOnlyDevice { handle, label: label.to_string() },
            mutable,
        }
    }
}

// let mut dev = Device::new(42, "dev1", 0);
// let h = dev.handle;    // ✅ read — goes through Deref to ReadOnlyDevice
// let l = &dev.label;    // ✅ read — goes through Deref
// dev.mutable = 1;       // ✅ write — mutable stays on the outer struct
// dev.handle = 0;        // ❌ compile error — handle is read-only

[!NOTE] The generated ReadOnly* struct is public. You can implement Deref, Display, or other traits on it to forward or customize behavior.

Choosing a Macro

  • Use #[cast] if you want the familiar readonly behavior and the smallest syntax change.
  • Use #[embed] if you want an explicit readonly view type and a safe composition-based implementation with no unsafe code in the generated access path.

Examples

Runnable examples are available under examples/:

  • cargo run --example basic
  • cargo run --example embed_deref

Alternatives

Directly comparable:

  • readonly: the canonical cast-based readonly-field crate and the upstream implementation re-exported here as #[cast].

Related accessor-generation crates:

  • getset: generates getter/setter methods instead of preserving direct field reads.
  • derive-getters: generates public getter methods for named structs.
  • fieldwork: a broader accessor macro system for structs and enums with many generated method styles.

The main difference is that those crates expose access through generated methods, while read-only focuses on preserving direct readonly field access syntax.

Testing

The workspace includes both compile-time macro tests and runtime tests.

  • trybuild macro tests cover pass/fail macro expansion behavior.
  • tests/miri.rs exercises the #[cast] unsafe pointer-cast path under Miri to help detect undefined behavior and provenance issues.

Run the Miri suite with:

cargo +nightly miri test --test miri

For source coverage, the CI allows one uncovered line/region for the remaining LLVM coverage artifact in the proc-macro expansion code:

cargo llvm-cov --workspace --all-features --summary-only --fail-uncovered-lines 1 --fail-uncovered-regions 1

License

Licensed under either of

at your option.