max / alloy
5 files changed,
+267 insertions,
-61 deletions
| @@ -30,6 +30,7 @@ | |||
| 30 | 30 | "serde", | |
| 31 | 31 | "serde_json", | |
| 32 | 32 | "sha-crypt", | |
| 33 | + | "synckit-config", | |
| 33 | 34 | "toml", | |
| 34 | 35 | "toml_edit", | |
| 35 | 36 | ] | |
| @@ -193,6 +194,16 @@ | |||
| 193 | 194 | "rustversion", | |
| 194 | 195 | ] | |
| 195 | 196 | ||
| 197 | + | [[package]] | |
| 198 | + | name = "cc" | |
| 199 | + | version = "1.4.0" | |
| 200 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 201 | + | checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" | |
| 202 | + | dependencies = [ | |
| 203 | + | "find-msvc-tools", | |
| 204 | + | "shlex", | |
| 205 | + | ] | |
| 206 | + | ||
| 196 | 207 | [[package]] | |
| 197 | 208 | name = "cfg-if" | |
| 198 | 209 | version = "1.0.4" | |
| @@ -495,6 +506,18 @@ | |||
| 495 | 506 | "num-traits", | |
| 496 | 507 | ] | |
| 497 | 508 | ||
| 509 | + | [[package]] | |
| 510 | + | name = "fallible-iterator" | |
| 511 | + | version = "0.3.0" | |
| 512 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 513 | + | checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" | |
| 514 | + | ||
| 515 | + | [[package]] | |
| 516 | + | name = "fallible-streaming-iterator" | |
| 517 | + | version = "0.1.9" | |
| 518 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 519 | + | checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" | |
| 520 | + | ||
| 498 | 521 | [[package]] | |
| 499 | 522 | name = "fancy-regex" | |
| 500 | 523 | version = "0.11.0" | |
| @@ -516,6 +539,12 @@ | |||
| 516 | 539 | "winapi", | |
| 517 | 540 | ] | |
| 518 | 541 | ||
| 542 | + | [[package]] | |
| 543 | + | name = "find-msvc-tools" | |
| 544 | + | version = "0.1.9" | |
| 545 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 546 | + | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 547 | + | ||
| 519 | 548 | [[package]] | |
| 520 | 549 | name = "finl_unicode" | |
| 521 | 550 | version = "1.4.0" | |
| @@ -614,6 +643,15 @@ | |||
| 614 | 643 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 615 | 644 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | |
| 616 | 645 | ||
| 646 | + | [[package]] | |
| 647 | + | name = "hashlink" | |
| 648 | + | version = "0.11.1" | |
| 649 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 650 | + | checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" | |
| 651 | + | dependencies = [ | |
| 652 | + | "hashbrown 0.16.1", | |
| 653 | + | ] | |
| 654 | + | ||
| 617 | 655 | [[package]] | |
| 618 | 656 | name = "heck" | |
| 619 | 657 | version = "0.5.0" | |
| @@ -753,6 +791,17 @@ | |||
| 753 | 791 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 754 | 792 | checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" | |
| 755 | 793 | ||
| 794 | + | [[package]] | |
| 795 | + | name = "libsqlite3-sys" | |
| 796 | + | version = "0.37.0" | |
| 797 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 798 | + | checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" | |
| 799 | + | dependencies = [ | |
| 800 | + | "cc", | |
| 801 | + | "pkg-config", | |
| 802 | + | "vcpkg", | |
| 803 | + | ] | |
| 804 | + | ||
| 756 | 805 | [[package]] | |
| 757 | 806 | name = "line-clipping" | |
| 758 | 807 | version = "0.3.7" | |
| @@ -1075,6 +1124,12 @@ | |||
| 1075 | 1124 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1076 | 1125 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 1077 | 1126 | ||
| 1127 | + | [[package]] | |
| 1128 | + | name = "pkg-config" | |
| 1129 | + | version = "0.3.33" | |
| 1130 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1131 | + | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" | |
| 1132 | + | ||
| 1078 | 1133 | [[package]] | |
| 1079 | 1134 | name = "portable-atomic" | |
| 1080 | 1135 | version = "1.14.0" | |
| @@ -1255,6 +1310,31 @@ | |||
| 1255 | 1310 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1256 | 1311 | checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | |
| 1257 | 1312 | ||
| 1313 | + | [[package]] | |
| 1314 | + | name = "rsqlite-vfs" | |
| 1315 | + | version = "0.1.1" | |
| 1316 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1317 | + | checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" | |
| 1318 | + | dependencies = [ | |
| 1319 | + | "hashbrown 0.16.1", | |
| 1320 | + | "thiserror 2.0.18", | |
| 1321 | + | ] | |
| 1322 | + | ||
| 1323 | + | [[package]] | |
| 1324 | + | name = "rusqlite" | |
| 1325 | + | version = "0.39.0" | |
| 1326 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1327 | + | checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" | |
| 1328 | + | dependencies = [ | |
| 1329 | + | "bitflags 2.13.0", | |
| 1330 | + | "fallible-iterator", | |
| 1331 | + | "fallible-streaming-iterator", | |
| 1332 | + | "hashlink", | |
| 1333 | + | "libsqlite3-sys", | |
| 1334 | + | "smallvec", | |
| 1335 | + | "sqlite-wasm-rs", | |
| 1336 | + | ] | |
| 1337 | + | ||
| 1258 | 1338 | [[package]] | |
| 1259 | 1339 | name = "rustc_version" | |
| 1260 | 1340 | version = "0.4.1" | |
| @@ -1388,6 +1468,12 @@ | |||
| 1388 | 1468 | "digest 0.11.3", | |
| 1389 | 1469 | ] | |
| 1390 | 1470 | ||
| 1471 | + | [[package]] | |
| 1472 | + | name = "shlex" | |
| 1473 | + | version = "2.0.1" | |
| 1474 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1475 | + | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 1476 | + | ||
| 1391 | 1477 | [[package]] | |
| 1392 | 1478 | name = "signal-hook" | |
| 1393 | 1479 | version = "0.3.18" | |
| @@ -1437,6 +1523,18 @@ | |||
| 1437 | 1523 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1438 | 1524 | checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" | |
| 1439 | 1525 | ||
| 1526 | + | [[package]] | |
| 1527 | + | name = "sqlite-wasm-rs" | |
| 1528 | + | version = "0.5.5" | |
| 1529 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1530 | + | checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" | |
| 1531 | + | dependencies = [ | |
| 1532 | + | "cc", | |
| 1533 | + | "js-sys", | |
| 1534 | + | "rsqlite-vfs", | |
| 1535 | + | "wasm-bindgen", | |
| 1536 | + | ] | |
| 1537 | + | ||
| 1440 | 1538 | [[package]] | |
| 1441 | 1539 | name = "static_assertions" | |
| 1442 | 1540 | version = "1.1.0" | |
| @@ -1503,6 +1601,15 @@ | |||
| 1503 | 1601 | "unicode-ident", | |
| 1504 | 1602 | ] | |
| 1505 | 1603 | ||
| 1604 | + | [[package]] | |
| 1605 | + | name = "synckit-config" | |
| 1606 | + | version = "0.1.1" | |
| 1607 | + | source = "git+https://makenot.work/git/max/synckit#698c801d3c22008217209e09d2088c96cbbf7533" | |
| 1608 | + | dependencies = [ | |
| 1609 | + | "rusqlite", | |
| 1610 | + | "thiserror 2.0.18", | |
| 1611 | + | ] | |
| 1612 | + | ||
| 1506 | 1613 | [[package]] | |
| 1507 | 1614 | name = "terminfo" | |
| 1508 | 1615 | version = "0.9.0" | |
| @@ -1738,6 +1845,12 @@ | |||
| 1738 | 1845 | "wasm-bindgen", | |
| 1739 | 1846 | ] | |
| 1740 | 1847 | ||
| 1848 | + | [[package]] | |
| 1849 | + | name = "vcpkg" | |
| 1850 | + | version = "0.2.15" | |
| 1851 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1852 | + | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | |
| 1853 | + | ||
| 1741 | 1854 | [[package]] | |
| 1742 | 1855 | name = "version_check" | |
| 1743 | 1856 | version = "0.9.5" |
| @@ -24,6 +24,7 @@ | |||
| 24 | 24 | sha-crypt = "0.6.0" | |
| 25 | 25 | getrandom = "0.4.3" | |
| 26 | 26 | toml_edit = "0.25.13" | |
| 27 | + | synckit-config = { git = "https://makenot.work/git/max/synckit" } | |
| 27 | 28 | ||
| 28 | 29 | [lints] | |
| 29 | 30 | workspace = true |
| @@ -425,6 +425,19 @@ | |||
| 425 | 425 | contents: String, | |
| 426 | 426 | mode: u32, | |
| 427 | 427 | }, | |
| 428 | + | /// Set a key in the console's config store. | |
| 429 | + | /// | |
| 430 | + | /// The theme preference is neither a command nor a file the way a wrapper | |
| 431 | + | /// script is — it is a row in the SQLite settings store the shared config | |
| 432 | + | /// crate owns. Modelled as its own effect so the log still shows it: the | |
| 433 | + | /// pane renders `set theme = akari-night`, which is what happened even | |
| 434 | + | /// though no command ran and no file was written by hand. | |
| 435 | + | Config { | |
| 436 | + | db: PathBuf, | |
| 437 | + | spec: synckit_config::ConfigSpec, | |
| 438 | + | key: String, | |
| 439 | + | value: String, | |
| 440 | + | }, | |
| 428 | 441 | } | |
| 429 | 442 | ||
| 430 | 443 | impl Effect { | |
| @@ -438,6 +451,7 @@ | |||
| 438 | 451 | match self { | |
| 439 | 452 | Effect::Run(invocation) => invocation.display(), | |
| 440 | 453 | Effect::Write { path, .. } => format!("write {}", contract_home(path)), | |
| 454 | + | Effect::Config { key, value, .. } => format!("set {key} = {value}"), | |
| 441 | 455 | } | |
| 442 | 456 | } | |
| 443 | 457 | ||
| @@ -461,10 +475,51 @@ | |||
| 461 | 475 | ); | |
| 462 | 476 | result | |
| 463 | 477 | } | |
| 478 | + | Effect::Config { | |
| 479 | + | db, | |
| 480 | + | spec, | |
| 481 | + | key, | |
| 482 | + | value, | |
| 483 | + | } => { | |
| 484 | + | let result = write_config(db, *spec, key, value); | |
| 485 | + | log.record( | |
| 486 | + | self.display(), | |
| 487 | + | if result.is_ok() { | |
| 488 | + | Severity::Healthy | |
| 489 | + | } else { | |
| 490 | + | Severity::Error | |
| 491 | + | }, | |
| 492 | + | ); | |
| 493 | + | result | |
| 494 | + | } | |
| 464 | 495 | } | |
| 465 | 496 | } | |
| 466 | 497 | } | |
| 467 | 498 | ||
| 499 | + | /// Set one key in the console's config store, creating the store on first write. | |
| 500 | + | /// | |
| 501 | + | /// The store's directory is the console's own config dir, which a fresh account | |
| 502 | + | /// may not have yet; creating it is part of writing there, the same as for a | |
| 503 | + | /// file write. Opening the store runs `CREATE TABLE IF NOT EXISTS`, so the first | |
| 504 | + | /// set is also what brings the store into existence — a console never told | |
| 505 | + | /// anything leaves no `console.db` behind. | |
| 506 | + | fn write_config(db: &Path, spec: synckit_config::ConfigSpec, key: &str, value: &str) -> Result<()> { | |
| 507 | + | use synckit_config::{ConfigStore, rusqlite::Connection}; | |
| 508 | + | ||
| 509 | + | if let Some(parent) = db.parent() { | |
| 510 | + | std::fs::create_dir_all(parent) | |
| 511 | + | .with_context(|| format!("failed to create {}", contract_home(parent)))?; | |
| 512 | + | } | |
| 513 | + | let conn = | |
| 514 | + | Connection::open(db).with_context(|| format!("failed to open {}", contract_home(db)))?; | |
| 515 | + | let store = ConfigStore::open(&conn, &spec) | |
| 516 | + | .with_context(|| format!("failed to open the config store in {}", contract_home(db)))?; | |
| 517 | + | store | |
| 518 | + | .set(&conn, key, value) | |
| 519 | + | .with_context(|| format!("failed to set `{key}` in {}", contract_home(db)))?; | |
| 520 | + | Ok(()) | |
| 521 | + | } | |
| 522 | + | ||
| 468 | 523 | fn write_file(path: &Path, contents: &str, mode: u32) -> Result<()> { | |
| 469 | 524 | use std::os::unix::fs::PermissionsExt; | |
| 470 | 525 |
| @@ -26,8 +26,8 @@ | |||
| 26 | 26 | //! | lang | `localectl set-locale` | `locale1.set-locale` | | |
| 27 | 27 | //! | keymap | `localectl set-keymap` | `locale1.set-keyboard` | | |
| 28 | 28 | //! | |
| 29 | - | //! The theme row is the exception that proves the shape: it writes | |
| 30 | - | //! `$XDG_CONFIG_HOME/alloy/console.toml` rather than running anything, because | |
| 29 | + | //! The theme row is the exception that proves the shape: it sets a key in | |
| 30 | + | //! `$XDG_CONFIG_HOME/alloy/console.db` rather than running anything, because | |
| 31 | 31 | //! which theme a terminal renders in is the person's business and not the | |
| 32 | 32 | //! machine's. It needs no privilege and grants none, and the console re-themes | |
| 33 | 33 | //! the moment it is written. | |
| @@ -552,11 +552,11 @@ | |||
| 552 | 552 | (KEYMAP, Value::String(keymap)) => { | |
| 553 | 553 | Invocation::new("localectl").args(["set-keymap", keymap]) | |
| 554 | 554 | } | |
| 555 | - | // A file, not a command, and the only row here that needs no | |
| 556 | - | // privilege at all. | |
| 555 | + | // A config-store set, not a command, and the only row here that | |
| 556 | + | // needs no privilege at all. | |
| 557 | 557 | (THEME, Value::String(chosen)) => { | |
| 558 | 558 | let selection = makeover::ThemeSelection::parse(Some(chosen)); | |
| 559 | - | return crate::theme::remember(&selection).map(|write| vec![write]); | |
| 559 | + | return crate::theme::remember(&selection).map(|effect| vec![effect]); | |
| 560 | 560 | } | |
| 561 | 561 | _ => anyhow::bail!("`{path}` has no setter"), | |
| 562 | 562 | }; | |
| @@ -864,32 +864,33 @@ | |||
| 864 | 864 | let effects = bind | |
| 865 | 865 | .commit(THEME, Value::String(makeover::FOLLOW.into())) | |
| 866 | 866 | .unwrap(); | |
| 867 | - | let [Effect::Write { contents, path, .. }] = effects.as_slice() else { | |
| 868 | - | panic!("a theme is a file write, not a command: {effects:?}") | |
| 867 | + | let [Effect::Config { key, value, .. }] = effects.as_slice() else { | |
| 868 | + | panic!("a theme is a config set, not a command or file write: {effects:?}") | |
| 869 | 869 | }; | |
| 870 | - | assert!( | |
| 871 | - | contents.contains(&format!("theme = \"{}\"", makeover::FOLLOW)), | |
| 872 | - | "{contents}", | |
| 870 | + | assert_eq!(key, "theme"); | |
| 871 | + | assert_eq!( | |
| 872 | + | value, | |
| 873 | + | makeover::FOLLOW, | |
| 874 | + | "follow is stored as itself, not resolved to an id", | |
| 873 | 875 | ); | |
| 874 | - | assert!(path.ends_with("alloy/console.toml"), "{}", path.display(),); | |
| 875 | 876 | } | |
| 876 | 877 | ||
| 877 | 878 | // A theme id round-trips as a pin. | |
| 878 | 879 | #[test] | |
| 879 | - | fn choosing_a_theme_writes_its_id() { | |
| 880 | + | fn choosing_a_theme_sets_its_id() { | |
| 880 | 881 | let mut bind = bind(); | |
| 881 | 882 | let effects = bind | |
| 882 | 883 | .commit(THEME, Value::String("akari-night".into())) | |
| 883 | 884 | .unwrap(); | |
| 884 | - | let [Effect::Write { contents, .. }] = effects.as_slice() else { | |
| 885 | - | panic!("one write") | |
| 885 | + | let [Effect::Config { value, .. }] = effects.as_slice() else { | |
| 886 | + | panic!("one config set") | |
| 886 | 887 | }; | |
| 887 | - | assert!(contents.contains("theme = \"akari-night\""), "{contents}"); | |
| 888 | + | assert_eq!(value, "akari-night"); | |
| 888 | 889 | } | |
| 889 | 890 | ||
| 890 | 891 | // The one row that runs no command and needs no privilege. | |
| 891 | 892 | #[test] | |
| 892 | - | fn the_theme_row_is_the_only_one_that_writes_a_file() { | |
| 893 | + | fn every_row_but_the_theme_runs_a_command() { | |
| 893 | 894 | let mut bind = bind(); | |
| 894 | 895 | for path in [ZONE, NTP, HOSTNAME, LOCALE, KEYMAP] { | |
| 895 | 896 | let value = match path { |
| @@ -10,6 +10,8 @@ | |||
| 10 | 10 | use alloy_tui::Theme; | |
| 11 | 11 | use anyhow::{Context, Result}; | |
| 12 | 12 | use makeover::{ThemeDefaults, ThemeDirs, ThemeMeta, ThemeSelection, Variant}; | |
| 13 | + | use synckit_config::rusqlite::Connection; | |
| 14 | + | use synckit_config::{ConfigSpec, ConfigStore, Posture}; | |
| 13 | 15 | ||
| 14 | 16 | use crate::cli::Effect; | |
| 15 | 17 | ||
| @@ -19,6 +21,19 @@ | |||
| 19 | 21 | /// Default dark theme (docs/TOKENS.md). | |
| 20 | 22 | pub(crate) const DEFAULT_DARK: &str = "akari-night"; | |
| 21 | 23 | ||
| 24 | + | /// The console's config store: one table, one key. | |
| 25 | + | /// | |
| 26 | + | /// Declared for the family's shared config store even though the console never | |
| 27 | + | /// syncs — so posture is inert here and `theme` takes the safe default, | |
| 28 | + | /// [`Posture::Local`]. The key name is the convention's, not this crate's: every | |
| 29 | + | /// app in the family stores the chosen theme under `theme`. | |
| 30 | + | pub(crate) const CONFIG: ConfigSpec = | |
| 31 | + | ConfigSpec::new("console_config", &[(THEME_KEY, Posture::Local)]); | |
| 32 | + | ||
| 33 | + | /// The one key the console stores, named once so the read and the write cannot | |
| 34 | + | /// disagree about its spelling. | |
| 35 | + | const THEME_KEY: &str = "theme"; | |
| 36 | + | ||
| 22 | 37 | /// Theme search path: the user's own themes win, then whatever the image ships, | |
| 23 | 38 | /// then makeover's bundled set for a run from a dev tree. | |
| 24 | 39 | /// | |
| @@ -85,19 +100,21 @@ | |||
| 85 | 100 | makeover::list_themes_from_dirs(&search_path()) | |
| 86 | 101 | } | |
| 87 | 102 | ||
| 88 | - | /// Where the console remembers a chosen theme. | |
| 103 | + | /// Where the console remembers what it is told. | |
| 89 | 104 | /// | |
| 90 | - | /// `$XDG_CONFIG_HOME/alloy/console.toml`. The console already owns that | |
| 91 | - | /// directory — custom themes live in `alloy/themes/`, user schemas in | |
| 92 | - | /// `alloy/schemas/` — so this needed no new place, only a file. TOML because | |
| 93 | - | /// everything else in the ecosystem is, and because a console preference that | |
| 94 | - | /// is not a theme will want a key of its own eventually. | |
| 105 | + | /// `$XDG_CONFIG_HOME/alloy/console.db`, a SQLite store the shared config crate | |
| 106 | + | /// owns. The console already owns that directory — custom themes live in | |
| 107 | + | /// `alloy/themes/`, user schemas in `alloy/schemas/` — so the store joins them | |
| 108 | + | /// rather than opening a new root. SQLite rather than the old `console.toml` | |
| 109 | + | /// because every app in the family now keeps settings in one store, so a | |
| 110 | + | /// preference can carry across devices where the app syncs; the console does not | |
| 111 | + | /// sync, but it reads and writes the same shape. | |
| 95 | 112 | /// | |
| 96 | 113 | /// Deliberately not a system-wide file: which theme a terminal renders in is | |
| 97 | 114 | /// the person's, not the machine's, and two accounts on one machine should not | |
| 98 | 115 | /// fight over it. | |
| 99 | - | pub(crate) fn preference_path() -> Option<PathBuf> { | |
| 100 | - | dirs_config_home().map(|config| config.join("alloy").join("console.toml")) | |
| 116 | + | pub(crate) fn store_path() -> Option<PathBuf> { | |
| 117 | + | dirs_config_home().map(|config| config.join("alloy").join("console.db")) | |
| 101 | 118 | } | |
| 102 | 119 | ||
| 103 | 120 | /// What the user chose, which is not the same as what is being rendered. | |
| @@ -106,21 +123,25 @@ | |||
| 106 | 123 | /// never been told anything tracks the terminal rather than pinning whatever it | |
| 107 | 124 | /// guessed on first launch. | |
| 108 | 125 | pub(crate) fn selection() -> ThemeSelection { | |
| 109 | - | let stored = preference_path() | |
| 110 | - | .and_then(|path| std::fs::read_to_string(path).ok()) | |
| 111 | - | .as_deref() | |
| 112 | - | .and_then(read_preference); | |
| 113 | - | ThemeSelection::parse(stored.as_deref()) | |
| 126 | + | ThemeSelection::parse(stored_theme().as_deref()) | |
| 114 | 127 | } | |
| 115 | 128 | ||
| 116 | - | /// The `theme` key, however it is spelled. The key name is the convention's, | |
| 117 | - | /// not this file's: every app in the family stores it under `theme`. | |
| 118 | - | fn read_preference(text: &str) -> Option<String> { | |
| 119 | - | text.parse::<toml_edit::DocumentMut>() | |
| 120 | - | .ok()? | |
| 121 | - | .get("theme")? | |
| 122 | - | .as_str() | |
| 123 | - | .map(str::to_string) | |
| 129 | + | /// The stored `theme` value, or `None` when nothing is saved or the store will | |
| 130 | + | /// not read. | |
| 131 | + | /// | |
| 132 | + | /// Best-effort by design: a missing store is the normal first-launch state, and | |
| 133 | + | /// a store that will not open is not worth crashing the console over before the | |
| 134 | + | /// first frame — either reads as "nothing chosen", which follows the terminal. | |
| 135 | + | /// Reading deliberately does **not** create the store; it appears only when a | |
| 136 | + | /// choice is written, so a console never told anything leaves no file behind. | |
| 137 | + | fn stored_theme() -> Option<String> { | |
| 138 | + | let path = store_path()?; | |
| 139 | + | if !path.exists() { | |
| 140 | + | return None; | |
| 141 | + | } | |
| 142 | + | let conn = Connection::open(&path).ok()?; | |
| 143 | + | let store = ConfigStore::open(&conn, &CONFIG).ok()?; | |
| 144 | + | store.get(&conn, THEME_KEY).ok()? | |
| 124 | 145 | } | |
| 125 | 146 | ||
| 126 | 147 | /// The id the console is rendering in. | |
| @@ -136,22 +157,16 @@ | |||
| 136 | 157 | /// The write that remembers a choice. | |
| 137 | 158 | /// | |
| 138 | 159 | /// Returned rather than performed, like every other effect in the console, so | |
| 139 | - | /// the log shows the file being written and the caller decides when. Edits the | |
| 140 | - | /// document in place, so a console preference this version does not know about | |
| 141 | - | /// survives being written by it. | |
| 160 | + | /// the log shows the setting being written and the caller decides when. The set | |
| 161 | + | /// is an upsert on one key, so writing again replaces the choice rather than | |
| 162 | + | /// accumulating rows. | |
| 142 | 163 | pub(crate) fn remember(selection: &ThemeSelection) -> Result<Effect> { | |
| 143 | - | let path = preference_path().context("no config directory to remember a theme in")?; | |
| 144 | - | let text = std::fs::read_to_string(&path).unwrap_or_default(); | |
| 145 | - | let mut document: toml_edit::DocumentMut = text | |
| 146 | - | .parse() | |
| 147 | - | .with_context(|| format!("{} is not valid TOML", path.display()))?; | |
| 148 | - | document["theme"] = toml_edit::value(selection.as_str()); | |
| 149 | - | ||
| 150 | - | Ok(Effect::Write { | |
| 151 | - | path, | |
| 152 | - | contents: document.to_string(), | |
| 153 | - | // A preference, not a secret and not executable. | |
| 154 | - | mode: 0o644, | |
| 164 | + | let db = store_path().context("no config directory to remember a theme in")?; | |
| 165 | + | Ok(Effect::Config { | |
| 166 | + | db, | |
| 167 | + | spec: CONFIG, | |
| 168 | + | key: THEME_KEY.to_string(), | |
| 169 | + | value: selection.as_str().to_string(), | |
| 155 | 170 | }) | |
| 156 | 171 | } | |
| 157 | 172 | ||
| @@ -262,19 +277,40 @@ | |||
| 262 | 277 | ); | |
| 263 | 278 | } | |
| 264 | 279 | ||
| 280 | + | // A choice becomes a config-store set under the family's `theme` key, for | |
| 281 | + | // both a pinned id and a standing "follow the terminal". Follow is stored as | |
| 282 | + | // itself, not resolved to whatever id it happens to point at right now. | |
| 265 | 283 | #[test] | |
| 266 | - | fn a_remembered_theme_is_read_back_out_of_the_file() { | |
| 284 | + | fn remembering_a_choice_sets_the_theme_key() { | |
| 285 | + | for selection in [ | |
| 286 | + | ThemeSelection::parse(Some("akari-night")), | |
| 287 | + | ThemeSelection::Follow, | |
| 288 | + | ] { | |
| 289 | + | let Effect::Config { | |
| 290 | + | key, value, spec, .. | |
| 291 | + | } = remember(&selection).unwrap() | |
| 292 | + | else { | |
| 293 | + | panic!("a theme is a config set, not a command or file write"); | |
| 294 | + | }; | |
| 295 | + | assert_eq!(key, THEME_KEY, "the family convention's key name"); | |
| 296 | + | assert_eq!(value, selection.as_str()); | |
| 297 | + | assert_eq!(spec.table(), "console_config"); | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // The console's spec drives a real store the same as any other consumer: a | |
| 302 | + | // set under `theme` reads back, proving the key name the console writes is | |
| 303 | + | // the key name it later reads. | |
| 304 | + | #[test] | |
| 305 | + | fn the_theme_key_round_trips_through_the_store() { | |
| 306 | + | let conn = Connection::open_in_memory().unwrap(); | |
| 307 | + | let store = ConfigStore::open(&conn, &CONFIG).unwrap(); | |
| 308 | + | assert_eq!(store.get(&conn, THEME_KEY).unwrap(), None, "unset follows"); | |
| 309 | + | store.set(&conn, THEME_KEY, "akari-night").unwrap(); | |
| 267 | 310 | assert_eq!( | |
| 268 | - | read_preference("theme = \"akari-night\"\n").as_deref(), | |
| 311 | + | store.get(&conn, THEME_KEY).unwrap().as_deref(), | |
| 269 | 312 | Some("akari-night"), | |
| 270 | 313 | ); | |
| 271 | - | assert_eq!( | |
| 272 | - | read_preference("# nothing chosen yet\n"), | |
| 273 | - | None, | |
| 274 | - | "an empty file is not a choice", | |
| 275 | - | ); | |
| 276 | - | assert_eq!(read_preference("theme = 3\n"), None, "nor is a non-string"); | |
| 277 | - | assert_eq!(read_preference("theme = ["), None, "nor is broken TOML"); | |
| 278 | 314 | } | |
| 279 | 315 | ||
| 280 | 316 | // The console ships against these two ids; a rename in makeover's themes |