Skip to main content

markon_core/
settings.rs

1use crate::server::{ServerConfig, WorkspaceInit};
2use crate::workspace::{generate_token, PersistHook, WorkspaceFlags, WorkspaceRegistry};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex, OnceLock};
6
7/// Restrict settings.json to the owning user (Unix only). Permission
8/// errors are logged at debug — failing to chmod on a temp filesystem
9/// (e.g. unit tests on tmpfs) is not worth interrupting the save.
10#[cfg(unix)]
11fn restrict_user_only(path: &Path) {
12    use std::os::unix::fs::PermissionsExt;
13    match std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
14        Ok(()) => tracing::debug!(path = %path.display(), "settings.json chmod 0600 applied"),
15        Err(e) => tracing::debug!(
16            path = %path.display(),
17            "settings.json chmod 0600 failed: {e} — proceeding"
18        ),
19    }
20}
21
22/// Windows defaults to per-user ACLs on files created under the user
23/// profile, which is where we live (`~/.markon/`). We don't attempt
24/// further hardening here; the default is already "owner only" on a
25/// standard install.
26#[cfg(not(unix))]
27fn restrict_user_only(_path: &Path) {}
28
29/// Emit a one-shot warning when a provider api_key is being persisted
30/// in plaintext. Anything more elaborate (OS keychain, encryption) is
31/// future work — for now we make sure the operator at least knows the
32/// file is sensitive.
33fn warn_sensitive_secret_persisted_once(path: &Path) {
34    static WARNED: OnceLock<()> = OnceLock::new();
35    WARNED.get_or_init(|| {
36        tracing::warn!(
37            path = %path.display(),
38            "chat provider api_key persisted in plaintext at this path; \
39             restrict access to your user account only"
40        );
41    });
42}
43
44fn default_true() -> bool {
45    true
46}
47fn default_stable() -> String {
48    "stable".to_string()
49}
50fn default_auto() -> String {
51    "auto".to_string()
52}
53fn default_in_page() -> String {
54    "in_page".to_string()
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum PortMode {
60    Auto,
61    #[default]
62    Spec,
63}
64
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct WorkspaceSettings {
67    pub path: String,
68    #[serde(flatten, default)]
69    pub flags: WorkspaceFlags,
70}
71
72/// Per-provider configuration block. Each provider keeps its own complete
73/// set of fields so switching the active provider doesn't lose values.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct ChatProviderSettings {
76    #[serde(default)]
77    pub api_key: String,
78    /// Override model id; empty = use provider's built-in default.
79    #[serde(default)]
80    pub model: String,
81    /// Override API base URL (proxies / compatible servers); empty = official.
82    #[serde(default)]
83    pub base_url: String,
84    /// Cached chat-model ids fetched from the provider's `/v1/models` endpoint.
85    /// Refreshed by the GUI on demand; surfaced to the model picker as
86    /// autocomplete options. Empty until the user clicks "refresh".
87    #[serde(default)]
88    pub models: Vec<String>,
89}
90
91/// Chat (AI assistant) configuration shared across CLI / GUI / server.
92/// `provider` selects the active block; both `anthropic` and `openai` keep
93/// their own complete settings so switching back doesn't lose values.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ChatSettings {
96    /// "anthropic" | "openai".
97    #[serde(default = "default_provider")]
98    pub provider: String,
99    #[serde(default)]
100    pub anthropic: ChatProviderSettings,
101    #[serde(default)]
102    pub openai: ChatProviderSettings,
103}
104
105fn default_provider() -> String {
106    "anthropic".to_string()
107}
108
109impl Default for ChatSettings {
110    fn default() -> Self {
111        Self {
112            provider: default_provider(),
113            anthropic: ChatProviderSettings::default(),
114            openai: ChatProviderSettings::default(),
115        }
116    }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(default)]
121pub struct AppSettings {
122    pub port_mode: PortMode,
123    pub port: u16,
124    pub host: String,
125    pub theme: String,
126    #[serde(default = "default_auto")]
127    pub language: String,
128    #[serde(default = "default_auto")]
129    pub web_theme: String,
130    #[serde(default = "default_auto")]
131    pub web_language: String,
132    pub db_path: Option<String>,
133    /// Per-install random salt for workspace-id hashing. Empty on first run;
134    /// `load()` lazily generates one and persists it. Keeping it stable across
135    /// restarts means bookmarked workspace URLs survive; randomizing per install
136    /// means the URL is not derivable from the file path alone.
137    #[serde(default)]
138    pub salt: String,
139    pub workspaces: Vec<WorkspaceSettings>,
140    #[serde(default = "default_true")]
141    pub tray_resident: bool,
142    #[serde(default = "default_true")]
143    pub default_search: bool,
144    #[serde(default = "default_true")]
145    pub default_viewed: bool,
146    #[serde(default)]
147    pub default_live: bool,
148    #[serde(default)]
149    pub default_edit: bool,
150    #[serde(default)]
151    pub default_chat: bool,
152    /// Default chat surface form: "in_page" (floating panel embedded in the
153    /// markdown view) or "popout" (a standalone window opened via window.open).
154    /// Drives:
155    ///   • sphere click → expand panel vs spawn popout
156    ///   • selection chat button → quote into in-page chat vs popout
157    ///   • TOGGLE_CHAT shortcut
158    /// In every case Shift inverts the choice for that single click/press.
159    #[serde(default = "default_in_page")]
160    pub default_chat_mode: String,
161    #[serde(default)]
162    pub default_shared_annotation: bool,
163    /// When false (default), `<details>`-style collapsed sections are hidden
164    /// in print and replaced by a small placeholder; when true the collapsed
165    /// content is forced visible so it shows up in the printed output.
166    #[serde(default)]
167    pub print_collapsed_content: bool,
168    #[serde(default)]
169    pub chat: ChatSettings,
170    #[serde(default)]
171    pub web_styles: std::collections::HashMap<String, String>,
172    #[serde(default)]
173    pub shortcuts: std::collections::HashMap<String, serde_json::Value>,
174    #[serde(default = "default_true")]
175    pub auto_update: bool,
176    #[serde(default = "default_stable")]
177    pub update_channel: String,
178    #[serde(default)]
179    pub window_width: Option<u32>,
180    #[serde(default)]
181    pub window_height: Option<u32>,
182}
183
184impl Default for AppSettings {
185    fn default() -> Self {
186        Self {
187            port_mode: PortMode::Spec,
188            port: 6419,
189            host: "127.0.0.1".to_string(),
190            theme: "auto".to_string(),
191            language: "auto".to_string(),
192            web_theme: "auto".to_string(),
193            web_language: "auto".to_string(),
194            db_path: None,
195            salt: String::new(),
196            workspaces: vec![],
197            tray_resident: true,
198            default_search: true,
199            default_viewed: true,
200            default_live: false,
201            default_edit: false,
202            default_chat: false,
203            default_chat_mode: default_in_page(),
204            default_shared_annotation: false,
205            print_collapsed_content: false,
206            chat: ChatSettings::default(),
207            web_styles: std::collections::HashMap::new(),
208            shortcuts: std::collections::HashMap::new(),
209            auto_update: true,
210            update_channel: "stable".to_string(),
211            window_width: None,
212            window_height: None,
213        }
214    }
215}
216
217impl AppSettings {
218    /// Returns the canonical settings path rooted at `home`. Extracted so
219    /// tests can inject a tempdir without mutating `HOME`.
220    pub(crate) fn settings_path_at(home: &Path) -> PathBuf {
221        home.join(".markon").join("settings.json")
222    }
223
224    #[allow(dead_code)]
225    pub(crate) fn settings_path() -> PathBuf {
226        let home = dirs::home_dir().expect("HOME directory required");
227        Self::settings_path_at(&home)
228    }
229
230    /// Load from `home/.markon/settings.json`. Generates and persists a salt
231    /// on first run. Used by tests to inject a controlled home directory.
232    pub(crate) fn load_at(home: &Path) -> Self {
233        let p = Self::settings_path_at(home);
234        let mut s = if let Ok(c) = std::fs::read_to_string(&p) {
235            serde_json::from_str::<Self>(&c).unwrap_or_default()
236        } else {
237            Self::default()
238        };
239        s.normalize();
240        if s.salt.is_empty() {
241            s.salt = generate_token();
242            let _ = s.save_at(home);
243        }
244        s
245    }
246
247    pub fn load() -> Self {
248        let home = dirs::home_dir().expect("HOME directory required");
249        Self::load_at(&home)
250    }
251
252    /// Clean up settings loaded from disk:
253    /// - drop duplicate workspaces (keep first occurrence, preserving its flags)
254    /// - coerce empty language/theme strings to "auto" so existing files written
255    ///   before the auto-default fix don't show blank dropdowns
256    /// - canonicalize default_chat_mode so downstream code can compare directly
257    fn normalize(&mut self) {
258        use std::collections::HashSet;
259        let mut seen = HashSet::new();
260        self.workspaces.retain(|w| seen.insert(w.path.clone()));
261        for field in [
262            &mut self.language,
263            &mut self.web_theme,
264            &mut self.web_language,
265        ] {
266            if field.is_empty() {
267                *field = "auto".to_string();
268            }
269        }
270        if self.default_chat_mode != "popout" {
271            self.default_chat_mode = "in_page".to_string();
272        }
273    }
274    pub(crate) fn save_at(&self, home: &Path) -> Result<(), String> {
275        let p = Self::settings_path_at(home);
276        if let Some(parent) = p.parent() {
277            let _ = std::fs::create_dir_all(parent);
278        }
279        let c = serde_json::to_string_pretty(self).unwrap();
280        std::fs::write(&p, c).map_err(|e| e.to_string())?;
281        // The file stores plaintext provider api_key fields. Tighten
282        // permissions on Unix so other users on the same machine cannot
283        // read it. Windows files under the user profile are already
284        // restricted to the owning user by default ACLs — we don't
285        // attempt to re-tighten there.
286        restrict_user_only(&p);
287        if self.has_sensitive_provider_secret() {
288            warn_sensitive_secret_persisted_once(&p);
289        }
290        Ok(())
291    }
292
293    /// True when at least one chat provider has an api_key set. Used to
294    /// gate the "plaintext key on disk" warning so we don't spam users
295    /// who haven't configured chat at all.
296    fn has_sensitive_provider_secret(&self) -> bool {
297        !self.chat.anthropic.api_key.trim().is_empty()
298            || !self.chat.openai.api_key.trim().is_empty()
299    }
300
301    pub fn save(&self) -> Result<(), String> {
302        let home = dirs::home_dir().expect("HOME directory required");
303        self.save_at(&home)
304    }
305    pub fn to_server_config(&self, port: u16) -> ServerConfig {
306        let initial_workspaces: Vec<WorkspaceInit> = self
307            .workspaces
308            .iter()
309            .filter(|w| !w.path.is_empty())
310            .map(|w| WorkspaceInit {
311                path: PathBuf::from(&w.path),
312                flags: w.flags,
313                initial_path: None,
314            })
315            .collect();
316        ServerConfig {
317            host: self.host.clone(),
318            port,
319            theme: if self.web_theme == "auto" {
320                self.theme.clone()
321            } else {
322                self.web_theme.clone()
323            },
324            qr: None,
325            open_browser: None,
326            shared_annotation: initial_workspaces.iter().any(|w| w.flags.shared_annotation),
327            salt: Some(self.salt.clone()),
328            initial_workspaces,
329            bound_listener: None,
330            registry: None,
331            management_token: None,
332            language: if self.web_language == "auto" {
333                Some(self.language.clone())
334            } else {
335                Some(self.web_language.clone())
336            },
337            styles_css: self.render_styles_css(),
338            shortcuts_json: self.render_shortcuts_json(),
339            default_chat_mode: self.default_chat_mode.clone(),
340            print_collapsed_content: self.print_collapsed_content,
341        }
342    }
343    pub fn effective_web_language(&self) -> Option<String> {
344        let l = if self.web_language == "auto" {
345            &self.language
346        } else {
347            &self.web_language
348        };
349        if l == "auto" || l.is_empty() {
350            None
351        } else {
352            Some(l.clone())
353        }
354    }
355    /// Build a registry persist-hook that mirrors every registry mutation
356    /// back into the shared `AppSettings` and writes to disk. CLI daemon and
357    /// GUI both wire this so workspace changes initiated from either side
358    /// end up with the same persistent state.
359    pub fn persist_hook(settings: Arc<Mutex<AppSettings>>) -> PersistHook {
360        Arc::new(move |reg| {
361            let mut s = settings.lock().unwrap();
362            s.sync_from_registry(reg);
363            let _ = s.save();
364        })
365    }
366
367    pub fn render_shortcuts_json(&self) -> Option<String> {
368        if self.shortcuts.is_empty() {
369            None
370        } else {
371            serde_json::to_string(&self.shortcuts).ok()
372        }
373    }
374    /// Overwrite `workspaces` with the current registry contents. Ephemeral
375    /// (single-file) workspaces are skipped — they're created on demand by
376    /// Open-With, live in memory only, and should not pile up in settings.json.
377    pub(crate) fn sync_from_registry(&mut self, registry: &WorkspaceRegistry) {
378        self.workspaces = registry
379            .info_list()
380            .into_iter()
381            .filter(|info| !info.ephemeral)
382            .map(|info| WorkspaceSettings {
383                path: info.path,
384                flags: info.flags,
385            })
386            .collect();
387    }
388
389    /// Render `web_styles` (GUI-supplied overrides) as a complete CSS block
390    /// targeting the `--markon-*` design tokens defined in `editor.css`.
391    ///
392    /// Storage convention from the GUI panel (`STYLE_DEFS` in `index.html`):
393    ///   • duo-color entries are keyed `<base>.light` / `<base>.dark`
394    ///     (e.g. `primary.light`, `muted.dark`)
395    ///   • single-value entries are keyed by the bare token name
396    ///     (`ui-font`, `ui-font-size`, `panel-opacity`)
397    ///
398    /// Routing rules:
399    ///   • `<base>.light`  → `:root`                       as `--markon-<base>`
400    ///   • `<base>.dark`   → `html[data-theme="dark"]`     as `--markon-<base>`
401    ///   • bare key        → `:root`                       as `--markon-<key>`
402    ///   • unknown suffix (anything other than `.light`/`.dark`) is treated
403    ///     as a single value and routed to `:root` so malformed/legacy
404    ///     storage doesn't panic.
405    ///
406    /// Selector blocks are emitted in a fixed order — `:root` first, then
407    /// the dark override — so callers/tests get stable framing even though
408    /// HashMap iteration over individual properties is unordered.
409    /// Returns `None` when neither selector would have any content.
410    pub fn render_styles_css(&self) -> Option<String> {
411        if self.web_styles.is_empty() {
412            return None;
413        }
414        let mut root: Vec<String> = Vec::new();
415        let mut dark: Vec<String> = Vec::new();
416        for (k, v) in &self.web_styles {
417            if let Some(base) = k.strip_suffix(".light") {
418                root.push(format!("--markon-{base}: {v};"));
419            } else if let Some(base) = k.strip_suffix(".dark") {
420                dark.push(format!("--markon-{base}: {v};"));
421            } else {
422                // Bare key (single-value token) or unknown suffix — both go
423                // to `:root` so unexpected data is rendered harmlessly rather
424                // than dropped or panicking.
425                root.push(format!("--markon-{k}: {v};"));
426            }
427        }
428        if root.is_empty() && dark.is_empty() {
429            return None;
430        }
431        let mut out = String::new();
432        if !root.is_empty() {
433            out.push_str(":root { ");
434            out.push_str(&root.join(" "));
435            out.push_str(" }");
436        }
437        if !dark.is_empty() {
438            if !out.is_empty() {
439                out.push(' ');
440            }
441            out.push_str("html[data-theme=\"dark\"] { ");
442            out.push_str(&dark.join(" "));
443            out.push_str(" }");
444        }
445        Some(out)
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::workspace::WorkspaceConfig;
453
454    /// Self-cleaning tempdir; each test gets an isolated `home` so they can
455    /// run in parallel without touching the real `HOME` env var.
456    struct TempHome(PathBuf);
457    impl TempHome {
458        fn new(label: &str) -> Self {
459            let base = std::env::temp_dir().join(format!(
460                "markon-test-{}-{}",
461                label,
462                uuid::Uuid::new_v4().simple()
463            ));
464            std::fs::create_dir_all(&base).expect("create tempdir");
465            TempHome(base)
466        }
467        fn path(&self) -> &Path {
468            &self.0
469        }
470    }
471    impl Drop for TempHome {
472        fn drop(&mut self) {
473            let _ = std::fs::remove_dir_all(&self.0);
474        }
475    }
476
477    // First load generates a non-empty salt and persists it; second load
478    // returns the same salt (workspace-id stability contract).
479    #[test]
480    fn load_generates_salt_and_persists_it() {
481        let home = TempHome::new("salt");
482        let s1 = AppSettings::load_at(home.path());
483        assert!(!s1.salt.is_empty());
484
485        assert!(AppSettings::settings_path_at(home.path()).exists());
486
487        let s2 = AppSettings::load_at(home.path());
488        assert_eq!(s1.salt, s2.salt, "salt must be stable across load() calls");
489    }
490
491    #[test]
492    fn load_missing_file_returns_default() {
493        let home = TempHome::new("missing");
494        let s = AppSettings::load_at(home.path());
495        let d = AppSettings::default();
496        assert_eq!(s.port, d.port);
497        assert_eq!(s.host, d.host);
498        assert_eq!(s.language, "auto");
499        assert_eq!(s.web_theme, "auto");
500        assert_eq!(s.default_chat_mode, "in_page");
501    }
502
503    #[cfg(unix)]
504    #[test]
505    fn save_chmods_settings_file_to_0600() {
506        use std::os::unix::fs::PermissionsExt;
507        let home = TempHome::new("chmod");
508        let mut s = AppSettings::load_at(home.path());
509        s.chat.anthropic.api_key = "sk-ant-test-key-1234567890".to_string();
510        s.save_at(home.path()).expect("save");
511        let p = AppSettings::settings_path_at(home.path());
512        let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
513        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
514    }
515
516    #[test]
517    fn load_corrupt_file_returns_default() {
518        let home = TempHome::new("corrupt");
519        let p = AppSettings::settings_path_at(home.path());
520        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
521        std::fs::write(&p, b"{garbage").unwrap();
522
523        let s = AppSettings::load_at(home.path());
524        assert_eq!(s.port, AppSettings::default().port);
525        assert_eq!(s.default_chat_mode, "in_page");
526    }
527
528    #[test]
529    fn normalize_dedup_and_coerce() {
530        let mut s = AppSettings {
531            workspaces: vec![
532                WorkspaceSettings {
533                    path: "/a".to_string(),
534                    flags: WorkspaceFlags::default(),
535                },
536                WorkspaceSettings {
537                    path: "/b".to_string(),
538                    flags: WorkspaceFlags::default(),
539                },
540                WorkspaceSettings {
541                    path: "/a".to_string(),
542                    flags: WorkspaceFlags::default(),
543                },
544            ],
545            language: String::new(),
546            web_theme: String::new(),
547            web_language: String::new(),
548            default_chat_mode: "sidebar".to_string(),
549            ..AppSettings::default()
550        };
551
552        s.normalize();
553
554        assert_eq!(s.workspaces.len(), 2);
555        assert_eq!(s.workspaces[0].path, "/a");
556        assert_eq!(s.workspaces[1].path, "/b");
557        assert_eq!(s.language, "auto");
558        assert_eq!(s.web_theme, "auto");
559        assert_eq!(s.web_language, "auto");
560        assert_eq!(s.default_chat_mode, "in_page");
561
562        s.default_chat_mode = "popout".to_string();
563        s.normalize();
564        assert_eq!(s.default_chat_mode, "popout");
565    }
566
567    #[test]
568    fn sync_from_registry_skips_ephemeral() {
569        let reg = WorkspaceRegistry::new("testsalt".to_string());
570        let home = TempHome::new("reg");
571        let ws_path = home.path().to_path_buf();
572
573        reg.add(WorkspaceConfig {
574            path: ws_path.clone(),
575            flags: WorkspaceFlags::default(),
576            single_file: None,
577        });
578        reg.add(WorkspaceConfig {
579            path: ws_path.clone(),
580            flags: WorkspaceFlags::default(),
581            single_file: Some("note.md".to_string()),
582        });
583
584        let mut s = AppSettings::default();
585        s.sync_from_registry(&reg);
586
587        assert_eq!(s.workspaces.len(), 1, "ephemeral entry must be excluded");
588        assert_eq!(s.workspaces[0].path, ws_path.to_string_lossy());
589    }
590
591    /// Helper: build settings with the given `web_styles` map and nothing else.
592    fn settings_with_styles(pairs: &[(&str, &str)]) -> AppSettings {
593        let mut s = AppSettings::default();
594        for (k, v) in pairs {
595            s.web_styles.insert((*k).to_string(), (*v).to_string());
596        }
597        s
598    }
599
600    #[test]
601    fn render_styles_css_empty_returns_none() {
602        let s = AppSettings::default();
603        assert!(s.web_styles.is_empty());
604        assert!(s.render_styles_css().is_none());
605    }
606
607    #[test]
608    fn render_styles_css_light_only_emits_root_block() {
609        let s = settings_with_styles(&[("primary.light", "#0969da"), ("muted.light", "#656d76")]);
610        let css = s.render_styles_css().expect("should render");
611        assert!(css.starts_with(":root { "), "got: {css}");
612        assert!(css.contains("--markon-primary: #0969da;"), "got: {css}");
613        assert!(css.contains("--markon-muted: #656d76;"), "got: {css}");
614        assert!(
615            !css.contains("html[data-theme=\"dark\"]"),
616            "dark block must be absent when no dark keys: {css}"
617        );
618        // Reverse-assert old bug shape never reappears.
619        assert!(!css.contains("primary.light:"), "leaked dotted key: {css}");
620        assert!(css.contains("--markon-"), "must carry token prefix: {css}");
621    }
622
623    #[test]
624    fn render_styles_css_dark_only_emits_dark_block_only() {
625        let s = settings_with_styles(&[("primary.dark", "#58a6ff")]);
626        let css = s.render_styles_css().expect("should render");
627        assert!(
628            !css.contains(":root {"),
629            "no :root block when no light/single-value keys: {css}"
630        );
631        assert!(
632            css.contains("html[data-theme=\"dark\"] { --markon-primary: #58a6ff; }"),
633            "got: {css}"
634        );
635        assert!(!css.contains("primary.dark:"), "leaked dotted key: {css}");
636        assert!(css.contains("--markon-"), "must carry token prefix: {css}");
637    }
638
639    #[test]
640    fn render_styles_css_mixed_routes_keys_correctly() {
641        let s = settings_with_styles(&[
642            ("primary.light", "#0969da"),
643            ("primary.dark", "#58a6ff"),
644            ("muted.light", "#656d76"),
645            ("ui-font", "Inter"),
646            ("ui-font-size", "0.95"),
647            ("panel-opacity", "0.85"),
648        ]);
649        let css = s.render_styles_css().expect("should render");
650
651        // Both selector blocks present, in fixed order: :root then dark.
652        let root_idx = css.find(":root {").expect("root block present");
653        let dark_idx = css
654            .find("html[data-theme=\"dark\"] {")
655            .expect("dark block present");
656        assert!(
657            root_idx < dark_idx,
658            "selector block order must be :root before dark: {css}"
659        );
660
661        // Light + single-value tokens routed to :root with --markon- prefix.
662        // Use HashMap-order-agnostic membership checks.
663        let root_block = &css[root_idx..dark_idx];
664        assert!(
665            root_block.contains("--markon-primary: #0969da;"),
666            "got: {root_block}"
667        );
668        assert!(
669            root_block.contains("--markon-muted: #656d76;"),
670            "got: {root_block}"
671        );
672        assert!(
673            root_block.contains("--markon-ui-font: Inter;"),
674            "got: {root_block}"
675        );
676        assert!(
677            root_block.contains("--markon-ui-font-size: 0.95;"),
678            "got: {root_block}"
679        );
680        assert!(
681            root_block.contains("--markon-panel-opacity: 0.85;"),
682            "got: {root_block}"
683        );
684
685        // Dark override carries only the .dark entries — single-value tokens
686        // must NOT leak into the dark block.
687        let dark_block = &css[dark_idx..];
688        assert!(
689            dark_block.contains("--markon-primary: #58a6ff;"),
690            "got: {dark_block}"
691        );
692        assert!(
693            !dark_block.contains("--markon-ui-font"),
694            "single-value token leaked into dark block: {dark_block}"
695        );
696        assert!(
697            !dark_block.contains("--markon-panel-opacity"),
698            "single-value token leaked into dark block: {dark_block}"
699        );
700
701        // Reverse assertions: none of the legacy-bug shapes should appear.
702        assert!(!css.contains("primary.light:"), "leaked dotted key: {css}");
703        assert!(!css.contains("primary.dark:"), "leaked dotted key: {css}");
704        assert!(
705            !css.contains(" ui-font:") && !css.starts_with("ui-font:"),
706            "bare (un-prefixed) property leaked: {css}"
707        );
708        assert!(css.contains("--markon-"), "must carry token prefix: {css}");
709    }
710
711    #[test]
712    fn render_styles_css_unknown_suffix_falls_back_to_root() {
713        // An out-of-spec key (e.g. legacy/typo) must not panic; route to :root
714        // verbatim under the --markon- prefix so the result is still legal CSS.
715        let s = settings_with_styles(&[("primary.weird", "#abcdef")]);
716        let css = s.render_styles_css().expect("should render");
717        assert!(
718            css.contains(":root { --markon-primary.weird: #abcdef; }"),
719            "got: {css}"
720        );
721        assert!(!css.contains("html[data-theme=\"dark\"]"), "got: {css}");
722    }
723
724    #[test]
725    fn to_server_config_propagates_salt_and_workspaces() {
726        let s = AppSettings {
727            salt: "mysalt".to_string(),
728            workspaces: vec![WorkspaceSettings {
729                path: "/docs".to_string(),
730                flags: WorkspaceFlags::default(),
731            }],
732            ..AppSettings::default()
733        };
734
735        let cfg = s.to_server_config(7777);
736
737        assert_eq!(cfg.salt, Some("mysalt".to_string()));
738        assert_eq!(cfg.port, 7777);
739        assert_eq!(cfg.initial_workspaces.len(), 1);
740        assert_eq!(cfg.initial_workspaces[0].path, Path::new("/docs"));
741    }
742}