Skip to main content

flyleaf_core/
lib.rs

1//! The document behind Tommy Flyleaf: a TOML file edited as a tree, with its
2//! comments, key order and formatting kept for everything that was not edited.
3//!
4//! No user interface. Everything here is an operation on a `toml_edit`
5//! document that the widget in `flyleaf` asks for and never performs itself,
6//! so that there is one place a change goes through.
7//
8// Author: David M. Anderson
9// Built with AI assistance (Claude, Anthropic)
10
11#![forbid(unsafe_code)]
12#![warn(missing_docs, clippy::pedantic)]
13
14pub use toml_edit;
15
16use toml_edit::{Datetime, InlineTable, Item, Key, Table, Value};
17
18/// What a new key starts as.
19///
20/// The scalar types, and a table to put them in. An array and an array of
21/// tables are structure inside structure and are not offered yet.
22#[derive(Clone, Copy, PartialEq, Eq, Debug)]
23pub enum NewKey {
24    /// An empty string.
25    Text,
26    /// Zero.
27    Integer,
28    /// Zero.
29    Float,
30    /// False.
31    Boolean,
32    /// The epoch, which is a date somebody will replace rather than a guess at
33    /// the one they meant.
34    Datetime,
35    /// An empty table to put keys in.
36    Table,
37}
38
39impl NewKey {
40    /// The kinds an inline table can hold.
41    ///
42    /// It holds values, and a table is not one. A table inside an inline table
43    /// would have to be another inline table, which is structure inside
44    /// structure and is not offered any more than an array is.
45    pub const SCALARS: [Self; 5] = [
46        Self::Text,
47        Self::Integer,
48        Self::Float,
49        Self::Boolean,
50        Self::Datetime,
51    ];
52
53    /// Every kind, in the order a picker offers them.
54    pub const ALL: [Self; 6] = [
55        Self::Text,
56        Self::Integer,
57        Self::Float,
58        Self::Boolean,
59        Self::Datetime,
60        Self::Table,
61    ];
62
63    /// What it is called where somebody chooses it.
64    #[must_use]
65    pub fn label(self) -> &'static str {
66        match self {
67            Self::Text => "text",
68            Self::Integer => "integer",
69            Self::Float => "float",
70            Self::Boolean => "boolean",
71            Self::Datetime => "date and time",
72            Self::Table => "table",
73        }
74    }
75
76    /// What it starts as, where only a value will do.
77    fn as_value(self) -> Option<Value> {
78        match self.item() {
79            Item::Value(v) => Some(v),
80            _ => None,
81        }
82    }
83
84    fn item(self) -> Item {
85        match self {
86            Self::Text => Item::Value(Value::from("")),
87            Self::Integer => Item::Value(Value::from(0_i64)),
88            Self::Float => Item::Value(Value::from(0.0_f64)),
89            Self::Boolean => Item::Value(Value::from(false)),
90            Self::Datetime => Item::Value(Value::from(
91                "1970-01-01T00:00:00Z"
92                    .parse::<Datetime>()
93                    .expect("the epoch is a datetime"),
94            )),
95            Self::Table => Item::Table(Table::new()),
96        }
97    }
98}
99
100/// Add a key to a table.
101///
102/// Refuses an empty name and one already there: inserting over an existing key
103/// would replace it, and losing a value to a name collision is not what
104/// pressing Add asks for.
105pub fn add_key(t: &mut Table, name: &str, kind: NewKey) -> bool {
106    if name.is_empty() || t.contains_key(name) {
107        return false;
108    }
109    t.insert(name, kind.item());
110    true
111}
112
113/// Remove a key, and everything under it where it is a table.
114pub fn remove_key(t: &mut Table, name: &str) -> bool {
115    t.remove(name).is_some()
116}
117
118/// Rename a key, keeping its place in the document and the decor it carried.
119///
120/// `toml_edit` has no rename. Removing and re-inserting would put the key at the
121/// end, and authoring order carries intent, so every entry is taken out in
122/// order and put back with the renamed one rebuilt under its new name.
123/// `remove_entry` hands back the `Key` itself, so the comments and whitespace
124/// attached to it come along.
125pub fn rename_key(t: &mut Table, from: &str, to: &str) -> bool {
126    if to.is_empty() || from == to || !t.contains_key(from) || t.contains_key(to) {
127        return false;
128    }
129
130    let names: Vec<String> = t.iter().map(|(k, _)| k.to_owned()).collect();
131    let mut taken: Vec<(Key, Item)> = Vec::with_capacity(names.len());
132    for name in &names {
133        if let Some(entry) = t.remove_entry(name) {
134            taken.push(entry);
135        }
136    }
137
138    for (key, item) in taken {
139        if key.get() == from {
140            let renamed = Key::new(to)
141                .with_leaf_decor(key.leaf_decor().clone())
142                .with_dotted_decor(key.dotted_decor().clone());
143            t.insert_formatted(&renamed, item);
144        } else {
145            t.insert_formatted(&key, item);
146        }
147    }
148    true
149}
150
151/// Add a key to an inline table.
152///
153/// The same refusals as [`add_key`], and one more: a kind an inline table
154/// cannot hold.
155pub fn add_inline_key(t: &mut InlineTable, name: &str, kind: NewKey) -> bool {
156    if name.is_empty() || t.contains_key(name) {
157        return false;
158    }
159    match kind.as_value() {
160        Some(value) => {
161            t.insert(name, value);
162            true
163        }
164        None => false,
165    }
166}
167
168/// Remove a key from an inline table.
169pub fn remove_inline_key(t: &mut InlineTable, name: &str) -> bool {
170    t.remove(name).is_some()
171}
172
173/// Rename a key in an inline table, keeping its place and its decor.
174///
175/// The same rebuild [`rename_key`] does, for the same reason: there is no
176/// rename, and re-inserting would move the key to the end of a line somebody
177/// wrote in an order they chose.
178pub fn rename_inline_key(t: &mut InlineTable, from: &str, to: &str) -> bool {
179    if to.is_empty() || from == to || !t.contains_key(from) || t.contains_key(to) {
180        return false;
181    }
182
183    let names: Vec<String> = t.iter().map(|(k, _)| k.to_owned()).collect();
184    let mut taken: Vec<(Key, Value)> = Vec::with_capacity(names.len());
185    for name in &names {
186        if let Some(entry) = t.remove_entry(name) {
187            taken.push(entry);
188        }
189    }
190
191    for (key, value) in taken {
192        if key.get() == from {
193            let renamed = Key::new(to)
194                .with_leaf_decor(key.leaf_decor().clone())
195                .with_dotted_decor(key.dotted_decor().clone());
196            t.insert_formatted(&renamed, value);
197        } else {
198            t.insert_formatted(&key, value);
199        }
200    }
201    true
202}
203
204/// Change a value, keeping the decor it was written with.
205///
206/// Dropping a new `Item` over an old one discards its decor, which is the
207/// whitespace and the comments attached to it, so the value is assigned into
208/// and its decor put back afterwards.
209pub fn set_value(slot: &mut Value, new: Value) {
210    let decor = slot.decor().clone();
211    *slot = new;
212    *slot.decor_mut() = decor;
213}
214
215#[cfg(test)]
216mod structure_tests {
217    use super::{add_key, remove_key, rename_key, NewKey};
218    use toml_edit::DocumentMut;
219
220    const DOC: &str = "\
221# above first
222first = \"one\"   # beside first
223second = 2
224
225[third]
226inner = true
227";
228
229    fn doc() -> DocumentMut {
230        DOC.parse().expect("valid TOML")
231    }
232
233    fn keys(d: &DocumentMut) -> Vec<String> {
234        d.as_table().iter().map(|(k, _)| k.to_owned()).collect()
235    }
236
237    /// Authoring order carries intent. A rename is where `toml_edit` would
238    /// quietly move the key to the end, having no rename of its own.
239    #[test]
240    fn a_rename_keeps_its_place_and_its_comments() {
241        let mut d = doc();
242        assert!(rename_key(d.as_table_mut(), "first", "primary"));
243
244        assert_eq!(keys(&d), ["primary", "second", "third"]);
245
246        let written = d.to_string();
247        assert!(written.contains("# above first"), "{written}");
248        assert!(written.contains("# beside first"), "{written}");
249        assert!(written.contains("primary = \"one\""), "{written}");
250        assert!(!written.contains("first ="), "{written}");
251    }
252
253    /// A table renamed keeps its place too, and its contents come with it.
254    #[test]
255    fn a_table_can_be_renamed() {
256        let mut d = doc();
257        assert!(rename_key(d.as_table_mut(), "third", "provenance"));
258
259        assert_eq!(keys(&d), ["first", "second", "provenance"]);
260        assert!(d.to_string().contains("inner = true"));
261    }
262
263    /// A rename that would land on a name already there is refused rather than
264    /// replacing it: losing a value to a collision is not what renaming asks
265    /// for.
266    #[test]
267    fn a_rename_onto_an_existing_key_is_refused() {
268        let mut d = doc();
269        assert!(!rename_key(d.as_table_mut(), "first", "second"));
270        assert!(!rename_key(d.as_table_mut(), "first", ""));
271        assert!(!rename_key(d.as_table_mut(), "absent", "anything"));
272
273        assert_eq!(keys(&d), ["first", "second", "third"]);
274        assert!(d.to_string().contains("second = 2"));
275    }
276
277    /// A key added to a document that already has tables stays at the root.
278    ///
279    /// It goes last in the map, after the table, and `toml_edit` still writes it
280    /// above the table's header. That is the difference between map order and
281    /// document order, and it is the one that matters: a bare key written after
282    /// `[third]` would be a key inside `third` rather than a key of the
283    /// document, which is a different document.
284    #[test]
285    fn a_key_added_to_a_document_with_tables_stays_at_the_root() {
286        let mut d = doc();
287        assert!(add_key(d.as_table_mut(), "author", NewKey::Text));
288        assert_eq!(keys(&d), ["first", "second", "third", "author"]);
289
290        let written = d.to_string();
291        assert!(written.contains("author = \"\""), "{written}");
292        assert!(
293            written.find("author").unwrap() < written.find("[third]").unwrap(),
294            "an added key must not fall inside the last table: {written}"
295        );
296
297        // Read back rather than trusted: this is where it would go wrong.
298        let back: DocumentMut = written.parse().expect("still parses");
299        assert!(back.as_table().contains_key("author"));
300        assert!(!back["third"]
301            .as_table()
302            .expect("a table")
303            .contains_key("author"));
304    }
305
306    #[test]
307    fn a_key_already_there_is_not_added_over() {
308        let mut d = doc();
309        assert!(!add_key(d.as_table_mut(), "first", NewKey::Integer));
310        assert!(!add_key(d.as_table_mut(), "", NewKey::Integer));
311        assert!(d.to_string().contains("first = \"one\""));
312    }
313
314    /// Every kind a picker offers produces a document that still parses.
315    #[test]
316    fn every_kind_of_new_key_is_valid_toml() {
317        for kind in NewKey::ALL {
318            let mut d = doc();
319            assert!(add_key(d.as_table_mut(), "added", kind), "{kind:?}");
320            let written = d.to_string();
321            written
322                .parse::<DocumentMut>()
323                .unwrap_or_else(|e| panic!("{kind:?} wrote something unparseable: {e}\n{written}"));
324        }
325    }
326
327    #[test]
328    fn removing_a_table_takes_what_is_under_it() {
329        let mut d = doc();
330        assert!(remove_key(d.as_table_mut(), "third"));
331        assert_eq!(keys(&d), ["first", "second"]);
332        assert!(!d.to_string().contains("inner"));
333        assert!(!remove_key(d.as_table_mut(), "third"));
334    }
335}
336
337#[cfg(test)]
338mod inline_tests {
339    use super::{add_inline_key, remove_inline_key, rename_inline_key, NewKey};
340    use toml_edit::DocumentMut;
341
342    fn doc() -> DocumentMut {
343        "owner = { name = \"D. Anderson\", team = \"consulting\" }\n"
344            .parse()
345            .expect("valid TOML")
346    }
347
348    fn owner(d: &mut DocumentMut) -> &mut toml_edit::InlineTable {
349        d["owner"].as_inline_table_mut().expect("an inline table")
350    }
351
352    /// The bug this closes: the buttons were drawn inside an inline table and
353    /// the change was thrown away, so `owner` could be removed and `owner.name`
354    /// could not.
355    #[test]
356    fn a_key_inside_an_inline_table_can_be_removed() {
357        let mut d = doc();
358        assert!(remove_inline_key(owner(&mut d), "name"));
359        assert!(!remove_inline_key(owner(&mut d), "name"));
360
361        let written = d.to_string();
362        assert!(!written.contains("name"), "{written}");
363        assert!(written.contains("team = \"consulting\""), "{written}");
364    }
365
366    /// Renamed in place, not moved to the end of a line somebody wrote in an
367    /// order they chose.
368    #[test]
369    fn a_key_inside_an_inline_table_keeps_its_place_when_renamed() {
370        let mut d = doc();
371        assert!(rename_inline_key(owner(&mut d), "name", "who"));
372
373        let written = d.to_string();
374        assert!(
375            written.find("who").unwrap() < written.find("team").unwrap(),
376            "{written}"
377        );
378        assert!(written.contains("who = \"D. Anderson\""), "{written}");
379
380        // Refused for the same reasons as anywhere else.
381        assert!(!rename_inline_key(owner(&mut d), "who", "team"));
382        assert!(!rename_inline_key(owner(&mut d), "who", ""));
383    }
384
385    /// An inline table holds values, so a table is not among the kinds offered
386    /// and is refused if it arrives anyway.
387    #[test]
388    fn an_inline_table_takes_values_and_not_tables() {
389        let mut d = doc();
390        assert!(add_inline_key(owner(&mut d), "since", NewKey::Integer));
391        assert!(!add_inline_key(owner(&mut d), "nested", NewKey::Table));
392        assert!(!add_inline_key(owner(&mut d), "name", NewKey::Text));
393
394        assert!(!NewKey::SCALARS.contains(&NewKey::Table));
395
396        let written = d.to_string();
397        assert!(written.contains("since = 0"), "{written}");
398        written.parse::<DocumentMut>().expect("still parses");
399    }
400}
401
402#[cfg(test)]
403mod value_tests {
404    use super::set_value;
405    use toml_edit::{DocumentMut, Value};
406
407    /// A changed value keeps the comment beside it and the spacing around it.
408    ///
409    /// In slipcase-desktop this was only ever checked through a whole save,
410    /// which stays there. Here it is the operation alone: without the decor
411    /// restore in `set_value`, `title = "after"` comes back with its comment
412    /// gone and its alignment collapsed, and this fails on both.
413    #[test]
414    fn a_changed_value_keeps_its_comment_and_its_spacing() {
415        let mut d: DocumentMut = "title   =   \"before\"   # beside the title\n"
416            .parse()
417            .expect("valid TOML");
418        set_value(
419            d["title"].as_value_mut().expect("a value"),
420            Value::from("after"),
421        );
422        assert_eq!(
423            d.to_string(),
424            "title   =   \"after\"   # beside the title\n"
425        );
426    }
427}