| 1 |
1 |
|
//! Where the console remembers what it is told.
|
| 2 |
2 |
|
//!
|
| 3 |
|
- |
//! `$XDG_CONFIG_HOME/alloy/console.db`, a SQLite store the shared config crate
|
| 4 |
|
- |
//! owns. The console already owns that directory — custom themes live in
|
| 5 |
|
- |
//! `alloy/themes/`, user schemas in `alloy/schemas/` — so the store joins them
|
| 6 |
|
- |
//! rather than opening a new root. SQLite rather than a `console.toml` because
|
| 7 |
|
- |
//! every app in the family now keeps settings in one store, so a preference can
|
| 8 |
|
- |
//! carry across devices where the app syncs; the console does not sync, but it
|
| 9 |
|
- |
//! reads and writes the same shape.
|
|
3 |
+ |
//! `$XDG_CONFIG_HOME/alloy/console.db`, a SQLite table of key/value pairs. The
|
|
4 |
+ |
//! console already owns that directory — custom themes live in `alloy/themes/`,
|
|
5 |
+ |
//! user schemas in `alloy/schemas/` — so the store joins them rather than
|
|
6 |
+ |
//! opening a new root.
|
|
7 |
+ |
//!
|
|
8 |
+ |
//! The table used to belong to `synckit-config`, the family's shared config
|
|
9 |
+ |
//! crate, and the schema here is deliberately the one that crate creates: same
|
|
10 |
+ |
//! table name, same two columns, same `WITHOUT ROWID`. An installed machine's
|
|
11 |
+ |
//! `console.db` keeps working across the change, and a console that ever wants
|
|
12 |
+ |
//! to rejoin the family store can hand this file back without a migration.
|
|
13 |
+ |
//!
|
|
14 |
+ |
//! The crate itself is gone (2026-07-30) because it is PolyForm Noncommercial
|
|
15 |
+ |
//! and a noncommercial dependency does not belong in a binary this project hands
|
|
16 |
+ |
//! out under a permissive license. Nothing was lost with it: syncing is what that
|
|
17 |
+ |
//! crate is for, the console does not sync, and both keys below were declared
|
|
18 |
+ |
//! `Posture::Local`. Do not take the dependency back. See
|
|
19 |
+ |
//! `crates/alloy/Cargo.toml` for the full reasoning.
|
| 10 |
20 |
|
//!
|
| 11 |
21 |
|
//! Deliberately not a system-wide file. Which theme a terminal renders in is
|
| 12 |
22 |
|
//! the person's, not the machine's, and two accounts on one machine should not
|
| 20 |
30 |
|
use std::path::PathBuf;
|
| 21 |
31 |
|
|
| 22 |
32 |
|
use anyhow::{Context, Result};
|
| 23 |
|
- |
use synckit_config::rusqlite::Connection;
|
| 24 |
|
- |
use synckit_config::{ConfigSpec, ConfigStore, Posture};
|
|
33 |
+ |
use rusqlite::Connection;
|
| 25 |
34 |
|
|
| 26 |
35 |
|
use crate::cli::Effect;
|
| 27 |
36 |
|
|
| 28 |
|
- |
/// The console's config store: one table, every key the console remembers.
|
|
37 |
+ |
/// The table every key the console remembers lives in.
|
| 29 |
38 |
|
///
|
| 30 |
|
- |
/// Declared for the family's shared config store even though the console never
|
| 31 |
|
- |
/// syncs, so posture is inert here and both keys take the safe default,
|
| 32 |
|
- |
/// [`Posture::Local`]. [`THEME`]'s name is the convention's, not this crate's:
|
| 33 |
|
- |
/// every app in the family stores the chosen theme under `theme`.
|
| 34 |
|
- |
pub(crate) const CONFIG: ConfigSpec = ConfigSpec::new(
|
| 35 |
|
- |
"console_config",
|
| 36 |
|
- |
&[(THEME, Posture::Local), (SETUP, Posture::Local)],
|
| 37 |
|
- |
);
|
|
39 |
+ |
/// Named for the crate that used to own it rather than renamed on the way out,
|
|
40 |
+ |
/// because renaming it would strand the settings on every machine already
|
|
41 |
+ |
/// installed for no gain.
|
|
42 |
+ |
pub(crate) const TABLE: &str = "console_config";
|
| 38 |
43 |
|
|
| 39 |
44 |
|
/// The chosen theme, or `follow` to track the terminal.
|
|
45 |
+ |
///
|
|
46 |
+ |
/// The name is the family's convention, not this crate's: every app in the
|
|
47 |
+ |
/// family stores the chosen theme under `theme`.
|
| 40 |
48 |
|
pub(crate) const THEME: &str = "theme";
|
| 41 |
49 |
|
|
| 42 |
50 |
|
/// Set once the first-boot screen has been shown.
|
| 43 |
51 |
|
///
|
| 44 |
|
- |
/// [`Posture::Local`] is load-bearing here in a way it is not for [`THEME`],
|
| 45 |
|
- |
/// which would be harmless to sync. This key must never replicate: it says
|
| 46 |
|
- |
/// something about *this* machine's first boot, and a synced copy would suppress
|
| 47 |
|
- |
/// the screen on the next machine the user sets up — which is exactly the
|
| 48 |
|
- |
/// machine that needs it.
|
|
52 |
+ |
/// Local to one machine in a way [`THEME`] is not. It says something about
|
|
53 |
+ |
/// *this* machine's first boot, so a copy of it arriving from somewhere else
|
|
54 |
+ |
/// would suppress the screen on the next machine the user sets up — which is
|
|
55 |
+ |
/// exactly the machine that needs it. Nothing replicates this file today; the
|
|
56 |
+ |
/// note is here for whoever changes that.
|
| 49 |
57 |
|
pub(crate) const SETUP: &str = "first_boot";
|
| 50 |
58 |
|
|
|
59 |
+ |
/// Create the table if it is not there, and hand back the connection's blessing
|
|
60 |
+ |
/// to read and write it.
|
|
61 |
+ |
///
|
|
62 |
+ |
/// The schema is `synckit-config`'s, character for character, so a `console.db`
|
|
63 |
+ |
/// written before that crate was dropped opens here unchanged. `WITHOUT ROWID`
|
|
64 |
+ |
/// included: it is part of what the file already is, and changing it would mean
|
|
65 |
+ |
/// rewriting every existing store to gain nothing.
|
|
66 |
+ |
pub(crate) fn open(conn: &Connection) -> rusqlite::Result<()> {
|
|
67 |
+ |
conn.execute_batch(&format!(
|
|
68 |
+ |
"CREATE TABLE IF NOT EXISTS {TABLE} (\
|
|
69 |
+ |
key TEXT PRIMARY KEY,\
|
|
70 |
+ |
value TEXT NOT NULL\
|
|
71 |
+ |
) WITHOUT ROWID;"
|
|
72 |
+ |
))
|
|
73 |
+ |
}
|
|
74 |
+ |
|
| 51 |
75 |
|
/// The config directory the store lives under.
|
| 52 |
76 |
|
pub(crate) fn config_home() -> Option<PathBuf> {
|
| 53 |
77 |
|
// XDG_CONFIG_HOME wins when set and absolute; the spec says a relative
|
| 79 |
103 |
|
return None;
|
| 80 |
104 |
|
}
|
| 81 |
105 |
|
let conn = Connection::open(&path).ok()?;
|
| 82 |
|
- |
let store = ConfigStore::open(&conn, &CONFIG).ok()?;
|
| 83 |
|
- |
store.get(&conn, key).ok()?
|
|
106 |
+ |
open(&conn).ok()?;
|
|
107 |
+ |
read(&conn, key).ok()?
|
|
108 |
+ |
}
|
|
109 |
+ |
|
|
110 |
+ |
/// One key out of an open store, with a missing row read as `None`.
|
|
111 |
+ |
///
|
|
112 |
+ |
/// Absent is a normal state, not an error: the caller supplies the default (see
|
|
113 |
+ |
/// the theme resolver, which reads `None` as "follow the system").
|
|
114 |
+ |
fn read(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
|
|
115 |
+ |
conn.query_row(
|
|
116 |
+ |
&format!("SELECT value FROM {TABLE} WHERE key = ?1"),
|
|
117 |
+ |
[key],
|
|
118 |
+ |
|row| row.get::<_, String>(0),
|
|
119 |
+ |
)
|
|
120 |
+ |
.map(Some)
|
|
121 |
+ |
.or_else(|error| match error {
|
|
122 |
+ |
rusqlite::Error::QueryReturnedNoRows => Ok(None),
|
|
123 |
+ |
other => Err(other),
|
|
124 |
+ |
})
|
|
125 |
+ |
}
|
|
126 |
+ |
|
|
127 |
+ |
/// Set `key` to `value`, replacing any previous value.
|
|
128 |
+ |
///
|
|
129 |
+ |
/// One upsert rather than a delete and an insert, which is what keeps a single
|
|
130 |
+ |
/// edit looking like a single edit to anything watching the table.
|
|
131 |
+ |
pub(crate) fn write(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> {
|
|
132 |
+ |
conn.execute(
|
|
133 |
+ |
&format!(
|
|
134 |
+ |
"INSERT INTO {TABLE} (key, value) VALUES (?1, ?2) \
|
|
135 |
+ |
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
136 |
+ |
),
|
|
137 |
+ |
(key, value),
|
|
138 |
+ |
)?;
|
|
139 |
+ |
Ok(())
|
| 84 |
140 |
|
}
|
| 85 |
141 |
|
|
| 86 |
142 |
|
/// The write that remembers one key.
|
| 93 |
149 |
|
let db = path().context("no config directory to remember settings in")?;
|
| 94 |
150 |
|
Ok(Effect::Config {
|
| 95 |
151 |
|
db,
|
| 96 |
|
- |
spec: CONFIG,
|
| 97 |
152 |
|
key: key.to_string(),
|
| 98 |
153 |
|
value: value.to_string(),
|
| 99 |
154 |
|
})
|
| 109 |
164 |
|
#[test]
|
| 110 |
165 |
|
fn every_key_round_trips_through_the_store() {
|
| 111 |
166 |
|
let conn = Connection::open_in_memory().unwrap();
|
| 112 |
|
- |
let store = ConfigStore::open(&conn, &CONFIG).unwrap();
|
|
167 |
+ |
open(&conn).unwrap();
|
| 113 |
168 |
|
for (key, value) in [(THEME, "akari-night"), (SETUP, "done")] {
|
| 114 |
|
- |
assert_eq!(store.get(&conn, key).unwrap(), None, "{key} starts unset");
|
| 115 |
|
- |
store.set(&conn, key, value).unwrap();
|
| 116 |
|
- |
assert_eq!(store.get(&conn, key).unwrap().as_deref(), Some(value));
|
|
169 |
+ |
assert_eq!(read(&conn, key).unwrap(), None, "{key} starts unset");
|
|
170 |
+ |
write(&conn, key, value).unwrap();
|
|
171 |
+ |
assert_eq!(read(&conn, key).unwrap().as_deref(), Some(value));
|
| 117 |
172 |
|
}
|
| 118 |
173 |
|
}
|
| 119 |
174 |
|
|
| 120 |
|
- |
// The first-boot marker is about one machine's first boot. Syncing it would
|
| 121 |
|
- |
// suppress the screen on the next machine the user sets up.
|
|
175 |
+ |
// Opening an existing store must not disturb it. `CREATE TABLE IF NOT
|
|
176 |
+ |
// EXISTS` is what makes the read path safe to run on every launch, and the
|
|
177 |
+ |
// day that becomes a plain CREATE the console stops reading its own
|
|
178 |
+ |
// settings.
|
| 122 |
179 |
|
#[test]
|
| 123 |
|
- |
fn the_first_boot_marker_never_replicates() {
|
| 124 |
|
- |
assert!(!CONFIG.is_synced(SETUP));
|
|
180 |
+ |
fn opening_a_store_twice_keeps_what_is_in_it() {
|
|
181 |
+ |
let conn = Connection::open_in_memory().unwrap();
|
|
182 |
+ |
open(&conn).unwrap();
|
|
183 |
+ |
write(&conn, THEME, "akari-night").unwrap();
|
|
184 |
+ |
open(&conn).unwrap();
|
|
185 |
+ |
assert_eq!(read(&conn, THEME).unwrap().as_deref(), Some("akari-night"));
|
|
186 |
+ |
}
|
|
187 |
+ |
|
|
188 |
+ |
// Writing again replaces rather than accumulating. Without the upsert this
|
|
189 |
+ |
// is a UNIQUE violation on the second theme a user picks.
|
|
190 |
+ |
#[test]
|
|
191 |
+ |
fn a_second_write_replaces_the_first() {
|
|
192 |
+ |
let conn = Connection::open_in_memory().unwrap();
|
|
193 |
+ |
open(&conn).unwrap();
|
|
194 |
+ |
write(&conn, THEME, "akari-night").unwrap();
|
|
195 |
+ |
write(&conn, THEME, "follow").unwrap();
|
|
196 |
+ |
assert_eq!(read(&conn, THEME).unwrap().as_deref(), Some("follow"));
|
|
197 |
+ |
let rows: u32 = conn
|
|
198 |
+ |
.query_row(&format!("SELECT count(*) FROM {TABLE}"), [], |row| {
|
|
199 |
+ |
row.get(0)
|
|
200 |
+ |
})
|
|
201 |
+ |
.unwrap();
|
|
202 |
+ |
assert_eq!(rows, 1, "the upsert grew a second row");
|
| 125 |
203 |
|
}
|
| 126 |
204 |
|
|
| 127 |
205 |
|
#[test]
|
| 128 |
206 |
|
fn a_set_is_a_config_effect_on_the_consoles_store() {
|
| 129 |
|
- |
let Effect::Config {
|
| 130 |
|
- |
key, value, spec, ..
|
| 131 |
|
- |
} = set(THEME, "akari-night").unwrap()
|
| 132 |
|
- |
else {
|
|
207 |
+ |
let Effect::Config { key, value, db } = set(THEME, "akari-night").unwrap() else {
|
| 133 |
208 |
|
panic!("a setting is a config write, not a command or a file");
|
| 134 |
209 |
|
};
|
| 135 |
210 |
|
assert_eq!(key, THEME);
|
| 136 |
211 |
|
assert_eq!(value, "akari-night");
|
| 137 |
|
- |
assert_eq!(spec.table(), "console_config");
|
|
212 |
+ |
assert!(
|
|
213 |
+ |
db.ends_with("alloy/console.db"),
|
|
214 |
+ |
"the effect points somewhere else: {}",
|
|
215 |
+ |
db.display()
|
|
216 |
+ |
);
|
| 138 |
217 |
|
}
|
| 139 |
218 |
|
}
|