|
1 |
+ |
//! The seam a form edits through, and its file implementation.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! [[alloy-settings]] settled `alloy settings` as two tabs over one form
|
|
4 |
+ |
//! engine: System reads and writes live state through `timedatectl` and friends,
|
|
5 |
+ |
//! Applications reads and writes a config file. The rows, the chrome, the modal
|
|
6 |
+ |
//! edit and the validation are the same on both sides; the only thing that
|
|
7 |
+ |
//! differs is where a value comes from and what committing one does. That
|
|
8 |
+ |
//! difference is [`Bind`].
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! The seam is extracted here, while [`FileBind`] is its only implementor,
|
|
11 |
+ |
//! because a trait carved around one implementation before the second exists is
|
|
12 |
+ |
//! a fill-in rather than a refactor. The System side is the next one in.
|
|
13 |
+ |
//!
|
|
14 |
+ |
//! ## Committing returns effects, it does not perform them
|
|
15 |
+ |
//!
|
|
16 |
+ |
//! [`Bind::commit`] and [`Bind::save`] return [`Effect`]s for the caller to
|
|
17 |
+ |
//! apply through the [`CommandLog`](crate::cli::CommandLog), which is the
|
|
18 |
+ |
//! pattern every backend in the console already follows: it keeps the log's
|
|
19 |
+ |
//! coverage structural and it keeps the layer testable on a machine with none
|
|
20 |
+ |
//! of the tools installed.
|
|
21 |
+ |
//!
|
|
22 |
+ |
//! It also settles the dirty/apply question [[alloy-settings]] left open,
|
|
23 |
+ |
//! without either tab having to know how the other behaves. A file bind holds
|
|
24 |
+ |
//! its edits in a document and emits one `Effect::Write` at save; a command
|
|
25 |
+ |
//! front will emit its setter at commit and have nothing to save. The form asks
|
|
26 |
+ |
//! [`Bind::dirty`] whether there is anything pending and shows the save
|
|
27 |
+ |
//! affordance accordingly, rather than being told which kind of tab it is on.
|
|
28 |
+ |
//!
|
|
29 |
+ |
//! ## Roundtrip safety
|
|
30 |
+ |
//!
|
|
31 |
+ |
//! Edits go into a `toml_edit::DocumentMut` in place, so comments, key order,
|
|
32 |
+ |
//! whitespace and keys no field declares survive an edit untouched. That is
|
|
33 |
+ |
//! what makes docs/CONSOLE.md's `unknown_keys = "preserve"` promise structural
|
|
34 |
+ |
//! rather than remembered.
|
|
35 |
+ |
//!
|
|
36 |
+ |
//! <!-- wiki: alloy-console -->
|
|
37 |
+ |
|
|
38 |
+ |
#![allow(dead_code)]
|
|
39 |
+ |
|
|
40 |
+ |
use std::path::{Path, PathBuf};
|
|
41 |
+ |
|
|
42 |
+ |
use anyhow::{Context, Result, ensure};
|
|
43 |
+ |
use toml::Value;
|
|
44 |
+ |
use toml_edit::{DocumentMut, Item, TableLike};
|
|
45 |
+ |
|
|
46 |
+ |
use crate::cli::Effect;
|
|
47 |
+ |
use crate::schema::{Field, Schema, Section, UnknownKeys, check_value};
|
|
48 |
+ |
|
|
49 |
+ |
/// Permissions for a written config file. Not a secret and not executable.
|
|
50 |
+ |
const CONFIG_MODE: u32 = 0o644;
|
|
51 |
+ |
|
|
52 |
+ |
/// What a form reads from and writes to.
|
|
53 |
+ |
pub(crate) trait Bind {
|
|
54 |
+ |
/// Where the values come from, for the form header. A file path or the name
|
|
55 |
+ |
/// of the front.
|
|
56 |
+ |
fn origin(&self) -> String;
|
|
57 |
+ |
|
|
58 |
+ |
/// The panes, in the order they are shown.
|
|
59 |
+ |
fn sections(&self) -> &[Section];
|
|
60 |
+ |
|
|
61 |
+ |
/// Every row, in the order they are shown.
|
|
62 |
+ |
fn fields(&self) -> &[Field];
|
|
63 |
+ |
|
|
64 |
+ |
/// The value the source of truth currently holds.
|
|
65 |
+ |
///
|
|
66 |
+ |
/// `None` means it holds none, which for a file is a key that is simply not
|
|
67 |
+ |
/// there. What a row displays in that case is the field's
|
|
68 |
+ |
/// [`default_value`](Field::default_value) — reading and defaulting are
|
|
69 |
+ |
/// kept apart so that showing a default cannot write one.
|
|
70 |
+ |
fn read(&self, path: &str) -> Option<Value>;
|
|
71 |
+ |
|
|
72 |
+ |
/// Commit a value. See the module docs for why this returns effects.
|
|
73 |
+ |
fn commit(&mut self, path: &str, value: Value) -> Result<Vec<Effect>>;
|
|
74 |
+ |
|
|
75 |
+ |
/// Whether there are edits that [`save`](Bind::save) has not emitted yet.
|
|
76 |
+ |
fn dirty(&self) -> bool;
|
|
77 |
+ |
|
|
78 |
+ |
/// Emit the effects that make the accumulated edits durable.
|
|
79 |
+ |
fn save(&mut self) -> Result<Vec<Effect>>;
|
|
80 |
+ |
|
|
81 |
+ |
/// The field at a path, if this bind has one.
|
|
82 |
+ |
fn field(&self, path: &str) -> Option<&Field> {
|
|
83 |
+ |
self.fields().iter().find(|field| field.path == path)
|
|
84 |
+ |
}
|
|
85 |
+ |
|
|
86 |
+ |
/// Whether a value can be committed at a path.
|
|
87 |
+ |
///
|
|
88 |
+ |
/// Schema constraints by default, which is the whole of it for a file. A
|
|
89 |
+ |
/// front whose vocabulary lives in a command (`timedatectl list-timezones`)
|
|
90 |
+ |
/// overrides this.
|
|
91 |
+ |
fn validate(&self, path: &str, value: &Value) -> Result<()> {
|
|
92 |
+ |
let field = self
|
|
93 |
+ |
.field(path)
|
|
94 |
+ |
.with_context(|| format!("`{path}` is not a field of {}", self.origin()))?;
|
|
95 |
+ |
check_value(&field.kind, value).with_context(|| format!("`{path}`"))
|
|
96 |
+ |
}
|
|
97 |
+ |
}
|
|
98 |
+ |
|
|
99 |
+ |
/// A form over a TOML file, bound through its schema.
|
|
100 |
+ |
pub(crate) struct FileBind {
|
|
101 |
+ |
schema: Schema,
|
|
102 |
+ |
path: PathBuf,
|
|
103 |
+ |
document: DocumentMut,
|
|
104 |
+ |
/// Edits since the last save. A count and not a flag because a preset is
|
|
105 |
+ |
/// one edit however many values it carries, and the count is what says so.
|
|
106 |
+ |
edits: usize,
|
|
107 |
+ |
}
|
|
108 |
+ |
|
|
109 |
+ |
impl FileBind {
|
|
110 |
+ |
/// Open the file the schema targets.
|
|
111 |
+ |
///
|
|
112 |
+ |
/// A missing file is not an error: an adopted tool the user has never
|
|
113 |
+ |
/// configured has nothing on disk, and the right form for it is every field
|
|
114 |
+ |
/// showing its default. The file appears at the first save.
|
|
115 |
+ |
pub(crate) fn open(schema: Schema, path: &Path) -> Result<Self> {
|
|
116 |
+ |
let text = match std::fs::read_to_string(path) {
|
|
117 |
+ |
Ok(text) => text,
|
|
118 |
+ |
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
|
119 |
+ |
Err(error) => {
|
|
120 |
+ |
return Err(error).with_context(|| format!("reading {}", path.display()));
|
|
121 |
+ |
}
|
|
122 |
+ |
};
|
|
123 |
+ |
Self::new(schema, path.to_path_buf(), &text)
|
|
124 |
+ |
.with_context(|| format!("in {}", path.display()))
|
|
125 |
+ |
}
|
|
126 |
+ |
|
|
127 |
+ |
pub(crate) fn new(schema: Schema, path: PathBuf, text: &str) -> Result<Self> {
|
|
128 |
+ |
let document: DocumentMut = text.parse().context("the file is not valid TOML")?;
|
|
129 |
+ |
let bind = Self {
|
|
130 |
+ |
schema,
|
|
131 |
+ |
path,
|
|
132 |
+ |
document,
|
|
133 |
+ |
edits: 0,
|
|
134 |
+ |
};
|
|
135 |
+ |
|
|
136 |
+ |
// `unknown_keys = "error"` is a schema saying it describes the whole
|
|
137 |
+ |
// file, so an undeclared key is a schema that has fallen behind its
|
|
138 |
+ |
// tool and the form would be editing a file it does not understand.
|
|
139 |
+ |
// Under `preserve`, the same keys are an info diagnostic the view
|
|
140 |
+ |
// shows, and they survive every edit untouched.
|
|
141 |
+ |
if bind.schema.header.unknown_keys == UnknownKeys::Error {
|
|
142 |
+ |
let unknown = bind.unknown_keys();
|
|
143 |
+ |
ensure!(
|
|
144 |
+ |
unknown.is_empty(),
|
|
145 |
+ |
"the schema declares no field for {} (unknown_keys = \"error\")",
|
|
146 |
+ |
unknown.join(", "),
|
|
147 |
+ |
);
|
|
148 |
+ |
}
|
|
149 |
+ |
Ok(bind)
|
|
150 |
+ |
}
|
|
151 |
+ |
|
|
152 |
+ |
/// The file as it would be written right now.
|
|
153 |
+ |
pub(crate) fn text(&self) -> String {
|
|
154 |
+ |
self.document.to_string()
|
|
155 |
+ |
}
|
|
156 |
+ |
|
|
157 |
+ |
/// Keys present in the file that no field declares.
|
|
158 |
+ |
///
|
|
159 |
+ |
/// Leaf paths only, and the walk stops at any declared field, so the rows
|
|
160 |
+ |
/// of a `[[bindings.keys]]` table are the list field's business rather than
|
|
161 |
+ |
/// two dozen unknown keys.
|
|
162 |
+ |
pub(crate) fn unknown_keys(&self) -> Vec<String> {
|
|
163 |
+ |
let mut found = Vec::new();
|
|
164 |
+ |
self.walk(self.document.as_table(), "", &mut found);
|
|
165 |
+ |
found
|
|
166 |
+ |
}
|
|
167 |
+ |
|
|
168 |
+ |
fn walk(&self, table: &dyn TableLike, prefix: &str, found: &mut Vec<String>) {
|
|
169 |
+ |
for (key, item) in table.iter() {
|
|
170 |
+ |
let path = if prefix.is_empty() {
|
|
171 |
+ |
key.to_string()
|
|
172 |
+ |
} else {
|
|
173 |
+ |
format!("{prefix}.{key}")
|
|
174 |
+ |
};
|
|
175 |
+ |
if self.schema.field(&path).is_some() {
|
|
176 |
+ |
continue;
|
|
177 |
+ |
}
|
|
178 |
+ |
match item.as_table_like() {
|
|
179 |
+ |
Some(child) => self.walk(child, &path, found),
|
|
180 |
+ |
// An array of tables is a leaf here: if it were declared, the
|
|
181 |
+ |
// branch above took it, and if it is not, naming the array is
|
|
182 |
+ |
// more use than naming every key inside it.
|
|
183 |
+ |
None => found.push(path),
|
|
184 |
+ |
}
|
|
185 |
+ |
}
|
|
186 |
+ |
}
|
|
187 |
+ |
|
|
188 |
+ |
/// Apply a preset's whole values map as one edit.
|
|
189 |
+ |
///
|
|
190 |
+ |
/// One increment, so quitting after applying one asks once and undoing it
|
|
191 |
+ |
/// later is one step. The values were checked against their fields when the
|
|
192 |
+ |
/// schema was parsed, so this cannot half-apply.
|
|
193 |
+ |
pub(crate) fn apply_preset(&mut self, name: &str) -> Result<()> {
|
|
194 |
+ |
let preset = self
|
|
195 |
+ |
.schema
|
|
196 |
+ |
.presets
|
|
197 |
+ |
.iter()
|
|
198 |
+ |
.find(|preset| preset.name == name)
|
|
199 |
+ |
.with_context(|| format!("no preset named \"{name}\""))?;
|
|
200 |
+ |
|
|
201 |
+ |
// Collected first: the write borrows the document mutably and the
|
|
202 |
+ |
// preset borrows the schema, which the same `self` owns.
|
|
203 |
+ |
let values: Vec<(String, Value)> = preset
|
|
204 |
+ |
.values
|
|
205 |
+ |
.iter()
|
|
206 |
+ |
.map(|(path, value)| (path.clone(), value.clone()))
|
|
207 |
+ |
.collect();
|
|
208 |
+ |
|
|
209 |
+ |
for (path, value) in values {
|
|
210 |
+ |
write(&mut self.document, &path, &value)?;
|
|
211 |
+ |
}
|
|
212 |
+ |
self.edits += 1;
|
|
213 |
+ |
Ok(())
|
|
214 |
+ |
}
|
|
215 |
+ |
}
|
|
216 |
+ |
|
|
217 |
+ |
impl Bind for FileBind {
|
|
218 |
+ |
fn origin(&self) -> String {
|
|
219 |
+ |
self.path.display().to_string()
|
|
220 |
+ |
}
|
|
221 |
+ |
|
|
222 |
+ |
fn sections(&self) -> &[Section] {
|
|
223 |
+ |
&self.schema.sections
|
|
224 |
+ |
}
|
|
225 |
+ |
|
|
226 |
+ |
fn fields(&self) -> &[Field] {
|
|
227 |
+ |
&self.schema.fields
|
|
228 |
+ |
}
|
|
229 |
+ |
|
|
230 |
+ |
fn read(&self, path: &str) -> Option<Value> {
|
|
231 |
+ |
let item = lookup(self.document.as_table(), path)?;
|
|
232 |
+ |
from_edit(item)
|
|
233 |
+ |
}
|
|
234 |
+ |
|
|
235 |
+ |
fn commit(&mut self, path: &str, value: Value) -> Result<Vec<Effect>> {
|
|
236 |
+ |
self.validate(path, &value)?;
|
|
237 |
+ |
write(&mut self.document, path, &value)?;
|
|
238 |
+ |
self.edits += 1;
|
|
239 |
+ |
// Nothing to run: the edit lives in the document until it is saved,
|
|
240 |
+ |
// which is what Ctrl-S is for and why a half-typed TOML never reaches
|
|
241 |
+ |
// disk.
|
|
242 |
+ |
Ok(Vec::new())
|
|
243 |
+ |
}
|
|
244 |
+ |
|
|
245 |
+ |
fn dirty(&self) -> bool {
|
|
246 |
+ |
self.edits > 0
|
|
247 |
+ |
}
|
|
248 |
+ |
|
|
249 |
+ |
fn save(&mut self) -> Result<Vec<Effect>> {
|
|
250 |
+ |
// The count clears on emitting the effect rather than on its success:
|
|
251 |
+ |
// the log is what reports a failed write, and a form that stayed dirty
|
|
252 |
+ |
// after a write it can see in the log would be reporting it twice.
|
|
253 |
+ |
self.edits = 0;
|
|
254 |
+ |
Ok(vec![Effect::Write {
|
|
255 |
+ |
path: self.path.clone(),
|
|
256 |
+ |
contents: self.document.to_string(),
|
|
257 |
+ |
mode: CONFIG_MODE,
|
|
258 |
+ |
}])
|
|
259 |
+ |
}
|
|
260 |
+ |
}
|
|
261 |
+ |
|
|
262 |
+ |
/// Follow a dotted path to the item it names.
|
|
263 |
+ |
fn lookup<'a>(table: &'a dyn TableLike, path: &str) -> Option<&'a Item> {
|
|
264 |
+ |
let mut segments = path.split('.');
|
|
265 |
+ |
let mut item = table.get(segments.next()?)?;
|
|
266 |
+ |
for segment in segments {
|
|
267 |
+ |
item = item.as_table_like()?.get(segment)?;
|
|
268 |
+ |
}
|
|
269 |
+ |
Some(item)
|
|
270 |
+ |
}
|
|
271 |
+ |
|
|
272 |
+ |
/// Write a value at a dotted path, creating the tables above it.
|
|
273 |
+ |
fn write(document: &mut DocumentMut, path: &str, value: &Value) -> Result<()> {
|
|
274 |
+ |
let mut segments: Vec<&str> = path.split('.').collect();
|
|
275 |
+ |
let leaf = segments.pop().expect("split yields at least one segment");
|
|
276 |
+ |
|
|
277 |
+ |
let mut table: &mut dyn TableLike = document.as_table_mut();
|
|
278 |
+ |
for segment in segments {
|
|
279 |
+ |
if table.get(segment).is_none() {
|
|
280 |
+ |
// Implicit, so that writing `fonts.regular.family` into an empty
|
|
281 |
+ |
// file emits `[fonts.regular]` and not an empty `[fonts]` header
|
|
282 |
+ |
// above it. Only ever set on a table this call created; an existing
|
|
283 |
+ |
// one keeps whatever form the author wrote it in.
|
|
284 |
+ |
let mut created = toml_edit::Table::new();
|
|
285 |
+ |
created.set_implicit(true);
|
|
286 |
+ |
table.insert(segment, Item::Table(created));
|
|
287 |
+ |
}
|
|
288 |
+ |
table = table
|
|
289 |
+ |
.get_mut(segment)
|
|
290 |
+ |
.and_then(Item::as_table_like_mut)
|
|
291 |
+ |
.with_context(|| format!("`{segment}` in `{path}` is not a table"))?;
|
|
292 |
+ |
}
|
|
293 |
+ |
|
|
294 |
+ |
match table.get_mut(leaf) {
|
|
295 |
+ |
// Assigning into the existing item keeps its decor: the comment after
|
|
296 |
+ |
// a value, and the spacing around it, are the user's and not ours.
|
|
297 |
+ |
Some(existing) => {
|
|
298 |
+ |
let decor = existing
|
|
299 |
+ |
.as_value()
|
|
300 |
+ |
.map(|value| value.decor().clone())
|
|
301 |
+ |
.unwrap_or_default();
|
|
302 |
+ |
let mut replacement = to_edit(value);
|
|
303 |
+ |
*replacement.decor_mut() = decor;
|
|
304 |
+ |
*existing = Item::Value(replacement);
|
|
305 |
+ |
}
|
|
306 |
+ |
None => {
|
|
307 |
+ |
table.insert(leaf, Item::Value(to_edit(value)));
|
|
308 |
+ |
}
|
|
309 |
+ |
}
|
|
310 |
+ |
Ok(())
|
|
311 |
+ |
}
|
|
312 |
+ |
|
|
313 |
+ |
/// A schema-side value as an editable one.
|
|
314 |
+ |
fn to_edit(value: &Value) -> toml_edit::Value {
|
|
315 |
+ |
match value {
|
|
316 |
+ |
Value::String(text) => text.as_str().into(),
|
|
317 |
+ |
Value::Integer(number) => (*number).into(),
|
|
318 |
+ |
Value::Float(number) => (*number).into(),
|
|
319 |
+ |
Value::Boolean(flag) => (*flag).into(),
|
|
320 |
+ |
Value::Datetime(stamp) => toml_edit::Value::from(stamp.to_string()),
|
|
321 |
+ |
Value::Array(items) => items
|
|
322 |
+ |
.iter()
|
|
323 |
+ |
.map(to_edit)
|
|
324 |
+ |
.collect::<toml_edit::Array>()
|
|
325 |
+ |
.into(),
|
|
326 |
+ |
Value::Table(table) => table
|
|
327 |
+ |
.iter()
|
|
328 |
+ |
.map(|(key, item)| (key.as_str(), to_edit(item)))
|
|
329 |
+ |
.collect::<toml_edit::InlineTable>()
|
|
330 |
+ |
.into(),
|
|
331 |
+ |
}
|
|
332 |
+ |
}
|
|
333 |
+ |
|
|
334 |
+ |
/// An item read out of the document, as a schema-side value.
|
|
335 |
+ |
///
|
|
336 |
+ |
/// `None` for anything with no value shape at all, which in practice is an
|
|
337 |
+ |
/// empty `[table]` header standing over nothing.
|
|
338 |
+ |
fn from_edit(item: &Item) -> Option<Value> {
|
|
339 |
+ |
match item {
|
|
340 |
+ |
Item::Value(value) => value_from_edit(value),
|
|
341 |
+ |
Item::Table(table) => Some(Value::Table(
|
|
342 |
+ |
table
|
|
343 |
+ |
.iter()
|
|
344 |
+ |
.filter_map(|(key, item)| Some((key.to_string(), from_edit(item)?)))
|
|
345 |
+ |
.collect(),
|
|
346 |
+ |
)),
|
|
347 |
+ |
// `[[bindings.keys]]`, which v1 renders read-only.
|
|
348 |
+ |
Item::ArrayOfTables(tables) => Some(Value::Array(
|
|
349 |
+ |
tables
|
|
350 |
+ |
.iter()
|
|
351 |
+ |
.map(|table| {
|
|
352 |
+ |
Value::Table(
|
|
353 |
+ |
table
|
|
354 |
+ |
.iter()
|
|
355 |
+ |
.filter_map(|(key, item)| Some((key.to_string(), from_edit(item)?)))
|
|
356 |
+ |
.collect(),
|
|
357 |
+ |
)
|
|
358 |
+ |
})
|
|
359 |
+ |
.collect(),
|
|
360 |
+ |
)),
|
|
361 |
+ |
Item::None => None,
|
|
362 |
+ |
}
|
|
363 |
+ |
}
|
|
364 |
+ |
|
|
365 |
+ |
fn value_from_edit(value: &toml_edit::Value) -> Option<Value> {
|
|
366 |
+ |
Some(match value {
|
|
367 |
+ |
toml_edit::Value::String(text) => Value::String(text.value().clone()),
|
|
368 |
+ |
toml_edit::Value::Integer(number) => Value::Integer(*number.value()),
|
|
369 |
+ |
toml_edit::Value::Float(number) => Value::Float(*number.value()),
|
|
370 |
+ |
toml_edit::Value::Boolean(flag) => Value::Boolean(*flag.value()),
|
|
371 |
+ |
// Held as its written form. Nothing in schema-DSL v1 is a datetime, so
|
|
372 |
+ |
// this only ever comes back out of an unknown key on its way through an
|
|
373 |
+ |
// edit untouched.
|
|
374 |
+ |
toml_edit::Value::Datetime(stamp) => Value::String(stamp.value().to_string()),
|
|
375 |
+ |
toml_edit::Value::Array(items) => {
|
|
376 |
+ |
Value::Array(items.iter().filter_map(value_from_edit).collect())
|
|
377 |
+ |
}
|
|
378 |
+ |
toml_edit::Value::InlineTable(table) => Value::Table(
|
|
379 |
+ |
table
|
|
380 |
+ |
.iter()
|
|
381 |
+ |
.filter_map(|(key, item)| Some((key.to_string(), value_from_edit(item)?)))
|
|
382 |
+ |
.collect(),
|
|
383 |
+ |
),
|
|
384 |
+ |
})
|
|
385 |
+ |
}
|
|
386 |
+ |
|
|
387 |
+ |
#[cfg(test)]
|
|
388 |
+ |
mod tests {
|
|
389 |
+ |
use super::*;
|
|
390 |
+ |
|
|
391 |
+ |
const SCHEMA: &str = "\
|
|
392 |
+ |
[schema]
|
|
393 |
+ |
target = \"rio.toml\"
|
|
394 |
+ |
target_tool = \"rio\"
|
|
395 |
+ |
schema_version = \"1\"
|
|
396 |
+ |
|
|
397 |
+ |
[[section]]
|
|
398 |
+ |
path = \"window\"
|
|
399 |
+ |
|
|
400 |
+ |
[[field]]
|
|
401 |
+ |
path = \"window.background-opacity\"
|
|
402 |
+ |
type = \"float\"
|
|
403 |
+ |
range = [0.0, 1.0]
|
|
404 |
+ |
default = 1.0
|
|
405 |
+ |
|
|
406 |
+ |
[[field]]
|
|
407 |
+ |
path = \"cursor.blinking\"
|
|
408 |
+ |
type = \"bool\"
|
|
409 |
+ |
default = false
|
|
410 |
+ |
|
|
411 |
+ |
[[field]]
|
|
412 |
+ |
path = \"fonts.regular.family\"
|
|
413 |
+ |
type = \"string\"
|
|
414 |
+ |
default = \"IosevkaTerm Nerd Font\"
|
|
415 |
+ |
|
|
416 |
+ |
[[field]]
|
|
417 |
+ |
path = \"fonts.regular.weight\"
|
|
418 |
+ |
type = \"int\"
|
|
419 |
+ |
range = [100, 900]
|
|
420 |
+ |
default = 400
|
|
421 |
+ |
|
|
422 |
+ |
[[field]]
|
|
423 |
+ |
path = \"cursor.shape\"
|
|
424 |
+ |
type = \"enum\"
|
|
425 |
+ |
values = [\"block\", \"underline\", \"beam\"]
|
|
426 |
+ |
default = \"block\"
|
|
427 |
+ |
|
|
428 |
+ |
[[group]]
|
|
429 |
+ |
path = \"colors\"
|
|
430 |
+ |
type = \"color\"
|
|
431 |
+ |
entries = [
|
|
432 |
+ |
{ key = \"background\", default = \"#e4ded6\" },
|
|
433 |
+ |
{ key = \"foreground\", default = \"#1a1816\" },
|
|
434 |
+ |
]
|
|
435 |
+ |
|
|
436 |
+ |
[[preset]]
|
|
437 |
+ |
name = \"Akari Night\"
|
|
438 |
+ |
values = { \"colors.background\" = \"#1a1816\", \"colors.foreground\" = \"#e4ded6\" }
|
|
439 |
+ |
";
|
|
440 |
+ |
|
|
441 |
+ |
fn bind(text: &str) -> FileBind {
|
|
442 |
+ |
let schema = Schema::parse(SCHEMA).expect("the test schema parses");
|
|
443 |
+ |
FileBind::new(
|
|
444 |
+ |
schema,
|
|
445 |
+ |
PathBuf::from("/home/max/.config/rio/config.toml"),
|
|
446 |
+ |
text,
|
|
447 |
+ |
)
|
|
448 |
+ |
.expect("the test document parses")
|
|
449 |
+ |
}
|
|
450 |
+ |
|
|
451 |
+ |
/// The whole context chain, which is what a bind error says. `Display` on
|
|
452 |
+ |
/// its own gives only the outermost layer; the reason is underneath it.
|
|
453 |
+ |
fn error<T>(result: Result<T>) -> String {
|
|
454 |
+ |
match result {
|
|
455 |
+ |
Ok(_) => panic!("expected an error"),
|
|
456 |
+ |
Err(error) => format!("{error:#}"),
|
|
457 |
+ |
}
|
|
458 |
+ |
}
|
|
459 |
+ |
|
|
460 |
+ |
#[test]
|
|
461 |
+ |
fn a_key_the_file_holds_reads_back() {
|
|
462 |
+ |
let bind = bind("[cursor]\nshape = \"beam\"\n");
|
|
463 |
+ |
assert_eq!(
|
|
464 |
+ |
bind.read("cursor.shape"),
|
|
465 |
+ |
Some(Value::String("beam".into())),
|
|
466 |
+ |
);
|
|
467 |
+ |
}
|
|
468 |
+ |
|
|
469 |
+ |
// The distinction the minimal-config promise rests on: reading is what the
|
|
470 |
+ |
// file says, defaulting is the schema's, and the row shows the second only
|
|
471 |
+ |
// because the first came back empty.
|
|
472 |
+ |
#[test]
|
|
473 |
+ |
fn a_key_the_file_omits_reads_as_nothing_not_as_its_default() {
|
|
474 |
+ |
let bind = bind("");
|
|
475 |
+ |
assert_eq!(bind.read("cursor.shape"), None);
|
|
476 |
+ |
assert_eq!(
|
|
477 |
+ |
bind.field("cursor.shape").unwrap().default_value(),
|
|
478 |
+ |
Some(Value::String("block".into())),
|
|
479 |
+ |
);
|
|
480 |
+ |
}
|
|
481 |
+ |
|
|
482 |
+ |
#[test]
|
|
483 |
+ |
fn a_dotted_path_walks_nested_tables() {
|
|
484 |
+ |
let bind = bind("[fonts.regular]\nfamily = \"Departure Mono\"\nweight = 700\n");
|
|
485 |
+ |
assert_eq!(
|
|
486 |
+ |
bind.read("fonts.regular.family"),
|
|
487 |
+ |
Some(Value::String("Departure Mono".into())),
|
|
488 |
+ |
);
|
|
489 |
+ |
assert_eq!(bind.read("fonts.regular.weight"), Some(Value::Integer(700)));
|
|
490 |
+ |
}
|
|
491 |
+ |
|
|
492 |
+ |
// The whole reason the layer is toml_edit and not a parse-and-reserialize.
|
|
493 |
+ |
#[test]
|
|
494 |
+ |
fn an_edit_leaves_comments_order_and_undeclared_keys_alone() {
|
|
495 |
+ |
let mut bind = bind(
|
|
496 |
+ |
"# rio, configured by Alloy\n\
|
|
497 |
+ |
[cursor]\n\
|
|
498 |
+ |
blinking = false # steady\n\
|
|
499 |
+ |
shape = \"block\"\n\
|
|
500 |
+ |
\n\
|