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/// Which fallback step resolved the default model.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum DefaultModelSource {
15    /// `MOLD_DEFAULT_MODEL` environment variable
16    EnvVar,
17    /// Config file `default_model` with a custom `[models]` entry
18    ConfigCustomEntry,
19    /// Config file `default_model` (manifest model, downloaded)
20    Config,
21    /// Last-used model from `$MOLD_HOME/last-model`
22    LastUsed,
23    /// Only one model is downloaded — auto-selected
24    OnlyDownloaded,
25    /// Config file default (model not downloaded, will auto-pull)
26    ConfigDefault,
27}
28
29/// Result of resolving the default model: the model name and how it was resolved.
30#[derive(Debug, Clone)]
31pub struct DefaultModelResolution {
32    pub model: String,
33    pub source: DefaultModelSource,
34}
35
36/// Per-model file path + default settings configuration.
37#[derive(Debug, Clone, Deserialize, Serialize, Default)]
38pub struct ModelConfig {
39    // --- paths ---
40    pub transformer: Option<String>,
41    /// Multi-shard transformer paths (Z-Image BF16); empty means use single `transformer`
42    pub transformer_shards: Option<Vec<String>>,
43    pub vae: Option<String>,
44    /// LTX latent upsampler / spatial upscaler weights.
45    pub spatial_upscaler: Option<String>,
46    /// Optional temporal upscaler weights for LTX-2/LTX-2.3.
47    pub temporal_upscaler: Option<String>,
48    /// Optional distilled LoRA bundled with a model manifest.
49    pub distilled_lora: Option<String>,
50    pub t5_encoder: Option<String>,
51    pub clip_encoder: Option<String>,
52    pub t5_tokenizer: Option<String>,
53    pub clip_tokenizer: Option<String>,
54    /// CLIP-G / OpenCLIP encoder path (SDXL only)
55    pub clip_encoder_2: Option<String>,
56    /// CLIP-G / OpenCLIP tokenizer path (SDXL only)
57    pub clip_tokenizer_2: Option<String>,
58    /// Generic text encoder shard paths (Qwen3 for Z-Image)
59    pub text_encoder_files: Option<Vec<String>>,
60    /// Generic text encoder tokenizer path (Qwen3 for Z-Image)
61    pub text_tokenizer: Option<String>,
62    /// Stage B decoder weights path (Wuerstchen only)
63    pub decoder: Option<String>,
64
65    // --- generation defaults ---
66    /// Default inference steps (e.g. 4 for schnell, 25 for dev)
67    pub default_steps: Option<u32>,
68    /// Default guidance scale (0.0 for schnell, 3.5 for dev finetuned)
69    pub default_guidance: Option<f64>,
70    /// Default output width
71    pub default_width: Option<u32>,
72    /// Default output height
73    pub default_height: Option<u32>,
74    /// Whether this model uses the schnell (distilled) timestep schedule.
75    /// If None, auto-detected from the transformer filename.
76    pub is_schnell: Option<bool>,
77    /// Whether this model uses a turbo (few-step distilled) schedule.
78    /// If None, auto-detected from the model name.
79    pub is_turbo: Option<bool>,
80    /// Scheduler algorithm for UNet-based models (SD1.5, SDXL). Ignored by flow-matching models.
81    pub scheduler: Option<Scheduler>,
82    /// Per-model default negative prompt for CFG-based models.
83    pub negative_prompt: Option<String>,
84    /// Default LoRA adapter path for this model.
85    pub lora: Option<String>,
86    /// Default LoRA scale for this model (0.0-2.0).
87    pub lora_scale: Option<f64>,
88    /// Default number of video frames for video models (e.g. 25 for ltx-video).
89    pub default_frames: Option<u32>,
90    /// Default video FPS for video models (e.g. 24 for ltx-video).
91    pub default_fps: Option<u32>,
92
93    // --- metadata ---
94    pub description: Option<String>,
95    pub family: Option<String>,
96}
97
98impl ModelConfig {
99    /// Collect all file path strings from this model config into a flat list.
100    /// Used for reference counting when determining which files are shared.
101    pub fn all_file_paths(&self) -> Vec<String> {
102        let mut paths = Vec::new();
103        let singles = [
104            &self.transformer,
105            &self.vae,
106            &self.spatial_upscaler,
107            &self.temporal_upscaler,
108            &self.distilled_lora,
109            &self.t5_encoder,
110            &self.clip_encoder,
111            &self.t5_tokenizer,
112            &self.clip_tokenizer,
113            &self.clip_encoder_2,
114            &self.clip_tokenizer_2,
115            &self.text_tokenizer,
116            &self.decoder,
117        ];
118        for p in singles.into_iter().flatten() {
119            paths.push(p.clone());
120        }
121        if let Some(ref shards) = self.transformer_shards {
122            paths.extend(shards.iter().cloned());
123        }
124        if let Some(ref files) = self.text_encoder_files {
125            paths.extend(files.iter().cloned());
126        }
127        paths
128    }
129
130    /// Total disk usage of all model files: `(bytes, gigabytes)`.
131    ///
132    /// Sums the file sizes of all paths referenced by this config entry.
133    /// Missing files are silently skipped.
134    pub fn disk_usage(&self) -> (u64, f64) {
135        let total: u64 = self
136            .all_file_paths()
137            .iter()
138            .filter_map(|p| std::fs::metadata(p).ok())
139            .map(|m| m.len())
140            .sum();
141        (total, total as f64 / 1_073_741_824.0)
142    }
143
144    /// Effective steps: model default → global fallback → hardcoded default.
145    pub fn effective_steps(&self, global_cfg: &Config) -> u32 {
146        self.default_steps.unwrap_or(global_cfg.default_steps)
147    }
148
149    /// Effective guidance.
150    pub fn effective_guidance(&self) -> f64 {
151        self.default_guidance.unwrap_or(3.5)
152    }
153
154    /// Effective width.
155    pub fn effective_width(&self, global_cfg: &Config) -> u32 {
156        self.default_width.unwrap_or(global_cfg.default_width)
157    }
158
159    /// Effective height.
160    pub fn effective_height(&self, global_cfg: &Config) -> u32 {
161        self.default_height.unwrap_or(global_cfg.default_height)
162    }
163
164    /// Effective negative prompt: per-model override → global default → None.
165    pub fn effective_negative_prompt(&self, global_cfg: &Config) -> Option<String> {
166        self.negative_prompt
167            .clone()
168            .or_else(|| global_cfg.default_negative_prompt.clone())
169    }
170
171    /// Effective LoRA config: per-model default path and scale, or None.
172    pub fn effective_lora(&self) -> Option<(String, f64)> {
173        self.lora
174            .as_ref()
175            .map(|path| (path.clone(), self.lora_scale.unwrap_or(1.0)))
176    }
177
178    /// Effective video frames: per-model default, or None for image-only models.
179    pub fn effective_frames(&self) -> Option<u32> {
180        self.default_frames
181    }
182
183    /// Effective video FPS: per-model default, or None for image-only models.
184    pub fn effective_fps(&self) -> Option<u32> {
185        self.default_fps
186    }
187}
188
189/// Resolved model file paths.
190/// For diffusion models, `transformer` and `vae` are always required.
191/// For upscaler models, only `transformer` (weights) is required; `vae` is empty.
192/// For utility models, only `transformer` is required; `vae` may be empty.
193/// Other paths are optional — each engine validates what it needs at load time.
194#[derive(Debug, Clone)]
195pub struct ModelPaths {
196    pub transformer: PathBuf,
197    /// Multi-shard transformer paths (Z-Image BF16); empty means use single `transformer`
198    pub transformer_shards: Vec<PathBuf>,
199    pub vae: PathBuf,
200    pub spatial_upscaler: Option<PathBuf>,
201    pub temporal_upscaler: Option<PathBuf>,
202    pub distilled_lora: Option<PathBuf>,
203    pub t5_encoder: Option<PathBuf>,
204    pub clip_encoder: Option<PathBuf>,
205    pub t5_tokenizer: Option<PathBuf>,
206    pub clip_tokenizer: Option<PathBuf>,
207    /// CLIP-G / OpenCLIP encoder (SDXL only)
208    pub clip_encoder_2: Option<PathBuf>,
209    /// CLIP-G / OpenCLIP tokenizer (SDXL only)
210    pub clip_tokenizer_2: Option<PathBuf>,
211    /// Generic text encoder shard paths (Qwen3 for Z-Image)
212    pub text_encoder_files: Vec<PathBuf>,
213    /// Generic text encoder tokenizer (Qwen3 for Z-Image)
214    pub text_tokenizer: Option<PathBuf>,
215    /// Stage B decoder weights (Wuerstchen only)
216    pub decoder: Option<PathBuf>,
217}
218
219impl ModelPaths {
220    /// Resolve paths for a model. Checks config, then env vars.
221    /// Returns None if transformer and VAE paths can't be resolved.
222    /// All other paths are optional (depend on model family).
223    pub fn resolve(model_name: &str, config: &Config) -> Option<Self> {
224        if let Some(model_cfg) = config.discovered_manifest_model_config(model_name) {
225            return Self::resolve_from_model_config(Some(&model_cfg));
226        }
227
228        if crate::manifest::find_manifest(model_name).is_some() && config.has_models_dir_override()
229        {
230            return Self::resolve_from_model_config(None);
231        }
232
233        let model_cfg = config.lookup_model_config(model_name);
234        Self::resolve_from_model_config(model_cfg.as_ref())
235    }
236
237    fn resolve_from_model_config(model_cfg: Option<&ModelConfig>) -> Option<Self> {
238        let transformer = Self::resolve_path(
239            model_cfg.and_then(|m| m.transformer.as_deref()),
240            "MOLD_TRANSFORMER_PATH",
241        )?;
242        let transformer_shards = model_cfg
243            .and_then(|m| m.transformer_shards.as_ref())
244            .map(|shards| shards.iter().map(PathBuf::from).collect())
245            .unwrap_or_default();
246        let vae = Self::resolve_path(model_cfg.and_then(|m| m.vae.as_deref()), "MOLD_VAE_PATH")?;
247        let spatial_upscaler = Self::resolve_path(
248            model_cfg.and_then(|m| m.spatial_upscaler.as_deref()),
249            "MOLD_SPATIAL_UPSCALER_PATH",
250        );
251        let temporal_upscaler = Self::resolve_path(
252            model_cfg.and_then(|m| m.temporal_upscaler.as_deref()),
253            "MOLD_TEMPORAL_UPSCALER_PATH",
254        );
255        let distilled_lora = Self::resolve_path(
256            model_cfg.and_then(|m| m.distilled_lora.as_deref()),
257            "MOLD_DISTILLED_LORA_PATH",
258        );
259        let t5_encoder = Self::resolve_path(
260            model_cfg.and_then(|m| m.t5_encoder.as_deref()),
261            "MOLD_T5_PATH",
262        );
263        let clip_encoder = Self::resolve_path(
264            model_cfg.and_then(|m| m.clip_encoder.as_deref()),
265            "MOLD_CLIP_PATH",
266        );
267        let t5_tokenizer = Self::resolve_path(
268            model_cfg.and_then(|m| m.t5_tokenizer.as_deref()),
269            "MOLD_T5_TOKENIZER_PATH",
270        );
271        let clip_tokenizer = Self::resolve_path(
272            model_cfg.and_then(|m| m.clip_tokenizer.as_deref()),
273            "MOLD_CLIP_TOKENIZER_PATH",
274        );
275        let clip_encoder_2 = Self::resolve_path(
276            model_cfg.and_then(|m| m.clip_encoder_2.as_deref()),
277            "MOLD_CLIP2_PATH",
278        );
279        let clip_tokenizer_2 = Self::resolve_path(
280            model_cfg.and_then(|m| m.clip_tokenizer_2.as_deref()),
281            "MOLD_CLIP2_TOKENIZER_PATH",
282        );
283        let text_encoder_files = model_cfg
284            .and_then(|m| m.text_encoder_files.as_ref())
285            .map(|files| files.iter().map(PathBuf::from).collect())
286            .unwrap_or_default();
287        let text_tokenizer = Self::resolve_path(
288            model_cfg.and_then(|m| m.text_tokenizer.as_deref()),
289            "MOLD_TEXT_TOKENIZER_PATH",
290        );
291        let decoder = Self::resolve_path(
292            model_cfg.and_then(|m| m.decoder.as_deref()),
293            "MOLD_DECODER_PATH",
294        );
295
296        Some(Self {
297            transformer,
298            transformer_shards,
299            vae,
300            spatial_upscaler,
301            temporal_upscaler,
302            distilled_lora,
303            t5_encoder,
304            clip_encoder,
305            t5_tokenizer,
306            clip_tokenizer,
307            clip_encoder_2,
308            clip_tokenizer_2,
309            text_encoder_files,
310            text_tokenizer,
311            decoder,
312        })
313    }
314
315    fn resolve_path(config_val: Option<&str>, env_var: &str) -> Option<PathBuf> {
316        if let Ok(path) = std::env::var(env_var) {
317            return Some(PathBuf::from(path));
318        }
319        if let Some(path) = config_val {
320            return Some(PathBuf::from(path));
321        }
322        None
323    }
324}
325
326/// Current config schema version. Increment when adding migrations.
327const CURRENT_CONFIG_VERSION: u32 = 1;
328
329#[derive(Debug, Clone, Deserialize, Serialize)]
330pub struct Config {
331    /// Config schema version for migrations. Old configs without this field
332    /// default to 0 and are migrated on first load.
333    #[serde(default)]
334    pub config_version: u32,
335
336    #[serde(default = "default_model")]
337    pub default_model: String,
338
339    #[serde(default = "default_models_dir")]
340    pub models_dir: String,
341
342    #[serde(default = "default_port")]
343    pub server_port: u16,
344
345    #[serde(default = "default_dimension")]
346    pub default_width: u32,
347
348    #[serde(default = "default_dimension")]
349    pub default_height: u32,
350
351    #[serde(default = "default_steps")]
352    pub default_steps: u32,
353
354    #[serde(default = "default_embed_metadata")]
355    pub embed_metadata: bool,
356
357    /// Preferred T5 encoder variant: "fp16" (default), "q8", "q6", "q5", "q4", "q3", or "auto".
358    /// "auto" selects the best variant that fits in GPU VRAM.
359    /// An explicit quantized tag always uses that variant regardless of VRAM.
360    #[serde(default)]
361    pub t5_variant: Option<String>,
362
363    /// Preferred Qwen3 text encoder variant: "bf16" (default), "q8", "q6", "iq4", "q3", or "auto".
364    /// "auto" selects the best variant that fits in GPU VRAM (with drop-and-reload).
365    #[serde(default)]
366    pub qwen3_variant: Option<String>,
367
368    /// Directory to persist generated images. Default: `~/.mold/output/`.
369    /// Override with `MOLD_OUTPUT_DIR` env var. Set to empty string to disable
370    /// (TUI gallery will not function when disabled).
371    #[serde(default)]
372    pub output_dir: Option<String>,
373
374    /// Global default negative prompt for CFG-based models (SD1.5, SDXL, SD3, Wuerstchen).
375    /// Overridden by per-model `negative_prompt` or CLI `--negative-prompt`.
376    #[serde(default)]
377    pub default_negative_prompt: Option<String>,
378
379    /// Prompt expansion settings.
380    #[serde(default)]
381    pub expand: ExpandSettings,
382
383    /// Logging configuration.
384    #[serde(default)]
385    pub logging: LoggingConfig,
386
387    /// RunPod integration settings (api key, defaults, auto-teardown behaviour).
388    #[serde(default)]
389    pub runpod: crate::runpod::RunPodSettings,
390
391    /// Per-model configurations, keyed by model name.
392    #[serde(default)]
393    pub models: HashMap<String, ModelConfig>,
394}
395
396/// Logging configuration for file output and rotation.
397#[derive(Debug, Clone, Deserialize, Serialize)]
398pub struct LoggingConfig {
399    /// Log level: trace, debug, info, warn, error. Overridden by MOLD_LOG env var.
400    #[serde(default = "default_log_level")]
401    pub level: String,
402
403    /// Enable file logging. When true, logs go to ~/.mold/logs/.
404    #[serde(default)]
405    pub file: bool,
406
407    /// Custom log file directory (default: ~/.mold/logs/).
408    #[serde(default)]
409    pub dir: Option<String>,
410
411    /// Number of days to retain log files. Default: 7.
412    #[serde(default = "default_log_max_days")]
413    pub max_days: u32,
414}
415
416fn default_log_level() -> String {
417    "info".to_string()
418}
419fn default_log_max_days() -> u32 {
420    7
421}
422
423impl Default for LoggingConfig {
424    fn default() -> Self {
425        Self {
426            level: default_log_level(),
427            file: false,
428            dir: None,
429            max_days: default_log_max_days(),
430        }
431    }
432}
433
434fn default_model() -> String {
435    "flux2-klein:q8".to_string()
436}
437
438fn default_models_dir() -> String {
439    if let Ok(home) = std::env::var("MOLD_HOME") {
440        format!("{home}/models")
441    } else {
442        "~/.mold/models".to_string()
443    }
444}
445
446fn default_port() -> u16 {
447    7680
448}
449
450fn default_dimension() -> u32 {
451    768
452}
453
454fn default_steps() -> u32 {
455    4
456}
457
458fn default_embed_metadata() -> bool {
459    true
460}
461
462impl Default for Config {
463    fn default() -> Self {
464        Self {
465            config_version: CURRENT_CONFIG_VERSION,
466            default_model: default_model(),
467            models_dir: default_models_dir(),
468            server_port: default_port(),
469            default_width: default_dimension(),
470            default_height: default_dimension(),
471            default_steps: default_steps(),
472            embed_metadata: default_embed_metadata(),
473            t5_variant: None,
474            qwen3_variant: None,
475            output_dir: None,
476            default_negative_prompt: None,
477            expand: ExpandSettings::default(),
478            logging: LoggingConfig::default(),
479            runpod: crate::runpod::RunPodSettings::default(),
480            models: HashMap::new(),
481        }
482    }
483}
484
485impl Config {
486    pub fn install_runtime_models_dir_override(models_dir: PathBuf) {
487        let _ = RUNTIME_MODELS_DIR_OVERRIDE.get_or_init(|| models_dir);
488    }
489
490    pub fn load_or_default() -> Self {
491        let Some(config_path) = Self::config_path() else {
492            eprintln!("warning: could not determine home directory — using default config");
493            return Config::default();
494        };
495        let mut cfg = if config_path.exists() {
496            match std::fs::read_to_string(&config_path) {
497                Ok(contents) => match toml::from_str(&contents) {
498                    Ok(cfg) => cfg,
499                    Err(e) => {
500                        eprintln!(
501                            "warning: failed to parse config at {}: {e} — using defaults",
502                            config_path.display()
503                        );
504                        Config::default()
505                    }
506                },
507                Err(e) => {
508                    eprintln!(
509                        "warning: failed to read config at {}: {e} — using defaults",
510                        config_path.display()
511                    );
512                    Config::default()
513                }
514            }
515        } else {
516            Config::default()
517        };
518
519        // Run config migrations if needed
520        if cfg.config_version < CURRENT_CONFIG_VERSION {
521            Self::run_migrations(&mut cfg);
522            cfg.config_version = CURRENT_CONFIG_VERSION;
523            if let Err(e) = cfg.save() {
524                eprintln!("warning: failed to save migrated config: {e}");
525            }
526        }
527
528        cfg
529    }
530
531    /// Run all pending config migrations from cfg.config_version to CURRENT.
532    pub(crate) fn run_migrations(cfg: &mut Config) {
533        if cfg.config_version < 1 {
534            Self::migrate_v0_to_v1(cfg);
535        }
536        // Future migrations:
537        // if cfg.config_version < 2 { Self::migrate_v1_to_v2(cfg); }
538    }
539
540    /// v0 → v1: Strip stale manifest defaults from known model entries.
541    ///
542    /// Old `mold pull` wrote all manifest defaults (steps, guidance, dimensions,
543    /// description, family, is_schnell, scheduler) into config.toml. These become
544    /// stale when manifests update. This migration removes them so
545    /// `resolved_model_config()` reads fresh values from the manifest at runtime.
546    fn migrate_v0_to_v1(cfg: &mut Config) {
547        let model_names: Vec<String> = cfg.models.keys().cloned().collect();
548        for name in model_names {
549            if crate::manifest::find_manifest(&name).is_some() {
550                if let Some(mc) = cfg.models.get_mut(&name) {
551                    mc.default_steps = None;
552                    mc.default_guidance = None;
553                    mc.default_width = None;
554                    mc.default_height = None;
555                    mc.is_schnell = None;
556                    mc.is_turbo = None;
557                    mc.scheduler = None;
558                    mc.negative_prompt = None;
559                    mc.default_frames = None;
560                    mc.default_fps = None;
561                    mc.description = None;
562                    mc.family = None;
563                }
564            }
565        }
566        eprintln!("config: migrated v0 → v1 (cleared stale manifest defaults)");
567    }
568
569    /// Reload config from disk while preserving runtime-only overrides.
570    pub fn reload_from_disk_preserving_runtime(&self) -> Self {
571        let mut fresh = Self::load_or_default();
572        fresh.models_dir = self.models_dir.clone();
573        fresh
574    }
575
576    /// The root mold directory.
577    /// Resolution: `MOLD_HOME` env var → `~/.mold/` → `./.mold` (if HOME unset).
578    pub fn mold_dir() -> Option<PathBuf> {
579        if let Ok(home) = std::env::var("MOLD_HOME") {
580            return Some(PathBuf::from(home));
581        }
582        Some(
583            dirs::home_dir()
584                .unwrap_or_else(|| PathBuf::from("."))
585                .join(".mold"),
586        )
587    }
588
589    pub fn config_path() -> Option<PathBuf> {
590        Self::mold_dir().map(|d| d.join("config.toml"))
591    }
592
593    pub fn data_dir() -> Option<PathBuf> {
594        Self::mold_dir()
595    }
596
597    pub fn resolved_models_dir(&self) -> PathBuf {
598        if let Some(models_dir) = RUNTIME_MODELS_DIR_OVERRIDE.get() {
599            return models_dir.clone();
600        }
601        if let Ok(env_dir) = std::env::var("MOLD_MODELS_DIR") {
602            PathBuf::from(env_dir)
603        } else {
604            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
605            let expanded = self.models_dir.replace("~", &home.to_string_lossy());
606            PathBuf::from(expanded)
607        }
608    }
609
610    pub fn has_models_dir_override(&self) -> bool {
611        RUNTIME_MODELS_DIR_OVERRIDE.get().is_some() || std::env::var_os("MOLD_MODELS_DIR").is_some()
612    }
613
614    /// Resolve the effective default model with idiot-proof fallback chain:
615    /// 1. `MOLD_DEFAULT_MODEL` env var (if set and non-empty)
616    /// 2. Config file `default_model` (if that model has a custom `[models]` entry)
617    /// 3. Config file `default_model` (if that model is a known manifest model that is downloaded)
618    /// 4. Last-used model from `$MOLD_HOME/last-model` (if downloaded)
619    /// 5. If exactly one model is downloaded, use it automatically
620    /// 6. Fall back to config value (will trigger auto-pull on use)
621    pub fn resolved_default_model(&self) -> String {
622        self.resolve_default_model().model
623    }
624
625    /// Like [`resolved_default_model`] but also returns which fallback step resolved it.
626    pub fn resolve_default_model(&self) -> DefaultModelResolution {
627        // 1. Env var override
628        if let Ok(m) = std::env::var("MOLD_DEFAULT_MODEL") {
629            if !m.is_empty() {
630                return DefaultModelResolution {
631                    model: m,
632                    source: DefaultModelSource::EnvVar,
633                };
634            }
635        }
636        // 2. Explicit config entry — honor custom/manual models even when not manifest-backed.
637        let configured = &self.default_model;
638        if self.lookup_model_config(configured).is_some() {
639            return DefaultModelResolution {
640                model: configured.clone(),
641                source: DefaultModelSource::ConfigCustomEntry,
642            };
643        }
644        // 3. Configured manifest model — if downloaded
645        if self.manifest_model_is_downloaded(configured) {
646            return DefaultModelResolution {
647                model: configured.clone(),
648                source: DefaultModelSource::Config,
649            };
650        }
651        // 4. Last-used model — if still downloaded
652        if let Some(last) = Self::read_last_model() {
653            if self.manifest_model_is_downloaded(&last) {
654                return DefaultModelResolution {
655                    model: last,
656                    source: DefaultModelSource::LastUsed,
657                };
658            }
659        }
660        // 5. Single downloaded model (exclude utility and upscaler models)
661        let downloaded: Vec<String> = crate::manifest::known_manifests()
662            .iter()
663            .filter(|m| {
664                !m.is_utility() && !m.is_upscaler() && self.manifest_model_is_downloaded(&m.name)
665            })
666            .map(|m| m.name.clone())
667            .collect();
668        if downloaded.len() == 1 {
669            return DefaultModelResolution {
670                model: downloaded.into_iter().next().unwrap(),
671                source: DefaultModelSource::OnlyDownloaded,
672            };
673        }
674        // 6. Config default (will auto-pull) — resolve bare names like
675        //    "flux2-klein" → "flux2-klein:q8" so the TUI/CLI show the real tag.
676        DefaultModelResolution {
677            model: crate::manifest::resolve_model_name(configured),
678            source: DefaultModelSource::ConfigDefault,
679        }
680    }
681
682    /// Path to the last-model state file: `$MOLD_HOME/last-model`
683    fn last_model_path() -> Option<PathBuf> {
684        Self::mold_dir().map(|d| d.join("last-model"))
685    }
686
687    /// Read the last-used model name from the state file.
688    pub fn read_last_model() -> Option<String> {
689        let path = Self::last_model_path()?;
690        std::fs::read_to_string(path).ok().and_then(|s| {
691            let trimmed = s.trim().to_string();
692            if trimmed.is_empty() {
693                None
694            } else {
695                Some(trimmed)
696            }
697        })
698    }
699
700    /// Write the last-used model name to the state file (best-effort, non-fatal).
701    pub fn write_last_model(model: &str) {
702        if let Some(path) = Self::last_model_path() {
703            if let Some(parent) = path.parent() {
704                let _ = std::fs::create_dir_all(parent);
705            }
706            let _ = std::fs::write(path, model);
707        }
708    }
709
710    /// Resolve the output directory for server-mode image persistence.
711    /// `MOLD_OUTPUT_DIR` env var takes precedence over the config file value.
712    /// Returns `None` when disabled (default).
713    pub fn resolved_output_dir(&self) -> Option<PathBuf> {
714        let raw = if let Ok(env_dir) = std::env::var("MOLD_OUTPUT_DIR") {
715            if env_dir.is_empty() {
716                None
717            } else {
718                Some(env_dir)
719            }
720        } else {
721            self.output_dir.clone().filter(|s| !s.is_empty())
722        };
723        raw.map(|dir| {
724            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
725            if dir == "~" {
726                home
727            } else if let Some(rest) = dir.strip_prefix("~/") {
728                home.join(rest)
729            } else {
730                PathBuf::from(dir)
731            }
732        })
733    }
734
735    /// Check if image output has been explicitly disabled by the user
736    /// (empty `MOLD_OUTPUT_DIR` env var or empty `output_dir` config field).
737    pub fn is_output_disabled(&self) -> bool {
738        if let Ok(env_dir) = std::env::var("MOLD_OUTPUT_DIR") {
739            return env_dir.is_empty();
740        }
741        matches!(self.output_dir.as_deref(), Some(""))
742    }
743
744    /// Resolved output directory with a default fallback to `~/.mold/output/`.
745    /// Unlike `resolved_output_dir()`, this always returns a path.
746    pub fn effective_output_dir(&self) -> PathBuf {
747        self.resolved_output_dir().unwrap_or_else(|| {
748            Self::mold_dir()
749                .unwrap_or_else(|| PathBuf::from(".mold"))
750                .join("output")
751        })
752    }
753
754    /// Resolved log directory from config or default (~/.mold/logs/).
755    pub fn resolved_log_dir(&self) -> PathBuf {
756        if let Some(ref dir) = self.logging.dir {
757            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
758            if dir == "~" {
759                home
760            } else if let Some(rest) = dir.strip_prefix("~/") {
761                home.join(rest)
762            } else {
763                PathBuf::from(dir)
764            }
765        } else {
766            Self::mold_dir()
767                .unwrap_or_else(|| PathBuf::from(".mold"))
768                .join("logs")
769        }
770    }
771
772    pub fn effective_embed_metadata(&self, override_value: Option<bool>) -> bool {
773        if let Some(value) = override_value {
774            return value;
775        }
776
777        match std::env::var("MOLD_EMBED_METADATA") {
778            Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
779                "1" | "true" | "yes" | "on" => true,
780                "0" | "false" | "no" | "off" => false,
781                _ => {
782                    eprintln!(
783                        "warning: invalid MOLD_EMBED_METADATA value '{value}' — using config/default"
784                    );
785                    self.embed_metadata
786                }
787            },
788            Err(_) => self.embed_metadata,
789        }
790    }
791
792    pub fn discovered_manifest_paths(&self, name: &str) -> Option<ModelPaths> {
793        let manifest = crate::manifest::find_manifest(name)?;
794        if self.incomplete_pull_blocks_manifest(manifest) {
795            return None;
796        }
797        let models_dir = self.resolved_models_dir();
798        let downloads = manifest
799            .files
800            .iter()
801            .map(|file| {
802                crate::manifest::storage_path_candidates(manifest, file)
803                    .into_iter()
804                    .map(|path| models_dir.join(path))
805                    .find(|path| path.exists())
806                    .map(|path| (file.component, path))
807            })
808            .collect::<Option<Vec<_>>>()?;
809        crate::manifest::paths_from_downloads(&downloads, &manifest.family)
810    }
811
812    pub fn manifest_model_is_downloaded(&self, name: &str) -> bool {
813        let manifest = match crate::manifest::find_manifest(name) {
814            Some(m) => m,
815            None => return false,
816        };
817        if self.incomplete_pull_blocks_manifest(manifest) {
818            return false;
819        }
820        // Upscaler and utility models don't produce full ModelPaths (no VAE).
821        // Check file existence directly instead of going through ModelPaths::resolve.
822        if manifest.is_upscaler() || manifest.is_utility() {
823            return self.manifest_files_exist(manifest);
824        }
825        self.resolved_local_manifest_model_config(name).is_some()
826    }
827
828    /// Return true when a known manifest-backed model is missing any required
829    /// downloadable asset and should be repaired with `mold pull`.
830    pub fn manifest_model_needs_download(&self, name: &str) -> bool {
831        let canonical = crate::manifest::resolve_model_name(name);
832        crate::manifest::find_manifest(&canonical).is_some()
833            && !self.manifest_model_is_downloaded(&canonical)
834    }
835
836    /// Check whether all files for a manifest exist on disk.
837    fn manifest_files_exist(&self, manifest: &crate::manifest::ModelManifest) -> bool {
838        let models_dir = self.resolved_models_dir();
839        manifest.files.iter().all(|file| {
840            crate::manifest::storage_path_candidates(manifest, file)
841                .into_iter()
842                .map(|path| models_dir.join(path))
843                .any(|path| path.exists())
844        })
845    }
846
847    /// Return true when an active `.pulling` marker should block manifest discovery.
848    ///
849    /// If all manifest files already exist, the marker is stale (for example a
850    /// prior pull finished but crashed before marker cleanup). In that case we
851    /// remove it and continue with manifest-derived paths instead of falling
852    /// back to potentially stale config entries.
853    fn incomplete_pull_blocks_manifest(&self, manifest: &crate::manifest::ModelManifest) -> bool {
854        let marker_path =
855            crate::download::pulling_marker_path_in(&self.resolved_models_dir(), &manifest.name);
856        if !marker_path.exists() {
857            return false;
858        }
859
860        if self.manifest_files_exist(manifest) {
861            let _ = std::fs::remove_file(&marker_path);
862            return false;
863        }
864
865        true
866    }
867
868    /// Return the ModelConfig for a given model name, or an empty default.
869    /// Tries the exact name first, then the canonical `name:tag` form.
870    pub fn model_config(&self, name: &str) -> ModelConfig {
871        let mut cfg = self.lookup_model_config(name).unwrap_or_default();
872
873        if let Some(discovered) = self.resolved_local_manifest_model_config(name) {
874            overlay_model_paths(&mut cfg, &discovered);
875            if cfg.description.is_none() {
876                cfg.description = discovered.description;
877            }
878            if cfg.family.is_none() {
879                cfg.family = discovered.family;
880            }
881        }
882
883        cfg
884    }
885
886    /// Return a model config merged with manifest defaults and metadata.
887    pub fn resolved_model_config(&self, name: &str) -> ModelConfig {
888        let mut cfg = self.model_config(name);
889
890        if let Some(manifest) = crate::manifest::find_manifest(name) {
891            // Manifest provides defaults when the config file doesn't specify them.
892            // Since to_model_config() no longer writes manifest defaults to config,
893            // config values are only Some when the user explicitly set them.
894            // User overrides are preserved; manifest fills in the rest.
895            if cfg.default_steps.is_none() {
896                cfg.default_steps = Some(manifest.defaults.steps);
897            }
898            if cfg.default_guidance.is_none() {
899                cfg.default_guidance = Some(manifest.defaults.guidance);
900            }
901            if cfg.default_width.is_none() {
902                cfg.default_width = Some(manifest.defaults.width);
903            }
904            if cfg.default_height.is_none() {
905                cfg.default_height = Some(manifest.defaults.height);
906            }
907            if cfg.is_schnell.is_none() {
908                cfg.is_schnell = Some(manifest.defaults.is_schnell);
909            }
910            if cfg.scheduler.is_none() {
911                cfg.scheduler = manifest.defaults.scheduler;
912            }
913            if cfg.negative_prompt.is_none() {
914                cfg.negative_prompt = manifest.defaults.negative_prompt.clone();
915            }
916            if cfg.default_frames.is_none() {
917                cfg.default_frames = manifest.defaults.frames;
918            }
919            if cfg.default_fps.is_none() {
920                cfg.default_fps = manifest.defaults.fps;
921            }
922            // Description and family always come from the manifest for known models.
923            // These are metadata, not user-configurable settings.
924            cfg.description = Some(manifest.description.clone());
925            cfg.family = Some(manifest.family.clone());
926        }
927
928        cfg
929    }
930
931    /// Insert or update a model configuration entry.
932    pub fn upsert_model(&mut self, name: String, config: ModelConfig) {
933        self.models.insert(name, config);
934    }
935
936    /// Remove a model entry from the config, returning it if it existed.
937    pub fn remove_model(&mut self, name: &str) -> Option<ModelConfig> {
938        self.models.remove(name)
939    }
940
941    /// Write the config to disk at `config_path()`.
942    ///
943    /// Safety: refuses to save if `models_dir` points to a temp/test directory,
944    /// which can happen when tests race on the `MOLD_HOME` env var.
945    pub fn save(&self) -> anyhow::Result<()> {
946        let path = Self::config_path()
947            .ok_or_else(|| anyhow::anyhow!("cannot determine home directory for config path"))?;
948
949        // Guard: refuse to persist a config with a test-temp models_dir into a
950        // non-temp config path. This catches the race condition where parallel tests
951        // set MOLD_HOME to /tmp/... and a config.save() writes the corrupted
952        // models_dir to the user's real config file.
953        let path_str = path.to_string_lossy();
954        let is_temp_config = path_str.contains("/tmp/") || path_str.contains("/mold-config-test-");
955        let has_temp_models_dir = self.models_dir.contains("/tmp/mold-")
956            || self.models_dir.contains("/mold-config-test-");
957        if has_temp_models_dir && !is_temp_config {
958            eprintln!(
959                "warning: refusing to save config with test models_dir ({}) to real config ({})",
960                self.models_dir,
961                path.display()
962            );
963            return Ok(());
964        }
965
966        if let Some(parent) = path.parent() {
967            std::fs::create_dir_all(parent)?;
968        }
969        let contents = toml::to_string_pretty(self)?;
970        std::fs::write(&path, contents)?;
971        Ok(())
972    }
973
974    /// Whether a config file exists on disk.
975    pub fn exists_on_disk() -> bool {
976        Self::config_path().is_some_and(|p| p.exists())
977    }
978
979    /// Look up a model config entry by name (exact or canonical `name:tag` form).
980    /// Public so CLI commands can check whether a model has a custom config entry.
981    pub fn lookup_model_config(&self, name: &str) -> Option<ModelConfig> {
982        if let Some(cfg) = self.models.get(name) {
983            return Some(cfg.clone());
984        }
985        let canonical = resolve_model_name(name);
986        if canonical != name {
987            return self.models.get(&canonical).cloned();
988        }
989        None
990    }
991
992    fn discovered_manifest_model_config(&self, name: &str) -> Option<ModelConfig> {
993        let manifest = crate::manifest::find_manifest(name)?;
994        let paths = self.discovered_manifest_paths(name)?;
995        Some(manifest.to_model_config(&paths))
996    }
997
998    fn resolved_local_manifest_model_config(&self, name: &str) -> Option<ModelConfig> {
999        let manifest = crate::manifest::find_manifest(name)?;
1000        let paths = if let Some(paths) = self.discovered_manifest_paths(name) {
1001            paths
1002        } else {
1003            let paths = ModelPaths::resolve(name, self)?;
1004            if !resolved_manifest_paths_exist(manifest, &paths) {
1005                return None;
1006            }
1007            paths
1008        };
1009        Some(manifest.to_model_config(&paths))
1010    }
1011}
1012
1013fn overlay_model_paths(target: &mut ModelConfig, source: &ModelConfig) {
1014    target.transformer = source.transformer.clone();
1015    target.transformer_shards = source.transformer_shards.clone();
1016    target.vae = source.vae.clone();
1017    if source.spatial_upscaler.is_some() {
1018        target.spatial_upscaler = source.spatial_upscaler.clone();
1019    }
1020    if source.temporal_upscaler.is_some() {
1021        target.temporal_upscaler = source.temporal_upscaler.clone();
1022    }
1023    if source.distilled_lora.is_some() {
1024        target.distilled_lora = source.distilled_lora.clone();
1025    }
1026
1027    if source.t5_encoder.is_some() {
1028        target.t5_encoder = source.t5_encoder.clone();
1029    }
1030    if source.clip_encoder.is_some() {
1031        target.clip_encoder = source.clip_encoder.clone();
1032    }
1033    if source.t5_tokenizer.is_some() {
1034        target.t5_tokenizer = source.t5_tokenizer.clone();
1035    }
1036    if source.clip_tokenizer.is_some() {
1037        target.clip_tokenizer = source.clip_tokenizer.clone();
1038    }
1039    if source.clip_encoder_2.is_some() {
1040        target.clip_encoder_2 = source.clip_encoder_2.clone();
1041    }
1042    if source.clip_tokenizer_2.is_some() {
1043        target.clip_tokenizer_2 = source.clip_tokenizer_2.clone();
1044    }
1045    if source.text_encoder_files.is_some() {
1046        target.text_encoder_files = source.text_encoder_files.clone();
1047    }
1048    if source.text_tokenizer.is_some() {
1049        target.text_tokenizer = source.text_tokenizer.clone();
1050    }
1051    if source.decoder.is_some() {
1052        target.decoder = source.decoder.clone();
1053    }
1054}
1055
1056fn resolved_manifest_paths_exist(
1057    manifest: &crate::manifest::ModelManifest,
1058    paths: &ModelPaths,
1059) -> bool {
1060    use crate::manifest::ModelComponent;
1061
1062    let mut transformer_shard_idx = 0usize;
1063    let mut text_encoder_idx = 0usize;
1064
1065    manifest.files.iter().all(|file| match file.component {
1066        ModelComponent::Transformer => paths.transformer.exists(),
1067        ModelComponent::TransformerShard => {
1068            let path = paths.transformer_shards.get(transformer_shard_idx);
1069            transformer_shard_idx += 1;
1070            path.is_some_and(|path| path.exists())
1071        }
1072        ModelComponent::Vae => paths.vae.exists(),
1073        ModelComponent::SpatialUpscaler => paths
1074            .spatial_upscaler
1075            .as_ref()
1076            .is_some_and(|path| path.exists()),
1077        ModelComponent::TemporalUpscaler => paths
1078            .temporal_upscaler
1079            .as_ref()
1080            .is_some_and(|path| path.exists()),
1081        ModelComponent::DistilledLora => paths
1082            .distilled_lora
1083            .as_ref()
1084            .is_some_and(|path| path.exists()),
1085        ModelComponent::T5Encoder => paths.t5_encoder.as_ref().is_some_and(|path| path.exists()),
1086        ModelComponent::ClipEncoder => paths
1087            .clip_encoder
1088            .as_ref()
1089            .is_some_and(|path| path.exists()),
1090        ModelComponent::T5Tokenizer => paths
1091            .t5_tokenizer
1092            .as_ref()
1093            .is_some_and(|path| path.exists()),
1094        ModelComponent::ClipTokenizer => paths
1095            .clip_tokenizer
1096            .as_ref()
1097            .is_some_and(|path| path.exists()),
1098        ModelComponent::ClipEncoder2 => paths
1099            .clip_encoder_2
1100            .as_ref()
1101            .is_some_and(|path| path.exists()),
1102        ModelComponent::ClipTokenizer2 => paths
1103            .clip_tokenizer_2
1104            .as_ref()
1105            .is_some_and(|path| path.exists()),
1106        ModelComponent::TextEncoder => {
1107            let path = paths.text_encoder_files.get(text_encoder_idx);
1108            text_encoder_idx += 1;
1109            path.is_some_and(|path| path.exists())
1110        }
1111        ModelComponent::TextTokenizer => paths
1112            .text_tokenizer
1113            .as_ref()
1114            .is_some_and(|path| path.exists()),
1115        ModelComponent::Decoder => paths.decoder.as_ref().is_some_and(|path| path.exists()),
1116        ModelComponent::Upscaler => paths.transformer.exists(),
1117    })
1118}