1use axum::{
10 extract::{Path, State},
11 http::StatusCode,
12 Json,
13};
14use mold_core::config_keys as keys;
15use mold_core::{Config, ConfigEntry, ConfigListing, ConfigProfiles};
16use serde::Deserialize;
17
18use crate::routes::ApiError;
19use crate::state::AppState;
20
21const CONFIG_UNAVAILABLE: &str = "CONFIG_UNAVAILABLE";
23const ENV_OVERRIDDEN: &str = "ENV_OVERRIDDEN";
25const UNKNOWN_CONFIG_KEY: &str = "UNKNOWN_CONFIG_KEY";
27const FILE_BACKED_KEY: &str = "FILE_BACKED_KEY";
29
30fn settings_db(state: &AppState) -> Result<&mold_db::MetadataDb, ApiError> {
31 state.metadata_db.as_ref().as_ref().ok_or_else(|| {
32 ApiError::with_code(
33 "config settings are unavailable because the metadata DB is disabled",
34 CONFIG_UNAVAILABLE,
35 StatusCode::SERVICE_UNAVAILABLE,
36 )
37 })
38}
39
40fn source_for(key: &str) -> (String, Option<String>) {
45 if let Some((var, _)) = keys::env_override_for(key) {
46 ("env".to_string(), Some(var.to_string()))
47 } else {
48 (keys::effective_surface(key).as_str().to_string(), None)
49 }
50}
51
52fn entry_for(key: &str, value: serde_json::Value) -> ConfigEntry {
53 let (source, env_var) = source_for(key);
54 ConfigEntry {
55 key: key.to_string(),
56 value,
57 source,
58 env_var,
59 }
60}
61
62fn unknown_key(err: impl std::fmt::Display, status: StatusCode) -> ApiError {
63 ApiError::with_code(err.to_string(), UNKNOWN_CONFIG_KEY, status)
64}
65
66#[utoipa::path(
71 get,
72 path = "/api/config",
73 tag = "config",
74 responses(
75 (status = 200, description = "Effective config rows with sources", body = mold_core::ConfigListing),
76 )
77)]
78pub async fn list_config(State(state): State<AppState>) -> Json<ConfigListing> {
79 let cfg = state.config.read().await;
80 let mut entries = Vec::new();
81 for info in keys::ALL_KEYS {
82 if let Ok(val) = keys::get_static_value(&cfg, info.key) {
83 entries.push(entry_for(info.key, val.to_json()));
84 }
85 }
86 for model_name in cfg.models.keys() {
87 for (field, _) in keys::MODEL_FIELDS {
88 let full_key = format!("models.{model_name}.{field}");
89 if let Ok(val) = keys::get_model_value(&cfg, &full_key) {
90 entries.push(entry_for(&full_key, val.to_json()));
91 }
92 }
93 }
94 let profile = state
95 .metadata_db
96 .as_ref()
97 .as_ref()
98 .map(mold_db::resolve_active_profile);
99 Json(ConfigListing { profile, entries })
100}
101
102#[utoipa::path(
106 get,
107 path = "/api/config/{key}",
108 tag = "config",
109 params(("key" = String, Path, description = "Config key (e.g. expand.enabled, models.flux-dev:q4.lora)")),
110 responses(
111 (status = 200, description = "Config row", body = mold_core::ConfigEntry),
112 (status = 404, description = "Unknown config key"),
113 )
114)]
115pub async fn get_config_key(
116 State(state): State<AppState>,
117 Path(key): Path<String>,
118) -> Result<Json<ConfigEntry>, ApiError> {
119 let cfg = state.config.read().await;
120 let value = keys::get_value(&cfg, &key)
121 .map_err(|e| unknown_key(e, StatusCode::NOT_FOUND))?
122 .to_json();
123 Ok(Json(entry_for(&key, value)))
124}
125
126#[derive(Debug, Deserialize, utoipa::ToSchema)]
129pub struct ConfigSetRequest {
130 #[schema(value_type = Object)]
132 pub value: serde_json::Value,
133}
134
135fn raw_value(value: &serde_json::Value) -> Result<String, ApiError> {
138 match value {
139 serde_json::Value::Null => Ok(String::new()),
140 serde_json::Value::Bool(b) => Ok(b.to_string()),
141 serde_json::Value::Number(n) => Ok(n.to_string()),
142 serde_json::Value::String(s) => Ok(s.clone()),
143 _ => Err(ApiError::validation(
144 "value must be a scalar (string, number, bool, or null)",
145 )),
146 }
147}
148
149#[utoipa::path(
153 put,
154 path = "/api/config/{key}",
155 tag = "config",
156 params(("key" = String, Path, description = "Config key")),
157 request_body = ConfigSetRequest,
158 responses(
159 (status = 200, description = "Updated config row", body = mold_core::ConfigEntry),
160 (status = 403, description = "Key is overridden by an environment variable"),
161 (status = 422, description = "Unknown key or invalid value"),
162 (status = 503, description = "Metadata DB disabled (DB-backed key)"),
163 )
164)]
165pub async fn put_config_key(
166 State(state): State<AppState>,
167 Path(key): Path<String>,
168 Json(req): Json<ConfigSetRequest>,
169) -> Result<Json<ConfigEntry>, ApiError> {
170 if !keys::is_known_key(&key) {
171 return Err(unknown_key(
172 keys::unknown_key_error(&key),
173 StatusCode::UNPROCESSABLE_ENTITY,
174 ));
175 }
176 if let Some((var, _)) = keys::env_override_for(&key) {
177 return Err(ApiError::with_code(
178 format!("'{key}' is set by {var} in the environment — unset it to edit"),
179 ENV_OVERRIDDEN,
180 StatusCode::FORBIDDEN,
181 ));
182 }
183 let raw = raw_value(&req.value)?;
184
185 let surface = keys::effective_surface(&key);
188 if surface == keys::Surface::Db {
189 settings_db(&state)?;
190 }
191
192 let mut cfg = state.config.write().await;
193 keys::set_value(&mut cfg, &key, &raw).map_err(|e| ApiError::validation(e.to_string()))?;
194
195 match surface {
196 keys::Surface::Db => {
197 let db = settings_db(&state)?;
198 let persisted = if key.starts_with("models.") {
199 mold_db::config_sync::persist_model_field(db, &cfg, &key)
200 } else {
201 mold_db::config_sync::persist_config_key(db, &cfg, &key)
202 };
203 persisted
204 .map_err(|e| ApiError::internal(format!("failed to persist '{key}': {e:#}")))?;
205 }
206 keys::Surface::File => {
207 cfg.save_bootstrap_only().map_err(|e| {
210 ApiError::internal(format!("failed to write config.toml for '{key}': {e:#}"))
211 })?;
212 }
213 }
214
215 let value = keys::get_value(&cfg, &key)
216 .map(|v| v.to_json())
217 .unwrap_or(serde_json::Value::Null);
218 Ok(Json(ConfigEntry {
219 key,
220 value,
221 source: surface.as_str().to_string(),
222 env_var: None,
223 }))
224}
225
226#[utoipa::path(
234 delete,
235 path = "/api/config/{key}",
236 tag = "config",
237 params(("key" = String, Path, description = "Config key")),
238 responses(
239 (status = 200, description = "Key reset; body carries the fallback value", body = mold_core::ConfigEntry),
240 (status = 422, description = "Unknown key or file-backed key"),
241 (status = 503, description = "Metadata DB disabled"),
242 )
243)]
244pub async fn delete_config_key(
245 State(state): State<AppState>,
246 Path(key): Path<String>,
247) -> Result<Json<ConfigEntry>, ApiError> {
248 if !keys::is_known_key(&key) {
249 return Err(unknown_key(
250 keys::unknown_key_error(&key),
251 StatusCode::UNPROCESSABLE_ENTITY,
252 ));
253 }
254 if keys::effective_surface(&key) != keys::Surface::Db {
255 return Err(ApiError::with_code(
256 format!("'{key}' is stored in config.toml — edit it via PUT /api/config/{key}"),
257 FILE_BACKED_KEY,
258 StatusCode::UNPROCESSABLE_ENTITY,
259 ));
260 }
261 let db = settings_db(&state)?;
262 let profile = mold_db::resolve_active_profile(db);
263 let dropped = if key.starts_with("models.") {
264 mold_db::config_sync::reset_model_field(db, &profile, &key)
265 } else {
266 mold_db::config_sync::reset_global_key(db, &profile, &key)
267 };
268 dropped.map_err(|e| ApiError::internal(format!("failed to reset '{key}': {e:#}")))?;
269
270 let fresh = Config::load_or_default();
275 let fallback = keys::get_value(&fresh, &key).ok();
276 {
277 let mut cfg = state.config.write().await;
278 let raw = fallback
279 .as_ref()
280 .map(|v| v.raw())
281 .unwrap_or_else(|| "none".to_string());
282 let _ = keys::set_value(&mut cfg, &key, &raw);
286 }
287
288 let (source, env_var) = match keys::env_override_for(&key) {
289 Some((var, _)) => ("env".to_string(), Some(var.to_string())),
290 None => ("default".to_string(), None),
291 };
292 Ok(Json(ConfigEntry {
293 key,
294 value: fallback
295 .map(|v| v.to_json())
296 .unwrap_or(serde_json::Value::Null),
297 source,
298 env_var,
299 }))
300}
301
302#[utoipa::path(
307 get,
308 path = "/api/config/profiles",
309 tag = "config",
310 responses(
311 (status = 200, description = "Profiles and the active profile", body = mold_core::ConfigProfiles),
312 (status = 503, description = "Metadata DB disabled"),
313 )
314)]
315pub async fn list_config_profiles(
316 State(state): State<AppState>,
317) -> Result<Json<ConfigProfiles>, ApiError> {
318 let db = settings_db(&state)?;
319 Ok(Json(profiles_snapshot(db)?))
320}
321
322fn profiles_snapshot(db: &mold_db::MetadataDb) -> Result<ConfigProfiles, ApiError> {
323 let active = mold_db::resolve_active_profile(db);
324 let mut profiles = mold_db::settings::list_profiles(db)
325 .map_err(|e| ApiError::internal(format!("failed to list profiles: {e:#}")))?;
326 if !profiles.iter().any(|p| p == &active) {
327 profiles.push(active.clone());
328 profiles.sort();
329 }
330 Ok(ConfigProfiles { active, profiles })
331}
332
333#[derive(Debug, Deserialize, utoipa::ToSchema)]
336pub struct ProfileSetRequest {
337 pub name: String,
339}
340
341#[utoipa::path(
346 put,
347 path = "/api/config/profile",
348 tag = "config",
349 request_body = ProfileSetRequest,
350 responses(
351 (status = 200, description = "Active profile after the switch", body = mold_core::ConfigProfiles),
352 (status = 422, description = "Invalid profile name"),
353 (status = 503, description = "Metadata DB disabled"),
354 )
355)]
356pub async fn put_config_profile(
357 State(state): State<AppState>,
358 Json(req): Json<ProfileSetRequest>,
359) -> Result<Json<ConfigProfiles>, ApiError> {
360 let name = req.name.trim();
361 if name.is_empty() {
362 return Err(ApiError::validation("profile name cannot be empty"));
363 }
364 let db = settings_db(&state)?;
365 mold_db::settings::set_active_profile(db, name)
366 .map_err(|e| ApiError::internal(format!("failed to switch profile: {e:#}")))?;
367 Ok(Json(profiles_snapshot(db)?))
368}