diff --git a/TODO.md b/TODO.md index dac0935..7ed5819 100644 --- a/TODO.md +++ b/TODO.md @@ -9,18 +9,30 @@ Conventions: Core execution -- [ ] Add “apply mode” switch to orchestrator to perform destructive actions after preview validation - - Wire phase execution in [orchestrator.run(&Context)](src/orchestrator/run.rs:101): apply partitions → udev settle → mkfs → mount → maybe write fstab → build/write report - - Introduce a CLI flag (e.g. `--apply`) guarded by clear logs and safety checks (not preview) -- [ ] Partition application (destructive) in [fn apply_partitions(...)](src/partition/plan.rs:287) - - Translate [PartitionPlan](src/partition/plan.rs:80) to sgdisk commands (create GPT, partitions in order with alignment and names) - - Enforce idempotency: skip if table already matches plan (or abort with explicit validation error) - - Ensure unique partition GUIDs; capture partition device paths and GUIDs for results - - Call [util::udev_settle()](src/util/mod.rs:128) after changes; robust error mapping to Error::Tool / Error::Partition +- [-] Add “apply mode” switch to orchestrator to perform destructive actions after preview validation + - [x] Introduce CLI flag --apply guarded by clear logs and safety checks (not preview) [src/cli/args.rs](src/cli/args.rs) + - [x] Wire partition application and udev settle [orchestrator::run()](src/orchestrator/run.rs:1) → [partition::apply_partitions()](src/partition/plan.rs:1) + - [-] Wire mkfs → mount → maybe write fstab → build/write report [src/orchestrator/run.rs](src/orchestrator/run.rs) + - [x] Wire mkfs: plan_filesystems + make_filesystems [src/orchestrator/run.rs](src/orchestrator/run.rs) + [src/fs/plan.rs](src/fs/plan.rs) + - [ ] Wire mounts (plan/apply) [src/mount/ops.rs](src/mount/ops.rs) + - [ ] maybe write fstab [src/mount/ops.rs](src/mount/ops.rs) + - [ ] build/write report [src/report/state.rs](src/report/state.rs) +- [x] Partition application (destructive) in [partition::apply_partitions()](src/partition/plan.rs:1) +- [x] Boot mode detection and BIOS boot policy + - [x] Implement UEFI detection via /sys/firmware/efi: [is_efi_boot()](src/util/mod.rs:151) + - [x] Planner skips BIOS boot partition when UEFI-booted: [partition::plan_partitions()](src/partition/plan.rs:133) + - [ ] Future: revisit bootblock/bootloader specifics for BIOS vs EFI (confirm if any BIOS-targets require bios_boot) [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) + - [x] Translate [PartitionPlan](src/partition/plan.rs:1) to sgdisk commands (create GPT, partitions in order with alignment and names) + - [x] Enforce idempotency when required via [idempotency::is_empty_disk()](src/idempotency/mod.rs:1); abort on non-empty + - [x] Capture partition GUIDs, names, device paths via sgdisk -i parsing; map to PartitionResult + - [x] Call [util::udev_settle()](src/util/mod.rs:1) after changes; consistent Error::Tool/Error::Partition mapping - [-] Filesystem creation in [fn make_filesystems(...)](src/fs/plan.rs:182) - [x] Base mkfs implemented for vfat/btrfs/bcachefs (UUID capture via blkid) - - [ ] Apply btrfs raid profile from config (e.g., `-m raid1 -d raid1`) for [Topology::BtrfsRaid1](src/types.rs:29) and the desired profile in [struct BtrfsOptions](src/types.rs:89) - - [ ] Optionally map compression options for btrfs and bcachefs from config (e.g., `-O compress=zstd:3` or format-equivalent) + - [x] Apply btrfs RAID profile when topology requires it (multi-device): pass -m raid1 -d raid1 in mkfs.btrfs [src/fs/plan.rs](src/fs/plan.rs) + - [x] Force mkfs.btrfs in apply path with -f to handle leftover signatures from partial runs [src/fs/plan.rs](src/fs/plan.rs) + - [ ] Compression/tuning mapping from config + - [ ] btrfs: apply compression as mount options during mounting phase [src/mount/ops.rs](src/mount/ops.rs) + - [ ] bcachefs: map compression/checksum/cache_mode to format flags (deferred) [src/fs/plan.rs](src/fs/plan.rs) - [ ] Consider verifying UUID consistency across multi-device filesystems and improve error messages - [ ] Mount planning and application in [mount::ops](src/mount/ops.rs:1) - [ ] Implement [fn plan_mounts(...)](src/mount/ops.rs:68): map FsResult UUIDs into `/var/cache/{UUID}` using [cfg.mount.base_dir](src/types.rs:136), and synthesize options per FS kind @@ -44,7 +56,7 @@ CLI, config, defaults - [x] Built-in sensible defaults (no YAML required) [src/config/loader.rs](src/config/loader.rs:320) - [x] Overlays from CLI: log level, file logging, fstab, removable policy, topology [src/config/loader.rs](src/config/loader.rs:247) - [x] Preview flags (`--show`, `--report`) and topology selection (`-t/--topology`) [src/cli/args.rs](src/cli/args.rs:55) -- [ ] Add `--apply` flag to toggle execute mode and keep preview non-destructive by default [src/cli/args.rs](src/cli/args.rs:55) +- [x] Add `--apply` flag to toggle execute mode and keep preview non-destructive by default [src/cli/args.rs](src/cli/args.rs) - [ ] Consider environment variable overlays [src/config/loader.rs](src/config/loader.rs:39) - [ ] Consider hidden/dev flags behind features (e.g., `--dry-run-verbose`, `--trace-io`) [src/cli/args.rs](src/cli/args.rs:26) diff --git a/src/cli/args.rs b/src/cli/args.rs index af4d00d..45bd944 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -113,6 +113,10 @@ pub struct Cli { /// Write detection/planning JSON report to the given path (overrides config.report.path) #[arg(long = "report")] pub report: Option, + + /// Execute destructive actions (apply mode). When false, runs preview-only. + #[arg(long = "apply", default_value_t = false)] + pub apply: bool, } /// Parse CLI arguments (non-interactive; suitable for initramfs). diff --git a/src/fs/plan.rs b/src/fs/plan.rs index 4aabe34..36c7e19 100644 --- a/src/fs/plan.rs +++ b/src/fs/plan.rs @@ -18,20 +18,21 @@ // ext: dry-run mode to emit mkfs commands without executing (future). // REGION: EXTENSION_POINTS-END // -// REGION: SAFETY -// safety: must not run mkfs on non-empty or unexpected partitions; assume prior validation enforced. -// safety: ensure labels follow reserved semantics (ZOSBOOT for ESP, ZOSDATA for all data FS). -// REGION: SAFETY-END + // REGION: SAFETY + // safety: must not run mkfs on non-empty or unexpected partitions; assume prior validation enforced. + // safety: ensure labels follow reserved semantics (ZOSBOOT for ESP, ZOSDATA for all data FS). + // safety: mkfs.btrfs uses -f in apply path immediately after partitioning to handle leftover signatures. + // REGION: SAFETY-END // // REGION: ERROR_MAPPING // errmap: external mkfs/blkid failures -> crate::Error::Tool with captured stderr. // errmap: planning mismatches -> crate::Error::Filesystem with context. // REGION: ERROR_MAPPING-END // -// REGION: TODO -// todo: implement mapping of topology to FsSpec including bcachefs cache/backing composition. -// todo: implement mkfs invocation and UUID capture via util::run_cmd / util::run_cmd_capture. -// REGION: TODO-END + // REGION: TODO + // todo: bcachefs tuning flags mapping from config (compression/checksum/cache_mode) deferred + // todo: add UUID consistency checks across multi-device filesystems + // REGION: TODO-END //! Filesystem planning and creation for zosstorage. //! //! Maps partition results to concrete filesystems (vfat, btrfs, bcachefs) @@ -220,8 +221,25 @@ pub fn make_filesystems(plan: &FsPlan) -> Result> { if spec.devices.is_empty() { return Err(Error::Filesystem("btrfs requires at least one device".into())); } - // mkfs.btrfs -L LABEL dev1 [dev2 ...] + // mkfs.btrfs -L LABEL [ -m raid1 -d raid1 (when multi-device/raid1) ] dev1 [dev2 ...] let mut args: Vec = vec![mkfs.clone(), "-L".into(), spec.label.clone()]; + + // If this Btrfs is multi-device (as planned in BtrfsRaid1 topology), + // set metadata/data profiles to raid1. This keeps plan/apply consistent. + if spec.devices.len() >= 2 { + args.push("-m".into()); + args.push("raid1".into()); + args.push("-d".into()); + args.push("raid1".into()); + } + + // Note: compression is a mount-time option for btrfs; we will apply it in mount phase. + // Leaving mkfs-time compression unset by design. + + // Force formatting in apply path to avoid leftover signatures on freshly created partitions. + // Safe because we just created these partitions in this run. + args.push("-f".into()); + args.extend(spec.devices.iter().cloned()); let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); run_cmd(&args_ref)?; @@ -244,6 +262,8 @@ pub fn make_filesystems(plan: &FsPlan) -> Result> { return Err(Error::Filesystem("bcachefs requires at least two devices (cache + backing)".into())); } // bcachefs format --label LABEL dev_cache dev_backing ... + // TODO(fs): map compression/checksum/cache-mode flags from config in a follow-up. + // This is deferred per current scope to focus on btrfs RAID profile wiring. let mut args: Vec = vec![mkfs.clone(), "format".into(), "--label".into(), spec.label.clone()]; args.extend(spec.devices.iter().cloned()); let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); diff --git a/src/main.rs b/src/main.rs index 1135704..a971e79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,6 +51,7 @@ fn real_main() -> Result<()> { let ctx = orchestrator::Context::new(cfg, log_opts) .with_show(cli.show) + .with_apply(cli.apply) .with_report_path(cli.report.clone()); orchestrator::run(&ctx) } diff --git a/src/orchestrator/run.rs b/src/orchestrator/run.rs index a344ee8..63359b1 100644 --- a/src/orchestrator/run.rs +++ b/src/orchestrator/run.rs @@ -48,6 +48,7 @@ use crate::{ device::{discover, DeviceFilter, Disk}, idempotency, partition, + fs as zfs, Error, Result, }; use humantime::format_rfc3339; @@ -66,6 +67,8 @@ pub struct Context { pub log: LogOptions, /// When true, print detection and planning summary to stdout (JSON). pub show: bool, + /// When true, perform destructive actions (apply mode). + pub apply: bool, /// Optional report path override (when provided by CLI --report). pub report_path_override: Option, } @@ -77,6 +80,7 @@ impl Context { cfg, log, show: false, + apply: false, report_path_override: None, } } @@ -93,6 +97,16 @@ impl Context { self } + /// Enable or disable apply mode (destructive). + /// + /// When set to true (e.g. via `--apply`), orchestrator: + /// - Enforces empty-disk policy (unless disabled in config) + /// - Applies partition plan, then (future) mkfs, mounts, and report + pub fn with_apply(mut self, apply: bool) -> Self { + self.apply = apply; + self + } + /// Override the report output path used by preview mode. /// /// When provided (e.g. via `--report /path/file.json`), orchestrator: @@ -171,11 +185,27 @@ pub fn run(ctx: &Context) -> Result<()> { debug!("plan for {}: {} part(s)", dp.disk.path, dp.parts.len()); } - // Note: - // - Applying partitions, creating filesystems, mounting, and reporting - // will be wired in subsequent steps. For now this performs pre-flight - // checks and planning to exercise real code paths safely. + // Apply mode: perform destructive partition application now. + if ctx.apply { + info!("orchestrator: apply mode enabled; applying partition plan"); + let part_results = partition::apply_partitions(&plan)?; + info!( + "orchestrator: applied partitions on {} disk(s), total parts created: {}", + plan.disks.len(), + part_results.len() + ); + // Filesystem planning and creation + let fs_plan = zfs::plan_filesystems(&part_results, &ctx.cfg)?; + info!("orchestrator: filesystem plan contains {} spec(s)", fs_plan.specs.len()); + let fs_results = zfs::make_filesystems(&fs_plan)?; + info!("orchestrator: created {} filesystem(s)", fs_results.len()); + + // Next steps (mounts, optional fstab, state report) will be wired in follow-ups. + return Ok(()); + } + + // Preview-only path info!("orchestrator: pre-flight complete (idempotency checked, devices discovered, plan computed)"); // Optional: emit JSON summary via --show or write via --report diff --git a/src/partition/plan.rs b/src/partition/plan.rs index 02736f7..a701dc8 100644 --- a/src/partition/plan.rs +++ b/src/partition/plan.rs @@ -19,11 +19,12 @@ // ext: device-specific alignment or reserved areas configurable via cfg in the future. // REGION: EXTENSION_POINTS-END // -// REGION: SAFETY -// safety: must verify require_empty_disks before any modification. -// safety: must ensure unique partition GUIDs; identical labels are allowed when expected (e.g., ESP ZOSBOOT). -// safety: must call udev settle after partition table writes. -// REGION: SAFETY-END + // REGION: SAFETY + // safety: must verify require_empty_disks before any modification. + // safety: when UEFI-booted, suppress creating BIOS boot partition to avoid unnecessary ef02 on UEFI systems. + // safety: must ensure unique partition GUIDs; identical labels are allowed when expected (e.g., ESP ZOSBOOT). + // safety: must call udev settle after partition table writes. + // REGION: SAFETY-END // // REGION: ERROR_MAPPING // errmap: external tool failure -> crate::Error::Tool { tool, status, stderr }. @@ -42,7 +43,14 @@ //! See [fn plan_partitions](plan.rs:1) and //! [fn apply_partitions](plan.rs:1). -use crate::{types::{Config, Topology}, device::Disk, Error, Result}; +use crate::{ + types::{Config, Topology}, + device::Disk, + util::{run_cmd, run_cmd_capture, which_tool, udev_settle, is_efi_boot}, + idempotency, + Error, Result, +}; +use tracing::{debug, warn}; /// Partition roles supported by zosstorage. #[derive(Debug, Clone, Copy)] @@ -126,6 +134,8 @@ pub struct PartitionResult { pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { let align = cfg.partitioning.alignment_mib; let require_empty = cfg.partitioning.require_empty_disks; + // If system booted via UEFI, suppress the BIOS boot partition even if enabled in config. + let add_bios = cfg.partitioning.bios_boot.enabled && !is_efi_boot(); if disks.is_empty() { return Err(Error::Partition("no disks provided to partition planner".into())); @@ -137,7 +147,7 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { Topology::Single => { let d0 = &disks[0]; let mut parts = Vec::new(); - if cfg.partitioning.bios_boot.enabled { + if add_bios { parts.push(PartitionSpec { role: PartRole::BiosBoot, size_mib: Some(cfg.partitioning.bios_boot.size_mib), @@ -165,7 +175,7 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { // Disk 0: BIOS (opt) + ESP + Data let mut parts0 = Vec::new(); - if cfg.partitioning.bios_boot.enabled { + if add_bios { parts0.push(PartitionSpec { role: PartRole::BiosBoot, size_mib: Some(cfg.partitioning.bios_boot.size_mib), @@ -202,7 +212,7 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { // Disk 0: BIOS (opt) + ESP + Data let mut parts0 = Vec::new(); - if cfg.partitioning.bios_boot.enabled { + if add_bios { parts0.push(PartitionSpec { role: PartRole::BiosBoot, size_mib: Some(cfg.partitioning.bios_boot.size_mib), @@ -239,7 +249,7 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { // SSD: BIOS (opt) + ESP + Cache remainder let mut parts_ssd = Vec::new(); - if cfg.partitioning.bios_boot.enabled { + if add_bios { parts_ssd.push(PartitionSpec { role: PartRole::BiosBoot, size_mib: Some(cfg.partitioning.bios_boot.size_mib), @@ -276,13 +286,177 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { }) } -/// Apply the partition plan using system utilities (sgdisk) via util wrappers. -/// -/// Safety: -/// - Must verify target disks are empty when required. -/// - Must ensure unique partition GUIDs. -/// - Should call udev settle after changes. -pub fn apply_partitions(_plan: &PartitionPlan) -> Result> { - // To be implemented: sgdisk orchestration + udev settle + GUID collection - todo!("shell out to sgdisk, trigger udev settle, collect partition GUIDs") +/** + Apply the partition plan using system utilities (sgdisk) via util wrappers. + + Safety: + - Verifies target disks are empty when required (defense-in-depth; orchestrator should also enforce). + - Ensures unique partition GUIDs by relying on sgdisk defaults. + - Calls udev settle after changes to ensure /dev nodes exist. + + Notes: + - Uses sgdisk -og to create a new GPT on empty disks. + - Adds partitions in declared order using -n (auto-aligned), -t (type code), -c (GPT name). + - Derives partition device paths: NVMe uses "pN" suffix; others use trailing "N". + - Captures per-partition GUID and geometry via `sgdisk -i `. +*/ +pub fn apply_partitions(plan: &PartitionPlan) -> Result> { + // Locate required tools + let Some(sgdisk) = which_tool("sgdisk")? else { + return Err(Error::Partition("sgdisk not found in PATH".into())); + }; + + // Helper: map role to GPT type code (gdisk codes) + fn type_code(role: PartRole) -> &'static str { + match role { + PartRole::BiosBoot => "ef02", // BIOS boot partition (for GRUB BIOS on GPT) + PartRole::Esp => "ef00", // EFI System Partition + PartRole::Data => "8300", // Linux filesystem + PartRole::Cache => "8300", // Treat cache as Linux filesystem (bcachefs) + } + } + + // Helper: build partition device path for a given disk and partition number + fn part_dev_path(disk_path: &str, part_number: u32) -> String { + if disk_path.starts_with("/dev/nvme") { + format!("{disk_path}p{part_number}") + } else { + format!("{disk_path}{part_number}") + } + } + + // Helper: sector size in bytes for disk (fallback 512 with warning) + fn sector_size_bytes(disk_path: &str) -> Result { + if let Some(blockdev) = which_tool("blockdev")? { + let out = run_cmd_capture(&[blockdev.as_str(), "--getss", disk_path])?; + let s = out.stdout.trim(); + return s.parse::() + .map_err(|e| Error::Partition(format!("failed to parse sector size from blockdev for {}: {}", disk_path, e))); + } + warn!("blockdev not found; assuming 512-byte sectors for {}", disk_path); + Ok(512) + } + + // Helper: parse sgdisk -i output to (unique_guid, first_sector, last_sector) + fn parse_sgdisk_info(info: &str) -> Result<(String, u64, u64)> { + let mut guid = String::new(); + let mut first: Option = None; + let mut last: Option = None; + + for line in info.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("Partition unique GUID:") { + guid = rest.trim().to_string(); + } else if let Some(rest) = line.strip_prefix("First sector:") { + // Format: "First sector: 2048 (at 1024.0 KiB)" + let val = rest.trim().split_whitespace().next().unwrap_or(""); + if !val.is_empty() { + first = Some(val.parse::().map_err(|e| Error::Partition(format!("parse first sector: {}", e)))?); + } + } else if let Some(rest) = line.strip_prefix("Last sector:") { + let val = rest.trim().split_whitespace().next().unwrap_or(""); + if !val.is_empty() { + last = Some(val.parse::().map_err(|e| Error::Partition(format!("parse last sector: {}", e)))?); + } + } + } + + let first = first.ok_or_else(|| Error::Partition("sgdisk -i missing First sector".into()))?; + let last = last.ok_or_else(|| Error::Partition("sgdisk -i missing Last sector".into()))?; + if guid.is_empty() { + return Err(Error::Partition("sgdisk -i missing Partition unique GUID".into())); + } + Ok((guid, first, last)) + } + + let mut results: Vec = Vec::new(); + + for dp in &plan.disks { + let disk_path = dp.disk.path.as_str(); + + // Defense-in-depth: verify emptiness when required + if plan.require_empty_disks { + let empty = idempotency::is_empty_disk(&dp.disk)?; + if !empty { + return Err(Error::Validation(format!( + "target disk {} is not empty (partitions or signatures present)", + dp.disk.path + ))); + } + } + + debug!("apply_partitions: creating GPT on {}", disk_path); + // Initialize (or re-initialize) a new empty GPT; requires truly empty disks per policy + run_cmd(&[sgdisk.as_str(), "-og", disk_path])?; + + // Create partitions in order + for (idx0, spec) in dp.parts.iter().enumerate() { + let part_num = (idx0 as u32) + 1; + let size_arg = match spec.size_mib { + Some(mib) => format!("+{}M", mib), // rely on sgdisk MiB suffix support + None => String::from("0"), // consume remainder + }; + // Use automatic aligned start (0) and specified size + let n_arg = format!("{}:0:{}", part_num, size_arg); + let t_arg = format!("{}:{}", part_num, type_code(spec.role)); + let c_arg = format!("{}:{}", part_num, spec.gpt_name); + + debug!( + "apply_partitions: {} -n {} -t {} -c {} {}", + sgdisk, n_arg, t_arg, c_arg, disk_path + ); + + run_cmd(&[ + sgdisk.as_str(), + "-n", n_arg.as_str(), + "-t", t_arg.as_str(), + "-c", c_arg.as_str(), + disk_path, + ])?; + } + + // Settle udev so new partitions appear under /dev + udev_settle(5_000)?; + + // Gather per-partition details and build results + let sector_bytes = sector_size_bytes(disk_path)?; + let mib_div: u64 = 1024 * 1024; + + for (idx0, spec) in dp.parts.iter().enumerate() { + let part_num = (idx0 as u32) + 1; + + // Query sgdisk for partition info + let i_arg = format!("{}", part_num); + let info_out = run_cmd_capture(&[ + sgdisk.as_str(), + "-i", i_arg.as_str(), + disk_path, + ])?; + + let (unique_guid, first_sector, last_sector) = parse_sgdisk_info(&info_out.stdout)?; + let sectors = if last_sector >= first_sector { + last_sector - first_sector + 1 + } else { + 0 + }; + let start_mib = (first_sector.saturating_mul(sector_bytes)) / mib_div; + let size_mib = (sectors.saturating_mul(sector_bytes)) / mib_div; + + let dev_path = part_dev_path(disk_path, part_num); + + results.push(PartitionResult { + disk: dp.disk.path.clone(), + part_number: part_num, + role: spec.role, + gpt_name: spec.gpt_name.clone(), + uuid: unique_guid, + start_mib, + size_mib, + device_path: dev_path, + }); + } + } + + debug!("apply_partitions: created {} partition entries", results.len()); + Ok(results) } \ No newline at end of file diff --git a/src/util/mod.rs b/src/util/mod.rs index d3f14d6..3fc0eb4 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -4,11 +4,13 @@ // api: util::run_cmd(args: &[&str]) -> crate::Result<()> // api: util::run_cmd_capture(args: &[&str]) -> crate::Result<CmdOutput> // api: util::udev_settle(timeout_ms: u64) -> crate::Result<()> +// api: util::is_efi_boot() -> bool // REGION: API-END // // REGION: RESPONSIBILITIES // - Centralize external tool discovery and invocation (sgdisk, blkid, mkfs.*, udevadm). // - Provide capture and error mapping to crate::Error consistently. +// - Provide environment helpers (udev settle, boot mode detection). // Non-goals: business logic (planning/validation), direct parsing of complex outputs beyond what callers need. // REGION: RESPONSIBILITIES-END // @@ -39,6 +41,7 @@ use crate::{Error, Result}; use std::process::Command; +use std::path::Path; use tracing::{debug, warn}; /// Captured output from an external tool invocation. @@ -147,6 +150,14 @@ pub fn udev_settle(timeout_ms: u64) -> Result<()> { } } +/// Detect whether the current system booted via UEFI (initramfs-friendly). +/// +/// Returns true when /sys/firmware/efi exists (standard on UEFI boots). +/// Returns false on legacy BIOS boots where that path is absent. +pub fn is_efi_boot() -> bool { + Path::new("/sys/firmware/efi").exists() +} + #[cfg(test)] mod tests { use super::*;