Skip to main content

tuitbot_server/routes/
settings.rs

1//! Settings endpoints for reading and updating the configuration.
2
3use std::path::Path;
4use std::sync::Arc;
5use std::time::Instant;
6
7use axum::extract::State;
8use axum::http::StatusCode;
9use axum::response::IntoResponse;
10use axum::Json;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use tuitbot_core::auth::error::AuthError;
14use tuitbot_core::auth::{passphrase, session};
15use tuitbot_core::config::{
16    effective_config, merge_overrides, split_patch_by_scope, Config, LlmConfig,
17};
18use tuitbot_core::error::ConfigError;
19use tuitbot_core::llm::factory::create_provider;
20use tuitbot_core::storage::accounts::{self, DEFAULT_ACCOUNT_ID};
21
22use crate::account::AccountContext;
23use crate::error::ApiError;
24use crate::state::AppState;
25
26/// Request body for the optional claim object within `POST /api/settings/init`.
27#[derive(Deserialize)]
28struct ClaimRequest {
29    passphrase: String,
30}
31
32// ---------------------------------------------------------------------------
33// Request / response types
34// ---------------------------------------------------------------------------
35
36#[derive(Serialize)]
37struct ValidationResponse {
38    valid: bool,
39    #[serde(skip_serializing_if = "Vec::is_empty")]
40    errors: Vec<ValidationErrorItem>,
41}
42
43#[derive(Serialize)]
44struct ValidationErrorItem {
45    field: String,
46    message: String,
47}
48
49#[derive(Deserialize)]
50pub struct TestLlmRequest {
51    pub provider: String,
52    #[serde(default)]
53    pub api_key: Option<String>,
54    #[serde(default)]
55    pub model: String,
56    #[serde(default)]
57    pub base_url: Option<String>,
58}
59
60#[derive(Serialize)]
61struct TestResult {
62    success: bool,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    error: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    latency_ms: Option<u64>,
67}
68
69// ---------------------------------------------------------------------------
70// Helpers
71// ---------------------------------------------------------------------------
72
73/// Read the config file, merge a JSON patch into it, and parse the result.
74///
75/// Returns `(merged_toml_string, parsed_config)` on success.
76fn merge_patch_and_parse(config_path: &Path, patch: &Value) -> Result<(String, Config), ApiError> {
77    let contents = std::fs::read_to_string(config_path).map_err(|e| {
78        ApiError::BadRequest(format!(
79            "could not read config file {}: {e}",
80            config_path.display()
81        ))
82    })?;
83
84    let mut toml_value: toml::Value = contents.parse().map_err(|e: toml::de::Error| {
85        ApiError::BadRequest(format!("failed to parse existing config: {e}"))
86    })?;
87
88    let patch_toml = json_to_toml(patch)
89        .map_err(|e| ApiError::BadRequest(format!("patch contains invalid values: {e}")))?;
90
91    merge_toml(&mut toml_value, &patch_toml);
92
93    let merged_str = toml::to_string_pretty(&toml_value)
94        .map_err(|e| ApiError::BadRequest(format!("failed to serialize merged config: {e}")))?;
95
96    let config: Config = toml::from_str(&merged_str)
97        .map_err(|e| ApiError::BadRequest(format!("merged config is invalid: {e}")))?;
98
99    Ok((merged_str, config))
100}
101
102/// Load and parse the base config from the TOML file.
103fn load_base_config(config_path: &Path) -> Result<Config, ApiError> {
104    let contents = std::fs::read_to_string(config_path).map_err(|e| {
105        ApiError::BadRequest(format!(
106            "could not read config file {}: {e}",
107            config_path.display()
108        ))
109    })?;
110
111    toml::from_str(&contents)
112        .map_err(|e| ApiError::BadRequest(format!("failed to parse config: {e}")))
113}
114
115fn config_errors_to_response(errors: Vec<ConfigError>) -> Vec<ValidationErrorItem> {
116    errors
117        .into_iter()
118        .map(|e| match e {
119            ConfigError::MissingField { field } => ValidationErrorItem {
120                field,
121                message: "this field is required".to_string(),
122            },
123            ConfigError::InvalidValue { field, message } => ValidationErrorItem { field, message },
124            other => ValidationErrorItem {
125                field: String::new(),
126                message: other.to_string(),
127            },
128        })
129        .collect()
130}
131
132// ---------------------------------------------------------------------------
133// Onboarding endpoints (no auth required)
134// ---------------------------------------------------------------------------
135
136/// `GET /api/settings/status` — check if config exists.
137///
138/// Also returns `deployment_mode` and `capabilities` so unauthenticated
139/// pages (e.g. onboarding) can adapt their source-type UI.
140pub async fn config_status(State(state): State<Arc<AppState>>) -> Json<Value> {
141    let configured = state.config_path.exists();
142    let claimed = passphrase::is_claimed(&state.data_dir);
143    let capabilities = state.deployment_mode.capabilities();
144    Json(serde_json::json!({
145        "configured": configured,
146        "claimed": claimed,
147        "deployment_mode": state.deployment_mode,
148        "capabilities": capabilities,
149    }))
150}
151
152/// `POST /api/settings/init` — create initial config from JSON.
153///
154/// Accepts the full configuration as JSON, validates it, converts to TOML,
155/// and writes to `config_path`. Returns 409 if config already exists.
156///
157/// Optionally accepts a `claim` object containing a passphrase to establish
158/// the instance passphrase and return a session cookie in one atomic step.
159pub async fn init_settings(
160    State(state): State<Arc<AppState>>,
161    Json(mut body): Json<Value>,
162) -> Result<impl IntoResponse, ApiError> {
163    if state.config_path.exists() {
164        return Err(ApiError::Conflict(
165            "configuration already exists; use PATCH /api/settings to update".to_string(),
166        ));
167    }
168
169    if !body.is_object() {
170        return Err(ApiError::BadRequest(
171            "request body must be a JSON object".to_string(),
172        ));
173    }
174
175    // Extract and remove `claim` before TOML conversion (it's not a config field).
176    let claim: Option<ClaimRequest> = body
177        .as_object_mut()
178        .and_then(|obj| obj.remove("claim"))
179        .map(serde_json::from_value)
180        .transpose()
181        .map_err(|e| ApiError::BadRequest(format!("invalid claim object: {e}")))?;
182
183    // Validate claim early — before any file I/O.
184    if let Some(ref claim) = claim {
185        if claim.passphrase.len() < 8 {
186            return Err(ApiError::BadRequest(
187                "passphrase must be at least 8 characters".into(),
188            ));
189        }
190        if passphrase::is_claimed(&state.data_dir) {
191            return Err(ApiError::Conflict("instance already claimed".into()));
192        }
193    }
194
195    // Convert JSON to TOML.
196    let toml_value = json_to_toml(&body)
197        .map_err(|e| ApiError::BadRequest(format!("invalid config values: {e}")))?;
198
199    let toml_str = toml::to_string_pretty(&toml_value)
200        .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
201
202    // Validate by parsing through Config.
203    let config: Config = toml::from_str(&toml_str)
204        .map_err(|e| ApiError::BadRequest(format!("invalid config: {e}")))?;
205
206    if let Err(errors) = config.validate() {
207        let items = config_errors_to_response(errors);
208        return Ok((
209            StatusCode::OK,
210            Json(serde_json::json!({
211                "status": "validation_failed",
212                "errors": items
213            })),
214        )
215            .into_response());
216    }
217
218    // Ensure parent directory exists and write.
219    if let Some(parent) = state.config_path.parent() {
220        std::fs::create_dir_all(parent)
221            .map_err(|e| ApiError::BadRequest(format!("failed to create config directory: {e}")))?;
222    }
223
224    std::fs::write(&state.config_path, &toml_str).map_err(|e| {
225        ApiError::BadRequest(format!(
226            "could not write config file {}: {e}",
227            state.config_path.display()
228        ))
229    })?;
230
231    // Set file permissions to 0600 on Unix.
232    #[cfg(unix)]
233    {
234        use std::os::unix::fs::PermissionsExt;
235        let _ =
236            std::fs::set_permissions(&state.config_path, std::fs::Permissions::from_mode(0o600));
237    }
238
239    let json = serde_json::to_value(&config)
240        .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
241
242    // If claim present, create passphrase hash + session.
243    if let Some(claim) = claim {
244        passphrase::create_passphrase_hash(&state.data_dir, &claim.passphrase).map_err(
245            |e| match e {
246                AuthError::AlreadyClaimed => ApiError::Conflict("instance already claimed".into()),
247                other => ApiError::Internal(format!("failed to create passphrase: {other}")),
248            },
249        )?;
250
251        // Update in-memory hash and mtime.
252        let new_hash = passphrase::load_passphrase_hash(&state.data_dir)
253            .map_err(|e| ApiError::Internal(format!("failed to load passphrase hash: {e}")))?;
254        {
255            let mut hash = state.passphrase_hash.write().await;
256            *hash = new_hash;
257        }
258        {
259            let mut mtime = state.passphrase_hash_mtime.write().await;
260            *mtime = passphrase::passphrase_hash_mtime(&state.data_dir);
261        }
262
263        // Create session (same pattern as login route).
264        let new_session = session::create_session(&state.db)
265            .await
266            .map_err(|e| ApiError::Internal(format!("failed to create session: {e}")))?;
267
268        let cookie = format!(
269            "tuitbot_session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800",
270            new_session.raw_token,
271        );
272
273        tracing::info!("instance claimed via /settings/init");
274
275        return Ok((
276            StatusCode::OK,
277            [(axum::http::header::SET_COOKIE, cookie)],
278            Json(serde_json::json!({
279                "status": "created",
280                "config": json,
281                "csrf_token": new_session.csrf_token,
282            })),
283        )
284            .into_response());
285    }
286
287    // No claim — return existing response shape (backward compatible).
288    Ok((
289        StatusCode::OK,
290        Json(serde_json::json!({
291            "status": "created",
292            "config": json
293        })),
294    )
295        .into_response())
296}
297
298// ---------------------------------------------------------------------------
299// Endpoints
300// ---------------------------------------------------------------------------
301
302/// `GET /api/settings` — return the current config as JSON.
303///
304/// For the default account, returns the raw `config.toml` (existing behavior).
305/// For non-default accounts, merges the account's `config_overrides` into
306/// the base config and returns the effective result with `_overrides` metadata.
307pub async fn get_settings(
308    State(state): State<Arc<AppState>>,
309    ctx: AccountContext,
310) -> Result<Json<Value>, ApiError> {
311    let base_config = load_base_config(&state.config_path)?;
312
313    if ctx.account_id == DEFAULT_ACCOUNT_ID {
314        let mut json = serde_json::to_value(base_config)
315            .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
316        redact_service_account_keys(&mut json);
317        return Ok(Json(json));
318    }
319
320    // Non-default account: merge overrides.
321    let account = accounts::get_account(&state.db, &ctx.account_id)
322        .await?
323        .ok_or_else(|| ApiError::NotFound(format!("account not found: {}", ctx.account_id)))?;
324
325    let result = effective_config(&base_config, &account.config_overrides)
326        .map_err(|e| ApiError::BadRequest(format!("config merge failed: {e}")))?;
327
328    let mut json = serde_json::to_value(&result.config)
329        .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
330    redact_service_account_keys(&mut json);
331
332    // Wrap in envelope with override metadata.
333    let response = serde_json::json!({
334        "config": json,
335        "_overrides": result.overridden_keys,
336    });
337
338    Ok(Json(response))
339}
340
341/// Replace any non-null `service_account_key` values in `content_sources.sources`
342/// with `"[redacted]"` so secrets are never returned in API responses.
343fn redact_service_account_keys(json: &mut Value) {
344    if let Some(sources) = json
345        .get_mut("content_sources")
346        .and_then(|cs| cs.get_mut("sources"))
347        .and_then(|s| s.as_array_mut())
348    {
349        for source in sources {
350            if let Some(key) = source.get_mut("service_account_key") {
351                if !key.is_null() {
352                    *key = serde_json::Value::String("[redacted]".to_string());
353                }
354            }
355        }
356    }
357}
358
359/// `PATCH /api/settings` — merge partial JSON into the config and write back.
360///
361/// For the default account, writes to `config.toml` (existing behavior).
362/// For non-default accounts, enforces scope contract: only account-scoped
363/// keys are allowed, and changes persist to `accounts.config_overrides`
364/// instead of `config.toml`.
365pub async fn patch_settings(
366    State(state): State<Arc<AppState>>,
367    ctx: AccountContext,
368    Json(patch): Json<Value>,
369) -> Result<Json<Value>, ApiError> {
370    if !patch.is_object() {
371        return Err(ApiError::BadRequest(
372            "request body must be a JSON object".to_string(),
373        ));
374    }
375
376    if ctx.account_id == DEFAULT_ACCOUNT_ID {
377        // Default account: write to config.toml (existing behavior).
378        let (merged_str, config) = merge_patch_and_parse(&state.config_path, &patch)?;
379
380        std::fs::write(&state.config_path, &merged_str).map_err(|e| {
381            ApiError::BadRequest(format!(
382                "could not write config file {}: {e}",
383                state.config_path.display()
384            ))
385        })?;
386
387        let mut json = serde_json::to_value(config)
388            .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
389        redact_service_account_keys(&mut json);
390        return Ok(Json(json));
391    }
392
393    // Non-default account: enforce scope contract.
394    let (_account_patch, rejected) = split_patch_by_scope(&patch);
395    if !rejected.is_empty() {
396        return Err(ApiError::Forbidden(format!(
397            "instance-scoped keys cannot be changed per-account: {}",
398            rejected.join(", ")
399        )));
400    }
401
402    // Load account and current overrides.
403    let account = accounts::get_account(&state.db, &ctx.account_id)
404        .await?
405        .ok_or_else(|| ApiError::NotFound(format!("account not found: {}", ctx.account_id)))?;
406
407    // Merge incoming patch into existing overrides.
408    let new_overrides = merge_overrides(&account.config_overrides, &patch)
409        .map_err(|e| ApiError::BadRequest(format!("override merge failed: {e}")))?;
410
411    // Validate the effective config (base + new overrides).
412    let base_config = load_base_config(&state.config_path)?;
413    let result = effective_config(&base_config, &new_overrides)
414        .map_err(|e| ApiError::BadRequest(format!("effective config invalid: {e}")))?;
415
416    // Persist updated overrides to database.
417    accounts::update_account(
418        &state.db,
419        &ctx.account_id,
420        accounts::UpdateAccountParams {
421            config_overrides: Some(&new_overrides),
422            ..Default::default()
423        },
424    )
425    .await?;
426
427    let mut json = serde_json::to_value(&result.config)
428        .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
429    redact_service_account_keys(&mut json);
430
431    let response = serde_json::json!({
432        "config": json,
433        "_overrides": result.overridden_keys,
434    });
435
436    Ok(Json(response))
437}
438
439/// `POST /api/settings/validate` — validate a config change without saving.
440///
441/// For non-default accounts, merges the patch into the account's current
442/// overrides and validates the effective config against the base.
443pub async fn validate_settings(
444    State(state): State<Arc<AppState>>,
445    ctx: AccountContext,
446    Json(patch): Json<Value>,
447) -> Result<Json<Value>, ApiError> {
448    if !patch.is_object() {
449        return Err(ApiError::BadRequest(
450            "request body must be a JSON object".to_string(),
451        ));
452    }
453
454    if ctx.account_id == DEFAULT_ACCOUNT_ID {
455        // Default account: existing behavior.
456        let (_merged_str, config) = merge_patch_and_parse(&state.config_path, &patch)?;
457
458        let response = match config.validate() {
459            Ok(()) => ValidationResponse {
460                valid: true,
461                errors: Vec::new(),
462            },
463            Err(errors) => ValidationResponse {
464                valid: false,
465                errors: config_errors_to_response(errors),
466            },
467        };
468
469        return Ok(Json(serde_json::to_value(response).unwrap()));
470    }
471
472    // Non-default account: check scope, merge into overrides, validate effective config.
473    let (_account_patch, rejected) = split_patch_by_scope(&patch);
474    if !rejected.is_empty() {
475        return Ok(Json(
476            serde_json::to_value(ValidationResponse {
477                valid: false,
478                errors: vec![ValidationErrorItem {
479                    field: "config_overrides".to_string(),
480                    message: format!(
481                        "instance-scoped keys cannot be changed per-account: {}",
482                        rejected.join(", ")
483                    ),
484                }],
485            })
486            .unwrap(),
487        ));
488    }
489
490    let account = accounts::get_account(&state.db, &ctx.account_id)
491        .await?
492        .ok_or_else(|| ApiError::NotFound(format!("account not found: {}", ctx.account_id)))?;
493
494    let new_overrides = merge_overrides(&account.config_overrides, &patch)
495        .map_err(|e| ApiError::BadRequest(format!("override merge failed: {e}")))?;
496
497    let base_config = load_base_config(&state.config_path)?;
498    let result = effective_config(&base_config, &new_overrides)
499        .map_err(|e| ApiError::BadRequest(format!("effective config invalid: {e}")))?;
500
501    let response = match result.config.validate() {
502        Ok(()) => ValidationResponse {
503            valid: true,
504            errors: Vec::new(),
505        },
506        Err(errors) => ValidationResponse {
507            valid: false,
508            errors: config_errors_to_response(errors),
509        },
510    };
511
512    Ok(Json(serde_json::to_value(response).unwrap()))
513}
514
515/// `GET /api/settings/defaults` — return the built-in default configuration.
516pub async fn get_defaults() -> Result<Json<Value>, ApiError> {
517    let defaults = Config::default();
518    let json = serde_json::to_value(defaults)
519        .map_err(|e| ApiError::BadRequest(format!("failed to serialize defaults: {e}")))?;
520    Ok(Json(json))
521}
522
523/// `POST /api/settings/test-llm` — test LLM provider connectivity.
524pub async fn test_llm(Json(body): Json<TestLlmRequest>) -> Result<Json<Value>, ApiError> {
525    let llm_config = LlmConfig {
526        provider: body.provider,
527        api_key: body.api_key,
528        model: body.model,
529        base_url: body.base_url,
530    };
531
532    let provider = match create_provider(&llm_config) {
533        Ok(p) => p,
534        Err(e) => {
535            return Ok(Json(
536                serde_json::to_value(TestResult {
537                    success: false,
538                    error: Some(e.to_string()),
539                    latency_ms: None,
540                })
541                .unwrap(),
542            ));
543        }
544    };
545
546    let start = Instant::now();
547    let latency_ms = |s: &Instant| s.elapsed().as_millis() as u64;
548
549    match provider.health_check().await {
550        Ok(()) => Ok(Json(
551            serde_json::to_value(TestResult {
552                success: true,
553                error: None,
554                latency_ms: Some(latency_ms(&start)),
555            })
556            .unwrap(),
557        )),
558        Err(e) => Ok(Json(
559            serde_json::to_value(TestResult {
560                success: false,
561                error: Some(e.to_string()),
562                latency_ms: Some(latency_ms(&start)),
563            })
564            .unwrap(),
565        )),
566    }
567}
568
569// ---------------------------------------------------------------------------
570// Factory reset
571// ---------------------------------------------------------------------------
572
573/// Confirmation phrase required for factory reset (case-sensitive, exact match).
574const FACTORY_RESET_PHRASE: &str = "RESET TUITBOT";
575
576#[derive(Deserialize)]
577pub struct FactoryResetRequest {
578    confirmation: String,
579}
580
581#[derive(Serialize)]
582struct FactoryResetResponse {
583    status: String,
584    cleared: FactoryResetCleared,
585}
586
587#[derive(Serialize)]
588struct FactoryResetCleared {
589    tables_cleared: u32,
590    rows_deleted: u64,
591    config_deleted: bool,
592    passphrase_deleted: bool,
593    media_deleted: bool,
594    credentials_deleted: bool,
595    runtimes_stopped: u32,
596}
597
598/// `POST /api/settings/factory-reset` -- erase all Tuitbot-managed data.
599///
600/// Requires authentication (bearer or session+CSRF). Validates a typed
601/// confirmation phrase before proceeding. Stops runtimes, clears all 31
602/// DB tables in a single transaction, deletes config/passphrase/media files,
603/// clears in-memory state, and returns a response that also clears the
604/// session cookie.
605pub async fn factory_reset(
606    State(state): State<Arc<AppState>>,
607    Json(body): Json<FactoryResetRequest>,
608) -> Result<impl IntoResponse, ApiError> {
609    // 1. Validate confirmation phrase.
610    if body.confirmation != FACTORY_RESET_PHRASE {
611        return Err(ApiError::BadRequest(
612            "incorrect confirmation phrase".to_string(),
613        ));
614    }
615
616    // 2. Stop all runtimes (before DB clearing to prevent races).
617    let runtimes_stopped = {
618        let mut runtimes = state.runtimes.lock().await;
619        let count = runtimes.len() as u32;
620        for (_, mut rt) in runtimes.drain() {
621            rt.shutdown().await;
622        }
623        count
624    };
625
626    // 3. Cancel watchtower.
627    if let Some(ref token) = state.watchtower_cancel {
628        token.cancel();
629    }
630
631    // 4. Clear all DB table contents (single transaction).
632    let reset_stats = tuitbot_core::storage::reset::factory_reset(&state.db).await?;
633
634    // 4b. Re-seed the default account (reset deletes it).
635    tuitbot_core::storage::accounts::ensure_default_account(&state.db).await?;
636
637    // 5. Delete config.toml (tolerate NotFound for idempotency).
638    let config_deleted = match std::fs::remove_file(&state.config_path) {
639        Ok(()) => true,
640        Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
641        Err(e) => {
642            tracing::warn!(error = %e, "failed to delete config file");
643            false
644        }
645    };
646
647    // 6. Delete passphrase_hash file (tolerate NotFound).
648    let passphrase_path = state.data_dir.join("passphrase_hash");
649    let passphrase_deleted = match std::fs::remove_file(&passphrase_path) {
650        Ok(()) => true,
651        Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
652        Err(e) => {
653            tracing::warn!(error = %e, "failed to delete passphrase hash");
654            false
655        }
656    };
657
658    // 7. Delete media directory (tolerate NotFound).
659    let media_dir = state.data_dir.join("media");
660    let media_deleted = match std::fs::remove_dir_all(&media_dir) {
661        Ok(()) => true,
662        Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
663        Err(e) => {
664            tracing::warn!(error = %e, "failed to delete media directory");
665            false
666        }
667    };
668
669    // 8. Delete credential files and per-account data directories.
670    let credentials_deleted = delete_all_credentials(&state.data_dir);
671
672    // 9. Clear in-memory state.
673    *state.passphrase_hash.write().await = None;
674    *state.passphrase_hash_mtime.write().await = None;
675    state.content_generators.lock().await.clear();
676    state.login_attempts.lock().await.clear();
677    state.pending_oauth.lock().await.clear();
678    state.token_managers.lock().await.clear();
679
680    tracing::info!(
681        tables = reset_stats.tables_cleared,
682        rows = reset_stats.rows_deleted,
683        config = config_deleted,
684        passphrase = passphrase_deleted,
685        media = media_deleted,
686        credentials = credentials_deleted,
687        runtimes = runtimes_stopped,
688        "Factory reset completed"
689    );
690
691    // 9. Build response with cookie-clearing header.
692    let response = FactoryResetResponse {
693        status: "reset_complete".to_string(),
694        cleared: FactoryResetCleared {
695            tables_cleared: reset_stats.tables_cleared,
696            rows_deleted: reset_stats.rows_deleted,
697            config_deleted,
698            passphrase_deleted,
699            media_deleted,
700            credentials_deleted,
701            runtimes_stopped,
702        },
703    };
704
705    let cookie = "tuitbot_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0";
706    Ok((
707        StatusCode::OK,
708        [(axum::http::header::SET_COOKIE, cookie)],
709        Json(serde_json::to_value(response).unwrap()),
710    ))
711}
712
713/// Delete all credential files produced during normal operation.
714///
715/// Removes root-level `scraper_session.json` / `tokens.json` (default
716/// account) plus the entire `accounts/` subdirectory tree (per-account
717/// credentials). Returns `true` if anything was actually removed.
718fn delete_all_credentials(data_dir: &std::path::Path) -> bool {
719    let mut deleted = false;
720
721    // Root-level credential files (default account).
722    for name in &["scraper_session.json", "tokens.json"] {
723        let path = data_dir.join(name);
724        match std::fs::remove_file(&path) {
725            Ok(()) => deleted = true,
726            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
727            Err(e) => {
728                tracing::warn!(path = %path.display(), error = %e, "failed to delete credential file")
729            }
730        }
731    }
732
733    // Per-account data directories (accounts/{uuid}/).
734    let accounts_dir = data_dir.join("accounts");
735    match std::fs::remove_dir_all(&accounts_dir) {
736        Ok(()) => deleted = true,
737        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
738        Err(e) => tracing::warn!(error = %e, "failed to delete accounts directory"),
739    }
740
741    deleted
742}
743
744// ---------------------------------------------------------------------------
745// TOML utilities
746// ---------------------------------------------------------------------------
747
748/// Recursively merge `patch` into `base`. Tables are merged key-by-key;
749/// scalar values in `patch` overwrite those in `base`.
750fn merge_toml(base: &mut toml::Value, patch: &toml::Value) {
751    match (base, patch) {
752        (toml::Value::Table(base_table), toml::Value::Table(patch_table)) => {
753            for (key, patch_val) in patch_table {
754                if let Some(base_val) = base_table.get_mut(key) {
755                    merge_toml(base_val, patch_val);
756                } else {
757                    base_table.insert(key.clone(), patch_val.clone());
758                }
759            }
760        }
761        (base, _) => {
762            *base = patch.clone();
763        }
764    }
765}
766
767/// Convert a `serde_json::Value` to a `toml::Value`.
768///
769/// Null values in objects are silently skipped (TOML has no null literal),
770/// allowing the frontend to send `null` for optional fields to clear them.
771/// Null values in arrays are rejected since arrays cannot have holes.
772fn json_to_toml(json: &serde_json::Value) -> Result<toml::Value, String> {
773    match json {
774        serde_json::Value::Object(map) => {
775            let mut table = toml::map::Map::new();
776            for (key, val) in map {
777                if val.is_null() {
778                    continue;
779                }
780                table.insert(key.clone(), json_to_toml(val)?);
781            }
782            Ok(toml::Value::Table(table))
783        }
784        serde_json::Value::Array(arr) => {
785            let values: Result<Vec<_>, _> = arr.iter().map(json_to_toml).collect();
786            Ok(toml::Value::Array(values?))
787        }
788        serde_json::Value::String(s) => Ok(toml::Value::String(s.clone())),
789        serde_json::Value::Number(n) => {
790            if let Some(i) = n.as_i64() {
791                Ok(toml::Value::Integer(i))
792            } else if let Some(f) = n.as_f64() {
793                Ok(toml::Value::Float(f))
794            } else {
795                Err(format!("unsupported number: {n}"))
796            }
797        }
798        serde_json::Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
799        serde_json::Value::Null => Err("null values are not supported in TOML arrays".to_string()),
800    }
801}