Skip to main content

bhtune_server/routes/
config.rs

1//! `GET`/`PUT /api/config` for the mutable global TOML policies and tune settings.
2
3use axum::extract::State;
4use axum::routing::get;
5use axum::{Json, Router};
6use bhtune_cli::config::{
7    ConfigPolicyUpdate, ConfigStoreError, LoadedConfigStore, TuningConfig, TuningConfigSource,
8    resolve_retention_days, resolve_tuning_config, validate_tuning_config,
9};
10use serde::{Deserialize, Serialize};
11use utoipa::ToSchema;
12
13use crate::error::{ApiError, ErrorBody};
14use crate::state::AppState;
15
16#[derive(Debug, Clone, Serialize, ToSchema)]
17pub struct ConfigValues {
18    pub allow_uncertain_quality: bool,
19    #[schema(minimum = 1)]
20    pub retention_days: Option<u32>,
21    pub tuning: ConfigTuningValues,
22}
23
24#[derive(Debug, Clone, Serialize, ToSchema)]
25pub struct ConfigTuningValues {
26    pub mrft_delay_secs: u32,
27    pub poll_interval_ms: u64,
28    pub timeout_secs: u64,
29    pub op_timeout_secs: u64,
30    pub restore_timeout_secs: u64,
31}
32
33#[derive(Debug, Clone, Serialize, ToSchema)]
34pub struct ConfigSources {
35    pub allow_uncertain_quality: String,
36    pub retention_days: String,
37    pub tuning: ConfigTuningSources,
38}
39
40#[derive(Debug, Clone, Serialize, ToSchema)]
41pub struct ConfigTuningSources {
42    pub mrft_delay_secs: String,
43    pub poll_interval_ms: String,
44    pub timeout_secs: String,
45    pub op_timeout_secs: String,
46    pub restore_timeout_secs: String,
47}
48
49#[derive(Debug, Clone, Serialize, ToSchema)]
50pub struct ConfigTomlValues {
51    pub allow_uncertain_quality: Option<bool>,
52    #[schema(minimum = 1)]
53    pub retention_days: Option<u32>,
54    pub tuning: ConfigTuningTomlValues,
55}
56
57#[derive(Debug, Clone, Serialize, ToSchema)]
58pub struct ConfigTuningTomlValues {
59    #[schema(maximum = 3600)]
60    pub mrft_delay_secs: Option<u32>,
61    #[schema(minimum = 1)]
62    pub poll_interval_ms: Option<u64>,
63    #[schema(minimum = 1)]
64    pub timeout_secs: Option<u64>,
65    #[schema(minimum = 1)]
66    pub op_timeout_secs: Option<u64>,
67    #[schema(minimum = 1)]
68    pub restore_timeout_secs: Option<u64>,
69}
70
71#[derive(Debug, Clone, Serialize, ToSchema)]
72pub struct ConfigResponse {
73    pub revision: String,
74    pub config_path: String,
75    pub toml: ConfigTomlValues,
76    pub effective: ConfigValues,
77    pub source: ConfigSources,
78    pub backup_path: Option<String>,
79}
80
81#[derive(Debug, Clone, Deserialize, ToSchema)]
82pub struct UpdateConfigRequest {
83    pub revision: String,
84    pub allow_uncertain_quality: bool,
85    #[schema(minimum = 1)]
86    pub retention_days: Option<u32>,
87    /// Omit this field (or send it as JSON `null`) to preserve the existing `[tuning]`
88    /// overrides for compatibility with older clients. When an object is supplied, it
89    /// replaces the complete tuning block: every nested field is written as an override
90    /// when it has a value, and a nested `null` (including an omitted nested field, which
91    /// deserializes to `None`) removes that field's override. An all-null object therefore
92    /// resets the whole tuning block to built-in defaults.
93    pub tuning: Option<UpdateTuningRequest>,
94}
95
96#[derive(Debug, Clone, Deserialize, ToSchema)]
97pub struct UpdateTuningRequest {
98    #[schema(maximum = 3600)]
99    pub mrft_delay_secs: Option<u32>,
100    #[schema(minimum = 1)]
101    pub poll_interval_ms: Option<u64>,
102    #[schema(minimum = 1)]
103    pub timeout_secs: Option<u64>,
104    #[schema(minimum = 1)]
105    pub op_timeout_secs: Option<u64>,
106    #[schema(minimum = 1)]
107    pub restore_timeout_secs: Option<u64>,
108}
109
110impl From<UpdateTuningRequest> for TuningConfig {
111    fn from(request: UpdateTuningRequest) -> Self {
112        Self {
113            mrft_delay_secs: request.mrft_delay_secs,
114            poll_interval_ms: request.poll_interval_ms,
115            timeout_secs: request.timeout_secs,
116            op_timeout_secs: request.op_timeout_secs,
117            restore_timeout_secs: request.restore_timeout_secs,
118        }
119    }
120}
121
122fn tuning_source_label(source: TuningConfigSource) -> String {
123    match source {
124        TuningConfigSource::Toml => "config_file",
125        TuningConfigSource::BuiltInDefault => "default",
126    }
127    .to_string()
128}
129
130fn response_from_store(store: &LoadedConfigStore, backup_path: Option<String>) -> ConfigResponse {
131    let env_retention = std::env::var("BHTUNE_RETENTION_DAYS")
132        .ok()
133        .and_then(|value| value.parse().ok());
134    response_from_store_with_retention(store, backup_path, env_retention)
135}
136
137fn response_from_store_with_retention(
138    store: &LoadedConfigStore,
139    backup_path: Option<String>,
140    env_retention: Option<u32>,
141) -> ConfigResponse {
142    let effective_retention = resolve_retention_days(env_retention, &store.config);
143    let effective_tuning = resolve_tuning_config(&store.toml_tuning);
144    let sources = ConfigSources {
145        allow_uncertain_quality: if store.toml_allow_uncertain_quality.is_some() {
146            "config_file".to_string()
147        } else {
148            "default".to_string()
149        },
150        retention_days: if env_retention.is_some() {
151            "environment".to_string()
152        } else if store.config.retention_days.is_some() {
153            "config_file".to_string()
154        } else {
155            "default".to_string()
156        },
157        tuning: ConfigTuningSources {
158            mrft_delay_secs: tuning_source_label(store.tuning_sources.mrft_delay_secs),
159            poll_interval_ms: tuning_source_label(store.tuning_sources.poll_interval_ms),
160            timeout_secs: tuning_source_label(store.tuning_sources.timeout_secs),
161            op_timeout_secs: tuning_source_label(store.tuning_sources.op_timeout_secs),
162            restore_timeout_secs: tuning_source_label(store.tuning_sources.restore_timeout_secs),
163        },
164    };
165    let effective = ConfigValues {
166        allow_uncertain_quality: store.config.allow_uncertain_quality,
167        retention_days: effective_retention,
168        tuning: ConfigTuningValues {
169            mrft_delay_secs: effective_tuning.mrft_delay_secs,
170            poll_interval_ms: effective_tuning.poll_interval_ms,
171            timeout_secs: effective_tuning.timeout_secs,
172            op_timeout_secs: effective_tuning.op_timeout_secs,
173            restore_timeout_secs: effective_tuning.restore_timeout_secs,
174        },
175    };
176    ConfigResponse {
177        revision: store.revision.clone(),
178        config_path: store
179            .path
180            .as_deref()
181            .map(|path| path.display().to_string())
182            .unwrap_or_default(),
183        toml: ConfigTomlValues {
184            allow_uncertain_quality: store.toml_allow_uncertain_quality,
185            retention_days: store.config.retention_days,
186            tuning: ConfigTuningTomlValues {
187                mrft_delay_secs: store.toml_tuning.mrft_delay_secs,
188                poll_interval_ms: store.toml_tuning.poll_interval_ms,
189                timeout_secs: store.toml_tuning.timeout_secs,
190                op_timeout_secs: store.toml_tuning.op_timeout_secs,
191                restore_timeout_secs: store.toml_tuning.restore_timeout_secs,
192            },
193        },
194        source: sources,
195        effective,
196        backup_path,
197    }
198}
199
200fn map_store_error(error: ConfigStoreError) -> ApiError {
201    match error {
202        ConfigStoreError::Conflict { message, .. } => ApiError::Conflict(message),
203        other => ApiError::Internal(anyhow::anyhow!(other.to_string())),
204    }
205}
206
207#[utoipa::path(
208    get,
209    path = "/api/config",
210    tag = "config",
211    responses(
212        (status = 200, description = "The TOML and effective global configuration.", body = ConfigResponse),
213        (status = 500, description = "The configuration store could not be read.", body = ErrorBody),
214    ),
215)]
216pub(crate) async fn get_config(
217    State(state): State<AppState>,
218) -> Result<Json<ConfigResponse>, ApiError> {
219    let store = state
220        .config_store
221        .read()
222        .map_err(|_| ApiError::Internal(anyhow::anyhow!("configuration store lock is poisoned")))?;
223    Ok(Json(response_from_store(&store, None)))
224}
225
226#[utoipa::path(
227    put,
228    path = "/api/config",
229    tag = "config",
230    request_body = UpdateConfigRequest,
231    responses(
232        (status = 200, description = "The configuration was saved.", body = ConfigResponse),
233        (status = 400, description = "The request contains an invalid retention or tuning policy.", body = ErrorBody),
234        (status = 409, description = "The supplied revision is stale or the file changed on disk.", body = ErrorBody),
235        (status = 500, description = "The configuration could not be written.", body = ErrorBody),
236    ),
237)]
238pub(crate) async fn put_config(
239    State(state): State<AppState>,
240    Json(request): Json<UpdateConfigRequest>,
241) -> Result<Json<ConfigResponse>, ApiError> {
242    if request.retention_days == Some(0) {
243        return Err(ApiError::BadRequest(
244            "retention_days must be at least 1 or null".to_string(),
245        ));
246    }
247    let mut store = state
248        .config_store
249        .write()
250        .map_err(|_| ApiError::Internal(anyhow::anyhow!("configuration store lock is poisoned")))?;
251    let tuning = request
252        .tuning
253        .map(TuningConfig::from)
254        .unwrap_or(store.toml_tuning);
255    let effective_tuning = resolve_tuning_config(&tuning);
256    validate_tuning_config(&effective_tuning, false)
257        .map_err(|error| ApiError::BadRequest(error.to_string()))?;
258    let saved = bhtune_cli::config::save_config_store(
259        &store,
260        &request.revision,
261        &ConfigPolicyUpdate {
262            allow_uncertain_quality: request.allow_uncertain_quality,
263            retention_days: request.retention_days,
264            mrft_delay_secs: tuning.mrft_delay_secs,
265            poll_interval_ms: tuning.poll_interval_ms,
266            timeout_secs: tuning.timeout_secs,
267            op_timeout_secs: tuning.op_timeout_secs,
268            restore_timeout_secs: tuning.restore_timeout_secs,
269        },
270    )
271    .map_err(map_store_error)?;
272    let backup_path = saved
273        .backup_path
274        .as_deref()
275        .map(|path| path.display().to_string());
276    *store = saved.state;
277    Ok(Json(response_from_store(&store, backup_path)))
278}
279
280pub fn router() -> Router<AppState> {
281    Router::new().route("/api/config", get(get_config).put(put_config))
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use axum::body::{Body, to_bytes};
288    use axum::http::{Request, StatusCode};
289    use std::sync::{Arc, RwLock};
290    use tempfile::tempdir;
291    use tower::ServiceExt;
292
293    async fn state_for(path: &std::path::Path) -> AppState {
294        let mut state = crate::test_support::in_memory_state().await;
295        let loaded =
296            bhtune_cli::config::load_config_store_from(Some(path), None, None, None, false)
297                .unwrap();
298        state.config_store = Arc::new(RwLock::new(loaded));
299        state
300    }
301
302    async fn json_body(response: axum::http::Response<Body>) -> serde_json::Value {
303        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
304        serde_json::from_slice(&bytes).unwrap()
305    }
306
307    #[tokio::test]
308    async fn get_returns_default_policy_and_revision() {
309        let dir = tempdir().unwrap();
310        let path = dir.path().join("bhtune.toml");
311        std::fs::write(&path, "bridge_host = \"gateway:7600\"\n").unwrap();
312        let state = state_for(&path).await;
313        let revision = state.config_store.read().unwrap().revision.clone();
314        let response = crate::build_router(state)
315            .oneshot(Request::get("/api/config").body(Body::empty()).unwrap())
316            .await
317            .unwrap();
318
319        assert_eq!(response.status(), StatusCode::OK);
320        let body = json_body(response).await;
321        assert_eq!(body["revision"], revision);
322        assert_eq!(
323            body["toml"]["allow_uncertain_quality"],
324            serde_json::Value::Null
325        );
326        assert_eq!(body["effective"]["allow_uncertain_quality"], true);
327        assert_eq!(body["source"]["allow_uncertain_quality"], "default");
328        assert_eq!(
329            body["toml"]["tuning"]["mrft_delay_secs"],
330            serde_json::Value::Null
331        );
332        assert_eq!(body["effective"]["tuning"]["mrft_delay_secs"], 0);
333        assert_eq!(body["effective"]["tuning"]["poll_interval_ms"], 800);
334        assert_eq!(body["effective"]["tuning"]["timeout_secs"], 3600);
335        assert_eq!(body["effective"]["tuning"]["op_timeout_secs"], 30);
336        assert_eq!(body["effective"]["tuning"]["restore_timeout_secs"], 30);
337        assert_eq!(body["source"]["tuning"]["poll_interval_ms"], "default");
338        assert!(
339            body["config_path"]
340                .as_str()
341                .unwrap()
342                .ends_with("bhtune.toml")
343        );
344    }
345
346    #[tokio::test]
347    async fn put_patches_only_supported_keys_and_returns_new_revision() {
348        let dir = tempdir().unwrap();
349        let path = dir.path().join("bhtune.toml");
350        std::fs::write(
351            &path,
352            "# preserve\nbridge_host = \"gateway:7600\"\nunknown = \"keep\"\n",
353        )
354        .unwrap();
355        let state = state_for(&path).await;
356        let revision = state.config_store.read().unwrap().revision.clone();
357        let app = crate::build_router(state);
358        let response = app
359            .oneshot(
360                Request::put("/api/config")
361                    .header("content-type", "application/json")
362                    .body(Body::from(
363                        serde_json::json!({
364                            "revision": revision,
365                            "allow_uncertain_quality": false,
366                            "retention_days": 30
367                        })
368                        .to_string(),
369                    ))
370                    .unwrap(),
371            )
372            .await
373            .unwrap();
374
375        assert_eq!(response.status(), StatusCode::OK);
376        let body = json_body(response).await;
377        assert_ne!(body["revision"], revision);
378        assert_eq!(body["effective"]["allow_uncertain_quality"], false);
379        assert_eq!(body["effective"]["retention_days"], 30);
380        let saved = std::fs::read_to_string(path).unwrap();
381        assert!(saved.contains("# preserve"));
382        assert!(saved.contains("unknown = \"keep\""));
383        assert!(saved.contains("allow_uncertain_quality = false"));
384        assert!(saved.contains("retention_days = 30"));
385    }
386
387    #[tokio::test]
388    async fn put_persists_tuning_values_and_reports_effective_sources() {
389        let dir = tempdir().unwrap();
390        let path = dir.path().join("bhtune.toml");
391        std::fs::write(&path, "# preserve\n").unwrap();
392        let state = state_for(&path).await;
393        let revision = state.config_store.read().unwrap().revision.clone();
394        let response = crate::build_router(state)
395            .oneshot(
396                Request::put("/api/config")
397                    .header("content-type", "application/json")
398                    .body(Body::from(
399                        serde_json::json!({
400                            "revision": revision,
401                            "allow_uncertain_quality": true,
402                            "retention_days": null,
403                            "tuning": {
404                                "mrft_delay_secs": 12,
405                                "poll_interval_ms": 250,
406                                "timeout_secs": 900,
407                                "op_timeout_secs": 45,
408                                "restore_timeout_secs": 8
409                            }
410                        })
411                        .to_string(),
412                    ))
413                    .unwrap(),
414            )
415            .await
416            .unwrap();
417
418        assert_eq!(response.status(), StatusCode::OK);
419        let body = json_body(response).await;
420        assert_eq!(body["toml"]["tuning"]["mrft_delay_secs"], 12);
421        assert_eq!(body["effective"]["tuning"]["poll_interval_ms"], 250);
422        assert_eq!(body["effective"]["tuning"]["timeout_secs"], 900);
423        assert_eq!(body["effective"]["tuning"]["op_timeout_secs"], 45);
424        assert_eq!(body["effective"]["tuning"]["restore_timeout_secs"], 8);
425        assert_eq!(body["source"]["tuning"]["mrft_delay_secs"], "config_file");
426        assert_eq!(
427            body["source"]["tuning"]["restore_timeout_secs"],
428            "config_file"
429        );
430        let saved = std::fs::read_to_string(path).unwrap();
431        assert!(saved.contains("[tuning]"));
432        assert!(saved.contains("mrft_delay_secs = 12"));
433        assert!(saved.contains("poll_interval_ms = 250"));
434        assert!(saved.contains("restore_timeout_secs = 8"));
435    }
436
437    #[tokio::test]
438    async fn put_omitting_tuning_preserves_existing_overrides() {
439        let dir = tempdir().unwrap();
440        let path = dir.path().join("bhtune.toml");
441        std::fs::write(
442            &path,
443            "[tuning]\nmrft_delay_secs = 12\npoll_interval_ms = 250\n",
444        )
445        .unwrap();
446        let state = state_for(&path).await;
447        let revision = state.config_store.read().unwrap().revision.clone();
448        let response = crate::build_router(state)
449            .oneshot(
450                Request::put("/api/config")
451                    .header("content-type", "application/json")
452                    .body(Body::from(
453                        serde_json::json!({
454                            "revision": revision,
455                            "allow_uncertain_quality": false,
456                            "retention_days": null
457                        })
458                        .to_string(),
459                    ))
460                    .unwrap(),
461            )
462            .await
463            .unwrap();
464
465        assert_eq!(response.status(), StatusCode::OK);
466        let body = json_body(response).await;
467        assert_eq!(body["toml"]["tuning"]["mrft_delay_secs"], 12);
468        assert_eq!(body["toml"]["tuning"]["poll_interval_ms"], 250);
469        assert_eq!(body["effective"]["tuning"]["mrft_delay_secs"], 12);
470        assert_eq!(body["effective"]["tuning"]["poll_interval_ms"], 250);
471        assert_eq!(body["effective"]["allow_uncertain_quality"], false);
472    }
473
474    #[tokio::test]
475    async fn put_supplied_partial_tuning_object_replaces_the_complete_block() {
476        let dir = tempdir().unwrap();
477        let path = dir.path().join("bhtune.toml");
478        std::fs::write(
479            &path,
480            "[tuning]\nmrft_delay_secs = 12\npoll_interval_ms = 250\ntimeout_secs = 900\nop_timeout_secs = 45\nrestore_timeout_secs = 8\n",
481        )
482        .unwrap();
483        let state = state_for(&path).await;
484        let revision = state.config_store.read().unwrap().revision.clone();
485        let response = crate::build_router(state)
486            .oneshot(
487                Request::put("/api/config")
488                    .header("content-type", "application/json")
489                    .body(Body::from(
490                        serde_json::json!({
491                            "revision": revision,
492                            "allow_uncertain_quality": true,
493                            "retention_days": null,
494                            "tuning": {
495                                "poll_interval_ms": 500
496                            }
497                        })
498                        .to_string(),
499                    ))
500                    .unwrap(),
501            )
502            .await
503            .unwrap();
504
505        assert_eq!(response.status(), StatusCode::OK);
506        let body = json_body(response).await;
507        assert_eq!(
508            body["toml"]["tuning"]["mrft_delay_secs"],
509            serde_json::Value::Null
510        );
511        assert_eq!(body["toml"]["tuning"]["poll_interval_ms"], 500);
512        assert_eq!(
513            body["toml"]["tuning"]["timeout_secs"],
514            serde_json::Value::Null
515        );
516        assert_eq!(
517            body["toml"]["tuning"]["op_timeout_secs"],
518            serde_json::Value::Null
519        );
520        assert_eq!(
521            body["toml"]["tuning"]["restore_timeout_secs"],
522            serde_json::Value::Null
523        );
524        assert_eq!(body["effective"]["tuning"]["mrft_delay_secs"], 0);
525        assert_eq!(body["effective"]["tuning"]["poll_interval_ms"], 500);
526        assert_eq!(body["effective"]["tuning"]["timeout_secs"], 3600);
527        assert_eq!(body["effective"]["tuning"]["op_timeout_secs"], 30);
528        assert_eq!(body["effective"]["tuning"]["restore_timeout_secs"], 30);
529        let saved = std::fs::read_to_string(path).unwrap();
530        assert!(saved.contains("poll_interval_ms = 500"));
531        assert!(!saved.contains("mrft_delay_secs"));
532        assert!(!saved.contains("timeout_secs"));
533        assert!(!saved.contains("op_timeout_secs"));
534        assert!(!saved.contains("restore_timeout_secs"));
535    }
536
537    #[tokio::test]
538    async fn put_all_null_tuning_resets_to_built_in_defaults() {
539        let dir = tempdir().unwrap();
540        let path = dir.path().join("bhtune.toml");
541        std::fs::write(
542            &path,
543            "[tuning]\nmrft_delay_secs = 12\npoll_interval_ms = 250\ntimeout_secs = 900\nop_timeout_secs = 45\nrestore_timeout_secs = 8\n",
544        )
545        .unwrap();
546        let state = state_for(&path).await;
547        let revision = state.config_store.read().unwrap().revision.clone();
548        let response = crate::build_router(state)
549            .oneshot(
550                Request::put("/api/config")
551                    .header("content-type", "application/json")
552                    .body(Body::from(
553                        serde_json::json!({
554                            "revision": revision,
555                            "allow_uncertain_quality": true,
556                            "retention_days": null,
557                            "tuning": {
558                                "mrft_delay_secs": null,
559                                "poll_interval_ms": null,
560                                "timeout_secs": null,
561                                "op_timeout_secs": null,
562                                "restore_timeout_secs": null
563                            }
564                        })
565                        .to_string(),
566                    ))
567                    .unwrap(),
568            )
569            .await
570            .unwrap();
571
572        assert_eq!(response.status(), StatusCode::OK);
573        let body = json_body(response).await;
574        assert_eq!(
575            body["toml"]["tuning"]["mrft_delay_secs"],
576            serde_json::Value::Null
577        );
578        assert_eq!(
579            body["toml"]["tuning"]["poll_interval_ms"],
580            serde_json::Value::Null
581        );
582        assert_eq!(body["effective"]["tuning"]["mrft_delay_secs"], 0);
583        assert_eq!(body["effective"]["tuning"]["poll_interval_ms"], 800);
584        assert_eq!(body["effective"]["tuning"]["timeout_secs"], 3600);
585        assert_eq!(body["source"]["tuning"]["restore_timeout_secs"], "default");
586        let saved = std::fs::read_to_string(path).unwrap();
587        assert!(!saved.contains("mrft_delay_secs"));
588        assert!(!saved.contains("restore_timeout_secs"));
589    }
590
591    #[tokio::test]
592    async fn put_rejects_invalid_tuning_values() {
593        let dir = tempdir().unwrap();
594        let path = dir.path().join("bhtune.toml");
595        std::fs::write(&path, "").unwrap();
596        let state = state_for(&path).await;
597        let revision = state.config_store.read().unwrap().revision.clone();
598        let response = crate::build_router(state)
599            .oneshot(
600                Request::put("/api/config")
601                    .header("content-type", "application/json")
602                    .body(Body::from(
603                        serde_json::json!({
604                            "revision": revision,
605                            "allow_uncertain_quality": true,
606                            "retention_days": null,
607                            "tuning": {
608                                "mrft_delay_secs": 3601,
609                                "poll_interval_ms": 0,
610                                "timeout_secs": 1,
611                                "op_timeout_secs": 1,
612                                "restore_timeout_secs": 1
613                            }
614                        })
615                        .to_string(),
616                    ))
617                    .unwrap(),
618            )
619            .await
620            .unwrap();
621
622        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
623        assert!(
624            json_body(response).await["error"]
625                .as_str()
626                .unwrap()
627                .contains("mrft_delay_secs")
628        );
629    }
630
631    #[tokio::test]
632    async fn put_creates_an_auto_discovered_missing_config_file() {
633        let dir = tempdir().unwrap();
634        let path = dir.path().join("bhtune").join("bhtune.toml");
635        let loaded = bhtune_cli::config::load_config_store_from(
636            None,
637            Some(dir.path().to_str().unwrap()),
638            None,
639            None,
640            false,
641        )
642        .unwrap();
643        let revision = loaded.revision.clone();
644        let mut state = crate::test_support::in_memory_state().await;
645        state.config_store = Arc::new(RwLock::new(loaded));
646        let response = crate::build_router(state)
647            .oneshot(
648                Request::put("/api/config")
649                    .header("content-type", "application/json")
650                    .body(Body::from(
651                        serde_json::json!({
652                            "revision": revision,
653                            "allow_uncertain_quality": true,
654                            "retention_days": null
655                        })
656                        .to_string(),
657                    ))
658                    .unwrap(),
659            )
660            .await
661            .unwrap();
662
663        assert_eq!(response.status(), StatusCode::OK);
664        assert!(path.exists());
665        assert!(
666            std::fs::read_to_string(path)
667                .unwrap()
668                .contains("allow_uncertain_quality = true")
669        );
670    }
671
672    #[tokio::test]
673    async fn put_rejects_stale_revision_and_external_disk_changes() {
674        let dir = tempdir().unwrap();
675        let path = dir.path().join("bhtune.toml");
676        std::fs::write(&path, "bridge_host = \"gateway:7600\"\n").unwrap();
677        let state = state_for(&path).await;
678        let revision = state.config_store.read().unwrap().revision.clone();
679        std::fs::write(&path, "bridge_host = \"other:7600\"\n").unwrap();
680        let response = crate::build_router(state)
681            .oneshot(
682                Request::put("/api/config")
683                    .header("content-type", "application/json")
684                    .body(Body::from(
685                        serde_json::json!({
686                            "revision": revision,
687                            "allow_uncertain_quality": false,
688                            "retention_days": null
689                        })
690                        .to_string(),
691                    ))
692                    .unwrap(),
693            )
694            .await
695            .unwrap();
696        assert_eq!(response.status(), StatusCode::CONFLICT);
697        assert!(
698            json_body(response).await["error"]
699                .as_str()
700                .unwrap()
701                .contains("changed on disk")
702        );
703    }
704
705    #[test]
706    fn response_reports_environment_retention_and_unresolved_path() {
707        let store = LoadedConfigStore {
708            path: None,
709            missing_is_allowed: true,
710            original_raw: None,
711            config: bhtune_cli::config::BhtuneConfig {
712                retention_days: Some(30),
713                ..Default::default()
714            },
715            revision: "revision".to_string(),
716            toml_allow_uncertain_quality: None,
717            toml_tuning: Default::default(),
718            tuning_sources: bhtune_cli::config::tuning_config_sources(
719                &bhtune_cli::config::TuningConfig::default(),
720            ),
721        };
722
723        let response = response_from_store_with_retention(&store, None, Some(90));
724
725        assert_eq!(response.config_path, "");
726        assert_eq!(response.source.retention_days, "environment");
727        assert_eq!(response.effective.retention_days, Some(90));
728        assert_eq!(response.effective.tuning.mrft_delay_secs, 0);
729        assert_eq!(response.source.tuning.op_timeout_secs, "default");
730    }
731
732    #[test]
733    fn map_store_error_maps_non_conflicts_to_internal_errors() {
734        let error = map_store_error(ConfigStoreError::PathNotResolved);
735        assert!(matches!(error, ApiError::Internal(_)));
736
737        let error = map_store_error(ConfigStoreError::Conflict {
738            path: None,
739            message: "stale".to_string(),
740        });
741        assert!(matches!(error, ApiError::Conflict(message) if message == "stale"));
742    }
743
744    #[tokio::test]
745    async fn put_rejects_zero_retention_days() {
746        let dir = tempdir().unwrap();
747        let path = dir.path().join("bhtune.toml");
748        std::fs::write(&path, "").unwrap();
749        let state = state_for(&path).await;
750        let revision = state.config_store.read().unwrap().revision.clone();
751        let response = crate::build_router(state)
752            .oneshot(
753                Request::put("/api/config")
754                    .header("content-type", "application/json")
755                    .body(Body::from(
756                        serde_json::json!({
757                            "revision": revision,
758                            "allow_uncertain_quality": true,
759                            "retention_days": 0
760                        })
761                        .to_string(),
762                    ))
763                    .unwrap(),
764            )
765            .await
766            .unwrap();
767
768        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
769        assert_eq!(
770            json_body(response).await["error"],
771            "retention_days must be at least 1 or null"
772        );
773    }
774
775    #[tokio::test]
776    async fn get_returns_internal_error_when_config_store_lock_is_poisoned() {
777        let dir = tempdir().unwrap();
778        let path = dir.path().join("bhtune.toml");
779        std::fs::write(&path, "").unwrap();
780        let state = state_for(&path).await;
781        let store = Arc::clone(&state.config_store);
782        std::thread::spawn(move || {
783            let _guard = store.write().unwrap();
784            panic!("poison configuration store");
785        })
786        .join()
787        .unwrap_err();
788
789        let response = crate::build_router(state)
790            .oneshot(Request::get("/api/config").body(Body::empty()).unwrap())
791            .await
792            .unwrap();
793
794        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
795    }
796
797    #[tokio::test]
798    async fn put_returns_internal_error_when_config_store_lock_is_poisoned() {
799        let dir = tempdir().unwrap();
800        let path = dir.path().join("bhtune.toml");
801        std::fs::write(&path, "").unwrap();
802        let state = state_for(&path).await;
803        let revision = state.config_store.read().unwrap().revision.clone();
804        let store = Arc::clone(&state.config_store);
805        std::thread::spawn(move || {
806            let _guard = store.write().unwrap();
807            panic!("poison configuration store");
808        })
809        .join()
810        .unwrap_err();
811
812        let response = crate::build_router(state)
813            .oneshot(
814                Request::put("/api/config")
815                    .header("content-type", "application/json")
816                    .body(Body::from(
817                        serde_json::json!({
818                            "revision": revision,
819                            "allow_uncertain_quality": true,
820                            "retention_days": null
821                        })
822                        .to_string(),
823                    ))
824                    .unwrap(),
825            )
826            .await
827            .unwrap();
828
829        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
830    }
831}