Skip to main content

mold_core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::OnceLock;
5
6use crate::expand::ExpandSettings;
7use crate::manifest::resolve_model_name;
8use crate::types::Scheduler;
9
10static RUNTIME_MODELS_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
11
12/// Banner comment written at the top of a `save_bootstrap_only` output so
13/// readers understand why the usual user-preference fields are missing.
14const BOOTSTRAP_ONLY_BANNER: &str = "\
15# mold config — bootstrap-only surface.
16#
17# User preferences (expand.*, generate.default_*, per-model generation
18# defaults, lora, scheduler) live in SQLite at <MOLD_HOME>/mold.db.
19# Edit them via:
20#   mold config set <key> <value>
21#   mold config get <key>
22#   mold config reset <key>     # drop the DB row, fall back to this file
23#
24# This file retains only identifiers, paths, ports, credentials, logging,
25# and per-model file-path entries. Adding generation defaults here is
26# silently overridden by the DB on load.
27
28";
29
30/// Global + per-model keys moved to the DB by issue #265. Stripped from
31/// the TOML value tree by [`Config::save_bootstrap_only_to`].
32const STRIPPED_GLOBAL_KEYS: &[&str] = &[
33    "default_width",
34    "default_height",
35    "default_steps",
36    "embed_metadata",
37    "default_negative_prompt",
38    "t5_variant",
39    "qwen3_variant",
40    "expand",
41];
42
43const STRIPPED_MODEL_KEYS: &[&str] = &[
44    "default_steps",
45    "default_guidance",
46    "default_width",
47    "default_height",
48    "scheduler",
49    "negative_prompt",
50    "lora",
51    "lora_scale",
52    "default_frames",
53    "default_fps",
54];
55
56fn strip_user_pref_fields(doc: &mut toml::Value) {
57    let Some(table) = doc.as_table_mut() else {
58        return;
59    };
60    for key in STRIPPED_GLOBAL_KEYS {
61        table.remove(*key);
62    }
63    if let Some(toml::Value::Table(models)) = table.get_mut("models") {
64        for (_, mc) in models.iter_mut() {
65            if let Some(mc_table) = mc.as_table_mut() {
66                for key in STRIPPED_MODEL_KEYS {
67                    mc_table.remove(*key);
68                }
69            }
70        }
71    }
72}
73
74/// Hook installed by callers (typically `mold-cli` at startup) that wants
75/// to overlay DB-backed user preferences onto every freshly-loaded
76/// `Config`. `mold-core` itself must not depend on `mold-db`, so the hook
77/// is just an opaque function pointer registered once per process.
78///
79/// Runs after TOML parsing + legacy v0→v1 migration and before the
80/// returned value is handed to the caller. Errors should be swallowed
81/// inside the hook (the DB layer logs); failing here must never break
82/// `load_or_default()`.
83pub type ConfigPostLoadHook = fn(&mut Config);
84static POST_LOAD_HOOK: OnceLock<ConfigPostLoadHook> = OnceLock::new();
85
86/// Register a post-load hook. First caller wins; subsequent calls are a
87/// no-op so tests can't clobber each other.
88pub fn install_post_load_hook(hook: ConfigPostLoadHook) {
89    let _ = POST_LOAD_HOOK.set(hook);
90}
91
92/// Hook for [`Config::read_last_model`] that lets the DB layer provide
93/// the value without `mold-core` depending on `mold-db`. When installed,
94/// the hook's return value takes precedence over the legacy sidecar file.
95pub type ReadLastModelHook = fn() -> Option<String>;
96static READ_LAST_MODEL_HOOK: OnceLock<ReadLastModelHook> = OnceLock::new();
97
98/// Register a read-last-model hook. First caller wins.
99pub fn install_read_last_model_hook(hook: ReadLastModelHook) {
100    let _ = READ_LAST_MODEL_HOOK.set(hook);
101}
102
103/// Which fallback step resolved the default model.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum DefaultModelSource {
106    /// `MOLD_DEFAULT_MODEL` environment variable
107    EnvVar,
108    /// Config file `default_model` with a custom `[models]` entry
109    ConfigCustomEntry,
110    /// Config file `default_model` (manifest model, downloaded)
111    Config,
112    /// Last-used model from `$MOLD_HOME/last-model`
113    LastUsed,
114    /// Only one model is downloaded — auto-selected
115    OnlyDownloaded,
116    /// Config file default (model not downloaded, will auto-pull)
117    ConfigDefault,
118}
119
120/// Result of resolving the default model: the model name and how it was resolved.
121#[derive(Debug, Clone)]
122pub struct DefaultModelResolution {
123    pub model: String,
124    pub source: DefaultModelSource,
125}
126
127/// Per-model file path + default settings configuration.
128#[derive(Debug, Clone, Deserialize, Serialize, Default)]
129pub struct ModelConfig {
130    // --- paths ---
131    pub transformer: Option<String>,
132    /// Multi-shard transformer paths (Z-Image BF16); empty means use single `transformer`
133    pub transformer_shards: Option<Vec<String>>,
134    pub vae: Option<String>,
135    /// LTX latent upsampler / spatial upscaler weights.
136    pub spatial_upscaler: Option<String>,
137    /// Optional temporal upscaler weights for LTX-2/LTX-2.3.
138    pub temporal_upscaler: Option<String>,
139    /// Optional distilled LoRA bundled with a model manifest.
140    pub distilled_lora: Option<String>,
141    pub t5_encoder: Option<String>,
142    pub clip_encoder: Option<String>,
143    pub t5_tokenizer: Option<String>,
144    pub clip_tokenizer: Option<String>,
145    /// CLIP-G / OpenCLIP encoder path (SDXL only)
146    pub clip_encoder_2: Option<String>,
147    /// CLIP-G / OpenCLIP tokenizer path (SDXL only)
148    pub clip_tokenizer_2: Option<String>,
149    /// Generic text encoder shard paths (Qwen3 for Z-Image)
150    pub text_encoder_files: Option<Vec<String>>,
151    /// Generic text encoder tokenizer path (Qwen3 for Z-Image)
152    pub text_tokenizer: Option<String>,
153    /// Stage B decoder weights path (Wuerstchen only)
154    pub decoder: Option<String>,
155
156    // --- generation defaults ---
157    /// Default inference steps (e.g. 4 for schnell, 25 for dev)
158    pub default_steps: Option<u32>,
159    /// Default guidance scale (0.0 for schnell, 3.5 for dev finetuned)
160    pub default_guidance: Option<f64>,
161    /// Default output width
162    pub default_width: Option<u32>,
163    /// Default output height
164    pub default_height: Option<u32>,
165    /// Whether this model uses the schnell (distilled) timestep schedule.
166    /// If None, auto-detected from the transformer filename.
167    pub is_schnell: Option<bool>,
168    /// Whether this model uses a turbo (few-step distilled) schedule.
169    /// If None, auto-detected from the model name.
170    pub is_turbo: Option<bool>,
171    /// Scheduler algorithm for UNet-based models (SD1.5, SDXL). Ignored by flow-matching models.
172    pub scheduler: Option<Scheduler>,
173    /// Per-model default negative prompt for CFG-based models.
174    pub negative_prompt: Option<String>,
175    /// Default LoRA adapter path for this model.
176    pub lora: Option<String>,
177    /// Default LoRA scale for this model (0.0-2.0).
178    pub lora_scale: Option<f64>,
179    /// Default number of video frames for video models (e.g. 25 for ltx-video).
180    pub default_frames: Option<u32>,
181    /// Default video FPS for video models (e.g. 24 for ltx-video).
182    pub default_fps: Option<u32>,
183
184    // --- metadata ---
185    pub description: Option<String>,
186    pub family: Option<String>,
187
188    /// Per-component device placement override. `None` preserves the
189    /// engine's VRAM-aware auto-placement.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub placement: Option<crate::types::DevicePlacement>,
192}
193
194impl ModelConfig {
195    /// Collect all file path strings from this model config into a flat list.
196    /// Used for reference counting when determining which files are shared.
197    pub fn all_file_paths(&self) -> Vec<String> {
198        let mut paths = Vec::new();
199        let singles = [
200            &self.transformer,
201            &self.vae,
202            &self.spatial_upscaler,
203            &self.temporal_upscaler,
204            &self.distilled_lora,
205            &self.t5_encoder,
206            &self.clip_encoder,
207            &self.t5_tokenizer,
208            &self.clip_tokenizer,
209            &self.clip_encoder_2,
210            &self.clip_tokenizer_2,
211            &self.text_tokenizer,
212            &self.decoder,
213        ];
214        for p in singles.into_iter().flatten() {
215            paths.push(p.clone());
216        }
217        if let Some(ref shards) = self.transformer_shards {
218            paths.extend(shards.iter().cloned());
219        }
220        if let Some(ref files) = self.text_encoder_files {
221            paths.extend(files.iter().cloned());
222        }
223        paths
224    }
225
226    /// Total disk usage of all model files: `(bytes, gigabytes)`.
227    ///
228    /// Sums the file sizes of all paths referenced by this config entry.
229    /// Missing files are silently skipped.
230    pub fn disk_usage(&self) -> (u64, f64) {
231        let total: u64 = self
232            .all_file_paths()
233            .iter()
234            .filter_map(|p| std::fs::metadata(p).ok())
235            .map(|m| m.len())
236            .sum();
237        (total, total as f64 / 1_073_741_824.0)
238    }
239
240    /// Effective steps: model default → global fallback → hardcoded default.
241    pub fn effective_steps(&self, global_cfg: &Config) -> u32 {
242        self.default_steps.unwrap_or(global_cfg.default_steps)
243    }
244
245    /// Effective guidance.
246    pub fn effective_guidance(&self) -> f64 {
247        self.default_guidance.unwrap_or(3.5)
248    }
249
250    /// Effective width.
251    pub fn effective_width(&self, global_cfg: &Config) -> u32 {
252        self.default_width.unwrap_or(global_cfg.default_width)
253    }
254
255    /// Effective height.
256    pub fn effective_height(&self, global_cfg: &Config) -> u32 {
257        self.default_height.unwrap_or(global_cfg.default_height)
258    }
259
260    /// Effective negative prompt: per-model override → global default → None.
261    pub fn effective_negative_prompt(&self, global_cfg: &Config) -> Option<String> {
262        self.negative_prompt
263            .clone()
264            .or_else(|| global_cfg.default_negative_prompt.clone())
265    }
266
267    /// Effective LoRA config: per-model default path and scale, or None.
268    pub fn effective_lora(&self) -> Option<(String, f64)> {
269        self.lora
270            .as_ref()
271            .map(|path| (path.clone(), self.lora_scale.unwrap_or(1.0)))
272    }
273
274    /// Effective video frames: per-model default, or None for image-only models.
275    pub fn effective_frames(&self) -> Option<u32> {
276        self.default_frames
277    }
278
279    /// Effective video FPS: per-model default, or None for image-only models.
280    pub fn effective_fps(&self) -> Option<u32> {
281        self.default_fps
282    }
283}
284
285/// Resolved model file paths.
286/// For diffusion models, `transformer` and `vae` are always required.
287/// For upscaler models, only `transformer` (weights) is required; `vae` is empty.
288/// For utility models, only `transformer` is required; `vae` may be empty.
289/// Other paths are optional — each engine validates what it needs at load time.
290#[derive(Debug, Clone)]
291pub struct ModelPaths {
292    pub transformer: PathBuf,
293    /// Multi-shard transformer paths (Z-Image BF16); empty means use single `transformer`
294    pub transformer_shards: Vec<PathBuf>,
295    pub vae: PathBuf,
296    pub spatial_upscaler: Option<PathBuf>,
297    pub temporal_upscaler: Option<PathBuf>,
298    pub distilled_lora: Option<PathBuf>,
299    pub t5_encoder: Option<PathBuf>,
300    pub clip_encoder: Option<PathBuf>,
301    pub t5_tokenizer: Option<PathBuf>,
302    pub clip_tokenizer: Option<PathBuf>,
303    /// CLIP-G / OpenCLIP encoder (SDXL only)
304    pub clip_encoder_2: Option<PathBuf>,
305    /// CLIP-G / OpenCLIP tokenizer (SDXL only)
306    pub clip_tokenizer_2: Option<PathBuf>,
307    /// Generic text encoder shard paths (Qwen3 for Z-Image)
308    pub text_encoder_files: Vec<PathBuf>,
309    /// Generic text encoder tokenizer (Qwen3 for Z-Image)
310    pub text_tokenizer: Option<PathBuf>,
311    /// Stage B decoder weights (Wuerstchen only)
312    pub decoder: Option<PathBuf>,
313}
314
315impl ModelPaths {
316    /// Resolve paths for a model. Checks config, then env vars.
317    /// Returns None if transformer and VAE paths can't be resolved.
318    /// All other paths are optional (depend on model family).
319    pub fn resolve(model_name: &str, config: &Config) -> Option<Self> {
320        if let Some(model_cfg) = config.discovered_manifest_model_config(model_name) {
321            return Self::resolve_from_model_config(Some(&model_cfg));
322        }
323
324        if crate::manifest::find_manifest(model_name).is_some() && config.has_models_dir_override()
325        {
326            return Self::resolve_from_model_config(None);
327        }
328
329        let model_cfg = config.lookup_model_config(model_name);
330        Self::resolve_from_model_config(model_cfg.as_ref())
331    }
332
333    fn resolve_from_model_config(model_cfg: Option<&ModelConfig>) -> Option<Self> {
334        let transformer = Self::resolve_path(
335            model_cfg.and_then(|m| m.transformer.as_deref()),
336            "MOLD_TRANSFORMER_PATH",
337        )?;
338        let transformer_shards = model_cfg
339            .and_then(|m| m.transformer_shards.as_ref())
340            .map(|shards| shards.iter().map(PathBuf::from).collect())
341            .unwrap_or_default();
342        let vae = Self::resolve_path(model_cfg.and_then(|m| m.vae.as_deref()), "MOLD_VAE_PATH")?;
343        let spatial_upscaler = Self::resolve_path(
344            model_cfg.and_then(|m| m.spatial_upscaler.as_deref()),
345            "MOLD_SPATIAL_UPSCALER_PATH",
346        );
347        let temporal_upscaler = Self::resolve_path(
348            model_cfg.and_then(|m| m.temporal_upscaler.as_deref()),
349            "MOLD_TEMPORAL_UPSCALER_PATH",
350        );
351        let distilled_lora = Self::resolve_path(
352            model_cfg.and_then(|m| m.distilled_lora.as_deref()),
353            "MOLD_DISTILLED_LORA_PATH",
354        );
355        let t5_encoder = Self::resolve_path(
356            model_cfg.and_then(|m| m.t5_encoder.as_deref()),
357            "MOLD_T5_PATH",
358        );
359        let clip_encoder = Self::resolve_path(
360            model_cfg.and_then(|m| m.clip_encoder.as_deref()),
361            "MOLD_CLIP_PATH",
362        );
363        let t5_tokenizer = Self::resolve_path(
364            model_cfg.and_then(|m| m.t5_tokenizer.as_deref()),
365            "MOLD_T5_TOKENIZER_PATH",
366        );
367        let clip_tokenizer = Self::resolve_path(
368            model_cfg.and_then(|m| m.clip_tokenizer.as_deref()),
369            "MOLD_CLIP_TOKENIZER_PATH",
370        );
371        let clip_encoder_2 = Self::resolve_path(
372            model_cfg.and_then(|m| m.clip_encoder_2.as_deref()),
373            "MOLD_CLIP2_PATH",
374        );
375        let clip_tokenizer_2 = Self::resolve_path(
376            model_cfg.and_then(|m| m.clip_tokenizer_2.as_deref()),
377            "MOLD_CLIP2_TOKENIZER_PATH",
378        );
379        let text_encoder_files = model_cfg
380            .and_then(|m| m.text_encoder_files.as_ref())
381            .map(|files| files.iter().map(PathBuf::from).collect())
382            .unwrap_or_default();
383        let text_tokenizer = Self::resolve_path(
384            model_cfg.and_then(|m| m.text_tokenizer.as_deref()),
385            "MOLD_TEXT_TOKENIZER_PATH",
386        );
387        let decoder = Self::resolve_path(
388            model_cfg.and_then(|m| m.decoder.as_deref()),
389            "MOLD_DECODER_PATH",
390        );
391
392        Some(Self {
393            transformer,
394            transformer_shards,
395            vae,
396            spatial_upscaler,
397            temporal_upscaler,
398            distilled_lora,
399            t5_encoder,
400            clip_encoder,
401            t5_tokenizer,
402            clip_tokenizer,
403            clip_encoder_2,
404            clip_tokenizer_2,
405            text_encoder_files,
406            text_tokenizer,
407            decoder,
408        })
409    }
410
411    fn resolve_path(config_val: Option<&str>, env_var: &str) -> Option<PathBuf> {
412        if let Ok(path) = std::env::var(env_var) {
413            return Some(PathBuf::from(path));
414        }
415        if let Some(path) = config_val {
416            return Some(PathBuf::from(path));
417        }
418        None
419    }
420}
421
422/// Current config schema version. Increment when adding migrations.
423const CURRENT_CONFIG_VERSION: u32 = 1;
424
425#[derive(Debug, Clone, Deserialize, Serialize)]
426pub struct Config {
427    /// Config schema version for migrations. Old configs without this field
428    /// default to 0 and are migrated on first load.
429    #[serde(default)]
430    pub config_version: u32,
431
432    #[serde(default = "default_model")]
433    pub default_model: String,
434
435    #[serde(default = "default_models_dir")]
436    pub models_dir: String,
437
438    #[serde(default = "default_port")]
439    pub server_port: u16,
440
441    #[serde(default = "default_dimension")]
442    pub default_width: u32,
443
444    #[serde(default = "default_dimension")]
445    pub default_height: u32,
446
447    #[serde(default = "default_steps")]
448    pub default_steps: u32,
449
450    #[serde(default = "default_embed_metadata")]
451    pub embed_metadata: bool,
452
453    /// Preferred T5 encoder variant: "fp16" (default), "q8", "q6", "q5", "q4", "q3", or "auto".
454    /// "auto" selects the best variant that fits in GPU VRAM.
455    /// An explicit quantized tag always uses that variant regardless of VRAM.
456    #[serde(default)]
457    pub t5_variant: Option<String>,
458
459    /// Preferred Qwen3 text encoder variant: "bf16" (default), "q8", "q6", "iq4", "q3", or "auto".
460    /// "auto" selects the best variant that fits in GPU VRAM (with drop-and-reload).
461    #[serde(default)]
462    pub qwen3_variant: Option<String>,
463
464    /// Directory to persist generated images. Default: `~/.mold/output/`.
465    /// Override with `MOLD_OUTPUT_DIR` env var. Set to empty string to disable
466    /// (TUI gallery will not function when disabled).
467    #[serde(default)]
468    pub output_dir: Option<String>,
469
470    /// Allow roots for trusted server-local media request paths.
471    /// Override with `MOLD_MEDIA_ROOTS` using the platform path-list separator.
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub media_roots: Option<Vec<String>>,
474
475    /// Global default negative prompt for CFG-based models (SD1.5, SDXL, SD3, Wuerstchen).
476    /// Overridden by per-model `negative_prompt` or CLI `--negative-prompt`.
477    #[serde(default)]
478    pub default_negative_prompt: Option<String>,
479
480    /// Prompt expansion settings.
481    #[serde(default)]
482    pub expand: ExpandSettings,
483
484    /// Logging configuration.
485    #[serde(default)]
486    pub logging: LoggingConfig,
487
488    /// RunPod integration settings (api key, defaults, auto-teardown behaviour).
489    #[serde(default)]
490    pub runpod: crate::runpod::RunPodSettings,
491
492    /// Lambda Cloud integration settings.
493    #[serde(default)]
494    pub lambda: crate::lambda::LambdaSettings,
495
496    /// GPU ordinals to use (None = all available).
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub gpus: Option<Vec<usize>>,
499
500    /// Max queued requests before 503 (default: 200).
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub queue_size: Option<usize>,
503
504    /// Per-model configurations, keyed by model name.
505    #[serde(default)]
506    pub models: HashMap<String, ModelConfig>,
507}
508
509/// Logging configuration for file output and rotation.
510#[derive(Debug, Clone, Deserialize, Serialize)]
511pub struct LoggingConfig {
512    /// Log level: trace, debug, info, warn, error. Overridden by MOLD_LOG env var.
513    #[serde(default = "default_log_level")]
514    pub level: String,
515
516    /// Enable file logging. When true, logs go to ~/.mold/logs/.
517    #[serde(default)]
518    pub file: bool,
519
520    /// Custom log file directory (default: ~/.mold/logs/).
521    #[serde(default)]
522    pub dir: Option<String>,
523
524    /// Number of days to retain log files. Default: 7.
525    #[serde(default = "default_log_max_days")]
526    pub max_days: u32,
527}
528
529fn default_log_level() -> String {
530    "info".to_string()
531}
532fn default_log_max_days() -> u32 {
533    7
534}
535
536impl Default for LoggingConfig {
537    fn default() -> Self {
538        Self {
539            level: default_log_level(),
540            file: false,
541            dir: None,
542            max_days: default_log_max_days(),
543        }
544    }
545}
546
547fn default_model() -> String {
548    "flux2-klein:q8".to_string()
549}
550
551fn default_models_dir() -> String {
552    if let Ok(home) = std::env::var("MOLD_HOME") {
553        format!("{home}/models")
554    } else {
555        "~/.mold/models".to_string()
556    }
557}
558
559fn default_port() -> u16 {
560    7680
561}
562
563fn default_dimension() -> u32 {
564    768
565}
566
567fn default_steps() -> u32 {
568    4
569}
570
571fn default_embed_metadata() -> bool {
572    true
573}
574
575impl Default for Config {
576    fn default() -> Self {
577        Self {
578            config_version: CURRENT_CONFIG_VERSION,
579            default_model: default_model(),
580            models_dir: default_models_dir(),
581            server_port: default_port(),
582            default_width: default_dimension(),
583            default_height: default_dimension(),
584            default_steps: default_steps(),
585            embed_metadata: default_embed_metadata(),
586            t5_variant: None,
587            qwen3_variant: None,
588            output_dir: None,
589            media_roots: None,
590            default_negative_prompt: None,
591            expand: ExpandSettings::default(),
592            logging: LoggingConfig::default(),
593            runpod: crate::runpod::RunPodSettings::default(),
594            lambda: crate::lambda::LambdaSettings::default(),
595            gpus: None,
596            queue_size: None,
597            models: HashMap::new(),
598        }
599    }
600}
601
602impl Config {
603    /// Build a `GpuSelection` from the config's `gpus` field.
604    pub fn gpu_selection(&self) -> crate::types::GpuSelection {
605        match &self.gpus {
606            Some(ordinals) if !ordinals.is_empty() => {
607                crate::types::GpuSelection::Specific(ordinals.clone())
608            }
609            _ => crate::types::GpuSelection::All,
610        }
611    }
612
613    /// Return the configured queue size or the default (200).
614    pub fn queue_size(&self) -> usize {
615        self.queue_size.unwrap_or(200)
616    }
617
618    pub fn install_runtime_models_dir_override(models_dir: PathBuf) {
619        let _ = RUNTIME_MODELS_DIR_OVERRIDE.get_or_init(|| models_dir);
620    }
621
622    pub fn load_or_default() -> Self {
623        let Some(config_path) = Self::config_path() else {
624            eprintln!("warning: could not determine home directory — using default config");
625            return Config::default();
626        };
627        let mut cfg = if config_path.exists() {
628            match std::fs::read_to_string(&config_path) {
629                Ok(contents) => match toml::from_str(&contents) {
630                    Ok(cfg) => cfg,
631                    Err(e) => {
632                        eprintln!(
633                            "warning: failed to parse config at {}: {e} — using defaults",
634                            config_path.display()
635                        );
636                        Config::default()
637                    }
638                },
639                Err(e) => {
640                    eprintln!(
641                        "warning: failed to read config at {}: {e} — using defaults",
642                        config_path.display()
643                    );
644                    Config::default()
645                }
646            }
647        } else {
648            Config::default()
649        };
650
651        // Run config migrations if needed
652        if cfg.config_version < CURRENT_CONFIG_VERSION {
653            Self::run_migrations(&mut cfg);
654            cfg.config_version = CURRENT_CONFIG_VERSION;
655            if let Err(e) = cfg.save() {
656                eprintln!("warning: failed to save migrated config: {e}");
657            }
658        }
659
660        // Post-load hook (DB-backed user-pref overlay, if installed).
661        if let Some(hook) = POST_LOAD_HOOK.get() {
662            hook(&mut cfg);
663        }
664
665        cfg
666    }
667
668    /// Run all pending config migrations from cfg.config_version to CURRENT.
669    pub(crate) fn run_migrations(cfg: &mut Config) {
670        if cfg.config_version < 1 {
671            Self::migrate_v0_to_v1(cfg);
672        }
673        // Future migrations:
674        // if cfg.config_version < 2 { Self::migrate_v1_to_v2(cfg); }
675    }
676
677    /// v0 → v1: Strip stale manifest defaults from known model entries.
678    ///
679    /// Old `mold pull` wrote all manifest defaults (steps, guidance, dimensions,
680    /// description, family, is_schnell, scheduler) into config.toml. These become
681    /// stale when manifests update. This migration removes them so
682    /// `resolved_model_config()` reads fresh values from the manifest at runtime.
683    fn migrate_v0_to_v1(cfg: &mut Config) {
684        let model_names: Vec<String> = cfg.models.keys().cloned().collect();
685        for name in model_names {
686            if crate::manifest::find_manifest(&name).is_some() {
687                if let Some(mc) = cfg.models.get_mut(&name) {
688                    mc.default_steps = None;
689                    mc.default_guidance = None;
690                    mc.default_width = None;
691                    mc.default_height = None;
692                    mc.is_schnell = None;
693                    mc.is_turbo = None;
694                    mc.scheduler = None;
695                    mc.negative_prompt = None;
696                    mc.default_frames = None;
697                    mc.default_fps = None;
698                    mc.description = None;
699                    mc.family = None;
700                }
701            }
702        }
703        eprintln!("config: migrated v0 → v1 (cleared stale manifest defaults)");
704    }
705
706    /// Reload config from disk while preserving runtime-only overrides.
707    pub fn reload_from_disk_preserving_runtime(&self) -> Self {
708        let mut fresh = Self::load_or_default();
709        fresh.models_dir = self.models_dir.clone();
710        fresh
711    }
712
713    /// The root mold directory.
714    /// Resolution: `MOLD_HOME` env var → `~/.mold/` → `./.mold` (if HOME unset).
715    pub fn mold_dir() -> Option<PathBuf> {
716        if let Ok(home) = std::env::var("MOLD_HOME") {
717            return Some(PathBuf::from(home));
718        }
719        Some(
720            dirs::home_dir()
721                .unwrap_or_else(|| PathBuf::from("."))
722                .join(".mold"),
723        )
724    }
725
726    pub fn config_path() -> Option<PathBuf> {
727        Self::mold_dir().map(|d| d.join("config.toml"))
728    }
729
730    pub fn data_dir() -> Option<PathBuf> {
731        Self::mold_dir()
732    }
733
734    pub fn resolved_models_dir(&self) -> PathBuf {
735        if let Some(models_dir) = RUNTIME_MODELS_DIR_OVERRIDE.get() {
736            return models_dir.clone();
737        }
738        if let Ok(env_dir) = std::env::var("MOLD_MODELS_DIR") {
739            PathBuf::from(env_dir)
740        } else {
741            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
742            let expanded = self.models_dir.replace("~", &home.to_string_lossy());
743            PathBuf::from(expanded)
744        }
745    }
746
747    pub fn has_models_dir_override(&self) -> bool {
748        RUNTIME_MODELS_DIR_OVERRIDE.get().is_some() || std::env::var_os("MOLD_MODELS_DIR").is_some()
749    }
750
751    /// Resolve the effective default model with idiot-proof fallback chain:
752    /// 1. `MOLD_DEFAULT_MODEL` env var (if set and non-empty)
753    /// 2. Config file `default_model` (if that model has a custom `[models]` entry)
754    /// 3. Config file `default_model` (if that model is a known manifest model that is downloaded)
755    /// 4. Last-used model from `$MOLD_HOME/last-model` (if downloaded)
756    /// 5. If exactly one model is downloaded, use it automatically
757    /// 6. Fall back to config value (will trigger auto-pull on use)
758    pub fn resolved_default_model(&self) -> String {
759        self.resolve_default_model().model
760    }
761
762    /// Like [`resolved_default_model`] but also returns which fallback step resolved it.
763    pub fn resolve_default_model(&self) -> DefaultModelResolution {
764        // 1. Env var override
765        if let Ok(m) = std::env::var("MOLD_DEFAULT_MODEL") {
766            if !m.is_empty() {
767                return DefaultModelResolution {
768                    model: m,
769                    source: DefaultModelSource::EnvVar,
770                };
771            }
772        }
773        // 2. Explicit config entry — honor custom/manual models even when not manifest-backed.
774        let configured = &self.default_model;
775        if self.lookup_model_config(configured).is_some() {
776            return DefaultModelResolution {
777                model: configured.clone(),
778                source: DefaultModelSource::ConfigCustomEntry,
779            };
780        }
781        // 3. Configured manifest model — if downloaded
782        if self.manifest_model_is_downloaded(configured) {
783            return DefaultModelResolution {
784                model: configured.clone(),
785                source: DefaultModelSource::Config,
786            };
787        }
788        // 4. Last-used model — if still downloaded
789        if let Some(last) = Self::read_last_model() {
790            if self.manifest_model_is_downloaded(&last) {
791                return DefaultModelResolution {
792                    model: last,
793                    source: DefaultModelSource::LastUsed,
794                };
795            }
796        }
797        // 5. Single downloaded model (exclude utility and upscaler models)
798        let downloaded: Vec<String> = crate::manifest::known_manifests()
799            .iter()
800            .filter(|m| {
801                !m.is_utility() && !m.is_upscaler() && self.manifest_model_is_downloaded(&m.name)
802            })
803            .map(|m| m.name.clone())
804            .collect();
805        if downloaded.len() == 1 {
806            return DefaultModelResolution {
807                model: downloaded.into_iter().next().unwrap(),
808                source: DefaultModelSource::OnlyDownloaded,
809            };
810        }
811        // 6. Config default (will auto-pull) — resolve bare names like
812        //    "flux2-klein" → "flux2-klein:q8" so the TUI/CLI show the real tag.
813        DefaultModelResolution {
814            model: crate::manifest::resolve_model_name(configured),
815            source: DefaultModelSource::ConfigDefault,
816        }
817    }
818
819    /// Path to the last-model state file: `$MOLD_HOME/last-model`
820    fn last_model_path() -> Option<PathBuf> {
821        Self::mold_dir().map(|d| d.join("last-model"))
822    }
823
824    /// Read the last-used model. When the DB-backed read hook is
825    /// installed (production path), defers to it entirely; otherwise
826    /// reads the legacy `$MOLD_HOME/last-model` sidecar. Callers doing
827    /// one-shot sidecar migration should use
828    /// [`Self::read_last_model_from_sidecar`] directly.
829    pub fn read_last_model() -> Option<String> {
830        if let Some(hook) = READ_LAST_MODEL_HOOK.get() {
831            return hook();
832        }
833        Self::read_last_model_from_sidecar()
834    }
835
836    /// Read the legacy `$MOLD_HOME/last-model` sidecar directly. Used by
837    /// the one-shot `config.toml + sidecar → DB` migration.
838    pub fn read_last_model_from_sidecar() -> Option<String> {
839        let path = Self::last_model_path()?;
840        std::fs::read_to_string(path).ok().and_then(|s| {
841            let trimmed = s.trim().to_string();
842            if trimmed.is_empty() {
843                None
844            } else {
845                Some(trimmed)
846            }
847        })
848    }
849
850    /// Resolve the output directory for server-mode image persistence.
851    /// `MOLD_OUTPUT_DIR` env var takes precedence over the config file value.
852    /// Returns `None` when disabled (default).
853    pub fn resolved_output_dir(&self) -> Option<PathBuf> {
854        let raw = if let Ok(env_dir) = std::env::var("MOLD_OUTPUT_DIR") {
855            if env_dir.is_empty() {
856                None
857            } else {
858                Some(env_dir)
859            }
860        } else {
861            self.output_dir.clone().filter(|s| !s.is_empty())
862        };
863        raw.map(|dir| {
864            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
865            if dir == "~" {
866                home
867            } else if let Some(rest) = dir.strip_prefix("~/") {
868                home.join(rest)
869            } else {
870                PathBuf::from(dir)
871            }
872        })
873    }
874
875    /// Check if image output has been explicitly disabled by the user
876    /// (empty `MOLD_OUTPUT_DIR` env var or empty `output_dir` config field).
877    pub fn is_output_disabled(&self) -> bool {
878        if let Ok(env_dir) = std::env::var("MOLD_OUTPUT_DIR") {
879            return env_dir.is_empty();
880        }
881        matches!(self.output_dir.as_deref(), Some(""))
882    }
883
884    /// Resolved output directory with a default fallback to `~/.mold/output/`.
885    /// Unlike `resolved_output_dir()`, this always returns a path.
886    pub fn effective_output_dir(&self) -> PathBuf {
887        self.resolved_output_dir().unwrap_or_else(|| {
888            Self::mold_dir()
889                .unwrap_or_else(|| PathBuf::from(".mold"))
890                .join("output")
891        })
892    }
893
894    pub fn resolved_media_roots(&self) -> Vec<PathBuf> {
895        if let Ok(roots) = std::env::var("MOLD_MEDIA_ROOTS") {
896            return crate::parse_media_roots_env(&roots);
897        }
898        self.media_roots
899            .as_deref()
900            .map(crate::configured_media_roots)
901            .unwrap_or_default()
902    }
903
904    /// Resolved log directory from config or default (~/.mold/logs/).
905    pub fn resolved_log_dir(&self) -> PathBuf {
906        if let Some(ref dir) = self.logging.dir {
907            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
908            if dir == "~" {
909                home
910            } else if let Some(rest) = dir.strip_prefix("~/") {
911                home.join(rest)
912            } else {
913                PathBuf::from(dir)
914            }
915        } else {
916            Self::mold_dir()
917                .unwrap_or_else(|| PathBuf::from(".mold"))
918                .join("logs")
919        }
920    }
921
922    pub fn effective_embed_metadata(&self, override_value: Option<bool>) -> bool {
923        if let Some(value) = override_value {
924            return value;
925        }
926
927        match std::env::var("MOLD_EMBED_METADATA") {
928            Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
929                "1" | "true" | "yes" | "on" => true,
930                "0" | "false" | "no" | "off" => false,
931                _ => {
932                    eprintln!(
933                        "warning: invalid MOLD_EMBED_METADATA value '{value}' — using config/default"
934                    );
935                    self.embed_metadata
936                }
937            },
938            Err(_) => self.embed_metadata,
939        }
940    }
941
942    pub fn discovered_manifest_paths(&self, name: &str) -> Option<ModelPaths> {
943        let manifest = crate::manifest::find_manifest(name)?;
944        if self.incomplete_pull_blocks_manifest(manifest) {
945            return None;
946        }
947        let models_dir = self.resolved_models_dir();
948        let downloads = manifest
949            .files
950            .iter()
951            .map(|file| {
952                // Prefer a canonical clean-path hit (or a documented legacy
953                // path) under the models dir.
954                let local = crate::manifest::storage_path_candidates(manifest, file)
955                    .into_iter()
956                    .map(|path| models_dir.join(path))
957                    // Same completeness rules as `manifest_files_exist`:
958                    // marker present OR size matches manifest. Plain
959                    // `.exists()` here let truncated downloads masquerade
960                    // as installed models — the gallery race lived here.
961                    .find(|path| Self::file_is_complete(path, file.size_bytes));
962                if let Some(path) = local {
963                    return Some((file.component, path));
964                }
965                // Fallback (companion manifests only): walk mold's managed
966                // `<models_dir>/.hf-cache/` for the same file. A previous
967                // mold install may have placed companion files under a
968                // different canonical layout — e.g. Gemma TE under
969                // `shared/ltx2/...` from a manifest LTX-2 model install
970                // vs the catalog `ltx2-te` companion expecting
971                // `shared/companion/...`. Letting the layout-agnostic
972                // hf-hub cache view satisfy either keeps a single Gemma
973                // download serving both. Restricted to `family ==
974                // "companion"` so non-companion manifests still require
975                // their files at the canonical clean-path location with
976                // the proper completeness guard above — that preserves
977                // the "model not downloaded" branch the tests rely on.
978                if manifest.family != "companion" {
979                    return None;
980                }
981                crate::download::cached_file_path_in_mold_cache(&file.hf_repo, &file.hf_filename)
982                    .map(|path| (file.component, path))
983            })
984            .collect::<Option<Vec<_>>>()?;
985        crate::manifest::paths_from_downloads(&downloads, &manifest.family)
986    }
987
988    pub fn manifest_model_is_downloaded(&self, name: &str) -> bool {
989        let manifest = match crate::manifest::find_manifest(name) {
990            Some(m) => m,
991            None => return false,
992        };
993        if self.incomplete_pull_blocks_manifest(manifest) {
994            return false;
995        }
996        // Upscaler and utility models don't produce full ModelPaths (no VAE).
997        // Check file existence directly instead of going through ModelPaths::resolve.
998        if manifest.is_upscaler() || manifest.is_utility() {
999            return self.manifest_files_exist(manifest);
1000        }
1001        self.resolved_local_manifest_model_config(name).is_some()
1002    }
1003
1004    /// Return true when a known manifest-backed model is missing any required
1005    /// downloadable asset and should be repaired with `mold pull`.
1006    pub fn manifest_model_needs_download(&self, name: &str) -> bool {
1007        let canonical = crate::manifest::resolve_model_name(name);
1008        crate::manifest::find_manifest(&canonical).is_some()
1009            && !self.manifest_model_is_downloaded(&canonical)
1010    }
1011
1012    /// Check whether all files for a manifest are completely on disk.
1013    ///
1014    /// Two acceptance signals (a file is considered complete if **either** holds):
1015    ///
1016    /// 1. A `.sha256-verified` sidecar exists. Written by the pull path on a
1017    ///    successful download — positive proof the file finished writing and,
1018    ///    when the manifest declared a hash, matches it.
1019    /// 2. The on-disk size matches the manifest's declared `size_bytes`.
1020    ///    Covers two legitimate cases without forcing an upgrade-day rehash:
1021    ///    legacy installs created before markers were written, and HF cache
1022    ///    symlinks pointing at fully-downloaded blobs in `~/.cache/huggingface`.
1023    ///
1024    /// Truncated / partial files reject under both signals — they have no
1025    /// marker (because no successful verify ever ran) and their size does not
1026    /// match. That's the load-bearing change for the "downloaded model
1027    /// sometimes doesn't show up" gallery race.
1028    fn manifest_files_exist(&self, manifest: &crate::manifest::ModelManifest) -> bool {
1029        let models_dir = self.resolved_models_dir();
1030        manifest.files.iter().all(|file| {
1031            crate::manifest::storage_path_candidates(manifest, file)
1032                .into_iter()
1033                .map(|path| models_dir.join(path))
1034                .any(|path| Self::file_is_complete(&path, file.size_bytes))
1035        })
1036    }
1037
1038    /// True when the on-disk file at `path` should be treated as a fully
1039    /// downloaded artifact. See [`Self::manifest_files_exist`] for the
1040    /// acceptance rules.
1041    fn file_is_complete(path: &std::path::Path, expected_size: u64) -> bool {
1042        if !path.exists() {
1043            return false;
1044        }
1045        if crate::download::has_sha256_marker(path) {
1046            return true;
1047        }
1048        // Marker missing — fall back to size match against the manifest.
1049        // Symlinks (HF cache) follow through to the target's metadata.
1050        match path.metadata() {
1051            Ok(meta) => meta.len() == expected_size,
1052            Err(_) => false,
1053        }
1054    }
1055
1056    /// Return true when an active `.pulling` marker should block manifest discovery.
1057    ///
1058    /// If all manifest files already exist, the marker is stale (for example a
1059    /// prior pull finished but crashed before marker cleanup). In that case we
1060    /// remove it and continue with manifest-derived paths instead of falling
1061    /// back to potentially stale config entries.
1062    fn incomplete_pull_blocks_manifest(&self, manifest: &crate::manifest::ModelManifest) -> bool {
1063        let marker_path =
1064            crate::download::pulling_marker_path_in(&self.resolved_models_dir(), &manifest.name);
1065        if !marker_path.exists() {
1066            return false;
1067        }
1068
1069        if self.manifest_files_exist(manifest) {
1070            let _ = std::fs::remove_file(&marker_path);
1071            return false;
1072        }
1073
1074        true
1075    }
1076
1077    /// Return the ModelConfig for a given model name, or an empty default.
1078    /// Tries the exact name first, then the canonical `name:tag` form.
1079    pub fn model_config(&self, name: &str) -> ModelConfig {
1080        let mut cfg = self.lookup_model_config(name).unwrap_or_default();
1081
1082        if let Some(discovered) = self.resolved_local_manifest_model_config(name) {
1083            overlay_model_paths(&mut cfg, &discovered);
1084            if cfg.description.is_none() {
1085                cfg.description = discovered.description;
1086            }
1087            if cfg.family.is_none() {
1088                cfg.family = discovered.family;
1089            }
1090        }
1091
1092        cfg
1093    }
1094
1095    /// Return a model config merged with manifest defaults and metadata.
1096    pub fn resolved_model_config(&self, name: &str) -> ModelConfig {
1097        let mut cfg = self.model_config(name);
1098
1099        if let Some(manifest) = crate::manifest::find_manifest(name) {
1100            // Manifest provides defaults when the config file doesn't specify them.
1101            // Since to_model_config() no longer writes manifest defaults to config,
1102            // config values are only Some when the user explicitly set them.
1103            // User overrides are preserved; manifest fills in the rest.
1104            if cfg.default_steps.is_none() {
1105                cfg.default_steps = Some(manifest.defaults.steps);
1106            }
1107            if cfg.default_guidance.is_none() {
1108                cfg.default_guidance = Some(manifest.defaults.guidance);
1109            }
1110            if cfg.default_width.is_none() {
1111                cfg.default_width = Some(manifest.defaults.width);
1112            }
1113            if cfg.default_height.is_none() {
1114                cfg.default_height = Some(manifest.defaults.height);
1115            }
1116            if cfg.is_schnell.is_none() {
1117                cfg.is_schnell = Some(manifest.defaults.is_schnell);
1118            }
1119            if cfg.scheduler.is_none() {
1120                cfg.scheduler = manifest.defaults.scheduler;
1121            }
1122            if cfg.negative_prompt.is_none() {
1123                cfg.negative_prompt = manifest.defaults.negative_prompt.clone();
1124            }
1125            if cfg.default_frames.is_none() {
1126                cfg.default_frames = manifest.defaults.frames;
1127            }
1128            if cfg.default_fps.is_none() {
1129                cfg.default_fps = manifest.defaults.fps;
1130            }
1131            // Description and family always come from the manifest for known models.
1132            // These are metadata, not user-configurable settings.
1133            cfg.description = Some(manifest.description.clone());
1134            cfg.family = Some(manifest.family.clone());
1135        }
1136
1137        cfg
1138    }
1139
1140    /// Insert or update a model configuration entry.
1141    pub fn upsert_model(&mut self, name: String, config: ModelConfig) {
1142        self.models.insert(name, config);
1143    }
1144
1145    /// Remove a model entry from the config, returning it if it existed.
1146    pub fn remove_model(&mut self, name: &str) -> Option<ModelConfig> {
1147        self.models.remove(name)
1148    }
1149
1150    /// Return the effective placement for a model: config entry plus env overrides.
1151    ///
1152    /// Precedence (higher wins):
1153    ///   1. `MOLD_PLACE_TRANSFORMER`, `MOLD_PLACE_VAE`, `MOLD_PLACE_TEXT_ENCODERS`,
1154    ///      `MOLD_PLACE_T5`, `MOLD_PLACE_CLIP_L`, `MOLD_PLACE_CLIP_G`,
1155    ///      `MOLD_PLACE_QWEN` (env overrides per-component).
1156    ///   2. Config file `[models."name:tag".placement]` table.
1157    ///   3. `None` (use engine auto).
1158    ///
1159    /// Each env var parses:
1160    ///   - `"auto"`    — `DeviceRef::Auto`
1161    ///   - `"cpu"`     — `DeviceRef::Cpu`
1162    ///   - `"gpu:N"`   — `DeviceRef::Gpu { ordinal: N }`
1163    ///   - `"gpu"`     — `DeviceRef::Gpu { ordinal: 0 }`
1164    pub fn resolved_placement(&self, model_name: &str) -> Option<crate::types::DevicePlacement> {
1165        use crate::types::DevicePlacement;
1166
1167        let mut placement = self
1168            .lookup_model_config(model_name)
1169            .and_then(|mc| mc.placement);
1170
1171        let env_tier1 = parse_device_ref_env("MOLD_PLACE_TEXT_ENCODERS");
1172        let env_transformer = parse_device_ref_env("MOLD_PLACE_TRANSFORMER");
1173        let env_vae = parse_device_ref_env("MOLD_PLACE_VAE");
1174        let env_t5 = parse_device_ref_env("MOLD_PLACE_T5");
1175        let env_clip_l = parse_device_ref_env("MOLD_PLACE_CLIP_L");
1176        let env_clip_g = parse_device_ref_env("MOLD_PLACE_CLIP_G");
1177        let env_qwen = parse_device_ref_env("MOLD_PLACE_QWEN");
1178
1179        let any_env = env_tier1.is_some()
1180            || env_transformer.is_some()
1181            || env_vae.is_some()
1182            || env_t5.is_some()
1183            || env_clip_l.is_some()
1184            || env_clip_g.is_some()
1185            || env_qwen.is_some();
1186
1187        if !any_env {
1188            return placement;
1189        }
1190
1191        let mut effective: DevicePlacement = placement.unwrap_or_default();
1192        if let Some(r) = env_tier1 {
1193            effective.text_encoders = r;
1194        }
1195        let any_advanced = env_transformer.is_some()
1196            || env_vae.is_some()
1197            || env_t5.is_some()
1198            || env_clip_l.is_some()
1199            || env_clip_g.is_some()
1200            || env_qwen.is_some();
1201        if any_advanced {
1202            let mut adv = effective.advanced.unwrap_or_default();
1203            if let Some(r) = env_transformer {
1204                adv.transformer = r;
1205            }
1206            if let Some(r) = env_vae {
1207                adv.vae = r;
1208            }
1209            if let Some(r) = env_t5 {
1210                adv.t5 = Some(r);
1211            }
1212            if let Some(r) = env_clip_l {
1213                adv.clip_l = Some(r);
1214            }
1215            if let Some(r) = env_clip_g {
1216                adv.clip_g = Some(r);
1217            }
1218            if let Some(r) = env_qwen {
1219                adv.qwen = Some(r);
1220            }
1221            effective.advanced = Some(adv);
1222        }
1223        placement = Some(effective);
1224        placement
1225    }
1226
1227    /// Persist a placement for `model_name`, creating the model entry if
1228    /// missing. `None` clears the placement (and leaves the rest of the
1229    /// entry intact).
1230    pub fn set_model_placement(
1231        &mut self,
1232        model_name: &str,
1233        placement: Option<crate::types::DevicePlacement>,
1234    ) {
1235        let entry = self.models.entry(model_name.to_string()).or_default();
1236        entry.placement = placement;
1237    }
1238
1239    /// Write the config to disk at `config_path()`.
1240    ///
1241    /// Safety: refuses to save if `models_dir` points to a temp/test directory,
1242    /// which can happen when tests race on the `MOLD_HOME` env var.
1243    pub fn save(&self) -> anyhow::Result<()> {
1244        let path = Self::config_path()
1245            .ok_or_else(|| anyhow::anyhow!("cannot determine home directory for config path"))?;
1246
1247        // Guard: refuse to persist a config with a test-temp models_dir into a
1248        // non-temp config path. This catches the race condition where parallel tests
1249        // set MOLD_HOME to /tmp/... and a config.save() writes the corrupted
1250        // models_dir to the user's real config file.
1251        let path_str = path.to_string_lossy();
1252        let is_temp_config = path_str.contains("/tmp/") || path_str.contains("/mold-config-test-");
1253        let has_temp_models_dir = self.models_dir.contains("/tmp/mold-")
1254            || self.models_dir.contains("/mold-config-test-");
1255        if has_temp_models_dir && !is_temp_config {
1256            eprintln!(
1257                "warning: refusing to save config with test models_dir ({}) to real config ({})",
1258                self.models_dir,
1259                path.display()
1260            );
1261            return Ok(());
1262        }
1263
1264        if let Some(parent) = path.parent() {
1265            std::fs::create_dir_all(parent)?;
1266        }
1267        let contents = toml::to_string_pretty(self)?;
1268        std::fs::write(&path, contents)?;
1269        Ok(())
1270    }
1271
1272    /// Serialize only the bootstrap/ops slice of the config to TOML:
1273    /// identifiers, paths, ports, credentials, logging, runpod, per-model
1274    /// file-path entries. User-preference fields that now live in the DB
1275    /// (`expand.*`, `generate.*` globals, per-model generation defaults,
1276    /// lora, scheduler) are stripped.
1277    ///
1278    /// Used by the post-migration rewrite to keep `config.toml` honest
1279    /// after the user-preference surface has moved to SQLite.
1280    pub fn save_bootstrap_only_to(&self, path: &std::path::Path) -> anyhow::Result<()> {
1281        if let Some(parent) = path.parent() {
1282            std::fs::create_dir_all(parent)?;
1283        }
1284        let mut doc = toml::Value::try_from(self)?;
1285        strip_user_pref_fields(&mut doc);
1286        let body = toml::to_string_pretty(&doc)?;
1287        let contents = format!("{BOOTSTRAP_ONLY_BANNER}{body}");
1288        std::fs::write(path, contents)?;
1289        Ok(())
1290    }
1291
1292    /// Save the bootstrap-only slice to the default config path.
1293    pub fn save_bootstrap_only(&self) -> anyhow::Result<()> {
1294        let path = Self::config_path()
1295            .ok_or_else(|| anyhow::anyhow!("cannot determine home directory for config path"))?;
1296        self.save_bootstrap_only_to(&path)
1297    }
1298
1299    /// Whether a config file exists on disk.
1300    pub fn exists_on_disk() -> bool {
1301        Self::config_path().is_some_and(|p| p.exists())
1302    }
1303
1304    /// Look up a model config entry by name (exact or canonical `name:tag` form).
1305    /// Public so CLI commands can check whether a model has a custom config entry.
1306    pub fn lookup_model_config(&self, name: &str) -> Option<ModelConfig> {
1307        if let Some(cfg) = self.models.get(name) {
1308            return Some(cfg.clone());
1309        }
1310        let canonical = resolve_model_name(name);
1311        if canonical != name {
1312            return self.models.get(&canonical).cloned();
1313        }
1314        None
1315    }
1316
1317    fn discovered_manifest_model_config(&self, name: &str) -> Option<ModelConfig> {
1318        let manifest = crate::manifest::find_manifest(name)?;
1319        let paths = self.discovered_manifest_paths(name)?;
1320        Some(manifest.to_model_config(&paths))
1321    }
1322
1323    fn resolved_local_manifest_model_config(&self, name: &str) -> Option<ModelConfig> {
1324        let manifest = crate::manifest::find_manifest(name)?;
1325        let paths = if let Some(paths) = self.discovered_manifest_paths(name) {
1326            paths
1327        } else {
1328            let paths = ModelPaths::resolve(name, self)?;
1329            if !resolved_manifest_paths_exist(manifest, &paths) {
1330                return None;
1331            }
1332            paths
1333        };
1334        Some(manifest.to_model_config(&paths))
1335    }
1336}
1337
1338fn overlay_model_paths(target: &mut ModelConfig, source: &ModelConfig) {
1339    target.transformer = source.transformer.clone();
1340    target.transformer_shards = source.transformer_shards.clone();
1341    target.vae = source.vae.clone();
1342    if source.spatial_upscaler.is_some() {
1343        target.spatial_upscaler = source.spatial_upscaler.clone();
1344    }
1345    if source.temporal_upscaler.is_some() {
1346        target.temporal_upscaler = source.temporal_upscaler.clone();
1347    }
1348    if source.distilled_lora.is_some() {
1349        target.distilled_lora = source.distilled_lora.clone();
1350    }
1351
1352    if source.t5_encoder.is_some() {
1353        target.t5_encoder = source.t5_encoder.clone();
1354    }
1355    if source.clip_encoder.is_some() {
1356        target.clip_encoder = source.clip_encoder.clone();
1357    }
1358    if source.t5_tokenizer.is_some() {
1359        target.t5_tokenizer = source.t5_tokenizer.clone();
1360    }
1361    if source.clip_tokenizer.is_some() {
1362        target.clip_tokenizer = source.clip_tokenizer.clone();
1363    }
1364    if source.clip_encoder_2.is_some() {
1365        target.clip_encoder_2 = source.clip_encoder_2.clone();
1366    }
1367    if source.clip_tokenizer_2.is_some() {
1368        target.clip_tokenizer_2 = source.clip_tokenizer_2.clone();
1369    }
1370    if source.text_encoder_files.is_some() {
1371        target.text_encoder_files = source.text_encoder_files.clone();
1372    }
1373    if source.text_tokenizer.is_some() {
1374        target.text_tokenizer = source.text_tokenizer.clone();
1375    }
1376    if source.decoder.is_some() {
1377        target.decoder = source.decoder.clone();
1378    }
1379}
1380
1381fn resolved_manifest_paths_exist(
1382    manifest: &crate::manifest::ModelManifest,
1383    paths: &ModelPaths,
1384) -> bool {
1385    use crate::manifest::ModelComponent;
1386
1387    let mut transformer_shard_idx = 0usize;
1388    let mut text_encoder_idx = 0usize;
1389
1390    manifest.files.iter().all(|file| match file.component {
1391        ModelComponent::Transformer => paths.transformer.exists(),
1392        ModelComponent::TransformerShard => {
1393            let path = paths.transformer_shards.get(transformer_shard_idx);
1394            transformer_shard_idx += 1;
1395            path.is_some_and(|path| path.exists())
1396        }
1397        ModelComponent::Vae => paths.vae.exists(),
1398        ModelComponent::SpatialUpscaler => paths
1399            .spatial_upscaler
1400            .as_ref()
1401            .is_some_and(|path| path.exists()),
1402        ModelComponent::TemporalUpscaler => paths
1403            .temporal_upscaler
1404            .as_ref()
1405            .is_some_and(|path| path.exists()),
1406        ModelComponent::DistilledLora => paths
1407            .distilled_lora
1408            .as_ref()
1409            .is_some_and(|path| path.exists()),
1410        ModelComponent::T5Encoder => paths.t5_encoder.as_ref().is_some_and(|path| path.exists()),
1411        ModelComponent::ClipEncoder => paths
1412            .clip_encoder
1413            .as_ref()
1414            .is_some_and(|path| path.exists()),
1415        ModelComponent::T5Tokenizer => paths
1416            .t5_tokenizer
1417            .as_ref()
1418            .is_some_and(|path| path.exists()),
1419        ModelComponent::ClipTokenizer => paths
1420            .clip_tokenizer
1421            .as_ref()
1422            .is_some_and(|path| path.exists()),
1423        ModelComponent::ClipEncoder2 => paths
1424            .clip_encoder_2
1425            .as_ref()
1426            .is_some_and(|path| path.exists()),
1427        ModelComponent::ClipTokenizer2 => paths
1428            .clip_tokenizer_2
1429            .as_ref()
1430            .is_some_and(|path| path.exists()),
1431        ModelComponent::TextEncoder => {
1432            let path = paths.text_encoder_files.get(text_encoder_idx);
1433            text_encoder_idx += 1;
1434            path.is_some_and(|path| path.exists())
1435        }
1436        ModelComponent::TextTokenizer => paths
1437            .text_tokenizer
1438            .as_ref()
1439            .is_some_and(|path| path.exists()),
1440        ModelComponent::Decoder => paths.decoder.as_ref().is_some_and(|path| path.exists()),
1441        ModelComponent::Upscaler => paths.transformer.exists(),
1442    })
1443}
1444
1445/// Parse a device-placement string (`auto`, `cpu`, `gpu`, `gpu:N`) into a
1446/// `DeviceRef`. Case-insensitive, whitespace-trimmed. Used by env-var and CLI
1447/// parsers alike so all three surfaces (TOML, env, CLI flag) accept the same
1448/// forms.
1449pub fn parse_device_ref_str(raw: &str) -> Result<crate::types::DeviceRef, String> {
1450    use crate::types::DeviceRef;
1451    let raw = raw.trim().to_lowercase();
1452    if raw == "auto" {
1453        Ok(DeviceRef::Auto)
1454    } else if raw == "cpu" {
1455        Ok(DeviceRef::Cpu)
1456    } else if raw == "gpu" {
1457        Ok(DeviceRef::gpu(0))
1458    } else if let Some(rest) = raw.strip_prefix("gpu:") {
1459        rest.parse::<usize>()
1460            .map(DeviceRef::gpu)
1461            .map_err(|_| format!("invalid device '{raw}' (expected auto|cpu|gpu[:N])"))
1462    } else {
1463        Err(format!(
1464            "invalid device '{raw}' (expected auto|cpu|gpu[:N])"
1465        ))
1466    }
1467}
1468
1469fn parse_device_ref_env(key: &str) -> Option<crate::types::DeviceRef> {
1470    let raw = std::env::var(key).ok()?;
1471    match parse_device_ref_str(&raw) {
1472        Ok(dr) => Some(dr),
1473        Err(msg) => {
1474            eprintln!("mold: ignoring {key}={raw}: {msg}");
1475            None
1476        }
1477    }
1478}