Skip to main content

mold_core/
config.rs

1use anyhow::Context;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7use crate::expand::ExpandSettings;
8use crate::manifest::resolve_model_name;
9use crate::types::Scheduler;
10
11static RUNTIME_MODELS_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
12
13/// Banner comment written at the top of a `save_bootstrap_only` output so
14/// readers understand why the usual user-preference fields are missing.
15const BOOTSTRAP_ONLY_BANNER: &str = "\
16# mold config — bootstrap-only surface.
17#
18# User preferences (expand.*, scheduler.*, generate.default_*, per-model generation
19# defaults, LoRA, and generation scheduler) live in SQLite at <MOLD_HOME>/mold.db.
20# Edit them via:
21#   mold config set <key> <value>
22#   mold config get <key>
23#   mold config reset <key>     # drop the DB row, fall back to this file
24#
25# This file retains only identifiers, paths, ports, credentials, logging,
26# and per-model file-path entries. Adding generation defaults here is
27# silently overridden by the DB on load.
28
29";
30
31/// Global + per-model keys moved to the DB by issue #265. Stripped from
32/// the TOML value tree by [`Config::save_bootstrap_only_to`].
33const STRIPPED_GLOBAL_KEYS: &[&str] = &[
34    "default_width",
35    "default_height",
36    "default_steps",
37    "embed_metadata",
38    "default_negative_prompt",
39    "t5_variant",
40    "umt5_variant",
41    "qwen3_variant",
42    "expand",
43    "scheduler",
44];
45
46const STRIPPED_MODEL_KEYS: &[&str] = &[
47    "default_steps",
48    "default_guidance",
49    "default_width",
50    "default_height",
51    "scheduler",
52    "negative_prompt",
53    "lora",
54    "lora_scale",
55    "default_frames",
56    "default_fps",
57];
58
59fn strip_user_pref_fields(doc: &mut toml::Value) {
60    let Some(table) = doc.as_table_mut() else {
61        return;
62    };
63    for key in STRIPPED_GLOBAL_KEYS {
64        table.remove(*key);
65    }
66    if let Some(toml::Value::Table(models)) = table.get_mut("models") {
67        for (_, mc) in models.iter_mut() {
68            if let Some(mc_table) = mc.as_table_mut() {
69                for key in STRIPPED_MODEL_KEYS {
70                    mc_table.remove(*key);
71                }
72            }
73        }
74    }
75}
76
77/// Hook installed by callers (typically `mold-cli` at startup) that wants
78/// to overlay DB-backed user preferences onto every freshly-loaded
79/// `Config`. `mold-core` itself must not depend on `mold-db`, so the hook
80/// is just an opaque function pointer registered once per process.
81///
82/// Runs after TOML parsing + legacy v0→v1 migration and before the
83/// returned value is handed to the caller. Errors should be swallowed
84/// inside the hook (the DB layer logs); failing here must never break
85/// `load_or_default()`.
86pub type ConfigPostLoadHook = fn(&mut Config);
87static POST_LOAD_HOOK: OnceLock<ConfigPostLoadHook> = OnceLock::new();
88
89/// Register a post-load hook. First caller wins; subsequent calls are a
90/// no-op so tests can't clobber each other.
91pub fn install_post_load_hook(hook: ConfigPostLoadHook) {
92    let _ = POST_LOAD_HOOK.set(hook);
93}
94
95/// Hook for [`Config::read_last_model`] that lets the DB layer provide
96/// the value without `mold-core` depending on `mold-db`. When installed,
97/// the hook's return value takes precedence over the legacy sidecar file.
98pub type ReadLastModelHook = fn() -> Option<String>;
99static READ_LAST_MODEL_HOOK: OnceLock<ReadLastModelHook> = OnceLock::new();
100
101/// Register a read-last-model hook. First caller wins.
102pub fn install_read_last_model_hook(hook: ReadLastModelHook) {
103    let _ = READ_LAST_MODEL_HOOK.set(hook);
104}
105
106/// Which fallback step resolved the default model.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum DefaultModelSource {
109    /// `MOLD_DEFAULT_MODEL` environment variable
110    EnvVar,
111    /// Config file `default_model` with a custom `[models]` entry
112    ConfigCustomEntry,
113    /// Config file `default_model` (manifest model, downloaded)
114    Config,
115    /// Last-used model from `$MOLD_HOME/last-model`
116    LastUsed,
117    /// Only one model is downloaded — auto-selected
118    OnlyDownloaded,
119    /// Config file default (model not downloaded, will auto-pull)
120    ConfigDefault,
121}
122
123/// Result of resolving the default model: the model name and how it was resolved.
124#[derive(Debug, Clone)]
125pub struct DefaultModelResolution {
126    pub model: String,
127    pub source: DefaultModelSource,
128}
129
130/// Per-model file path + default settings configuration.
131#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
132pub struct ModelConfig {
133    // --- paths ---
134    pub transformer: Option<String>,
135    /// Multi-shard transformer paths (Z-Image BF16); empty means use single `transformer`
136    pub transformer_shards: Option<Vec<String>>,
137    /// Low-noise expert of a two-expert checkpoint pair (Wan 2.2 A14B); the
138    /// high-noise expert is `transformer`.
139    pub low_noise_transformer: Option<String>,
140    pub vae: Option<String>,
141    /// LTX latent upsampler / spatial upscaler weights.
142    pub spatial_upscaler: Option<String>,
143    /// Optional temporal upscaler weights for LTX-2/LTX-2.3.
144    pub temporal_upscaler: Option<String>,
145    /// Optional distilled LoRA bundled with a model manifest.
146    pub distilled_lora: Option<String>,
147    /// Distilled LoRA for `low_noise_transformer`.
148    pub low_noise_distilled_lora: Option<String>,
149    pub t5_encoder: Option<String>,
150    pub clip_encoder: Option<String>,
151    pub t5_tokenizer: Option<String>,
152    pub clip_tokenizer: Option<String>,
153    /// CLIP-G / OpenCLIP encoder path (SDXL only)
154    pub clip_encoder_2: Option<String>,
155    /// CLIP-G / OpenCLIP tokenizer path (SDXL only)
156    pub clip_tokenizer_2: Option<String>,
157    /// Generic text encoder shard paths (Qwen3 for Z-Image)
158    pub text_encoder_files: Option<Vec<String>>,
159    /// Generic text encoder tokenizer path (Qwen3 for Z-Image)
160    pub text_tokenizer: Option<String>,
161    /// Stage B decoder weights path (Wuerstchen only)
162    pub decoder: Option<String>,
163
164    // --- generation defaults ---
165    /// Default inference steps (e.g. 4 for schnell, 25 for dev)
166    pub default_steps: Option<u32>,
167    /// Default guidance scale (0.0 for schnell, 3.5 for dev finetuned)
168    pub default_guidance: Option<f64>,
169    /// Default output width
170    pub default_width: Option<u32>,
171    /// Default output height
172    pub default_height: Option<u32>,
173    /// Whether this model uses the schnell (distilled) timestep schedule.
174    /// If None, auto-detected from the transformer filename.
175    pub is_schnell: Option<bool>,
176    /// Whether this model uses a turbo (few-step distilled) schedule.
177    /// If None, auto-detected from the model name.
178    pub is_turbo: Option<bool>,
179    /// Scheduler algorithm for UNet-based models (SD1.5, SDXL). Ignored by flow-matching models.
180    pub scheduler: Option<Scheduler>,
181    /// Per-model default negative prompt for CFG-based models.
182    pub negative_prompt: Option<String>,
183    /// Default LoRA adapter path for this model.
184    pub lora: Option<String>,
185    /// Default LoRA scale for this model (0.0-2.0).
186    pub lora_scale: Option<f64>,
187    /// Default number of video frames for video models (e.g. 25 for ltx-video).
188    pub default_frames: Option<u32>,
189    /// Default video FPS for video models (e.g. 24 for ltx-video).
190    pub default_fps: Option<u32>,
191
192    // --- metadata ---
193    pub description: Option<String>,
194    pub family: Option<String>,
195
196    /// Per-component device placement override. `None` preserves the
197    /// engine's VRAM-aware auto-placement.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub placement: Option<crate::types::DevicePlacement>,
200}
201
202impl ModelConfig {
203    /// Collect all file path strings from this model config into a flat list.
204    /// Used for reference counting when determining which files are shared.
205    pub fn all_file_paths(&self) -> Vec<String> {
206        let mut paths = Vec::new();
207        let singles = [
208            &self.transformer,
209            &self.low_noise_transformer,
210            &self.vae,
211            &self.spatial_upscaler,
212            &self.temporal_upscaler,
213            &self.distilled_lora,
214            &self.low_noise_distilled_lora,
215            &self.t5_encoder,
216            &self.clip_encoder,
217            &self.t5_tokenizer,
218            &self.clip_tokenizer,
219            &self.clip_encoder_2,
220            &self.clip_tokenizer_2,
221            &self.text_tokenizer,
222            &self.decoder,
223            &self.lora,
224        ];
225        for p in singles.into_iter().flatten() {
226            paths.push(p.clone());
227        }
228        if let Some(ref shards) = self.transformer_shards {
229            paths.extend(shards.iter().cloned());
230        }
231        if let Some(ref files) = self.text_encoder_files {
232            paths.extend(files.iter().cloned());
233        }
234        paths
235    }
236
237    /// Total disk usage of all model files: `(bytes, gigabytes)`.
238    ///
239    /// Sums the file sizes of all paths referenced by this config entry.
240    /// Missing files are silently skipped.
241    pub fn disk_usage(&self) -> (u64, f64) {
242        let total: u64 = self
243            .all_file_paths()
244            .iter()
245            .filter_map(|p| std::fs::metadata(p).ok())
246            .map(|m| m.len())
247            .sum();
248        (total, total as f64 / 1_073_741_824.0)
249    }
250
251    /// Effective steps: model default → global fallback → hardcoded default.
252    pub fn effective_steps(&self, global_cfg: &Config) -> u32 {
253        self.default_steps.unwrap_or(global_cfg.default_steps)
254    }
255
256    /// Effective guidance.
257    pub fn effective_guidance(&self) -> f64 {
258        self.default_guidance.unwrap_or(3.5)
259    }
260
261    /// Effective width.
262    pub fn effective_width(&self, global_cfg: &Config) -> u32 {
263        self.default_width.unwrap_or(global_cfg.default_width)
264    }
265
266    /// Effective height.
267    pub fn effective_height(&self, global_cfg: &Config) -> u32 {
268        self.default_height.unwrap_or(global_cfg.default_height)
269    }
270
271    /// Effective negative prompt: per-model override → global default → None.
272    pub fn effective_negative_prompt(&self, global_cfg: &Config) -> Option<String> {
273        self.negative_prompt
274            .clone()
275            .or_else(|| global_cfg.default_negative_prompt.clone())
276    }
277
278    /// Effective LoRA config: per-model default path and scale, or None.
279    pub fn effective_lora(&self) -> Option<(String, f64)> {
280        self.lora
281            .as_ref()
282            .map(|path| (path.clone(), self.lora_scale.unwrap_or(1.0)))
283    }
284
285    /// Effective video frames: per-model default, or None for image-only models.
286    pub fn effective_frames(&self) -> Option<u32> {
287        self.default_frames
288    }
289
290    /// Effective video FPS: per-model default, or None for image-only models.
291    pub fn effective_fps(&self) -> Option<u32> {
292        self.default_fps
293    }
294}
295
296/// Resolved model file paths.
297/// For diffusion models, `transformer` and `vae` are always required.
298/// For upscaler models, only `transformer` (weights) is required; `vae` is empty.
299/// For utility models, only `transformer` is required; `vae` may be empty.
300/// Other paths are optional — each engine validates what it needs at load time.
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct ModelPaths {
303    pub transformer: PathBuf,
304    /// Multi-shard transformer paths (Z-Image BF16); empty means use single `transformer`
305    pub transformer_shards: Vec<PathBuf>,
306    /// The low-noise expert of a two-expert checkpoint pair (Wan 2.2 A14B).
307    ///
308    /// `Some` is the two-expert predicate. The high-noise expert lives in
309    /// `transformer` because it runs first — the sampler starts at high sigma —
310    /// so every consumer that only understands one transformer keeps working
311    /// against the one that opens the generation.
312    pub low_noise_transformer: Option<PathBuf>,
313    pub vae: PathBuf,
314    pub spatial_upscaler: Option<PathBuf>,
315    pub temporal_upscaler: Option<PathBuf>,
316    pub distilled_lora: Option<PathBuf>,
317    /// The distilled adapter for `low_noise_transformer`. Each expert of an
318    /// A14B pair is distilled separately; swapping them is not a degradation,
319    /// it is the wrong model.
320    pub low_noise_distilled_lora: Option<PathBuf>,
321    pub t5_encoder: Option<PathBuf>,
322    pub clip_encoder: Option<PathBuf>,
323    pub t5_tokenizer: Option<PathBuf>,
324    pub clip_tokenizer: Option<PathBuf>,
325    /// CLIP-G / OpenCLIP encoder (SDXL only)
326    pub clip_encoder_2: Option<PathBuf>,
327    /// CLIP-G / OpenCLIP tokenizer (SDXL only)
328    pub clip_tokenizer_2: Option<PathBuf>,
329    /// Generic text encoder shard paths (Qwen3 for Z-Image)
330    pub text_encoder_files: Vec<PathBuf>,
331    /// Generic text encoder tokenizer (Qwen3 for Z-Image)
332    pub text_tokenizer: Option<PathBuf>,
333    /// Stage B decoder weights (Wuerstchen only)
334    pub decoder: Option<PathBuf>,
335}
336
337impl ModelPaths {
338    const FROZEN_MODEL_PREFIX: &'static str = "\0mold-frozen-chain:";
339
340    /// Every resolved local artifact that can participate in engine creation.
341    /// Policy fences consume this before inspecting or loading any artifact.
342    pub fn all_file_paths(&self) -> Vec<&Path> {
343        let mut paths = vec![self.transformer.as_path(), self.vae.as_path()];
344        paths.extend(self.transformer_shards.iter().map(PathBuf::as_path));
345        paths.extend(self.low_noise_transformer.as_deref());
346        paths.extend(self.spatial_upscaler.as_deref());
347        paths.extend(self.temporal_upscaler.as_deref());
348        paths.extend(self.distilled_lora.as_deref());
349        paths.extend(self.low_noise_distilled_lora.as_deref());
350        paths.extend(self.t5_encoder.as_deref());
351        paths.extend(self.clip_encoder.as_deref());
352        paths.extend(self.t5_tokenizer.as_deref());
353        paths.extend(self.clip_tokenizer.as_deref());
354        paths.extend(self.clip_encoder_2.as_deref());
355        paths.extend(self.clip_tokenizer_2.as_deref());
356        paths.extend(self.text_encoder_files.iter().map(PathBuf::as_path));
357        paths.extend(self.text_tokenizer.as_deref());
358        paths.extend(self.decoder.as_deref());
359        paths
360    }
361
362    /// Resolve paths for a model. Checks config, then env vars.
363    /// Returns None if transformer and VAE paths can't be resolved.
364    /// All other paths are optional (depend on model family).
365    pub fn resolve(model_name: &str, config: &Config) -> Option<Self> {
366        if let Some(model_cfg) = config
367            .models
368            .get(&format!("{}{model_name}", Self::FROZEN_MODEL_PREFIX))
369        {
370            return Self::resolve_from_model_config_exact(model_cfg);
371        }
372        if let Some(model_cfg) = config.discovered_manifest_model_config(model_name) {
373            return Self::resolve_from_model_config(Some(&model_cfg));
374        }
375
376        if crate::manifest::find_manifest(model_name).is_some() && config.has_models_dir_override()
377        {
378            return Self::resolve_from_model_config(None);
379        }
380
381        let model_cfg = config.lookup_model_config(model_name);
382        Self::resolve_from_model_config(model_cfg.as_ref())
383    }
384
385    /// Resolve only the paths captured in `model_cfg`.
386    ///
387    /// This intentionally does not consult `MOLD_*_PATH` environment variables,
388    /// manifests, sidecars, or model discovery. Durable chain jobs use it after
389    /// admission so a restart cannot silently substitute different artifacts.
390    pub fn resolve_from_model_config_exact(model_cfg: &ModelConfig) -> Option<Self> {
391        let path = |value: Option<&str>| value.map(PathBuf::from);
392        let vae = match model_cfg.vae.as_deref().filter(|path| !path.is_empty()) {
393            Some(path) => PathBuf::from(path),
394            None if model_cfg.family.as_deref().is_some_and(|family| {
395                family == "ltx2"
396                    || crate::manifest::UTILITY_FAMILIES.contains(&family)
397                    || crate::manifest::UPSCALER_FAMILIES.contains(&family)
398            }) =>
399            {
400                PathBuf::new()
401            }
402            None => return None,
403        };
404        Some(Self {
405            transformer: PathBuf::from(model_cfg.transformer.as_deref()?),
406            transformer_shards: model_cfg
407                .transformer_shards
408                .as_ref()
409                .map(|paths| paths.iter().map(PathBuf::from).collect())
410                .unwrap_or_default(),
411            low_noise_transformer: path(model_cfg.low_noise_transformer.as_deref()),
412            vae,
413            spatial_upscaler: path(model_cfg.spatial_upscaler.as_deref()),
414            temporal_upscaler: path(model_cfg.temporal_upscaler.as_deref()),
415            distilled_lora: path(model_cfg.distilled_lora.as_deref()),
416            low_noise_distilled_lora: path(model_cfg.low_noise_distilled_lora.as_deref()),
417            t5_encoder: path(model_cfg.t5_encoder.as_deref()),
418            clip_encoder: path(model_cfg.clip_encoder.as_deref()),
419            t5_tokenizer: path(model_cfg.t5_tokenizer.as_deref()),
420            clip_tokenizer: path(model_cfg.clip_tokenizer.as_deref()),
421            clip_encoder_2: path(model_cfg.clip_encoder_2.as_deref()),
422            clip_tokenizer_2: path(model_cfg.clip_tokenizer_2.as_deref()),
423            text_encoder_files: model_cfg
424                .text_encoder_files
425                .as_ref()
426                .map(|paths| paths.iter().map(PathBuf::from).collect())
427                .unwrap_or_default(),
428            text_tokenizer: path(model_cfg.text_tokenizer.as_deref()),
429            decoder: path(model_cfg.decoder.as_deref()),
430        })
431    }
432
433    fn resolve_from_model_config(model_cfg: Option<&ModelConfig>) -> Option<Self> {
434        let transformer = Self::resolve_path(
435            model_cfg.and_then(|m| m.transformer.as_deref()),
436            "MOLD_TRANSFORMER_PATH",
437        )?;
438        let transformer_shards = model_cfg
439            .and_then(|m| m.transformer_shards.as_ref())
440            .map(|shards| shards.iter().map(PathBuf::from).collect())
441            .unwrap_or_default();
442        let low_noise_transformer = Self::resolve_path(
443            model_cfg.and_then(|m| m.low_noise_transformer.as_deref()),
444            "MOLD_LOW_NOISE_TRANSFORMER_PATH",
445        );
446        let vae = Self::resolve_path(model_cfg.and_then(|m| m.vae.as_deref()), "MOLD_VAE_PATH")?;
447        let spatial_upscaler = Self::resolve_path(
448            model_cfg.and_then(|m| m.spatial_upscaler.as_deref()),
449            "MOLD_SPATIAL_UPSCALER_PATH",
450        );
451        let temporal_upscaler = Self::resolve_path(
452            model_cfg.and_then(|m| m.temporal_upscaler.as_deref()),
453            "MOLD_TEMPORAL_UPSCALER_PATH",
454        );
455        let distilled_lora = Self::resolve_path(
456            model_cfg.and_then(|m| m.distilled_lora.as_deref()),
457            "MOLD_DISTILLED_LORA_PATH",
458        );
459        let low_noise_distilled_lora = Self::resolve_path(
460            model_cfg.and_then(|m| m.low_noise_distilled_lora.as_deref()),
461            "MOLD_LOW_NOISE_DISTILLED_LORA_PATH",
462        );
463        let t5_encoder = Self::resolve_path(
464            model_cfg.and_then(|m| m.t5_encoder.as_deref()),
465            "MOLD_T5_PATH",
466        );
467        let clip_encoder = Self::resolve_path(
468            model_cfg.and_then(|m| m.clip_encoder.as_deref()),
469            "MOLD_CLIP_PATH",
470        );
471        let t5_tokenizer = Self::resolve_path(
472            model_cfg.and_then(|m| m.t5_tokenizer.as_deref()),
473            "MOLD_T5_TOKENIZER_PATH",
474        );
475        let clip_tokenizer = Self::resolve_path(
476            model_cfg.and_then(|m| m.clip_tokenizer.as_deref()),
477            "MOLD_CLIP_TOKENIZER_PATH",
478        );
479        let clip_encoder_2 = Self::resolve_path(
480            model_cfg.and_then(|m| m.clip_encoder_2.as_deref()),
481            "MOLD_CLIP2_PATH",
482        );
483        let clip_tokenizer_2 = Self::resolve_path(
484            model_cfg.and_then(|m| m.clip_tokenizer_2.as_deref()),
485            "MOLD_CLIP2_TOKENIZER_PATH",
486        );
487        let text_encoder_files = model_cfg
488            .and_then(|m| m.text_encoder_files.as_ref())
489            .map(|files| files.iter().map(PathBuf::from).collect())
490            .unwrap_or_default();
491        let text_tokenizer = Self::resolve_path(
492            model_cfg.and_then(|m| m.text_tokenizer.as_deref()),
493            "MOLD_TEXT_TOKENIZER_PATH",
494        );
495        let decoder = Self::resolve_path(
496            model_cfg.and_then(|m| m.decoder.as_deref()),
497            "MOLD_DECODER_PATH",
498        );
499
500        Some(Self {
501            transformer,
502            transformer_shards,
503            low_noise_transformer,
504            vae,
505            spatial_upscaler,
506            temporal_upscaler,
507            distilled_lora,
508            low_noise_distilled_lora,
509            t5_encoder,
510            clip_encoder,
511            t5_tokenizer,
512            clip_tokenizer,
513            clip_encoder_2,
514            clip_tokenizer_2,
515            text_encoder_files,
516            text_tokenizer,
517            decoder,
518        })
519    }
520
521    fn resolve_path(config_val: Option<&str>, env_var: &str) -> Option<PathBuf> {
522        if let Ok(path) = std::env::var(env_var) {
523            return Some(PathBuf::from(path));
524        }
525        if let Some(path) = config_val {
526            return Some(PathBuf::from(path));
527        }
528        None
529    }
530}
531
532/// Current config schema version. Increment when adding migrations.
533const CURRENT_CONFIG_VERSION: u32 = 1;
534
535#[derive(Debug, Clone, Deserialize, Serialize)]
536pub struct Config {
537    /// Config schema version for migrations. Old configs without this field
538    /// default to 0 and are migrated on first load.
539    #[serde(default)]
540    pub config_version: u32,
541
542    #[serde(default = "default_model")]
543    pub default_model: String,
544
545    #[serde(default = "default_models_dir")]
546    pub models_dir: String,
547
548    #[serde(default = "default_port")]
549    pub server_port: u16,
550
551    #[serde(default = "default_dimension")]
552    pub default_width: u32,
553
554    #[serde(default = "default_dimension")]
555    pub default_height: u32,
556
557    #[serde(default = "default_steps")]
558    pub default_steps: u32,
559
560    #[serde(default = "default_embed_metadata")]
561    pub embed_metadata: bool,
562
563    /// Preferred T5 encoder variant: "fp16" (default), "q8", "q6", "q5", "q4", "q3", or "auto".
564    /// "auto" selects the best variant that fits in GPU VRAM.
565    /// An explicit quantized tag always uses that variant regardless of VRAM.
566    #[serde(default)]
567    pub t5_variant: Option<String>,
568    /// Quantized UMT5 encoder variant for Wan (`q8`, `q6`, `q5`, `fp16`,
569    /// `auto`). Wan's encoder is 11.4 GB at FP16 and is the floor of every
570    /// wan render's memory estimate.
571    pub umt5_variant: Option<String>,
572
573    /// Preferred Qwen3 text encoder variant: "bf16" (default), "q8", "q6", "iq4", "q3", or "auto".
574    /// "auto" selects the best variant that fits in GPU VRAM (with drop-and-reload).
575    #[serde(default)]
576    pub qwen3_variant: Option<String>,
577
578    /// Directory to persist generated images. Default: `~/.mold/output/`.
579    /// Override with `MOLD_OUTPUT_DIR` env var. Set to empty string to disable
580    /// (TUI gallery will not function when disabled).
581    #[serde(default)]
582    pub output_dir: Option<String>,
583
584    /// Allow roots for trusted server-local media request paths.
585    /// Override with `MOLD_MEDIA_ROOTS` using the platform path-list separator.
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub media_roots: Option<Vec<String>>,
588
589    /// Global default negative prompt for CFG-based models (SD1.5, SDXL, SD3, Wuerstchen).
590    /// Overridden by per-model `negative_prompt` or CLI `--negative-prompt`.
591    #[serde(default)]
592    pub default_negative_prompt: Option<String>,
593
594    /// Prompt expansion settings.
595    #[serde(default)]
596    pub expand: ExpandSettings,
597
598    /// Profile-scoped scheduler behavior. Persisted in `mold.db`; the
599    /// serialized field exists only for one-shot import of older/manual TOML.
600    #[serde(default)]
601    pub scheduler: SchedulerSettings,
602
603    /// Logging configuration.
604    #[serde(default)]
605    pub logging: LoggingConfig,
606
607    /// RunPod integration settings (api key, defaults, auto-teardown behaviour).
608    #[serde(default)]
609    pub runpod: crate::runpod::RunPodSettings,
610
611    /// Lambda Cloud integration settings.
612    #[serde(default)]
613    pub lambda: crate::lambda::LambdaSettings,
614
615    /// GPUs to use at startup (None = all visible).
616    ///
617    /// Accepts legacy ordinal arrays, stable/NVIDIA UUID string arrays, and
618    /// explicit `"all"` / `"none"` keywords.
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub gpus: Option<crate::types::GpuSelection>,
621
622    /// Max queued requests before 503 (default: 200).
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub queue_size: Option<usize>,
625
626    /// Per-model configurations, keyed by model name.
627    #[serde(default)]
628    pub models: HashMap<String, ModelConfig>,
629}
630
631/// Logging configuration for file output and rotation.
632#[derive(Debug, Clone, Deserialize, Serialize)]
633pub struct LoggingConfig {
634    /// Log level: trace, debug, info, warn, error. Overridden by MOLD_LOG env var.
635    #[serde(default = "default_log_level")]
636    pub level: String,
637
638    /// Enable file logging. When true, logs go to ~/.mold/logs/.
639    #[serde(default)]
640    pub file: bool,
641
642    /// Custom log file directory (default: ~/.mold/logs/).
643    #[serde(default)]
644    pub dir: Option<String>,
645
646    /// Number of days to retain log files. Default: 7.
647    #[serde(default = "default_log_max_days")]
648    pub max_days: u32,
649}
650
651pub const SCHEDULER_TIMING_MAX_MS: u32 = 30_000;
652
653#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
654pub struct SchedulerSettings {
655    #[serde(default = "default_replan_debounce_ms")]
656    pub replan_debounce_ms: u32,
657    #[serde(default = "default_replan_max_delay_ms")]
658    pub replan_max_delay_ms: u32,
659    #[serde(default = "default_warm_wait_max_ms")]
660    pub warm_wait_max_ms: u32,
661}
662
663impl<'de> Deserialize<'de> for SchedulerSettings {
664    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
665    where
666        D: serde::Deserializer<'de>,
667    {
668        #[derive(Deserialize)]
669        struct Wire {
670            #[serde(default = "default_replan_debounce_ms")]
671            replan_debounce_ms: u32,
672            #[serde(default = "default_replan_max_delay_ms")]
673            replan_max_delay_ms: u32,
674            #[serde(default = "default_warm_wait_max_ms")]
675            warm_wait_max_ms: u32,
676        }
677
678        let wire = Wire::deserialize(deserializer)?;
679        SchedulerSettings {
680            replan_debounce_ms: wire.replan_debounce_ms,
681            replan_max_delay_ms: wire.replan_max_delay_ms,
682            warm_wait_max_ms: wire.warm_wait_max_ms,
683        }
684        .validate()
685        .map_err(serde::de::Error::custom)
686    }
687}
688
689const fn default_replan_debounce_ms() -> u32 {
690    2_000
691}
692
693const fn default_replan_max_delay_ms() -> u32 {
694    5_000
695}
696
697const fn default_warm_wait_max_ms() -> u32 {
698    2_000
699}
700
701impl SchedulerSettings {
702    pub fn validate(self) -> anyhow::Result<Self> {
703        for (key, value) in [
704            ("scheduler.replan_debounce_ms", self.replan_debounce_ms),
705            ("scheduler.replan_max_delay_ms", self.replan_max_delay_ms),
706            ("scheduler.warm_wait_max_ms", self.warm_wait_max_ms),
707        ] {
708            anyhow::ensure!(
709                value <= SCHEDULER_TIMING_MAX_MS,
710                "{key} must be between 0 and {SCHEDULER_TIMING_MAX_MS}"
711            );
712        }
713        anyhow::ensure!(
714            self.replan_max_delay_ms >= self.replan_debounce_ms,
715            "scheduler.replan_max_delay_ms must be greater than or equal to \
716             scheduler.replan_debounce_ms"
717        );
718        Ok(self)
719    }
720}
721
722impl Default for SchedulerSettings {
723    fn default() -> Self {
724        Self {
725            replan_debounce_ms: default_replan_debounce_ms(),
726            replan_max_delay_ms: default_replan_max_delay_ms(),
727            warm_wait_max_ms: default_warm_wait_max_ms(),
728        }
729    }
730}
731
732fn default_log_level() -> String {
733    "info".to_string()
734}
735fn default_log_max_days() -> u32 {
736    7
737}
738
739impl Default for LoggingConfig {
740    fn default() -> Self {
741        Self {
742            level: default_log_level(),
743            file: false,
744            dir: None,
745            max_days: default_log_max_days(),
746        }
747    }
748}
749
750fn default_model() -> String {
751    "flux2-klein:q8".to_string()
752}
753
754fn default_models_dir() -> String {
755    if let Ok(home) = std::env::var("MOLD_HOME") {
756        format!("{home}/models")
757    } else if let Some(home) = Config::saved_mold_dir() {
758        home.join("models").to_string_lossy().into_owned()
759    } else {
760        "~/.mold/models".to_string()
761    }
762}
763
764fn default_port() -> u16 {
765    7680
766}
767
768fn default_dimension() -> u32 {
769    768
770}
771
772fn default_steps() -> u32 {
773    4
774}
775
776fn default_embed_metadata() -> bool {
777    true
778}
779
780impl Default for Config {
781    fn default() -> Self {
782        Self {
783            config_version: CURRENT_CONFIG_VERSION,
784            default_model: default_model(),
785            models_dir: default_models_dir(),
786            server_port: default_port(),
787            default_width: default_dimension(),
788            default_height: default_dimension(),
789            default_steps: default_steps(),
790            embed_metadata: default_embed_metadata(),
791            t5_variant: None,
792            umt5_variant: None,
793            qwen3_variant: None,
794            output_dir: None,
795            media_roots: None,
796            default_negative_prompt: None,
797            expand: ExpandSettings::default(),
798            scheduler: SchedulerSettings::default(),
799            logging: LoggingConfig::default(),
800            runpod: crate::runpod::RunPodSettings::default(),
801            lambda: crate::lambda::LambdaSettings::default(),
802            gpus: None,
803            queue_size: None,
804            models: HashMap::new(),
805        }
806    }
807}
808
809impl Config {
810    /// Install an immutable model snapshot for one in-memory execution.
811    ///
812    /// The private sentinel makes [`ModelPaths::resolve`] choose the exact
813    /// captured paths before any mutable manifest, sidecar, or environment
814    /// source. The ordinary model entry is also replaced so engine-shaping
815    /// defaults (family, LoRA, scheduler, dtype flags) remain frozen while the
816    /// semantic model name is preserved.
817    pub fn install_frozen_model_config(&mut self, model_name: &str, model: ModelConfig) {
818        self.models.insert(
819            format!("{}{model_name}", ModelPaths::FROZEN_MODEL_PREFIX),
820            model.clone(),
821        );
822        self.models.insert(model_name.to_string(), model);
823    }
824
825    pub fn has_frozen_model_config(&self, model_name: &str) -> bool {
826        self.models
827            .contains_key(&format!("{}{model_name}", ModelPaths::FROZEN_MODEL_PREFIX))
828    }
829
830    /// Build a `GpuSelection` from the config's `gpus` field.
831    pub fn gpu_selection(&self) -> crate::types::GpuSelection {
832        self.gpus.clone().unwrap_or_default()
833    }
834
835    /// Return the configured queue size or the default (200).
836    pub fn queue_size(&self) -> usize {
837        self.queue_size.unwrap_or(200)
838    }
839
840    pub fn install_runtime_models_dir_override(models_dir: PathBuf) {
841        let _ = RUNTIME_MODELS_DIR_OVERRIDE.get_or_init(|| models_dir);
842    }
843
844    pub fn load_or_default() -> Self {
845        let Some(config_path) = Self::config_path() else {
846            eprintln!("warning: could not determine home directory — using default config");
847            return Config::default();
848        };
849        let mut cfg = if config_path.exists() {
850            match std::fs::read_to_string(&config_path) {
851                Ok(contents) => match toml::from_str(&contents) {
852                    Ok(cfg) => cfg,
853                    Err(e) => {
854                        eprintln!(
855                            "warning: failed to parse config at {}: {e} — using defaults",
856                            config_path.display()
857                        );
858                        Config::default()
859                    }
860                },
861                Err(e) => {
862                    eprintln!(
863                        "warning: failed to read config at {}: {e} — using defaults",
864                        config_path.display()
865                    );
866                    Config::default()
867                }
868            }
869        } else {
870            Config::default()
871        };
872
873        // Run config migrations if needed
874        if cfg.config_version < CURRENT_CONFIG_VERSION {
875            Self::run_migrations(&mut cfg);
876            cfg.config_version = CURRENT_CONFIG_VERSION;
877            if let Err(e) = cfg.save() {
878                eprintln!("warning: failed to save migrated config: {e}");
879            }
880        }
881
882        // Post-load hook (DB-backed user-pref overlay, if installed).
883        if let Some(hook) = POST_LOAD_HOOK.get() {
884            hook(&mut cfg);
885        }
886
887        cfg
888    }
889
890    /// Run all pending config migrations from cfg.config_version to CURRENT.
891    pub(crate) fn run_migrations(cfg: &mut Config) {
892        if cfg.config_version < 1 {
893            Self::migrate_v0_to_v1(cfg);
894        }
895        // Future migrations:
896        // if cfg.config_version < 2 { Self::migrate_v1_to_v2(cfg); }
897    }
898
899    /// v0 → v1: Strip stale manifest defaults from known model entries.
900    ///
901    /// Old `mold pull` wrote all manifest defaults (steps, guidance, dimensions,
902    /// description, family, is_schnell, scheduler) into config.toml. These become
903    /// stale when manifests update. This migration removes them so
904    /// `resolved_model_config()` reads fresh values from the manifest at runtime.
905    fn migrate_v0_to_v1(cfg: &mut Config) {
906        let model_names: Vec<String> = cfg.models.keys().cloned().collect();
907        for name in model_names {
908            if crate::manifest::find_manifest(&name).is_some() {
909                if let Some(mc) = cfg.models.get_mut(&name) {
910                    mc.default_steps = None;
911                    mc.default_guidance = None;
912                    mc.default_width = None;
913                    mc.default_height = None;
914                    mc.is_schnell = None;
915                    mc.is_turbo = None;
916                    mc.scheduler = None;
917                    mc.negative_prompt = None;
918                    mc.default_frames = None;
919                    mc.default_fps = None;
920                    mc.description = None;
921                    mc.family = None;
922                }
923            }
924        }
925        eprintln!("config: migrated v0 → v1 (cleared stale manifest defaults)");
926    }
927
928    /// Reload config from disk while preserving runtime-only overrides.
929    pub fn reload_from_disk_preserving_runtime(&self) -> Self {
930        let mut fresh = Self::load_or_default();
931        fresh.models_dir = self.models_dir.clone();
932        fresh
933    }
934
935    /// The root Mold directory shared by every local surface.
936    /// Resolution: `MOLD_HOME` env var → saved bootstrap selection →
937    /// `~/.mold/` → `./.mold` (if HOME unset).
938    pub fn mold_dir() -> Option<PathBuf> {
939        if let Ok(home) = std::env::var("MOLD_HOME") {
940            return Some(PathBuf::from(home));
941        }
942        if let Some(home) = Self::saved_mold_dir() {
943            return Some(home);
944        }
945        Some(
946            dirs::home_dir()
947                .unwrap_or_else(|| PathBuf::from("."))
948                .join(".mold"),
949        )
950    }
951
952    /// Location of the tiny bootstrap pointer used to find a non-default
953    /// Mold home before config.toml or mold.db can be opened.
954    pub fn mold_home_pointer_path() -> Option<PathBuf> {
955        if let Some(path) = std::env::var_os("MOLD_HOME_POINTER_PATH") {
956            return Some(PathBuf::from(path));
957        }
958        dirs::config_dir().map(|dir| dir.join("mold").join("home"))
959    }
960
961    pub fn saved_mold_dir() -> Option<PathBuf> {
962        Self::read_saved_mold_dir().ok().flatten()
963    }
964
965    /// Read the bootstrap pointer without treating corruption like absence.
966    /// `NotFound` means the default root is still authoritative; every other
967    /// malformed/unreadable state must fail closed at process startup.
968    pub fn read_saved_mold_dir() -> std::io::Result<Option<PathBuf>> {
969        let Some(pointer) = Self::mold_home_pointer_path() else {
970            return Ok(None);
971        };
972        let raw = match std::fs::read_to_string(&pointer) {
973            Ok(raw) => raw,
974            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
975            Err(error) => return Err(error),
976        };
977        let value = raw.trim();
978        if value.is_empty() {
979            return Err(std::io::Error::new(
980                std::io::ErrorKind::InvalidData,
981                format!("{} is empty", pointer.display()),
982            ));
983        }
984        let path = PathBuf::from(value);
985        if !path.is_absolute() {
986            return Err(std::io::Error::new(
987                std::io::ErrorKind::InvalidData,
988                format!("{} does not contain an absolute path", pointer.display()),
989            ));
990        }
991        Ok(Some(path))
992    }
993
994    /// Refuse to initialize a missing saved root. A saved selection is only
995    /// written after Desktop has created or validated it, so disappearance
996    /// means an external drive is unavailable rather than a new root that a
997    /// CLI/server process should recreate. Explicit `MOLD_HOME` remains
998    /// allowed to name a directory that the invoking process intends to make.
999    pub fn ensure_saved_mold_dir_available() -> anyhow::Result<()> {
1000        if std::env::var_os("MOLD_HOME").is_some() {
1001            return Ok(());
1002        }
1003        let saved = Self::read_saved_mold_dir().with_context(|| {
1004            "the saved Mold home selection is unreadable or invalid; repair it in Mold Desktop Settings"
1005        })?;
1006        if let Some(path) = saved.filter(|path| !path.is_dir()) {
1007            anyhow::bail!(
1008                "the saved Mold home at {} is unavailable; reconnect its drive or change the location in Mold Desktop Settings",
1009                path.display()
1010            );
1011        }
1012        Ok(())
1013    }
1014
1015    /// Atomically save the shared local Mold-home selection. Environment
1016    /// `MOLD_HOME` remains the highest-precedence, process-scoped override.
1017    pub fn save_mold_dir(path: &Path) -> std::io::Result<()> {
1018        let pointer = Self::mold_home_pointer_path().ok_or_else(|| {
1019            std::io::Error::new(
1020                std::io::ErrorKind::NotFound,
1021                "could not resolve the Mold home bootstrap location",
1022            )
1023        })?;
1024        if let Some(parent) = pointer.parent() {
1025            std::fs::create_dir_all(parent)?;
1026        }
1027        let temp = pointer.with_extension("tmp");
1028        std::fs::write(&temp, path.to_string_lossy().as_bytes())?;
1029        match std::fs::rename(&temp, &pointer) {
1030            Ok(()) => Ok(()),
1031            Err(_error) if pointer.exists() => {
1032                // Windows does not replace an existing destination with
1033                // rename. The temp file is already complete, so use the
1034                // narrow non-atomic fallback required by that platform.
1035                std::fs::remove_file(&pointer)?;
1036                std::fs::rename(temp, pointer)
1037            }
1038            Err(error) => Err(error),
1039        }
1040    }
1041
1042    pub fn config_path() -> Option<PathBuf> {
1043        Self::mold_dir().map(|d| d.join("config.toml"))
1044    }
1045
1046    pub fn data_dir() -> Option<PathBuf> {
1047        Self::mold_dir()
1048    }
1049
1050    pub fn resolved_models_dir(&self) -> PathBuf {
1051        if let Some(models_dir) = RUNTIME_MODELS_DIR_OVERRIDE.get() {
1052            return models_dir.clone();
1053        }
1054        if let Ok(env_dir) = std::env::var("MOLD_MODELS_DIR") {
1055            PathBuf::from(env_dir)
1056        } else if self.models_dir == "~/.mold/models" {
1057            Self::mold_dir()
1058                .unwrap_or_else(|| PathBuf::from(".mold"))
1059                .join("models")
1060        } else {
1061            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
1062            let expanded = self.models_dir.replace("~", &home.to_string_lossy());
1063            PathBuf::from(expanded)
1064        }
1065    }
1066
1067    pub fn has_models_dir_override(&self) -> bool {
1068        RUNTIME_MODELS_DIR_OVERRIDE.get().is_some() || std::env::var_os("MOLD_MODELS_DIR").is_some()
1069    }
1070
1071    /// Resolve the effective default model with idiot-proof fallback chain:
1072    /// 1. `MOLD_DEFAULT_MODEL` env var (if set and non-empty)
1073    /// 2. Config file `default_model` (if that model has a custom `[models]` entry)
1074    /// 3. Config file `default_model` (if that model is a known manifest model that is downloaded)
1075    /// 4. Last-used model from `$MOLD_HOME/last-model` (if downloaded)
1076    /// 5. If exactly one model is downloaded, use it automatically
1077    /// 6. Fall back to config value (will trigger auto-pull on use)
1078    pub fn resolved_default_model(&self) -> String {
1079        self.resolve_default_model().model
1080    }
1081
1082    /// Like [`resolved_default_model`] but also returns which fallback step resolved it.
1083    pub fn resolve_default_model(&self) -> DefaultModelResolution {
1084        // 1. Env var override
1085        if let Ok(m) = std::env::var("MOLD_DEFAULT_MODEL") {
1086            if !m.is_empty() {
1087                return DefaultModelResolution {
1088                    model: m,
1089                    source: DefaultModelSource::EnvVar,
1090                };
1091            }
1092        }
1093        // 2. Explicit config entry — honor custom/manual models even when not manifest-backed.
1094        let configured = &self.default_model;
1095        if self.lookup_model_config(configured).is_some() {
1096            return DefaultModelResolution {
1097                model: configured.clone(),
1098                source: DefaultModelSource::ConfigCustomEntry,
1099            };
1100        }
1101        // 3. Configured manifest model — if downloaded
1102        if self.manifest_model_is_downloaded(configured) {
1103            return DefaultModelResolution {
1104                model: configured.clone(),
1105                source: DefaultModelSource::Config,
1106            };
1107        }
1108        // 4. Last-used model — if still downloaded
1109        if let Some(last) = Self::read_last_model() {
1110            if self.manifest_model_is_downloaded(&last) {
1111                return DefaultModelResolution {
1112                    model: last,
1113                    source: DefaultModelSource::LastUsed,
1114                };
1115            }
1116        }
1117        // 5. Single downloaded model (exclude utility and upscaler models)
1118        let downloaded: Vec<String> = crate::manifest::known_manifests()
1119            .iter()
1120            .filter(|m| {
1121                !m.is_utility() && !m.is_upscaler() && self.manifest_model_is_downloaded(&m.name)
1122            })
1123            .map(|m| m.name.clone())
1124            .collect();
1125        if downloaded.len() == 1 {
1126            return DefaultModelResolution {
1127                model: downloaded.into_iter().next().unwrap(),
1128                source: DefaultModelSource::OnlyDownloaded,
1129            };
1130        }
1131        // 6. Config default (will auto-pull) — resolve bare names like
1132        //    "flux2-klein" → "flux2-klein:q8" so the TUI/CLI show the real tag.
1133        DefaultModelResolution {
1134            model: crate::manifest::resolve_model_name(configured),
1135            source: DefaultModelSource::ConfigDefault,
1136        }
1137    }
1138
1139    /// Path to the last-model state file: `$MOLD_HOME/last-model`
1140    fn last_model_path() -> Option<PathBuf> {
1141        Self::mold_dir().map(|d| d.join("last-model"))
1142    }
1143
1144    /// Read the last-used model. When the DB-backed read hook is
1145    /// installed (production path), defers to it entirely; otherwise
1146    /// reads the legacy `$MOLD_HOME/last-model` sidecar. Callers doing
1147    /// one-shot sidecar migration should use
1148    /// [`Self::read_last_model_from_sidecar`] directly.
1149    pub fn read_last_model() -> Option<String> {
1150        if let Some(hook) = READ_LAST_MODEL_HOOK.get() {
1151            return hook();
1152        }
1153        Self::read_last_model_from_sidecar()
1154    }
1155
1156    /// Read the legacy `$MOLD_HOME/last-model` sidecar directly. Used by
1157    /// the one-shot `config.toml + sidecar → DB` migration.
1158    pub fn read_last_model_from_sidecar() -> Option<String> {
1159        let path = Self::last_model_path()?;
1160        std::fs::read_to_string(path).ok().and_then(|s| {
1161            let trimmed = s.trim().to_string();
1162            if trimmed.is_empty() {
1163                None
1164            } else {
1165                Some(trimmed)
1166            }
1167        })
1168    }
1169
1170    /// Resolve the output directory for server-mode image persistence.
1171    /// `MOLD_OUTPUT_DIR` env var takes precedence over the config file value.
1172    /// Returns `None` when disabled (default).
1173    pub fn resolved_output_dir(&self) -> Option<PathBuf> {
1174        let raw = if let Ok(env_dir) = std::env::var("MOLD_OUTPUT_DIR") {
1175            if env_dir.is_empty() {
1176                None
1177            } else {
1178                Some(env_dir)
1179            }
1180        } else {
1181            self.output_dir.clone().filter(|s| !s.is_empty())
1182        };
1183        raw.map(|dir| {
1184            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
1185            if dir == "~" {
1186                home
1187            } else if let Some(rest) = dir.strip_prefix("~/") {
1188                home.join(rest)
1189            } else {
1190                PathBuf::from(dir)
1191            }
1192        })
1193    }
1194
1195    /// Check if image output has been explicitly disabled by the user
1196    /// (empty `MOLD_OUTPUT_DIR` env var or empty `output_dir` config field).
1197    pub fn is_output_disabled(&self) -> bool {
1198        if let Ok(env_dir) = std::env::var("MOLD_OUTPUT_DIR") {
1199            return env_dir.is_empty();
1200        }
1201        matches!(self.output_dir.as_deref(), Some(""))
1202    }
1203
1204    /// Resolved output directory with a default fallback to `~/.mold/output/`.
1205    /// Unlike `resolved_output_dir()`, this always returns a path.
1206    pub fn effective_output_dir(&self) -> PathBuf {
1207        self.resolved_output_dir().unwrap_or_else(|| {
1208            Self::mold_dir()
1209                .unwrap_or_else(|| PathBuf::from(".mold"))
1210                .join("output")
1211        })
1212    }
1213
1214    pub fn resolved_media_roots(&self) -> Vec<PathBuf> {
1215        if let Ok(roots) = std::env::var("MOLD_MEDIA_ROOTS") {
1216            return crate::parse_media_roots_env(&roots);
1217        }
1218        self.media_roots
1219            .as_deref()
1220            .map(crate::configured_media_roots)
1221            .unwrap_or_default()
1222    }
1223
1224    /// Resolved log directory from config or default (~/.mold/logs/).
1225    pub fn resolved_log_dir(&self) -> PathBuf {
1226        if let Some(ref dir) = self.logging.dir {
1227            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
1228            if dir == "~" {
1229                home
1230            } else if let Some(rest) = dir.strip_prefix("~/") {
1231                home.join(rest)
1232            } else {
1233                PathBuf::from(dir)
1234            }
1235        } else {
1236            Self::mold_dir()
1237                .unwrap_or_else(|| PathBuf::from(".mold"))
1238                .join("logs")
1239        }
1240    }
1241
1242    pub fn effective_embed_metadata(&self, override_value: Option<bool>) -> bool {
1243        if let Some(value) = override_value {
1244            return value;
1245        }
1246
1247        match std::env::var("MOLD_EMBED_METADATA") {
1248            Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
1249                "1" | "true" | "yes" | "on" => true,
1250                "0" | "false" | "no" | "off" => false,
1251                _ => {
1252                    eprintln!(
1253                        "warning: invalid MOLD_EMBED_METADATA value '{value}' — using config/default"
1254                    );
1255                    self.embed_metadata
1256                }
1257            },
1258            Err(_) => self.embed_metadata,
1259        }
1260    }
1261
1262    pub fn discovered_manifest_paths(&self, name: &str) -> Option<ModelPaths> {
1263        let manifest = crate::manifest::find_manifest(name)?;
1264        if self.incomplete_pull_blocks_manifest(manifest) {
1265            return None;
1266        }
1267        let models_dir = self.resolved_models_dir();
1268        let downloads = manifest
1269            .files
1270            .iter()
1271            .map(|file| {
1272                // Prefer a canonical clean-path hit (or a documented legacy
1273                // path) under the models dir.
1274                let local = crate::manifest::storage_path_candidates(manifest, file)
1275                    .into_iter()
1276                    .map(|path| models_dir.join(path))
1277                    // Same completeness rules as `manifest_files_exist`:
1278                    // marker present OR size matches manifest. Plain
1279                    // `.exists()` here let truncated downloads masquerade
1280                    // as installed models — the gallery race lived here.
1281                    .find(|path| Self::file_is_complete(path, file.size_bytes));
1282                if let Some(path) = local {
1283                    return Some((file.component, path));
1284                }
1285                // Fallback (companion manifests only): walk mold's managed
1286                // `<models_dir>/.hf-cache/` for the same file. A previous
1287                // mold install may have placed companion files under a
1288                // different canonical layout — e.g. Gemma TE under
1289                // `shared/ltx2/...` from a manifest LTX-2 model install
1290                // vs the catalog `ltx2-te` companion expecting
1291                // `shared/companion/...`. Letting the layout-agnostic
1292                // hf-hub cache view satisfy either keeps a single Gemma
1293                // download serving both. Restricted to `family ==
1294                // "companion"` so non-companion manifests still require
1295                // their files at the canonical clean-path location with
1296                // the proper completeness guard above — that preserves
1297                // the "model not downloaded" branch the tests rely on.
1298                if manifest.family != "companion" {
1299                    return None;
1300                }
1301                crate::download::cached_file_path_in_mold_cache(&file.hf_repo, &file.hf_filename)
1302                    .map(|path| (file.component, path))
1303            })
1304            .collect::<Option<Vec<_>>>()?;
1305        crate::manifest::paths_from_downloads(&downloads, &manifest.family)
1306    }
1307
1308    pub fn manifest_model_is_downloaded(&self, name: &str) -> bool {
1309        let manifest = match crate::manifest::find_manifest(name) {
1310            Some(m) => m,
1311            None => return false,
1312        };
1313        if self.incomplete_pull_blocks_manifest(manifest) {
1314            return false;
1315        }
1316        // Upscaler and utility models don't produce full ModelPaths (no VAE).
1317        // Check file existence directly instead of going through ModelPaths::resolve.
1318        if manifest.is_upscaler() || manifest.is_utility() {
1319            return self.manifest_files_exist(manifest);
1320        }
1321        self.resolved_local_manifest_model_config(name).is_some()
1322    }
1323
1324    /// Return true when a known manifest-backed model is missing any required
1325    /// downloadable asset and should be repaired with `mold pull`.
1326    pub fn manifest_model_needs_download(&self, name: &str) -> bool {
1327        let canonical = crate::manifest::resolve_model_name(name);
1328        crate::manifest::find_manifest(&canonical).is_some()
1329            && !self.manifest_model_is_downloaded(&canonical)
1330    }
1331
1332    /// Check whether all files for a manifest are completely on disk.
1333    ///
1334    /// Two acceptance signals (a file is considered complete if **either** holds):
1335    ///
1336    /// 1. A `.sha256-verified` sidecar exists. Written by the pull path on a
1337    ///    successful download — positive proof the file finished writing and,
1338    ///    when the manifest declared a hash, matches it.
1339    /// 2. The on-disk size matches the manifest's declared `size_bytes`.
1340    ///    Covers two legitimate cases without forcing an upgrade-day rehash:
1341    ///    legacy installs created before markers were written, and HF cache
1342    ///    symlinks pointing at fully-downloaded blobs in `~/.cache/huggingface`.
1343    ///
1344    /// Truncated / partial files reject under both signals — they have no
1345    /// marker (because no successful verify ever ran) and their size does not
1346    /// match. That's the load-bearing change for the "downloaded model
1347    /// sometimes doesn't show up" gallery race.
1348    fn manifest_files_exist(&self, manifest: &crate::manifest::ModelManifest) -> bool {
1349        let models_dir = self.resolved_models_dir();
1350        manifest.files.iter().all(|file| {
1351            crate::manifest::storage_path_candidates(manifest, file)
1352                .into_iter()
1353                .map(|path| models_dir.join(path))
1354                .any(|path| Self::file_is_complete(&path, file.size_bytes))
1355        })
1356    }
1357
1358    /// True when the on-disk file at `path` should be treated as a fully
1359    /// downloaded artifact. See [`Self::manifest_files_exist`] for the
1360    /// acceptance rules.
1361    fn file_is_complete(path: &std::path::Path, expected_size: u64) -> bool {
1362        if !path.exists() {
1363            return false;
1364        }
1365        if crate::download::has_sha256_marker(path) {
1366            return true;
1367        }
1368        // Marker missing — fall back to size match against the manifest.
1369        // Symlinks (HF cache) follow through to the target's metadata.
1370        match path.metadata() {
1371            Ok(meta) => meta.len() == expected_size,
1372            Err(_) => false,
1373        }
1374    }
1375
1376    /// Return true when an active `.pulling` marker should block manifest discovery.
1377    ///
1378    /// If all manifest files already exist, the marker is stale (for example a
1379    /// prior pull finished but crashed before marker cleanup). In that case we
1380    /// remove it and continue with manifest-derived paths instead of falling
1381    /// back to potentially stale config entries.
1382    fn incomplete_pull_blocks_manifest(&self, manifest: &crate::manifest::ModelManifest) -> bool {
1383        let marker_path =
1384            crate::download::pulling_marker_path_in(&self.resolved_models_dir(), &manifest.name);
1385        if !marker_path.exists() {
1386            return false;
1387        }
1388
1389        if self.manifest_files_exist(manifest) {
1390            let _ = std::fs::remove_file(&marker_path);
1391            return false;
1392        }
1393
1394        true
1395    }
1396
1397    /// Return the ModelConfig for a given model name, or an empty default.
1398    /// Tries the exact name first, then the canonical `name:tag` form.
1399    pub fn model_config(&self, name: &str) -> ModelConfig {
1400        let mut cfg = self.lookup_model_config(name).unwrap_or_default();
1401
1402        if let Some(discovered) = self.resolved_local_manifest_model_config(name) {
1403            overlay_model_paths(&mut cfg, &discovered);
1404            if cfg.description.is_none() {
1405                cfg.description = discovered.description;
1406            }
1407            if cfg.family.is_none() {
1408                cfg.family = discovered.family;
1409            }
1410        }
1411
1412        cfg
1413    }
1414
1415    /// Return a model config merged with manifest defaults and metadata.
1416    pub fn resolved_model_config(&self, name: &str) -> ModelConfig {
1417        let mut cfg = self.model_config(name);
1418
1419        if let Some(manifest) = crate::manifest::find_manifest(name) {
1420            // Manifest provides defaults when the config file doesn't specify them.
1421            // Since to_model_config() no longer writes manifest defaults to config,
1422            // config values are only Some when the user explicitly set them.
1423            // User overrides are preserved; manifest fills in the rest.
1424            if cfg.default_steps.is_none() {
1425                cfg.default_steps = Some(manifest.defaults.steps);
1426            }
1427            if cfg.default_guidance.is_none() {
1428                cfg.default_guidance = Some(manifest.defaults.guidance);
1429            }
1430            if cfg.default_width.is_none() {
1431                cfg.default_width = Some(manifest.defaults.width);
1432            }
1433            if cfg.default_height.is_none() {
1434                cfg.default_height = Some(manifest.defaults.height);
1435            }
1436            if cfg.is_schnell.is_none() {
1437                cfg.is_schnell = Some(manifest.defaults.is_schnell);
1438            }
1439            if cfg.scheduler.is_none() {
1440                cfg.scheduler = manifest.defaults.scheduler;
1441            }
1442            if cfg.negative_prompt.is_none() {
1443                cfg.negative_prompt = manifest.defaults.negative_prompt.clone();
1444            }
1445            if cfg.default_frames.is_none() {
1446                cfg.default_frames = manifest.defaults.frames;
1447            }
1448            if cfg.default_fps.is_none() {
1449                cfg.default_fps = manifest.defaults.fps;
1450            }
1451            // Description and family always come from the manifest for known models.
1452            // These are metadata, not user-configurable settings.
1453            cfg.description = Some(manifest.description.clone());
1454            cfg.family = Some(manifest.family.clone());
1455        }
1456
1457        cfg
1458    }
1459
1460    /// Insert or update a model configuration entry.
1461    pub fn upsert_model(&mut self, name: String, config: ModelConfig) {
1462        self.models.insert(name, config);
1463    }
1464
1465    /// Remove a model entry from the config, returning it if it existed.
1466    pub fn remove_model(&mut self, name: &str) -> Option<ModelConfig> {
1467        self.models.remove(name)
1468    }
1469
1470    /// Return the effective placement for a model: config entry plus env overrides.
1471    ///
1472    /// Precedence (higher wins):
1473    ///   1. `MOLD_PLACE_TRANSFORMER`, `MOLD_PLACE_VAE`, `MOLD_PLACE_TEXT_ENCODERS`,
1474    ///      `MOLD_PLACE_T5`, `MOLD_PLACE_CLIP_L`, `MOLD_PLACE_CLIP_G`,
1475    ///      `MOLD_PLACE_QWEN` (env overrides per-component).
1476    ///   2. Config file `[models."name:tag".placement]` table.
1477    ///   3. `None` (use engine auto).
1478    ///
1479    /// Each env var parses:
1480    ///   - `"auto"`    — `DeviceRef::Auto`
1481    ///   - `"cpu"`     — `DeviceRef::Cpu`
1482    ///   - `"gpu:N"`   — `DeviceRef::Gpu { ordinal: N }`
1483    ///   - `"gpu"`     — `DeviceRef::Gpu { ordinal: 0 }`
1484    pub fn resolved_placement(&self, model_name: &str) -> Option<crate::types::DevicePlacement> {
1485        use crate::types::DevicePlacement;
1486
1487        let mut placement = self
1488            .lookup_model_config(model_name)
1489            .and_then(|mc| mc.placement);
1490
1491        let env_tier1 = parse_device_ref_env("MOLD_PLACE_TEXT_ENCODERS");
1492        let env_transformer = parse_device_ref_env("MOLD_PLACE_TRANSFORMER");
1493        let env_vae = parse_device_ref_env("MOLD_PLACE_VAE");
1494        let env_t5 = parse_device_ref_env("MOLD_PLACE_T5");
1495        let env_clip_l = parse_device_ref_env("MOLD_PLACE_CLIP_L");
1496        let env_clip_g = parse_device_ref_env("MOLD_PLACE_CLIP_G");
1497        let env_qwen = parse_device_ref_env("MOLD_PLACE_QWEN");
1498
1499        let any_env = env_tier1.is_some()
1500            || env_transformer.is_some()
1501            || env_vae.is_some()
1502            || env_t5.is_some()
1503            || env_clip_l.is_some()
1504            || env_clip_g.is_some()
1505            || env_qwen.is_some();
1506
1507        if !any_env {
1508            return placement;
1509        }
1510
1511        let mut effective: DevicePlacement = placement.unwrap_or_default();
1512        if let Some(r) = env_tier1 {
1513            effective.text_encoders = r;
1514        }
1515        let any_advanced = env_transformer.is_some()
1516            || env_vae.is_some()
1517            || env_t5.is_some()
1518            || env_clip_l.is_some()
1519            || env_clip_g.is_some()
1520            || env_qwen.is_some();
1521        if any_advanced {
1522            let mut adv = effective.advanced.unwrap_or_default();
1523            if let Some(r) = env_transformer {
1524                adv.transformer = r;
1525            }
1526            if let Some(r) = env_vae {
1527                adv.vae = r;
1528            }
1529            if let Some(r) = env_t5 {
1530                adv.t5 = Some(r);
1531            }
1532            if let Some(r) = env_clip_l {
1533                adv.clip_l = Some(r);
1534            }
1535            if let Some(r) = env_clip_g {
1536                adv.clip_g = Some(r);
1537            }
1538            if let Some(r) = env_qwen {
1539                adv.qwen = Some(r);
1540            }
1541            effective.advanced = Some(adv);
1542        }
1543        placement = Some(effective);
1544        placement
1545    }
1546
1547    /// Normalize placement at the request boundary.
1548    ///
1549    /// An explicit request placement is a complete user decision, including
1550    /// any `Auto` fields it contains, and therefore wins wholly over
1551    /// environment and persisted defaults. Without a request override,
1552    /// `resolved_placement` applies environment over persisted values. The
1553    /// final fallback is an all-`Auto` placement.
1554    pub fn effective_placement(
1555        &self,
1556        model_name: &str,
1557        request: Option<&crate::types::DevicePlacement>,
1558    ) -> crate::types::DevicePlacement {
1559        request
1560            .cloned()
1561            .or_else(|| self.resolved_placement(model_name))
1562            .unwrap_or_default()
1563    }
1564
1565    /// Persist a placement for `model_name`, creating the model entry if
1566    /// missing. `None` clears the placement (and leaves the rest of the
1567    /// entry intact).
1568    pub fn set_model_placement(
1569        &mut self,
1570        model_name: &str,
1571        placement: Option<crate::types::DevicePlacement>,
1572    ) {
1573        let entry = self.models.entry(model_name.to_string()).or_default();
1574        entry.placement = placement;
1575    }
1576
1577    /// Write the config to disk at `config_path()`.
1578    ///
1579    /// Safety: refuses to save if `models_dir` points to a temp/test directory,
1580    /// which can happen when tests race on the `MOLD_HOME` env var.
1581    pub fn save(&self) -> anyhow::Result<()> {
1582        let path = Self::config_path()
1583            .ok_or_else(|| anyhow::anyhow!("cannot determine home directory for config path"))?;
1584
1585        // Guard: refuse to persist a config with a test-temp models_dir into a
1586        // non-temp config path. This catches the race condition where parallel tests
1587        // set MOLD_HOME to /tmp/... and a config.save() writes the corrupted
1588        // models_dir to the user's real config file.
1589        let path_str = path.to_string_lossy();
1590        let is_temp_config = path_str.contains("/tmp/") || path_str.contains("/mold-config-test-");
1591        let has_temp_models_dir = self.models_dir.contains("/tmp/mold-")
1592            || self.models_dir.contains("/mold-config-test-");
1593        if has_temp_models_dir && !is_temp_config {
1594            eprintln!(
1595                "warning: refusing to save config with test models_dir ({}) to real config ({})",
1596                self.models_dir,
1597                path.display()
1598            );
1599            return Ok(());
1600        }
1601
1602        if let Some(parent) = path.parent() {
1603            std::fs::create_dir_all(parent)?;
1604        }
1605        let contents = toml::to_string_pretty(self)?;
1606        std::fs::write(&path, contents)?;
1607        Ok(())
1608    }
1609
1610    /// Serialize only the bootstrap/ops slice of the config to TOML:
1611    /// identifiers, paths, ports, credentials, logging, runpod, per-model
1612    /// file-path entries. User-preference fields that now live in the DB
1613    /// (`expand.*`, `generate.*` globals, per-model generation defaults,
1614    /// lora, scheduler) are stripped.
1615    ///
1616    /// Used by the post-migration rewrite to keep `config.toml` honest
1617    /// after the user-preference surface has moved to SQLite.
1618    pub fn save_bootstrap_only_to(&self, path: &std::path::Path) -> anyhow::Result<()> {
1619        if let Some(parent) = path.parent() {
1620            std::fs::create_dir_all(parent)?;
1621        }
1622        let mut doc = toml::Value::try_from(self)?;
1623        strip_user_pref_fields(&mut doc);
1624        let body = toml::to_string_pretty(&doc)?;
1625        let contents = format!("{BOOTSTRAP_ONLY_BANNER}{body}");
1626        std::fs::write(path, contents)?;
1627        Ok(())
1628    }
1629
1630    /// Save the bootstrap-only slice to the default config path.
1631    pub fn save_bootstrap_only(&self) -> anyhow::Result<()> {
1632        let path = Self::config_path()
1633            .ok_or_else(|| anyhow::anyhow!("cannot determine home directory for config path"))?;
1634        self.save_bootstrap_only_to(&path)
1635    }
1636
1637    /// Whether a config file exists on disk.
1638    pub fn exists_on_disk() -> bool {
1639        Self::config_path().is_some_and(|p| p.exists())
1640    }
1641
1642    /// Look up a model config entry by name (exact or canonical `name:tag` form).
1643    /// Public so CLI commands can check whether a model has a custom config entry.
1644    pub fn lookup_model_config(&self, name: &str) -> Option<ModelConfig> {
1645        if let Some(cfg) = self.models.get(name) {
1646            return Some(cfg.clone());
1647        }
1648        let canonical = resolve_model_name(name);
1649        if canonical != name {
1650            return self.models.get(&canonical).cloned();
1651        }
1652        None
1653    }
1654
1655    fn discovered_manifest_model_config(&self, name: &str) -> Option<ModelConfig> {
1656        let manifest = crate::manifest::find_manifest(name)?;
1657        let paths = self.discovered_manifest_paths(name)?;
1658        Some(manifest.to_model_config(&paths))
1659    }
1660
1661    fn resolved_local_manifest_model_config(&self, name: &str) -> Option<ModelConfig> {
1662        let manifest = crate::manifest::find_manifest(name)?;
1663        let paths = if let Some(paths) = self.discovered_manifest_paths(name) {
1664            paths
1665        } else {
1666            let paths = ModelPaths::resolve(name, self)?;
1667            if !resolved_manifest_paths_exist(manifest, &paths) {
1668                return None;
1669            }
1670            paths
1671        };
1672        Some(manifest.to_model_config(&paths))
1673    }
1674}
1675
1676fn overlay_model_paths(target: &mut ModelConfig, source: &ModelConfig) {
1677    target.transformer = source.transformer.clone();
1678    target.transformer_shards = source.transformer_shards.clone();
1679    target.vae = source.vae.clone();
1680    if source.spatial_upscaler.is_some() {
1681        target.spatial_upscaler = source.spatial_upscaler.clone();
1682    }
1683    if source.temporal_upscaler.is_some() {
1684        target.temporal_upscaler = source.temporal_upscaler.clone();
1685    }
1686    if source.distilled_lora.is_some() {
1687        target.distilled_lora = source.distilled_lora.clone();
1688    }
1689
1690    if source.t5_encoder.is_some() {
1691        target.t5_encoder = source.t5_encoder.clone();
1692    }
1693    if source.clip_encoder.is_some() {
1694        target.clip_encoder = source.clip_encoder.clone();
1695    }
1696    if source.t5_tokenizer.is_some() {
1697        target.t5_tokenizer = source.t5_tokenizer.clone();
1698    }
1699    if source.clip_tokenizer.is_some() {
1700        target.clip_tokenizer = source.clip_tokenizer.clone();
1701    }
1702    if source.clip_encoder_2.is_some() {
1703        target.clip_encoder_2 = source.clip_encoder_2.clone();
1704    }
1705    if source.clip_tokenizer_2.is_some() {
1706        target.clip_tokenizer_2 = source.clip_tokenizer_2.clone();
1707    }
1708    if source.text_encoder_files.is_some() {
1709        target.text_encoder_files = source.text_encoder_files.clone();
1710    }
1711    if source.text_tokenizer.is_some() {
1712        target.text_tokenizer = source.text_tokenizer.clone();
1713    }
1714    if source.decoder.is_some() {
1715        target.decoder = source.decoder.clone();
1716    }
1717}
1718
1719fn resolved_manifest_paths_exist(
1720    manifest: &crate::manifest::ModelManifest,
1721    paths: &ModelPaths,
1722) -> bool {
1723    use crate::manifest::ModelComponent;
1724
1725    let mut transformer_shard_idx = 0usize;
1726    let mut text_encoder_idx = 0usize;
1727
1728    manifest.files.iter().all(|file| match file.component {
1729        ModelComponent::Transformer => paths.transformer.exists(),
1730        ModelComponent::TransformerShard => {
1731            let path = paths.transformer_shards.get(transformer_shard_idx);
1732            transformer_shard_idx += 1;
1733            path.is_some_and(|path| path.exists())
1734        }
1735        ModelComponent::Vae => paths.vae.exists(),
1736        ModelComponent::SpatialUpscaler => paths
1737            .spatial_upscaler
1738            .as_ref()
1739            .is_some_and(|path| path.exists()),
1740        ModelComponent::TemporalUpscaler => paths
1741            .temporal_upscaler
1742            .as_ref()
1743            .is_some_and(|path| path.exists()),
1744        ModelComponent::LowNoiseTransformer => paths
1745            .low_noise_transformer
1746            .as_ref()
1747            .is_some_and(|path| path.exists()),
1748        ModelComponent::DistilledLora => paths
1749            .distilled_lora
1750            .as_ref()
1751            .is_some_and(|path| path.exists()),
1752        ModelComponent::LowNoiseDistilledLora => paths
1753            .low_noise_distilled_lora
1754            .as_ref()
1755            .is_some_and(|path| path.exists()),
1756        ModelComponent::T5Encoder => paths.t5_encoder.as_ref().is_some_and(|path| path.exists()),
1757        ModelComponent::ClipEncoder => paths
1758            .clip_encoder
1759            .as_ref()
1760            .is_some_and(|path| path.exists()),
1761        ModelComponent::T5Tokenizer => paths
1762            .t5_tokenizer
1763            .as_ref()
1764            .is_some_and(|path| path.exists()),
1765        ModelComponent::ClipTokenizer => paths
1766            .clip_tokenizer
1767            .as_ref()
1768            .is_some_and(|path| path.exists()),
1769        ModelComponent::ClipEncoder2 => paths
1770            .clip_encoder_2
1771            .as_ref()
1772            .is_some_and(|path| path.exists()),
1773        ModelComponent::ClipTokenizer2 => paths
1774            .clip_tokenizer_2
1775            .as_ref()
1776            .is_some_and(|path| path.exists()),
1777        ModelComponent::TextEncoder => {
1778            let path = paths.text_encoder_files.get(text_encoder_idx);
1779            text_encoder_idx += 1;
1780            path.is_some_and(|path| path.exists())
1781        }
1782        ModelComponent::TextTokenizer => paths
1783            .text_tokenizer
1784            .as_ref()
1785            .is_some_and(|path| path.exists()),
1786        // H3 remains contract-only. ModelPaths does not yet carry these
1787        // components, so a manifest can never be mistaken for runnable merely
1788        // because the legacy transformer/VAE subset exists.
1789        ModelComponent::AudioVae
1790        | ModelComponent::Processor
1791        | ModelComponent::VideoScheduler
1792        | ModelComponent::AudioScheduler
1793        | ModelComponent::ModelConfig
1794        | ModelComponent::TaskConfig => false,
1795        ModelComponent::Decoder => paths.decoder.as_ref().is_some_and(|path| path.exists()),
1796        ModelComponent::Upscaler => paths.transformer.exists(),
1797    })
1798}
1799
1800/// Parse a device-placement string into a `DeviceRef`.
1801///
1802/// Keywords and the legacy `gpu:N` prefix are case-insensitive. Durable IDs
1803/// preserve their spelling and accept `device:<id>` plus direct `cuda:` and
1804/// `metal:` forms. Raw NVIDIA `GPU-`/`MIG-` selectors belong to startup GPU
1805/// selection; component placement uses the exact opaque ID advertised by
1806/// `/api/devices`.
1807pub fn parse_device_ref_str(raw: &str) -> Result<crate::types::DeviceRef, String> {
1808    use crate::types::DeviceRef;
1809    let raw = raw.trim();
1810    let normalized = raw.to_ascii_lowercase();
1811    if normalized == "auto" {
1812        Ok(DeviceRef::Auto)
1813    } else if normalized == "cpu" {
1814        Ok(DeviceRef::Cpu)
1815    } else if normalized == "gpu" {
1816        Ok(DeviceRef::gpu(0))
1817    } else if let Some(rest) = normalized.strip_prefix("gpu:") {
1818        rest.parse::<usize>()
1819            .map(DeviceRef::gpu)
1820            .map_err(|_| invalid_device_ref(raw))
1821    } else if normalized.starts_with("device:") {
1822        parse_stable_device_id(&raw["device:".len()..])
1823    } else if is_stable_device_id(raw) {
1824        Ok(DeviceRef::device(raw))
1825    } else {
1826        Err(invalid_device_ref(raw))
1827    }
1828}
1829
1830fn parse_stable_device_id(raw: &str) -> Result<crate::types::DeviceRef, String> {
1831    if is_stable_device_id(raw) {
1832        Ok(crate::types::DeviceRef::device(raw))
1833    } else {
1834        Err(invalid_device_ref(raw))
1835    }
1836}
1837
1838fn is_stable_device_id(raw: &str) -> bool {
1839    let lower = raw.to_ascii_lowercase();
1840    (lower.starts_with("cuda:") && raw.len() > "cuda:".len())
1841        || (lower.starts_with("metal:") && raw.len() > "metal:".len())
1842}
1843
1844fn invalid_device_ref(raw: &str) -> String {
1845    format!(
1846        "invalid device '{raw}' (expected auto|cpu|gpu[:N]|device:<stable-id>|cuda:<id>|metal:<id>)"
1847    )
1848}
1849
1850fn parse_device_ref_env(key: &str) -> Option<crate::types::DeviceRef> {
1851    let raw = std::env::var(key).ok()?;
1852    match parse_device_ref_str(&raw) {
1853        Ok(dr) => Some(dr),
1854        Err(msg) => {
1855            eprintln!("mold: ignoring {key}={raw}: {msg}");
1856            None
1857        }
1858    }
1859}
1860
1861#[cfg(test)]
1862mod scheduler_settings_tests {
1863    use super::SchedulerSettings;
1864
1865    #[test]
1866    fn scheduler_settings_defaults_and_rejects_invalid_toml() {
1867        let defaults: SchedulerSettings = toml::from_str("").unwrap();
1868        assert_eq!(defaults, SchedulerSettings::default());
1869
1870        let error = toml::from_str::<SchedulerSettings>(
1871            "replan_debounce_ms = 5000\nreplan_max_delay_ms = 4999",
1872        )
1873        .unwrap_err();
1874        assert!(error
1875            .to_string()
1876            .contains("replan_max_delay_ms must be greater than or equal"));
1877        assert!(toml::from_str::<SchedulerSettings>("warm_wait_max_ms = 30001").is_err());
1878    }
1879}