diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index fdfaeda967..3867c9716c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -41,6 +41,7 @@ mod credentials; mod diagnostics; mod grok_accounts; mod locale_cmd; +mod preferences_transfer; mod provider_detail; mod provider_refresh; mod provider_settings; @@ -63,6 +64,7 @@ pub use credentials::*; pub use diagnostics::*; pub use grok_accounts::*; pub use locale_cmd::*; +pub use preferences_transfer::*; pub use provider_detail::*; #[cfg(test)] pub(crate) use provider_refresh::is_provider_cache_fresh; diff --git a/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs b/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs new file mode 100644 index 0000000000..cd7975b09a --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs @@ -0,0 +1,103 @@ +//! Export and import of the portable preferences document. +//! +//! The document format, allowlist and validation live in +//! `codexbar::settings::PreferencesDocument`; this module only adds the +//! desktop side effects that a settings save normally triggers. + +use std::collections::HashSet; +use std::path::Path; +use std::sync::Mutex; + +use codexbar::core::ProviderId; +use codexbar::settings::{PreferencesDocument, Settings}; +use tauri::{Emitter, Manager}; + +use super::{SettingsSnapshot, language_label}; +use crate::events; +use crate::state::AppState; + +fn checked_path(path: &str) -> Result<&Path, String> { + let path = path.trim(); + if path.is_empty() { + return Err("Preferences path must not be empty".to_string()); + } + Ok(Path::new(path)) +} + +fn enabled_provider_set_changed(before: &[ProviderId], after: &[ProviderId]) -> bool { + before.iter().copied().collect::>() != after.iter().copied().collect() +} + +/// Write the current portable preferences to `path`; returns how many were written. +#[tauri::command] +pub fn export_preferences(path: String) -> Result { + let path = checked_path(&path)?; + let document = + PreferencesDocument::from_settings(&Settings::load()).map_err(|error| error.to_string())?; + document + .write_file(path) + .map_err(|error| error.to_string())?; + tracing::info!(count = document.len(), "exported portable preferences"); + Ok(document.len()) +} + +/// Apply the preferences file at `path` to this machine's settings. +/// +/// Nothing is saved when the file is rejected. On success the same live +/// updates as `update_settings` follow: locale, float bar, tray and dependent +/// windows, plus a provider cache prune and refresh when the enabled set changed. +#[tauri::command] +pub async fn import_preferences( + app: tauri::AppHandle, + path: String, +) -> Result { + let path = checked_path(&path)?; + let document = PreferencesDocument::read_file(path).map_err(|error| error.to_string())?; + let mut settings = Settings::load(); + let previous_language = settings.ui_language; + let previous_enabled = settings.get_enabled_provider_ids(); + let applied = document + .apply_to(&mut settings) + .map_err(|error| error.to_string())?; + settings.save().map_err(|error| error.to_string())?; + tracing::info!(applied, "imported portable preferences"); + + if settings.ui_language != previous_language { + let _ = app.emit(events::LOCALE_CHANGED, language_label(settings.ui_language)); + } + let enabled_ids = settings.get_enabled_provider_ids(); + let providers_changed = enabled_provider_set_changed(&previous_enabled, &enabled_ids); + if providers_changed { + let state = app.state::>(); + let _ = super::invalidate_provider_refresh_and_prune_disabled(&state, &enabled_ids); + } + crate::floatbar::notify_settings_changed(&app); + crate::floatbar::apply_state(&app, &settings); + crate::tray_bridge::rebuild_tray_menu(&app); + crate::tray_bridge::refresh_tray_presentation(&app); + events::emit_settings_changed(&app); + + if providers_changed { + let refresh_app = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = super::do_refresh_providers(&refresh_app).await; + }); + } + Ok(SettingsSnapshot::from(settings)) +} + +#[cfg(test)] +mod tests { + use super::enabled_provider_set_changed; + use codexbar::core::ProviderId; + + #[test] + fn refresh_decision_tracks_provider_membership() { + let before = [ProviderId::Claude, ProviderId::Codex]; + assert!(!enabled_provider_set_changed( + &before, + &[ProviderId::Codex, ProviderId::Claude] + )); + assert!(enabled_provider_set_changed(&before, &[ProviderId::Claude])); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 02f48672b6..8bb06fe1af 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -230,6 +230,8 @@ fn main() { commands::get_provider_local_usage_summary, commands::get_usage_spend_summary, commands::write_usage_spend_export, + commands::export_preferences, + commands::import_preferences, commands::get_spend_contract, commands::get_codex_workspaces_snapshot, commands::reorder_providers, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bf..2bbb371f3d 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -530,6 +530,12 @@ export const ALL_LOCALE_KEYS = [ "CriticalUsageWarningHelper", "GlobalShortcutFieldLabel", "GlobalShortcutToggleHelper", + "SectionPreferencesTransfer", + "PreferencesTransferCaption", + "PreferencesExportButton", + "PreferencesImportButton", + "PreferencesExportSuccess", + "PreferencesImportSuccess", "ShortcutRecordButton", "ShortcutRecordingLabel", "ShortcutRecordingHint", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 6ccd63c0de..f53ae41f41 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -295,6 +295,16 @@ export function writeUsageSpendExport(path: string, payload: string): Promise("write_usage_spend_export", { path, payload }); } +/** Write portable preferences to `path`; resolves with how many were exported. */ +export function exportPreferences(path: string): Promise { + return invoke("export_preferences", { path }); +} + +/** Apply the preferences file at `path`; the shell validates before saving anything. */ +export function importPreferences(path: string): Promise { + return invoke("import_preferences", { path }); +} + export function getSpendContract( providerId: string, options?: { historyDays?: number; includeOpenCodex?: boolean }, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx index fcf98d09c3..be048a5dec 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx @@ -8,6 +8,7 @@ import { import { ShortcutCapture } from "../../../components/ShortcutCapture"; import { Field, Toggle } from "../../../components/FormControls"; import type { TabProps } from "../settingsTabs"; +import PreferencesTransferSection from "./PreferencesTransferSection"; function formatCodexSessionsDirs(paths: string[]): string { return paths.join("; "); @@ -372,6 +373,8 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { + + {/* ── Diagnostics ──────────────────────────────────────────── */}

diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/PreferencesTransferSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/PreferencesTransferSection.test.tsx new file mode 100644 index 0000000000..7e98389cc7 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/PreferencesTransferSection.test.tsx @@ -0,0 +1,123 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + open: vi.fn(), + save: vi.fn(), + exportPreferences: vi.fn(), + importPreferences: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: mocks.open, + save: mocks.save, +})); +vi.mock("../../../lib/tauri", () => ({ + exportPreferences: mocks.exportPreferences, + importPreferences: mocks.importPreferences, +})); +vi.mock("../../../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key }), +})); + +import PreferencesTransferSection from "./PreferencesTransferSection"; + +describe("PreferencesTransferSection", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("exports to the chosen path and reports success", async () => { + mocks.save.mockResolvedValue("C:\\temp\\prefs.json"); + mocks.exportPreferences.mockResolvedValue(12); + render(); + + fireEvent.click(screen.getByText("PreferencesExportButton")); + + await waitFor(() => + expect(mocks.exportPreferences).toHaveBeenCalledWith("C:\\temp\\prefs.json"), + ); + expect(await screen.findByRole("status")).toHaveTextContent( + "PreferencesExportSuccess", + ); + expect(mocks.save).toHaveBeenCalledWith( + expect.objectContaining({ defaultPath: "codexbar-preferences.json" }), + ); + }); + + it("does nothing when the save dialog is cancelled", async () => { + mocks.save.mockResolvedValue(null); + render(); + + fireEvent.click(screen.getByText("PreferencesExportButton")); + + await waitFor(() => expect(mocks.save).toHaveBeenCalled()); + expect(mocks.exportPreferences).not.toHaveBeenCalled(); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("imports the chosen file and reports success", async () => { + mocks.open.mockResolvedValue("C:\\temp\\prefs.json"); + mocks.importPreferences.mockResolvedValue({}); + render(); + + fireEvent.click(screen.getByText("PreferencesImportButton")); + + await waitFor(() => + expect(mocks.importPreferences).toHaveBeenCalledWith("C:\\temp\\prefs.json"), + ); + expect(await screen.findByRole("status")).toHaveTextContent( + "PreferencesImportSuccess", + ); + expect(mocks.open).toHaveBeenCalledWith( + expect.objectContaining({ multiple: false }), + ); + }); + + it("does nothing when the open dialog is cancelled", async () => { + mocks.open.mockResolvedValue(null); + render(); + + fireEvent.click(screen.getByText("PreferencesImportButton")); + + await waitFor(() => expect(mocks.open).toHaveBeenCalled()); + expect(mocks.importPreferences).not.toHaveBeenCalled(); + }); + + it("shows the shell's rejection and no success message", async () => { + mocks.open.mockResolvedValue("C:\\temp\\bad.json"); + mocks.importPreferences.mockRejectedValue( + "Invalid or non-portable preference: refresh_interval_secs", + ); + render(); + + fireEvent.click(screen.getByText("PreferencesImportButton")); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Invalid or non-portable preference: refresh_interval_secs", + ); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("disables both buttons while a transfer is running", async () => { + let finish: (value: string | null) => void = () => {}; + mocks.save.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + render(); + + fireEvent.click(screen.getByText("PreferencesExportButton")); + + await waitFor(() => + expect(screen.getByText("PreferencesImportButton")).toBeDisabled(), + ); + expect(screen.getByText("PreferencesExportButton")).toBeDisabled(); + finish(null); + await waitFor(() => + expect(screen.getByText("PreferencesImportButton")).toBeEnabled(), + ); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/PreferencesTransferSection.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/PreferencesTransferSection.tsx new file mode 100644 index 0000000000..1d195291a7 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/PreferencesTransferSection.tsx @@ -0,0 +1,106 @@ +import { useCallback, useState } from "react"; +import { open, save } from "@tauri-apps/plugin-dialog"; +import { useLocale } from "../../../hooks/useLocale"; +import { exportPreferences, importPreferences } from "../../../lib/tauri"; + +const JSON_FILTERS = [{ name: "JSON", extensions: ["json"] }]; + +type TransferOutcome = + | { kind: "success"; message: string } + | { kind: "error"; message: string }; + +/** + * Export or import the portable preferences document. The shell validates the + * whole file before saving anything, so a rejected import changes nothing. + */ +export default function PreferencesTransferSection() { + const { t } = useLocale(); + const [busy, setBusy] = useState(false); + const [outcome, setOutcome] = useState(null); + + const run = useCallback( + async (action: () => Promise) => { + setBusy(true); + setOutcome(null); + try { + const message = await action(); + if (message) setOutcome({ kind: "success", message }); + } catch (cause: unknown) { + setOutcome({ + kind: "error", + message: cause instanceof Error ? cause.message : String(cause), + }); + } finally { + setBusy(false); + } + }, + [], + ); + + const onExport = useCallback( + () => + run(async () => { + const path = await save({ + defaultPath: "codexbar-preferences.json", + filters: JSON_FILTERS, + }); + if (!path) return null; + await exportPreferences(path); + return t("PreferencesExportSuccess"); + }), + [run, t], + ); + + const onImport = useCallback( + () => + run(async () => { + const path = await open({ multiple: false, filters: JSON_FILTERS }); + if (typeof path !== "string") return null; + await importPreferences(path); + return t("PreferencesImportSuccess"); + }), + [run, t], + ); + + return ( +
+

+ {t("SectionPreferencesTransfer")} +

+

+ {t("PreferencesTransferCaption")} +

+
+ + +
+ {outcome?.kind === "success" && ( +

+ {outcome.message} +

+ )} + {outcome?.kind === "error" && ( +

+ {outcome.message} +

+ )} +
+ ); +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 99e470487b..8b69928e5d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -49,8 +49,19 @@ codexbar config path # API key via stdin (example) printf '%s' $env:OPENROUTER_API_KEY | codexbar config set-api-key -p openrouter --stdin + +# Portable preferences (also under Settings > Advanced) +codexbar config preferences export --file prefs.json # omit --file to print to stdout +codexbar config preferences import --file prefs.json ``` +Portable preferences are a versioned JSON document (`{"version": 1, "preferences": {...}}`) +limited to display, refresh, notification, provider-order and float-bar choices. API keys, cookies, +token accounts, folders, SSH hosts, proxy settings and update settings are never exported, and +an import that contains any other key, or any invalid value, is rejected without changing settings. +The CLI import writes `settings.json` only; restart a running CodexBar to pick it up. The desktop +Import button applies the file live. + Notes: - `enable` / `disable` are **persistent** (same idea as upstream). diff --git a/rust/src/cli/config.rs b/rust/src/cli/config.rs index b49be7ae2c..cd4f9f46be 100755 --- a/rust/src/cli/config.rs +++ b/rust/src/cli/config.rs @@ -7,7 +7,7 @@ use serde::de::DeserializeOwned; use std::path::Path; use crate::core::{ProviderId, TokenAccountStore, instantiate_provider}; -use crate::settings::{ApiKeys, ManualCookies, Settings}; +use crate::settings::{ApiKeys, ManualCookies, PreferencesDocument, Settings}; /// Arguments for the config command #[derive(Parser, Debug)] @@ -57,6 +57,27 @@ pub enum ConfigCommand { }, /// Show configuration file paths Path, + /// Export or import portable preferences (no secrets, no machine state) + Preferences { + #[command(subcommand)] + action: PreferencesAction, + }, +} + +#[derive(Subcommand, Debug)] +pub enum PreferencesAction { + /// Write portable preferences as JSON (to stdout unless --file is given) + Export { + /// Destination file + #[arg(long)] + file: Option, + }, + /// Apply a preferences file; restart a running CodexBar afterwards + Import { + /// Preferences file to read + #[arg(long)] + file: std::path::PathBuf, + }, } /// Run the config command @@ -77,9 +98,41 @@ pub async fn run(args: ConfigArgs) -> anyhow::Result<()> { no_enable, } => set_api_key(&provider, api_key.as_deref(), stdin, !no_enable).await, ConfigCommand::Path => show_paths().await, + ConfigCommand::Preferences { action } => transfer_preferences(action), } } +/// Export or import the portable preferences document. +fn transfer_preferences(action: PreferencesAction) -> anyhow::Result<()> { + match action { + PreferencesAction::Export { file } => { + let document = PreferencesDocument::from_settings(&Settings::load())?; + match file { + Some(path) => { + document.write_file(&path)?; + println!( + "Config: exported {} preferences to {}", + document.len(), + path.display() + ); + } + None => print!("{}", document.to_json()), + } + } + PreferencesAction::Import { file: path } => { + let document = PreferencesDocument::read_file(&path)?; + let mut settings = Settings::load(); + let applied = document.apply_to(&mut settings)?; + settings.save()?; + println!( + "Config: imported {applied} preferences from {}. Restart CodexBar to apply them to a running app.", + path.display() + ); + } + } + Ok(()) +} + /// Validate configuration files async fn validate_config() -> anyhow::Result<()> { let mut report = ValidationReport::default(); @@ -484,6 +537,40 @@ mod tests { use crate::settings::ManualCookies; use serde_json::json; + #[test] + fn preferences_subcommands_parse_a_path() { + use super::{ConfigArgs, ConfigCommand, PreferencesAction}; + use clap::Parser; + + let export = + ConfigArgs::try_parse_from(["config", "preferences", "export", "--file", "p.json"]) + .expect("export parses"); + assert!(matches!( + export.command, + ConfigCommand::Preferences { + action: PreferencesAction::Export { file: Some(_) } + } + )); + let stdout = ConfigArgs::try_parse_from(["config", "preferences", "export"]) + .expect("export without --file parses"); + assert!(matches!( + stdout.command, + ConfigCommand::Preferences { + action: PreferencesAction::Export { file: None } + } + )); + let import = + ConfigArgs::try_parse_from(["config", "preferences", "import", "--file", "p.json"]) + .expect("import parses"); + assert!(matches!( + import.command, + ConfigCommand::Preferences { + action: PreferencesAction::Import { .. } + } + )); + assert!(ConfigArgs::try_parse_from(["config", "preferences", "import"]).is_err()); + } + #[cfg(windows)] #[test] fn validates_dpapi_protected_manual_cookies_without_plaintext() { diff --git a/rust/src/locale.rs b/rust/src/locale.rs index e6ec918ac4..70ee10bf53 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -794,6 +794,12 @@ locale_keys! { CriticalUsageWarningHelper, GlobalShortcutFieldLabel, GlobalShortcutToggleHelper, + SectionPreferencesTransfer, + PreferencesTransferCaption, + PreferencesExportButton, + PreferencesImportButton, + PreferencesExportSuccess, + PreferencesImportSuccess, ShortcutRecordButton, ShortcutRecordingLabel, ShortcutRecordingHint, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index fb96ecd4b8..fb58e2c59e 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -467,6 +467,12 @@ HighUsageWarningHelper = Show a warning when usage exceeds this percentage. CriticalUsageWarningHelper = Show a critical alert when usage exceeds this percentage. GlobalShortcutFieldLabel = Global shortcut GlobalShortcutToggleHelper = Key combination to toggle the tray panel. +SectionPreferencesTransfer = Portable preferences +PreferencesTransferCaption = Copy display, refresh, notification and provider choices to another PC. API keys, cookies, folders and hosts are never included. +PreferencesExportButton = Export preferences +PreferencesImportButton = Import preferences +PreferencesExportSuccess = Preferences exported. +PreferencesImportSuccess = Preferences imported. ShortcutRecordButton = Record ShortcutRecordingLabel = Recording… ShortcutRecordingHint = Press modifiers + a key. Esc cancels, Backspace clears. diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 9863f194ce..4afc4ec2f0 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -27,6 +27,7 @@ pub const CLAUDE_DAILY_ROUTINES_USAGE_ITEM_ID: &str = "metric:extra-claude-routi mod api_keys; mod manual_cookies; +mod preferences_document; mod provider_workspace; mod raw; mod status; @@ -34,6 +35,7 @@ mod types; pub use api_keys::*; pub use manual_cookies::*; +pub use preferences_document::*; pub use provider_workspace::*; use raw::RawSettings; pub use status::*; diff --git a/rust/src/settings/preferences_document.rs b/rust/src/settings/preferences_document.rs new file mode 100644 index 0000000000..c26be8fe2d --- /dev/null +++ b/rust/src/settings/preferences_document.rs @@ -0,0 +1,379 @@ +//! Portable UI preferences document (upstream 0.67.0 "portable preferences"). +//! +//! A versioned JSON file, `{"version":1,"preferences":{...}}`, that moves the +//! look-and-behavior preferences between machines. It is an explicit +//! allowlist, independent of the on-disk `settings.json` shape: +//! +//! - Unknown keys, unsupported versions, wrong types and out-of-range values +//! are rejected before anything is applied. +//! - Keys missing from a document leave the receiving machine unchanged. +//! - `null` restores the documented default (`Settings::default()`). +//! +//! Secrets and machine-specific or consent settings are never portable. The +//! [`EXCLUDED_KEYS`] list names every other [`Settings`] field, and a test +//! fails when a new field is added without being classified, so a new field +//! is excluded until someone opts it in. + +use std::collections::{BTreeMap, HashSet}; +use std::io::Read; +use std::path::Path; + +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; + +use super::{MetricPreference, Settings}; +use crate::core::ProviderId; + +/// Current (and only) document version. +pub const PREFERENCES_DOCUMENT_VERSION: u64 = 1; + +/// Upper bound for a document read from disk. A real document is a few KiB. +pub const MAX_PREFERENCES_DOCUMENT_BYTES: u64 = 256 * 1024; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum PreferencesError { + #[error("Preferences file is not a valid preferences document")] + Malformed, + #[error("Unsupported preferences version")] + UnsupportedVersion, + #[error("Invalid or non-portable preference: {0}")] + InvalidPreference(String), + #[error("Preferences file is larger than {} KiB", MAX_PREFERENCES_DOCUMENT_BYTES / 1024)] + TooLarge, + #[error("Could not read or write the preferences file: {0}")] + Io(String), + #[error("Could not apply preferences: {0}")] + Apply(String), +} + +type Validator = fn(&Value) -> bool; + +/// Portable keys, named after the `Settings` fields, with their validators. +const ALLOWED_PREFERENCES: &[(&str, Validator)] = &[ + ("refresh_interval_secs", refresh_interval_secs), + ("adaptive_refresh", typed::), + ("refresh_all_providers_on_menu_open", typed::), + ( + "low_power_mode_preference", + typed::, + ), + ("show_notifications", typed::), + ("sound_enabled", typed::), + ("high_usage_threshold", percent), + ("critical_usage_threshold", percent), + ("provider_usage_thresholds", provider_usage_thresholds), + ("predictive_pace_warning_enabled", typed::), + ("show_pace", typed::), + ("show_as_used", typed::), + ("reset_time_relative", typed::), + ("show_reset_when_exhausted", typed::), + ("hide_personal_info", typed::), + ("menu_bar_shows_highest_usage", typed::), + ("menu_bar_shows_percent", typed::), + ("tray_icon_mode", typed::), + ("switcher_shows_icons", typed::), + ("overview_layout", |v| one_of(v, &["compact", "detailed"])), + ("menu_bar_display_mode", |v| { + one_of(v, &["minimal", "compact", "detailed"]) + }), + ("provider_order", provider_id_list), + ("enabled_providers", provider_id_list), + ("provider_metrics", provider_metrics), + ("theme", typed::), + ("ui_language", typed::), + ("window_scale_percent", uint_in::<100, 250>), + ("tray_scale_percent", uint_in::<100, 200>), + ("float_bar_opacity", uint_in::<30, 100>), + ("float_bar_scale", uint_in::<75, 200>), + ("float_bar_orientation", |v| { + one_of(v, &["horizontal", "vertical"]) + }), + ("float_bar_style", |v| one_of(v, &["floating", "taskbar"])), + ("float_bar_dark_text", typed::), + ("float_bar_show_reset_inline", typed::), + ("float_bar_show_cost", typed::), +]; + +/// Every other `Settings` field. Secrets (`provider_configs` holds API +/// tokens and manual cookie headers, `http_proxy_password`), machine paths and +/// hosts, and consent or side-effect toggles must stay out of a portable file. +pub const EXCLUDED_KEYS: &[&str] = &[ + // Secrets and credential-bearing config. + "provider_configs", + "http_proxy_enabled", + "http_proxy_url", + "http_proxy_username", + "http_proxy_password", + // Machine-specific paths and hosts. + "notification_sound_paths", + "codex_custom_sessions_dirs", + "agent_session_ssh_hosts", + // Consent, autostart and other side-effect toggles. + "start_at_login", + "start_minimized", + "hooks_enabled", + "global_shortcut", + "agent_sessions_enabled", + "disable_keychain_access", + "claude_allow_reading_claude_code_credentials", + "codex_external_oauth_sources_allowed", + "open_codex_usage_logs_enabled", + "hide_native_codex_cost_when_open_codex_present", + "powertoys_status_pipe_enabled", + "float_bar_enabled", + "float_bar_click_through", + "tray_panel_always_on_top", + "promote_tray_icon", + // Update settings. + "update_channel", + "auto_download_updates", + "install_updates_on_quit", + // Not part of the v1 allowlist. + "notification_sound_theme", + "enable_animations", + "show_all_token_accounts_in_menu", + "float_bar_provider_ids", + "claude_daily_routines_usage_visible", + "weekly_progress_work_days", + "alibaba_token_plan_region", + "cost_summary_display_style", + // No reader anywhere in the app. + "merge_tray_icons", +]; + +fn typed(value: &Value) -> bool { + serde_json::from_value::(value.clone()).is_ok() +} + +fn one_of(value: &Value, choices: &[&str]) -> bool { + value.as_str().is_some_and(|raw| choices.contains(&raw)) +} + +fn uint_in(value: &Value) -> bool { + value + .as_u64() + .is_some_and(|number| (MIN..=MAX).contains(&number)) +} + +/// `0` is manual refresh; otherwise the interval must be at least a minute +/// (the shortest option offered in Settings) and at most a day. +fn refresh_interval_secs(value: &Value) -> bool { + value + .as_u64() + .is_some_and(|secs| secs == 0 || (60..=86_400).contains(&secs)) +} + +fn percent(value: &Value) -> bool { + value + .as_f64() + .is_some_and(|number| number.is_finite() && (0.0..=100.0).contains(&number)) +} + +fn known_provider_ids() -> HashSet<&'static str> { + ProviderId::all().iter().map(|id| id.cli_name()).collect() +} + +fn provider_id_list(value: &Value) -> bool { + let (Some(items), known) = (value.as_array(), known_provider_ids()) else { + return false; + }; + let mut seen = HashSet::new(); + items.iter().all(|item| { + item.as_str() + .is_some_and(|id| known.contains(id) && seen.insert(id)) + }) +} + +fn provider_metrics(value: &Value) -> bool { + let known = known_provider_ids(); + value.as_object().is_some_and(|map| { + map.iter().all(|(provider, metric)| { + known.contains(provider.as_str()) && typed::(metric) + }) + }) +} + +/// Keys are `` or `:session|weekly`; values carry optional +/// `high` / `critical` percentages. +fn provider_usage_thresholds(value: &Value) -> bool { + let known = known_provider_ids(); + let Some(map) = value.as_object() else { + return false; + }; + map.iter().all(|(key, entry)| { + let (provider, window) = key + .split_once(':') + .map_or((key.as_str(), None), |(provider, window)| { + (provider, Some(window)) + }); + known.contains(provider) + && window.is_none_or(|window| matches!(window, "session" | "weekly")) + && entry.as_object().is_some_and(|fields| { + fields.iter().all(|(field, number)| { + matches!(field.as_str(), "high" | "critical") + && (number.is_null() || percent(number)) + }) + }) + }) +} + +fn invalid(key: &str) -> PreferencesError { + PreferencesError::InvalidPreference(key.to_string()) +} + +/// A validated preferences document. +#[derive(Debug, Clone, PartialEq)] +pub struct PreferencesDocument { + preferences: BTreeMap, +} + +impl PreferencesDocument { + /// Parse and fully validate a document. Nothing is applied here. + pub fn from_json(text: &str) -> Result { + if text.len() as u64 > MAX_PREFERENCES_DOCUMENT_BYTES { + return Err(PreferencesError::TooLarge); + } + let root: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')) + .map_err(|_| PreferencesError::Malformed)?; + let Value::Object(mut root) = root else { + return Err(PreferencesError::Malformed); + }; + if root.get("version").and_then(Value::as_u64) != Some(PREFERENCES_DOCUMENT_VERSION) { + return Err(PreferencesError::UnsupportedVersion); + } + let Some(Value::Object(preferences)) = root.remove("preferences") else { + return Err(PreferencesError::Malformed); + }; + if root.len() != 1 { + return Err(PreferencesError::Malformed); + } + Self::validated(preferences) + } + + fn validated(preferences: Map) -> Result { + for (key, value) in &preferences { + let Some((_, valid)) = ALLOWED_PREFERENCES.iter().find(|(name, _)| name == key) else { + return Err(invalid(key)); + }; + if !value.is_null() && !valid(value) { + return Err(invalid(key)); + } + } + Ok(Self { + preferences: preferences.into_iter().collect(), + }) + } + + /// Snapshot the portable preferences of `settings`. + pub fn from_settings(settings: &Settings) -> Result { + let Value::Object(mut all) = serde_json::to_value(settings) + .map_err(|error| PreferencesError::Apply(error.to_string()))? + else { + return Err(PreferencesError::Apply( + "settings did not serialize to an object".to_string(), + )); + }; + let mut preferences = Map::new(); + for (key, _) in ALLOWED_PREFERENCES { + if let Some(value) = all.remove(*key) { + preferences.insert((*key).to_string(), value); + } + } + // Settings can retain provider IDs written by a newer or older build. + // They are not portable to this build, so omit those entries instead + // of making an otherwise valid export fail strict document validation. + let known = known_provider_ids(); + if let Some(Value::Array(ids)) = preferences.get_mut("enabled_providers") { + ids.retain(|id| id.as_str().is_some_and(|id| known.contains(id))); + } + if let Some(Value::Object(metrics)) = preferences.get_mut("provider_metrics") { + metrics.retain(|provider, _| known.contains(provider.as_str())); + } + if let Some(Value::Object(thresholds)) = preferences.get_mut("provider_usage_thresholds") { + thresholds.retain(|key, _| { + let provider = key.split_once(':').map_or(key.as_str(), |(id, _)| id); + known.contains(provider) + }); + } + if let Some(Value::Array(ids)) = preferences.get_mut("enabled_providers") { + // `enabled_providers` is a set; keep exports deterministic. + ids.sort_by(|a, b| a.as_str().cmp(&b.as_str())); + } + Self::validated(preferences) + } + + /// Pretty-printed JSON with a trailing newline. + pub fn to_json(&self) -> String { + let document = serde_json::json!({ + "version": PREFERENCES_DOCUMENT_VERSION, + "preferences": self.preferences, + }); + let mut text = serde_json::to_string_pretty(&document).unwrap_or_default(); + text.push('\n'); + text + } + + /// Number of preferences the document sets. + pub fn len(&self) -> usize { + self.preferences.len() + } + + pub fn is_empty(&self) -> bool { + self.preferences.is_empty() + } + + /// Apply to `settings`. Absent keys are untouched; `null` restores the + /// default. Returns the number of preferences applied. `settings` is left + /// unchanged on error. + pub fn apply_to(&self, settings: &mut Settings) -> Result { + let apply_error = |error: serde_json::Error| PreferencesError::Apply(error.to_string()); + let Value::Object(mut current) = serde_json::to_value(&*settings).map_err(apply_error)? + else { + return Err(PreferencesError::Apply( + "settings did not serialize to an object".to_string(), + )); + }; + let Value::Object(defaults) = + serde_json::to_value(Settings::default()).map_err(apply_error)? + else { + return Err(PreferencesError::Apply( + "default settings did not serialize to an object".to_string(), + )); + }; + for (key, value) in &self.preferences { + let replacement = if value.is_null() { + defaults.get(key).cloned().ok_or_else(|| invalid(key))? + } else { + value.clone() + }; + current.insert(key.clone(), replacement); + } + // Re-read through the normal settings loader so clamping and + // normalization match what `settings.json` gets on load. + *settings = serde_json::from_value(Value::Object(current)).map_err(apply_error)?; + Ok(self.preferences.len()) + } + + /// Read a document from `path`, bounded to [`MAX_PREFERENCES_DOCUMENT_BYTES`]. + pub fn read_file(path: &Path) -> Result { + let file = + std::fs::File::open(path).map_err(|error| PreferencesError::Io(error.to_string()))?; + let mut bytes = Vec::new(); + file.take(MAX_PREFERENCES_DOCUMENT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| PreferencesError::Io(error.to_string()))?; + if bytes.len() as u64 > MAX_PREFERENCES_DOCUMENT_BYTES { + return Err(PreferencesError::TooLarge); + } + let text = String::from_utf8(bytes).map_err(|_| PreferencesError::Malformed)?; + Self::from_json(&text) + } + + pub fn write_file(&self, path: &Path) -> Result<(), PreferencesError> { + std::fs::write(path, self.to_json()) + .map_err(|error| PreferencesError::Io(error.to_string())) + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/src/settings/preferences_document/tests.rs b/rust/src/settings/preferences_document/tests.rs new file mode 100644 index 0000000000..fb542ae596 --- /dev/null +++ b/rust/src/settings/preferences_document/tests.rs @@ -0,0 +1,522 @@ +use super::*; +use crate::settings::{ + Language, ProviderConfig, ThemePreference, TrayIconMode, UsageThresholdOverride, +}; +use serde_json::json; + +fn document(preferences: Value) -> String { + json!({ "version": 1, "preferences": preferences }).to_string() +} + +fn parse(preferences: Value) -> Result { + PreferencesDocument::from_json(&document(preferences)) +} + +fn max_document_bytes() -> usize { + usize::try_from(MAX_PREFERENCES_DOCUMENT_BYTES).expect("limit fits in usize") +} + +/// Settings as JSON with the set-valued `enabled_providers` in stable order. +fn snapshot(settings: &Settings) -> Value { + let mut value = serde_json::to_value(settings).expect("serialize settings"); + if let Some(Value::Array(ids)) = value.get_mut("enabled_providers") { + ids.sort_by(|a, b| a.as_str().cmp(&b.as_str())); + } + value +} + +fn customized_settings() -> Settings { + let mut settings = Settings { + refresh_interval_secs: 900, + adaptive_refresh: true, + refresh_all_providers_on_menu_open: true, + low_power_mode_preference: crate::settings::LowPowerModePreference::Automatic, + show_notifications: false, + sound_enabled: false, + high_usage_threshold: 55.5, + critical_usage_threshold: 80.0, + predictive_pace_warning_enabled: true, + show_pace: false, + show_as_used: false, + reset_time_relative: false, + show_reset_when_exhausted: true, + hide_personal_info: true, + menu_bar_shows_highest_usage: true, + menu_bar_shows_percent: true, + tray_icon_mode: TrayIconMode::PerProvider, + switcher_shows_icons: false, + overview_layout: "detailed".to_string(), + menu_bar_display_mode: "minimal".to_string(), + // Loading normalizes the order to the full canonical list. + provider_order: crate::settings::normalize_provider_order(&[ + "codex".to_string(), + "claude".to_string(), + ]), + theme: ThemePreference::Dark, + ui_language: Language::Japanese, + window_scale_percent: 150, + tray_scale_percent: 120, + float_bar_opacity: 60, + float_bar_scale: 120, + float_bar_orientation: "vertical".to_string(), + float_bar_style: "taskbar".to_string(), + float_bar_dark_text: true, + float_bar_show_reset_inline: true, + float_bar_show_cost: true, + ..Settings::default() + }; + settings.enabled_providers = ["claude", "codex", "zai"].map(String::from).into(); + settings.provider_usage_thresholds.insert( + "codex:weekly".to_string(), + UsageThresholdOverride { + high: Some(60.0), + critical: None, + }, + ); + settings.provider_metrics.insert( + "claude".to_string(), + crate::settings::MetricPreference::Weekly, + ); + settings +} + +/// Settings whose secret and machine-specific fields are all populated. +fn secret_settings() -> Settings { + let mut settings = customized_settings(); + settings.provider_configs.insert( + ProviderId::Claude, + ProviderConfig { + api_token: Some("SECRET-API-TOKEN".to_string()), + manual_cookie_header: Some("sessionKey=SECRET-COOKIE".to_string()), + management_api_token: Some("SECRET-MGMT".to_string()), + ..ProviderConfig::default() + }, + ); + settings.http_proxy_enabled = true; + settings.http_proxy_url = "http://proxy.invalid:8080".to_string(); + settings.http_proxy_username = "SECRET-PROXY-USER".to_string(); + settings.http_proxy_password = "SECRET-PROXY-PASSWORD".to_string(); + settings.codex_custom_sessions_dirs = vec!["C:\\secret\\sessions".to_string()]; + settings.agent_session_ssh_hosts = vec!["secret-host.invalid".to_string()]; + settings.notification_sound_paths.high_usage = Some("C:\\secret\\ding.wav".to_string()); + settings +} + +#[test] +fn round_trips_every_allowlisted_preference() { + let source = customized_settings(); + let exported = PreferencesDocument::from_settings(&source).expect("export"); + assert_eq!(exported.len(), ALLOWED_PREFERENCES.len()); + + let reparsed = PreferencesDocument::from_json(&exported.to_json()).expect("reimport"); + assert_eq!(reparsed, exported); + + let mut target = Settings { + start_at_login: true, + global_shortcut: "Ctrl+Alt+K".to_string(), + ..Settings::default() + }; + let applied = reparsed.apply_to(&mut target).expect("apply"); + assert_eq!(applied, ALLOWED_PREFERENCES.len()); + + let again = PreferencesDocument::from_settings(&target).expect("re-export"); + assert_eq!(again, exported); + assert_eq!(target.window_scale_percent, 150); + assert_eq!(target.ui_language, Language::Japanese); + assert_eq!( + target.enabled_providers, + ["claude", "codex", "zai"].map(String::from).into() + ); + // Non-portable state on the receiving machine is untouched. + assert!(target.start_at_login); + assert_eq!(target.global_shortcut, "Ctrl+Alt+K"); +} + +#[test] +fn export_is_deterministic_and_sorted() { + let first = PreferencesDocument::from_settings(&customized_settings()) + .expect("export") + .to_json(); + let second = PreferencesDocument::from_settings(&customized_settings()) + .expect("export") + .to_json(); + assert_eq!(first, second); + assert!(first.ends_with("}\n")); + let claude = first.find("\"claude\"").expect("claude listed"); + let codex = first.find("\"codex\"").expect("codex listed"); + let zai = first.find("\"zai\"").expect("zai listed"); + assert!(claude < codex && codex < zai); +} + +#[test] +fn export_skips_provider_preferences_unknown_to_this_build() { + let mut settings = customized_settings(); + settings + .enabled_providers + .insert("future-provider".to_string()); + settings.provider_metrics.insert( + "future-provider".to_string(), + crate::settings::MetricPreference::Weekly, + ); + settings.provider_usage_thresholds.insert( + "future-provider:weekly".to_string(), + UsageThresholdOverride { + high: Some(65.0), + critical: None, + }, + ); + + let exported = PreferencesDocument::from_settings(&settings) + .expect("stale provider IDs do not prevent export"); + let json: Value = serde_json::from_str(&exported.to_json()).expect("valid JSON"); + let preferences = json.get("preferences").expect("preferences object"); + + assert!( + !preferences["enabled_providers"] + .as_array() + .expect("provider list") + .iter() + .any(|id| id == "future-provider") + ); + assert!( + preferences["provider_metrics"] + .get("future-provider") + .is_none() + ); + assert!( + preferences["provider_usage_thresholds"] + .get("future-provider:weekly") + .is_none() + ); + assert!(preferences["provider_metrics"].get("claude").is_some()); + assert!( + preferences["provider_usage_thresholds"] + .get("codex:weekly") + .is_some() + ); +} + +#[test] +fn rejection_matrix_names_the_offending_key() { + let cases = [ + ( + "unknown key", + json!({ "not_a_preference": true }), + "not_a_preference", + ), + ( + "provider_configs smuggled in", + json!({ "provider_configs": { "claude": { "api_token": "x" } } }), + "provider_configs", + ), + ( + "http proxy password", + json!({ "http_proxy_password": "x" }), + "http_proxy_password", + ), + ( + "start at login", + json!({ "start_at_login": true }), + "start_at_login", + ), + ("bad enum", json!({ "theme": "sepia" }), "theme"), + ( + "bad language", + json!({ "ui_language": "klingon" }), + "ui_language", + ), + ( + "bad layout", + json!({ "overview_layout": "huge" }), + "overview_layout", + ), + ( + "threshold over 100", + json!({ "high_usage_threshold": 100.5 }), + "high_usage_threshold", + ), + ( + "negative threshold", + json!({ "critical_usage_threshold": -1 }), + "critical_usage_threshold", + ), + ( + "threshold as string", + json!({ "high_usage_threshold": "70" }), + "high_usage_threshold", + ), + ( + "bool as string", + json!({ "show_pace": "true" }), + "show_pace", + ), + ("bool as number", json!({ "show_pace": 1 }), "show_pace"), + ( + "scale too small", + json!({ "window_scale_percent": 99 }), + "window_scale_percent", + ), + ( + "scale too large", + json!({ "tray_scale_percent": 201 }), + "tray_scale_percent", + ), + ( + "scale fractional", + json!({ "window_scale_percent": 100.5 }), + "window_scale_percent", + ), + ( + "interval below a minute", + json!({ "refresh_interval_secs": 5 }), + "refresh_interval_secs", + ), + ( + "interval above a day", + json!({ "refresh_interval_secs": 86_401 }), + "refresh_interval_secs", + ), + ( + "unknown provider id", + json!({ "enabled_providers": ["nope"] }), + "enabled_providers", + ), + ( + "duplicate provider id", + json!({ "provider_order": ["codex", "codex"] }), + "provider_order", + ), + ( + "provider alias", + json!({ "enabled_providers": ["openai"] }), + "enabled_providers", + ), + ( + "metric value", + json!({ "provider_metrics": { "codex": "bogus" } }), + "provider_metrics", + ), + ( + "metric provider", + json!({ "provider_metrics": { "nope": "weekly" } }), + "provider_metrics", + ), + ( + "threshold window", + json!({ "provider_usage_thresholds": { "codex:monthly": { "high": 50 } } }), + "provider_usage_thresholds", + ), + ( + "threshold field", + json!({ "provider_usage_thresholds": { "codex": { "low": 50 } } }), + "provider_usage_thresholds", + ), + ( + "threshold range", + json!({ "provider_usage_thresholds": { "codex": { "high": 150 } } }), + "provider_usage_thresholds", + ), + ]; + for (name, preferences, key) in cases { + assert_eq!( + parse(preferences).expect_err(name), + PreferencesError::InvalidPreference(key.to_string()), + "{name}" + ); + } +} + +#[test] +fn rejects_bad_envelopes() { + for text in [ + r#"{"version":2,"preferences":{}}"#, + r#"{"version":"1","preferences":{}}"#, + r#"{"preferences":{}}"#, + ] { + assert_eq!( + PreferencesDocument::from_json(text), + Err(PreferencesError::UnsupportedVersion), + "{text}" + ); + } + for text in [ + "not json", + "[]", + r#"{"version":1}"#, + r#"{"version":1,"preferences":[]}"#, + r#"{"version":1,"preferences":{},"extra":true}"#, + ] { + assert_eq!( + PreferencesDocument::from_json(text), + Err(PreferencesError::Malformed), + "{text}" + ); + } + let oversized = format!( + r#"{{"version":1,"preferences":{{}},"pad":"{}"}}"#, + "x".repeat(max_document_bytes()) + ); + assert_eq!( + PreferencesDocument::from_json(&oversized), + Err(PreferencesError::TooLarge) + ); +} + +#[test] +fn errors_never_echo_values() { + let error = parse(json!({ "theme": "TOP-SECRET-VALUE" })).expect_err("bad theme"); + assert!(!error.to_string().contains("TOP-SECRET-VALUE")); + let error = PreferencesDocument::from_json("TOP-SECRET-VALUE").expect_err("not json"); + assert!(!error.to_string().contains("TOP-SECRET-VALUE")); +} + +#[test] +fn missing_keys_leave_settings_unchanged() { + let empty = parse(json!({})).expect("empty document"); + let mut settings = customized_settings(); + // The first apply normalizes state the way a settings load does. + empty.apply_to(&mut settings).expect("apply"); + let before = snapshot(&settings); + assert_eq!(empty.apply_to(&mut settings).expect("apply"), 0); + assert_eq!(snapshot(&settings), before); + + parse(json!({ "theme": "light" })) + .expect("theme") + .apply_to(&mut settings) + .expect("apply"); + assert_eq!(settings.theme, ThemePreference::Light); + assert_eq!(settings.window_scale_percent, 150); +} + +#[test] +fn null_restores_the_default() { + let mut settings = customized_settings(); + let applied = parse(json!({ + "window_scale_percent": null, + "theme": null, + "enabled_providers": null, + "provider_usage_thresholds": null, + "provider_metrics": null, + "provider_order": null, + "show_pace": null, + })) + .expect("nulls are valid") + .apply_to(&mut settings) + .expect("apply"); + assert_eq!(applied, 7); + + let defaults = Settings::default(); + assert_eq!(settings.window_scale_percent, defaults.window_scale_percent); + assert_eq!(settings.theme, defaults.theme); + assert_eq!(settings.enabled_providers, defaults.enabled_providers); + assert!(settings.provider_usage_thresholds.is_empty()); + assert!(settings.provider_metrics.is_empty()); + assert!(settings.provider_order.is_empty()); + assert!(settings.show_pace); + // Untouched preferences keep their customized values. + assert_eq!(settings.tray_scale_percent, 120); +} + +#[test] +fn allowed_and_excluded_keys_classify_every_settings_field() { + let Value::Object(serialized) = snapshot(&secret_settings()) else { + panic!("settings must serialize to an object"); + }; + let allowed: HashSet<&str> = ALLOWED_PREFERENCES.iter().map(|(key, _)| *key).collect(); + let excluded: HashSet<&str> = EXCLUDED_KEYS.iter().copied().collect(); + + assert_eq!( + allowed.len(), + ALLOWED_PREFERENCES.len(), + "duplicate allowlist key" + ); + assert_eq!( + excluded.len(), + EXCLUDED_KEYS.len(), + "duplicate excluded key" + ); + assert!( + allowed.is_disjoint(&excluded), + "a key is both allowed and excluded" + ); + + for key in serialized.keys() { + assert!( + allowed.contains(key.as_str()) || excluded.contains(key.as_str()), + "Settings field `{key}` is not classified: add it to ALLOWED_PREFERENCES \ + (portable) or EXCLUDED_KEYS (never exported)" + ); + } + for key in allowed.iter().chain(excluded.iter()) { + assert!( + serialized.contains_key(*key), + "`{key}` is classified but is not a Settings field" + ); + } +} + +#[test] +fn export_never_contains_secrets_or_machine_state() { + let text = PreferencesDocument::from_settings(&secret_settings()) + .expect("export") + .to_json(); + for secret in [ + "SECRET", + "proxy.invalid", + "secret-host", + "secret\\\\", + "sessionKey", + "provider_configs", + "http_proxy", + "start_at_login", + "global_shortcut", + ] { + assert!(!text.contains(secret), "export leaked `{secret}`"); + } +} + +#[test] +fn applying_keeps_everything_else_on_the_receiving_machine() { + let empty = parse(json!({})).expect("empty"); + let mut target = secret_settings(); + empty.apply_to(&mut target).expect("apply"); + let before = snapshot(&target); + // The whole-struct JSON round trip used by apply is lossless once normalized. + empty.apply_to(&mut target).expect("apply"); + assert_eq!(snapshot(&target), before); + + parse(json!({ "theme": "light" })) + .expect("theme") + .apply_to(&mut target) + .expect("apply"); + let config = target + .provider_configs + .get(&ProviderId::Claude) + .expect("config"); + assert_eq!(config.api_token.as_deref(), Some("SECRET-API-TOKEN")); + assert_eq!(target.http_proxy_password, "SECRET-PROXY-PASSWORD"); +} + +#[test] +fn file_round_trip_and_size_bound() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("preferences.json"); + let exported = PreferencesDocument::from_settings(&customized_settings()).expect("export"); + exported.write_file(&path).expect("write"); + assert_eq!( + PreferencesDocument::read_file(&path).expect("read"), + exported + ); + + let big = dir.path().join("big.json"); + std::fs::write(&big, vec![b' '; max_document_bytes() + 10]).expect("write"); + assert_eq!( + PreferencesDocument::read_file(&big), + Err(PreferencesError::TooLarge) + ); + + assert!(matches!( + PreferencesDocument::read_file(&dir.path().join("missing.json")), + Err(PreferencesError::Io(_)) + )); +}