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