Skip to main content

limnifs_write/config/
mod.rs

1//! User-facing write configuration.
2//!
3//! [`WriteConfig`] captures all user-tunable writer settings in one
4//! place. The configuration is model-driven: each sub-config is a
5//! distinct type that owns its own validation, serialization, and
6//! defaults. This eliminates the v0.1 pattern of hardcoded
7//! constants scattered across `limnifs-write/src/lib.rs`.
8//!
9//! ## Architecture
10//!
11//! ```text
12//! WriteConfig (top-level)
13//!   ├── Defaults           (codecs, qualities, inline threshold)
14//!   ├── CategorizerConfig[] (extension/magic → codec routing)
15//!   ├── ChunkingConfig      (FastCDC parameters)
16//!   ├── TournamentConfig   (which codecs + min sizes)
17//!   ├── EncryptionConfig    (AEAD + key wrap)
18//!   └── DictionaryConfig    (ZSTD dictionary training)
19//! ```
20//!
21//! ## OCP
22//!
23//! Adding a new sub-config = adding a new struct + wiring into
24//! [`WriteConfig::default_v0_1`] + adding a TOML section. No
25//! existing code changes.
26
27pub mod defaults;
28pub mod error;
29pub mod profile;
30pub mod toml;
31
32use std::collections::BTreeMap;
33
34use serde::{Deserialize, Serialize};
35
36use crate::config::error::ConfigError;
37
38/// Default codec for text/code/sparse content.
39/// Matches v0.1 behavior: Brotli.
40pub const DEFAULT_TEXT_CODEC: &str = "brotli";
41/// Default codec for binary content.
42/// Matches v0.1 behavior: LZ4.
43pub const DEFAULT_BINARY_CODEC: &str = "lz4";
44/// Default codec for the metadata blob.
45/// Matches v0.1 behavior: Brotli.
46pub const DEFAULT_METADATA_CODEC: &str = "brotli";
47/// Default Brotli quality for small metadata blobs.
48pub const DEFAULT_METADATA_QUALITY: u8 = 5;
49/// Default inline-data threshold (bytes).
50pub const DEFAULT_INLINE_THRESHOLD: u16 = 4096;
51/// Default `FastCDC` average chunk size.
52pub const DEFAULT_AVG_CHUNK_SIZE: u32 = 8192;
53/// Default `FastCDC` minimum chunk size.
54pub const DEFAULT_MIN_CHUNK_SIZE: u32 = 1024;
55/// Default `FastCDC` maximum chunk size.
56pub const DEFAULT_MAX_CHUNK_SIZE: u32 = 65_536;
57/// Default minimum size for the tournament to try a codec.
58pub const DEFAULT_TOURNAMENT_MIN_SIZE: u32 = 256;
59/// Default: skip tournament for binary class.
60pub const DEFAULT_TOURNAMENT_SKIP_BINARY: bool = true;
61/// Default AEAD algorithm.
62pub const DEFAULT_AEAD: &str = "chacha20-poly1305";
63/// Default key wrap algorithm.
64pub const DEFAULT_KEY_WRAP: &str = "x25519-hkdf";
65/// Default: enable dictionary training.
66pub const DEFAULT_DICT_ENABLED: bool = true;
67/// Default minimum drops per class to train a dict.
68pub const DEFAULT_DICT_MIN_CLASS_SIZE: u32 = 100;
69/// Default maximum dictionary size in bytes.
70pub const DEFAULT_DICT_MAX_SIZE: u32 = 65_536;
71
72/// Top-level write configuration. All fields are public so the
73/// TOML loader can construct values directly; runtime validation
74/// lives in [`WriteConfig::validate`].
75#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
76pub struct WriteConfig {
77    /// Default codec selection + inline threshold.
78    pub defaults: Defaults,
79    /// File categorizer rules (extension/magic → codec).
80    #[serde(default, rename = "categorizer")]
81    pub categorizers: Vec<CategorizerConfig>,
82    /// `FastCDC` chunking parameters.
83    pub chunking: ChunkingConfig,
84    /// Compression tournament settings.
85    pub tournament: TournamentConfig,
86    /// Per-codec tunable parameters (memory budgets, quality levels).
87    #[serde(default)]
88    pub codec_tunables: CodecTunables,
89    /// Image mode: read-only archive or read-write filesystem.
90    #[serde(default)]
91    pub mode: ImageMode,
92    /// Codec for incremental writes (RW mode only). Defaults to LZ4.
93    /// During turnover, `defaults.text_codec` is used for re-compression.
94    #[serde(default = "default_write_codec")]
95    pub write_codec: String,
96    /// Turnover threshold: number of history entries before automatic
97    /// compaction triggers (RW mode only). 0 = manual turnover only.
98    #[serde(default)]
99    pub turnover_threshold: u32,
100    /// Skip FastCDC chunking; compress each file as a single drop.
101    /// Trades dedup granularity for create speed. Recommended for
102    /// `max-write` profile where speed >> ratio.
103    #[serde(default)]
104    pub skip_chunking: bool,
105    /// Encryption configuration.
106    pub encryption: EncryptionConfig,
107    /// ZSTD dictionary configuration.
108    pub dictionaries: DictionaryConfig,
109}
110
111fn default_write_codec() -> String {
112    "lz4".into()
113}
114
115fn default_metadata_externalize_threshold() -> usize {
116    crate::METADATA_EXTERNALIZE_THRESHOLD
117}
118
119/// Default codec + quality settings.
120#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
121pub struct Defaults {
122    pub text_codec: String,
123    pub binary_codec: String,
124    pub metadata_codec: String,
125    pub metadata_quality: u8,
126    /// Compressed-metadata size (bytes) above which the blob is
127    /// externalized to a `metadata.bin` sidecar instead of inlined
128    /// in the manifest. Default: just under the reader's 1 MiB
129    /// inline ceiling (`DEFAULT_INLINE_METADATA_MAX_BYTES`); raise to
130    /// that ceiling for maximally self-contained images, lower it to
131    /// keep manifests small. See limnifs#187.
132    #[serde(default = "default_metadata_externalize_threshold")]
133    pub metadata_externalize_threshold: usize,
134    /// Inline data threshold (bytes).
135    pub inline_threshold: u16,
136}
137
138/// One file categorizer rule.
139#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
140pub struct CategorizerConfig {
141    /// Human-readable name (e.g. "dna", "json").
142    pub name: String,
143    /// File extensions that trigger this rule (lowercase, no dot).
144    #[serde(default)]
145    pub extensions: Vec<String>,
146    /// Magic bytes at offset 0 that trigger this rule.
147    #[serde(default)]
148    pub magic_bytes: Vec<u8>,
149    /// Codec identifier (string name or numeric id).
150    pub codec: String,
151    /// Maximum file size to apply this rule to.
152    #[serde(default)]
153    pub max_size: Option<u32>,
154    /// Whether this rule is active.
155    #[serde(default = "default_true")]
156    pub enabled: bool,
157}
158
159fn default_true() -> bool {
160    true
161}
162
163/// `FastCDC` parameters.
164#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
165pub struct ChunkingConfig {
166    /// Algorithm name. Defaults to `"fastcdc"`. Reserved for future
167    /// chunkers (`"gear-simd"`, `"leap-cdc"`, etc.) — today only
168    /// `FastCDC` is wired. The writer ignores unknown values today;
169    /// a `chunker_from_config` factory lands with the second chunker.
170    #[serde(default = "default_chunker_name")]
171    pub name: String,
172    #[serde(default)]
173    pub avg_chunk_size: u32,
174    #[serde(default)]
175    pub min_chunk_size: u32,
176    #[serde(default)]
177    pub max_chunk_size: u32,
178}
179
180fn default_chunker_name() -> String {
181    "fastcdc".into()
182}
183
184/// Compression tournament settings.
185#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
186pub struct TournamentConfig {
187    /// Codecs to try in the tournament (ordered fast → slow).
188    /// The writer iterates these in order and stops early when
189    /// `short_circuit_threshold` is met.
190    pub codecs: Vec<String>,
191    /// Minimum chunk size for the tournament to try a codec.
192    pub min_size_threshold: u32,
193    /// Skip tournament for binary class (use `binary_codec` directly).
194    pub skip_for_binary: bool,
195    /// Short-circuit the tournament once a codec achieves this ratio
196    /// or better. Stored as per-mille (parts per 1000) so it serialises
197    /// as an integer — 250 means "accept the moment a codec reaches
198    /// 25% of original size". 0 disables short-circuit (try every codec).
199    ///
200    /// For example, on a highly-compressible CSV chunk, LZ4 typically
201    /// reaches ~10% ratio in microseconds; the short-circuit lets us
202    /// accept that and skip the much-slower Brotli pass we would
203    /// otherwise run for ratio parity. On hard-to-compress text where
204    /// LZ4 only reaches ~40%, the tournament continues to Brotli to
205    /// preserve ratio.
206    #[serde(default = "default_short_circuit_threshold")]
207    pub short_circuit_threshold: u32,
208}
209
210fn default_short_circuit_threshold() -> u32 {
211    250
212}
213
214/// Encryption configuration.
215#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
216pub struct EncryptionConfig {
217    /// AEAD algorithm name.
218    pub aead: String,
219    /// Key wrap algorithm name.
220    pub key_wrap: String,
221}
222
223/// ZSTD dictionary training configuration.
224#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
225pub struct DictionaryConfig {
226    pub enabled: bool,
227    pub min_class_size: u32,
228    pub max_dict_size: u32,
229    /// Trainer algorithm: `"frequency"` (default — top-K substrings
230    /// by frequency × length) or `"fastcover"` (dmer-frequency
231    /// scoring per FastCover, Facebook 2018). FastCover tends to
232    /// win on corpora with distributed redundancy (mixed JSON,
233    /// source files, log lines); FrequencyTrainer wins on corpora
234    /// with strong common substrings.
235    #[serde(default = "default_trainer")]
236    pub trainer: String,
237}
238
239fn default_trainer() -> String {
240    "frequency".into()
241}
242
243/// Image mode: read-only (one-shot archive) or read-write (live filesystem).
244///
245/// LimniFS's key differentiator vs SquashFS/DwarFS is RW support —
246/// images can be updated incrementally without full rebuilds.
247#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)]
248pub enum ImageMode {
249    /// Read-only archive. Created once, read many times. All data
250    /// is available at creation time — aggressive compression and
251    /// full dedup are worthwhile.
252    #[default]
253    ReadOnly,
254    /// Read-write image supporting incremental updates.
255    ReadWrite(RWMode),
256}
257
258/// Read-write sub-mode controlling how updates are applied.
259#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)]
260pub enum RWMode {
261    /// Append-only: files can be added but never modified or deleted.
262    /// No history tracking needed. Best for archival, data lakes.
263    AppendOnly,
264    /// Update-in-place: files can be modified and deleted. Old versions
265    /// are kept as history entries. Best for dev directories, config mgmt.
266    #[default]
267    UpdateInPlace,
268    /// Copy-on-write: modifications create new drops; old drops are
269    /// unreferenced and reclaimed during turnover. Best for container
270    /// layers, VM disk images.
271    CopyOnWrite,
272}
273
274/// Per-codec tunable parameters. Each sub-struct has serde defaults
275/// so the TOML can omit any codec the user doesn't want to customise.
276///
277/// ```toml
278/// [codec_tunables.ppmd7]
279/// order = 4
280/// memory_budget_mb = 80
281///
282/// [codec_tunables.brotli]
283/// quality = 11
284/// window = 22
285/// ```
286#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)]
287pub struct CodecTunables {
288    #[serde(default)]
289    pub ppmd7: Ppmd7Tunables,
290    #[serde(default)]
291    pub ppmd8: Ppmd8Tunables,
292    #[serde(default)]
293    pub brotli: BrotliTunables,
294    #[serde(default)]
295    pub lzma: LzmaTunables,
296    #[serde(default)]
297    pub bzip2: Bzip2Tunables,
298}
299
300/// PPMd7 tunables: context order + memory budget.
301#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
302pub struct Ppmd7Tunables {
303    pub order: u8,
304    pub memory_budget_mb: u32,
305}
306
307impl Default for Ppmd7Tunables {
308    fn default() -> Self {
309        Self {
310            order: 4,
311            memory_budget_mb: 80,
312        }
313    }
314}
315
316/// PPMd8 tunables: context order + memory budget.
317#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
318pub struct Ppmd8Tunables {
319    pub order: u8,
320    pub memory_budget_mb: u32,
321}
322
323impl Default for Ppmd8Tunables {
324    fn default() -> Self {
325        Self {
326            order: 6,
327            memory_budget_mb: 64,
328        }
329    }
330}
331
332/// Brotli tunables: quality (0..=11) + window log2 (10..=24).
333#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
334pub struct BrotliTunables {
335    pub quality: u8,
336    pub window: u8,
337}
338
339impl Default for BrotliTunables {
340    fn default() -> Self {
341        Self {
342            quality: 11,
343            window: 22,
344        }
345    }
346}
347
348/// LZMA tunables: lc/lp/pb + dictionary size in MiB + optimal parser.
349#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
350pub struct LzmaTunables {
351    pub lc: u8,
352    pub lp: u8,
353    pub pb: u8,
354    pub dict_size_mb: u32,
355    pub use_optimal_parser: bool,
356}
357
358impl Default for LzmaTunables {
359    fn default() -> Self {
360        Self {
361            lc: 3,
362            lp: 0,
363            pb: 2,
364            dict_size_mb: 16,
365            use_optimal_parser: false,
366        }
367    }
368}
369
370/// BZip2 tunables: block size in KB (100..=900, must be multiple of 100).
371#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
372pub struct Bzip2Tunables {
373    pub block_size_kb: u32,
374}
375
376impl Default for Bzip2Tunables {
377    fn default() -> Self {
378        Self { block_size_kb: 900 }
379    }
380}
381
382impl WriteConfig {
383    /// Create the v0.1-compatible default configuration.
384    /// All fields match the behavior of `limnifs-write` before
385    /// this config was introduced.
386    #[must_use]
387    pub fn default_v0_1() -> Self {
388        Self {
389            defaults: Defaults {
390                text_codec: DEFAULT_TEXT_CODEC.to_string(),
391                binary_codec: DEFAULT_BINARY_CODEC.to_string(),
392                metadata_codec: DEFAULT_METADATA_CODEC.to_string(),
393                metadata_quality: DEFAULT_METADATA_QUALITY,
394                metadata_externalize_threshold: crate::METADATA_EXTERNALIZE_THRESHOLD,
395                inline_threshold: DEFAULT_INLINE_THRESHOLD,
396            },
397            categorizers: Vec::new(),
398            chunking: ChunkingConfig {
399                name: "fastcdc".into(),
400                avg_chunk_size: DEFAULT_AVG_CHUNK_SIZE,
401                min_chunk_size: DEFAULT_MIN_CHUNK_SIZE,
402                max_chunk_size: DEFAULT_MAX_CHUNK_SIZE,
403            },
404            tournament: TournamentConfig {
405                codecs: vec![
406                    "store".to_string(),
407                    "lz4".to_string(),
408                    "zstd".to_string(),
409                    "brotli".to_string(),
410                ],
411                min_size_threshold: DEFAULT_TOURNAMENT_MIN_SIZE,
412                skip_for_binary: DEFAULT_TOURNAMENT_SKIP_BINARY,
413                short_circuit_threshold: default_short_circuit_threshold(),
414            },
415            encryption: EncryptionConfig {
416                aead: DEFAULT_AEAD.to_string(),
417                key_wrap: DEFAULT_KEY_WRAP.to_string(),
418            },
419            dictionaries: DictionaryConfig {
420                enabled: DEFAULT_DICT_ENABLED,
421                min_class_size: DEFAULT_DICT_MIN_CLASS_SIZE,
422                max_dict_size: DEFAULT_DICT_MAX_SIZE,
423                trainer: "frequency".into(),
424            },
425            codec_tunables: CodecTunables::default(),
426            mode: ImageMode::ReadOnly,
427            write_codec: default_write_codec(),
428            turnover_threshold: 0,
429            skip_chunking: false,
430        }
431    }
432
433    /// Load a built-in profile by name, then override fields via
434    /// builder methods.
435    #[must_use]
436    pub fn from_profile(name: &str) -> Option<Self> {
437        profile::select(name)
438    }
439
440    /// Override the text codec.
441    #[must_use]
442    pub fn with_text_codec(mut self, codec: &str) -> Self {
443        self.defaults.text_codec = codec.into();
444        self
445    }
446
447    /// Override the binary codec.
448    #[must_use]
449    pub fn with_binary_codec(mut self, codec: &str) -> Self {
450        self.defaults.binary_codec = codec.into();
451        self
452    }
453
454    /// Override the average chunk size.
455    #[must_use]
456    pub fn with_chunk_size(mut self, size: u32) -> Self {
457        self.chunking.avg_chunk_size = size;
458        self
459    }
460
461    /// Override the image mode (RO vs RW).
462    #[must_use]
463    pub fn with_mode(mut self, mode: ImageMode) -> Self {
464        self.mode = mode;
465        self
466    }
467
468    /// Override Brotli quality.
469    #[must_use]
470    pub fn with_brotli_quality(mut self, quality: u8) -> Self {
471        self.codec_tunables.brotli.quality = quality;
472        self
473    }
474
475    /// Finalize (validate and return).
476    /// # Errors
477    /// Returns [`ConfigError`] on invalid configuration.
478    pub fn build(self) -> Result<Self, ConfigError> {
479        self.validate()?;
480        Ok(self)
481    }
482
483    /// Validate field relationships and range constraints.
484    /// # Errors
485    /// Returns a [`ConfigError`] on any invalid value.
486    pub fn validate(&self) -> Result<(), ConfigError> {
487        if self.chunking.min_chunk_size > self.chunking.avg_chunk_size {
488            return Err(ConfigError::InvalidValue {
489                field: "chunking.min_chunk_size".into(),
490                reason: format!(
491                    "min_chunk_size ({}) > avg_chunk_size ({})",
492                    self.chunking.min_chunk_size, self.chunking.avg_chunk_size
493                ),
494            });
495        }
496        if self.chunking.avg_chunk_size > self.chunking.max_chunk_size {
497            return Err(ConfigError::InvalidValue {
498                field: "chunking.avg_chunk_size".into(),
499                reason: format!(
500                    "avg_chunk_size ({}) > max_chunk_size ({})",
501                    self.chunking.avg_chunk_size, self.chunking.max_chunk_size
502                ),
503            });
504        }
505        if self.defaults.metadata_quality < 1 || self.defaults.metadata_quality > 11 {
506            return Err(ConfigError::InvalidValue {
507                field: "defaults.metadata_quality".into(),
508                reason: format!(
509                    "metadata_quality ({}) out of range 1..=11",
510                    self.defaults.metadata_quality
511                ),
512            });
513        }
514        let ceiling = limnifs_core::metadata_reference::DEFAULT_INLINE_METADATA_MAX_BYTES as usize;
515        if self.defaults.metadata_externalize_threshold == 0
516            || self.defaults.metadata_externalize_threshold > ceiling
517        {
518            return Err(ConfigError::InvalidValue {
519                field: "defaults.metadata_externalize_threshold".into(),
520                reason: format!(
521                    "metadata_externalize_threshold ({}) must be within 1..={ceiling}                      (the reader inline ceiling; larger inline metadata is unreadable)",
522                    self.defaults.metadata_externalize_threshold
523                ),
524            });
525        }
526        if self.tournament.short_circuit_threshold > 1000 {
527            return Err(ConfigError::InvalidValue {
528                field: "tournament.short_circuit_threshold".into(),
529                reason: format!(
530                    "short_circuit_threshold ({}) out of range 0..=1000 (per-mille)",
531                    self.tournament.short_circuit_threshold
532                ),
533            });
534        }
535        // Validate unique categorizer names.
536        let mut names_seen: BTreeMap<&str, ()> = BTreeMap::new();
537        for rule in &self.categorizers {
538            if !names_seen
539                .insert(rule.name.as_str(), ())
540                .map_or(true, |()| false)
541            {
542                return Err(ConfigError::DuplicateCategorizer(rule.name.clone()));
543            }
544        }
545        Ok(())
546    }
547
548    /// Build the codec registry to use for this config.
549    /// Maps codec names to numeric ids.
550    pub fn codec_registry(&self) -> Result<CodecRegistry, ConfigError> {
551        let mut registry = CodecRegistry::default();
552        registry.insert("store", 0x00);
553        registry.insert("lz4", 0x01);
554        registry.insert("lz4-hc", 0x13);
555        registry.insert("zstd", 0x02);
556        registry.insert("xz", 0x03);
557        registry.insert("brotli", 0x04);
558        registry.insert("deflate", 0x05);
559        registry.insert("snappy", 0x06);
560        registry.insert("flac", 0x07);
561        registry.insert("ricepp", 0x08);
562        registry.insert("fsst+brotli", 0x09);
563        registry.insert("shuffle+lz4", 0x0A);
564        registry.insert("zpaq", 0x0B);
565        registry.insert("ppmd", 0x0C);
566        registry.insert("glza", 0x0D);
567        registry.insert("shuffle+zstd", 0x0E);
568        registry.insert("bitshuffle+lz4", 0x0F);
569        registry.insert("bzip2", 0x10);
570        registry.insert("deflate64", 0x11);
571        registry.insert("libdeflate", 0x14);
572
573        if !registry.contains_name(&self.defaults.text_codec) {
574            return Err(ConfigError::UnknownCodec(self.defaults.text_codec.clone()));
575        }
576        if !registry.contains_name(&self.defaults.binary_codec) {
577            return Err(ConfigError::UnknownCodec(
578                self.defaults.binary_codec.clone(),
579            ));
580        }
581        if !registry.contains_name(&self.defaults.metadata_codec) {
582            return Err(ConfigError::UnknownCodec(
583                self.defaults.metadata_codec.clone(),
584            ));
585        }
586        for rule in &self.categorizers {
587            if !registry.contains_name(&rule.codec) {
588                return Err(ConfigError::UnknownCodec(rule.codec.clone()));
589            }
590        }
591        for codec in &self.tournament.codecs {
592            if !registry.contains_name(codec) {
593                return Err(ConfigError::UnknownCodec(codec.clone()));
594            }
595        }
596        Ok(registry)
597    }
598
599    /// Resolve the default text codec id.
600    /// # Errors
601    /// Returns [`ConfigError`] if the codec name is unknown.
602    pub fn text_codec_id(&self) -> Result<u8, ConfigError> {
603        let registry = self.codec_registry()?;
604        Ok(registry
605            .lookup_by_name(&self.defaults.text_codec)
606            .unwrap_or(0x04))
607    }
608
609    /// Resolve the default binary codec id.
610    /// # Errors
611    /// Returns [`ConfigError`] if the codec name is unknown.
612    pub fn binary_codec_id(&self) -> Result<u8, ConfigError> {
613        let registry = self.codec_registry()?;
614        Ok(registry
615            .lookup_by_name(&self.defaults.binary_codec)
616            .unwrap_or(0x01))
617    }
618
619    /// Build the codec-agnostic [`limnifs_core::codec::CodecTunables`]
620    /// view of this config's per-codec knobs. Used by the parallel
621    /// writer to honour PPMd order/budget, Brotli quality, ZSTD
622    /// level, Bzip2 block size — anything else falls back to codec
623    /// defaults.
624    #[must_use]
625    pub fn to_core_tunables(&self) -> limnifs_core::codec::CodecTunables {
626        limnifs_core::codec::CodecTunables {
627            quality: self.codec_tunables.brotli.quality,
628            ppmd_order: self
629                .codec_tunables
630                .ppmd7
631                .order
632                .max(self.codec_tunables.ppmd8.order),
633            ppmd7_budget: (self.codec_tunables.ppmd7.memory_budget_mb as usize)
634                .saturating_mul(1024 * 1024),
635            ppmd8_budget: (self.codec_tunables.ppmd8.memory_budget_mb as usize)
636                .saturating_mul(1024 * 1024),
637            bzip2_block_kb: self.codec_tunables.bzip2.block_size_kb,
638            lzma_dict_mb: self.codec_tunables.lzma.dict_size_mb,
639        }
640    }
641
642    /// Resolve the default metadata codec id.
643    /// # Errors
644    /// Returns [`ConfigError`] if the codec name is unknown.
645    pub fn metadata_codec_id(&self) -> Result<u8, ConfigError> {
646        let registry = self.codec_registry()?;
647        Ok(registry
648            .lookup_by_name(&self.defaults.metadata_codec)
649            .unwrap_or(0x04))
650    }
651}
652
653/// Bidirectional map between codec names and numeric ids.
654#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
655pub struct CodecRegistry {
656    by_name: BTreeMap<String, u8>,
657    by_id: BTreeMap<u8, String>,
658}
659
660impl CodecRegistry {
661    /// Insert a new (name, id) mapping.
662    pub fn insert(&mut self, name: &str, id: u8) {
663        self.by_name.insert(name.to_string(), id);
664        self.by_id.insert(id, name.to_string());
665    }
666
667    /// Look up a codec id by name.
668    #[must_use]
669    pub fn lookup_by_name(&self, name: &str) -> Option<u8> {
670        self.by_name.get(name).copied()
671    }
672
673    /// Look up a codec name by id.
674    #[must_use]
675    pub fn lookup_by_id(&self, id: u8) -> Option<&str> {
676        self.by_id.get(&id).map(String::as_str)
677    }
678
679    /// Returns true if the name is registered.
680    #[must_use]
681    pub fn contains_name(&self, name: &str) -> bool {
682        self.by_name.contains_key(name)
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689
690    #[test]
691    fn default_v0_1_validates() {
692        let config = WriteConfig::default_v0_1();
693        config.validate().expect("v0.1 default should validate");
694    }
695
696    #[test]
697    fn default_v0_1_codec_ids() {
698        let config = WriteConfig::default_v0_1();
699        assert_eq!(config.text_codec_id().unwrap(), 0x04); // brotli
700        assert_eq!(config.binary_codec_id().unwrap(), 0x01); // lz4
701        assert_eq!(config.metadata_codec_id().unwrap(), 0x04); // brotli
702    }
703
704    #[test]
705    fn rejects_invalid_chunking() {
706        let mut config = WriteConfig::default_v0_1();
707        config.chunking.min_chunk_size = 16_000;
708        config.chunking.avg_chunk_size = 8_000;
709        assert!(config.validate().is_err());
710    }
711
712    #[test]
713    fn rejects_invalid_quality() {
714        let mut config = WriteConfig::default_v0_1();
715        config.defaults.metadata_quality = 12;
716        assert!(config.validate().is_err());
717    }
718
719    #[test]
720    fn rejects_duplicate_categorizer_names() {
721        let mut config = WriteConfig::default_v0_1();
722        config.categorizers.push(CategorizerConfig {
723            name: "dna".into(),
724            extensions: vec!["fasta".into()],
725            magic_bytes: vec![],
726            codec: "glza".into(),
727            max_size: None,
728            enabled: true,
729        });
730        config.categorizers.push(CategorizerConfig {
731            name: "dna".into(),
732            extensions: vec!["fa".into()],
733            magic_bytes: vec![],
734            codec: "glza".into(),
735            max_size: None,
736            enabled: true,
737        });
738        assert!(config.validate().is_err());
739    }
740
741    #[test]
742    fn rejects_unknown_codec() {
743        let mut config = WriteConfig::default_v0_1();
744        config.defaults.text_codec = "does-not-exist".into();
745        assert!(config.codec_registry().is_err());
746    }
747}