Skip to main content

flyleaf_core/
document.rs

1//! A document as a file: the parsed tree, the three things about its bytes
2//! that `toml_edit` reads past and does not write back, and its history.
3//!
4//! Measured on 2026-09-07 against every valid TOML 1.1.0 case in toml-test:
5//! a leading byte order mark is dropped, CRLF line endings come back as LF,
6//! and a file without a final newline gains one. Each is a fact about the
7//! file rather than about the document, so this records them at parse and
8//! puts them back at render, and `tests/roundtrip.rs` holds it to that.
9//!
10//! The history is the document's rendered text, one copy per step. A step
11//! is what changed between two calls to [`Document::record`], and calls
12//! that name the same row coalesce, so typing into a field is one step and
13//! not one per keystroke. Undo parses the previous text back, which loses
14//! nothing, the text being the whole of the document. A copy of the file per
15//! step is the cost, about 100 KB for a `Cargo.lock`, which is cheap beside
16//! what the window holds for the same document.
17
18use std::fmt;
19
20use std::ops::Range;
21
22use toml_edit::{DocumentMut, Item, Table, TomlError, Value};
23
24/// The line ending a file was written with.
25#[derive(Clone, Copy, PartialEq, Eq, Debug)]
26pub enum Newline {
27    /// `\n`, and what a file with no line ending at all is taken to use.
28    Lf,
29    /// `\r\n`.
30    CrLf,
31}
32
33/// Why a file did not become a document, or a document a file.
34#[derive(Debug)]
35pub enum Error {
36    /// The bytes are not UTF-8, which TOML requires them to be.
37    NotUtf8(std::str::Utf8Error),
38    /// The text is not TOML, with the parser's own account of where.
39    Toml(TomlError),
40    /// The file could not be read or written.
41    Io(std::io::Error),
42}
43
44impl fmt::Display for Error {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::NotUtf8(e) => write!(f, "not UTF-8: {e}"),
48            Self::Toml(e) => e.fmt(f),
49            Self::Io(e) => e.fmt(f),
50        }
51    }
52}
53
54impl From<std::io::Error> for Error {
55    fn from(e: std::io::Error) -> Self {
56        Self::Io(e)
57    }
58}
59
60impl std::error::Error for Error {}
61
62/// A TOML document together with what its file looked like.
63///
64/// The tree is `toml_edit`'s, reached through [`Document::tree_mut`] for the
65/// operations in this crate to work on. What this adds is the byte order
66/// mark, the line ending and the final newline, which `toml_edit` does not
67/// keep, and a baseline for saying whether anything has been edited.
68#[derive(Debug, Clone)]
69pub struct Document {
70    doc: DocumentMut,
71    bom: bool,
72    newline: Newline,
73    final_newline: bool,
74    /// The document as it rendered when it was parsed or last saved, for
75    /// [`Document::edited`]. Compared against rather than the source bytes,
76    /// because the three facts above make those differ for an unedited file.
77    baseline: String,
78    /// The document as it rendered at the last [`Document::record`], which
79    /// is what the next change is measured against.
80    current: String,
81    /// The text before each step, newest last.
82    undo: Vec<String>,
83    /// The text undone, newest last, emptied by any new change.
84    redo: Vec<String>,
85    /// The row the last step was made in, which is what a further change
86    /// to the same row coalesces with.
87    group: Option<Vec<String>>,
88}
89
90impl Document {
91    /// Parse a file's bytes.
92    ///
93    /// # Errors
94    ///
95    /// When the bytes are not UTF-8, or the text is not TOML.
96    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
97        let text = std::str::from_utf8(bytes).map_err(Error::NotUtf8)?;
98        Self::parse(text)
99    }
100
101    /// Parse text, which may begin with a byte order mark.
102    ///
103    /// # Errors
104    ///
105    /// When the text is not TOML.
106    pub fn parse(text: &str) -> Result<Self, Error> {
107        // Recorded, not stripped. `toml_edit` accepts one leading byte order
108        // mark and refuses a second, and stripping one here let a file with
109        // two through: toml-test's `invalid/encoding/bom-not-at-start-02`
110        // found that, and is what holds this line to reading rather than
111        // cutting.
112        let bom = text.starts_with('\u{feff}');
113        // The first line ending decides. A file that mixes them is written
114        // back with one of them, which is the only thing a line-ending
115        // setting can mean; the corpus test says which cases that touches.
116        let newline = match text.find('\n') {
117            Some(i) if i > 0 && text.as_bytes()[i - 1] == b'\r' => Newline::CrLf,
118            _ => Newline::Lf,
119        };
120        let final_newline = text.is_empty() || text.ends_with('\n');
121        let doc = text.parse::<DocumentMut>().map_err(Error::Toml)?;
122        let baseline = doc.to_string();
123        Ok(Self {
124            doc,
125            bom,
126            newline,
127            final_newline,
128            current: baseline.clone(),
129            baseline,
130            undo: Vec::new(),
131            redo: Vec::new(),
132            group: None,
133        })
134    }
135
136    /// Read and parse a file.
137    ///
138    /// # Errors
139    ///
140    /// When the file cannot be read, is not UTF-8, or is not TOML.
141    #[cfg(feature = "fs")]
142    pub fn from_path(path: &std::path::Path) -> Result<Self, Error> {
143        Self::from_bytes(&std::fs::read(path)?)
144    }
145
146    /// Write what [`Document::render`] gives to a file, and mark the
147    /// document saved.
148    ///
149    /// Written beside the file and renamed over it, so that a failure at any
150    /// point leaves the original as it was and never a file half written.
151    /// The rename is what makes it one step, and it needs the two on one
152    /// file system, which a sibling is. A platform whose sandbox refuses a
153    /// sibling, which macOS's does for a file a dialog granted, needs its
154    /// own arm here; `PROMPT.md` carries that under Phase 3.
155    ///
156    /// A rename needs only the directory to be writable, so two things an
157    /// in-place write would do for free are done here on purpose: a file
158    /// marked read-only is refused rather than replaced, and the file keeps
159    /// the permissions it had rather than the staged file's. Both were
160    /// found by hand on 2026-09-07, when a read-only fixture saved without
161    /// a word and came back mode 0600.
162    ///
163    /// # Errors
164    ///
165    /// When the file is read-only, or the sibling cannot be created,
166    /// written, or renamed over the file. The document is not marked saved
167    /// then.
168    #[cfg(feature = "fs")]
169    pub fn save_to(&mut self, path: &std::path::Path) -> Result<(), Error> {
170        use std::io::Write as _;
171        let dir = path
172            .parent()
173            .filter(|p| !p.as_os_str().is_empty())
174            .unwrap_or_else(|| std::path::Path::new("."));
175        let original = match std::fs::metadata(path) {
176            Ok(m) => Some(m),
177            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
178            Err(e) => return Err(e.into()),
179        };
180        if original
181            .as_ref()
182            .is_some_and(|m| m.permissions().readonly())
183        {
184            return Err(std::io::Error::new(
185                std::io::ErrorKind::PermissionDenied,
186                "the file is read-only",
187            )
188            .into());
189        }
190
191        let mut builder = tempfile::Builder::new();
192        // A temporary file is made private, which is right for one and
193        // wrong for a document somebody will keep: a new file gets what
194        // the umask gives any new file, and an existing one gets its own
195        // permissions back below.
196        #[cfg(unix)]
197        {
198            use std::os::unix::fs::PermissionsExt as _;
199            builder.permissions(std::fs::Permissions::from_mode(0o666));
200        }
201        let mut staged = builder.tempfile_in(dir)?;
202        staged.write_all(self.render().as_bytes())?;
203        staged.as_file().sync_all()?;
204        if let Some(original) = original {
205            staged.as_file().set_permissions(original.permissions())?;
206        }
207        staged.persist(path).map_err(|e| e.error)?;
208        self.mark_saved();
209        Ok(())
210    }
211
212    /// The tree, to read.
213    #[must_use]
214    pub fn tree(&self) -> &DocumentMut {
215        &self.doc
216    }
217
218    /// The tree, to edit.
219    pub fn tree_mut(&mut self) -> &mut DocumentMut {
220        &mut self.doc
221    }
222
223    /// The line ending the file was written with.
224    #[must_use]
225    pub fn newline(&self) -> Newline {
226        self.newline
227    }
228
229    /// Whether the file began with a byte order mark.
230    #[must_use]
231    pub fn has_bom(&self) -> bool {
232        self.bom
233    }
234
235    /// What a save writes: the document, with the byte order mark, the line
236    /// ending and the final newline the file had.
237    #[must_use]
238    pub fn render(&self) -> String {
239        let mut text = self.doc.to_string();
240        if !self.final_newline && text.ends_with('\n') {
241            // `toml_edit` ends every document with a newline. One that did
242            // not have one loses it again here, and only here: a newline
243            // inside the document is content.
244            text.pop();
245        }
246        if self.newline == Newline::CrLf {
247            text = with_crlf(&text);
248        }
249        if self.bom {
250            text.insert(0, '\u{feff}');
251        }
252        text
253    }
254
255    /// Whether the document differs from what was parsed or last saved.
256    #[must_use]
257    pub fn edited(&self) -> bool {
258        self.doc.to_string() != self.baseline
259    }
260
261    /// Record that what the document now holds is what is on disk.
262    pub fn mark_saved(&mut self) {
263        self.baseline = self.doc.to_string();
264    }
265
266    /// Record whatever changed since the last call as a step, and say
267    /// whether anything did.
268    ///
269    /// `group` names the row the change was made in, where there is one.
270    /// A change in the same row as the step before joins that step rather
271    /// than starting another, so a word typed into a field undoes as a word;
272    /// a change with no row, or in another row, is a step of its own. Any
273    /// change empties the redo stack, since what was undone no longer
274    /// follows from what is there.
275    pub fn record(&mut self, group: Option<&[String]>) -> bool {
276        let now = self.doc.to_string();
277        if now == self.current {
278            return false;
279        }
280        let same_row = group.is_some() && self.group.as_deref() == group;
281        if !same_row {
282            let before = std::mem::take(&mut self.current);
283            self.undo.push(before);
284            self.group = group.map(<[String]>::to_vec);
285        }
286        self.current = now;
287        self.redo.clear();
288        true
289    }
290
291    /// Whether there is a step to undo.
292    #[must_use]
293    pub fn can_undo(&self) -> bool {
294        !self.undo.is_empty()
295    }
296
297    /// Whether there is a step to redo.
298    #[must_use]
299    pub fn can_redo(&self) -> bool {
300        !self.redo.is_empty()
301    }
302
303    /// Put the document back as it was before the last step.
304    ///
305    /// Anything changed since the last [`Document::record`] is recorded
306    /// first, so that it can be redone rather than lost.
307    pub fn undo(&mut self) -> bool {
308        self.record(None);
309        let Some(before) = self.undo.pop() else {
310            return false;
311        };
312        let now = std::mem::replace(&mut self.current, before);
313        self.redo.push(now);
314        self.restore();
315        true
316    }
317
318    /// Put back the last step undone.
319    pub fn redo(&mut self) -> bool {
320        let Some(after) = self.redo.pop() else {
321            return false;
322        };
323        let now = std::mem::replace(&mut self.current, after);
324        self.undo.push(now);
325        self.restore();
326        true
327    }
328
329    /// The lines of the rendered text that the item at a path occupies,
330    /// counted from zero, the end exclusive: a row from its key to the end
331    /// of its value, a table or an array-of-tables element its header line.
332    /// `None` for a path that names nothing.
333    ///
334    /// The tree keeps no positions once it can be edited, so this parses the
335    /// rendered text again, which does. That is a full parse per call, and a
336    /// caller asks only when the selection or the document has changed.
337    #[must_use]
338    pub fn lines_of(&self, path: &[String]) -> Option<Range<usize>> {
339        let text = self.doc.to_string();
340        let parsed = toml_edit::Document::parse(text.as_str()).ok()?;
341        let span = span_in_table(parsed.as_table(), path)?;
342        let line_at = |offset: usize| text[..offset.min(text.len())].matches('\n').count();
343        Some(line_at(span.start)..line_at(span.end.saturating_sub(1)) + 1)
344    }
345
346    /// The tree as `current` says, which parses because it was rendered
347    /// from a tree; a step is never a row's own, so the next change starts
348    /// one.
349    fn restore(&mut self) {
350        self.doc = self
351            .current
352            .parse::<DocumentMut>()
353            .expect("a rendered document parses");
354        self.group = None;
355    }
356}
357
358/// The span of the item at a path under a table: the key and the item
359/// together where the path ends here, or whatever is further down.
360fn span_in_table(t: &Table, path: &[String]) -> Option<Range<usize>> {
361    let (head, rest) = path.split_first()?;
362    let (key, item) = t.get_key_value(head)?;
363    if rest.is_empty() {
364        return join(key.span(), item.span());
365    }
366    match item {
367        Item::Table(inner) => span_in_table(inner, rest),
368        Item::ArrayOfTables(a) => {
369            let (index, rest) = rest.split_first()?;
370            let element = a.get(indexed(index)?)?;
371            if rest.is_empty() {
372                element.span()
373            } else {
374                span_in_table(element, rest)
375            }
376        }
377        Item::Value(v) => span_in_value(v, rest),
378        Item::None => None,
379    }
380}
381
382/// The same under a value, which is an inline table or an array if the path
383/// goes on.
384fn span_in_value(v: &Value, path: &[String]) -> Option<Range<usize>> {
385    let (head, rest) = path.split_first()?;
386    match v {
387        Value::InlineTable(t) => {
388            let (key, inner) = t.get_key_value(head)?;
389            if rest.is_empty() {
390                join(key.span(), inner.span())
391            } else {
392                span_in_value(inner.as_value()?, rest)
393            }
394        }
395        Value::Array(a) => {
396            let element = a.get(indexed(head)?)?;
397            if rest.is_empty() {
398                element.span()
399            } else {
400                span_in_value(element, rest)
401            }
402        }
403        _ => None,
404    }
405}
406
407/// The index an element's path segment names, written `[3]` as the tree
408/// labels it.
409fn indexed(segment: &str) -> Option<usize> {
410    segment.strip_prefix('[')?.strip_suffix(']')?.parse().ok()
411}
412
413/// One span from the start of the first to the end of the last.
414fn join(a: Option<Range<usize>>, b: Option<Range<usize>>) -> Option<Range<usize>> {
415    match (a, b) {
416        (Some(a), Some(b)) => Some(a.start.min(b.start)..a.end.max(b.end)),
417        (Some(a), None) | (None, Some(a)) => Some(a),
418        (None, None) => None,
419    }
420}
421
422/// Every bare `\n` as `\r\n`, leaving the ones already paired alone.
423///
424/// A `\r\n` inside a multi-line string survives the parse as itself, so a
425/// blind replacement would double it.
426fn with_crlf(text: &str) -> String {
427    let mut out = String::with_capacity(text.len() + text.len() / 40);
428    let mut previous = '\0';
429    for c in text.chars() {
430        if c == '\n' && previous != '\r' {
431            out.push('\r');
432        }
433        out.push(c);
434        previous = c;
435    }
436    out
437}
438
439#[cfg(test)]
440mod tests {
441    use super::{Document, Error, Newline};
442
443    /// The three things `toml_edit` reads past come back on render, each on
444    /// its own and all together. Without the record a document with any of
445    /// them would be rewritten the first time it was saved, edited or not.
446    #[test]
447    fn what_toml_edit_drops_is_put_back() {
448        for text in [
449            "\u{feff}a = 1\n",
450            "a = 1\r\nb = 2\r\n",
451            "a = 1",
452            "\u{feff}a = 1\r\nb = 2",
453            "",
454        ] {
455            let doc = Document::parse(text).expect("valid TOML");
456            assert_eq!(doc.render(), text, "{text:?}");
457            assert!(!doc.edited(), "{text:?} is edited before anything happened");
458        }
459    }
460
461    /// A `\r\n` inside a multi-line string is content, and survives the
462    /// parse as itself. Restoring the file's CRLF must not make it `\r\r\n`.
463    #[test]
464    fn a_crlf_inside_a_string_is_not_doubled() {
465        let text = "s = \"\"\"\r\nx\r\n\"\"\"\r\n";
466        let doc = Document::parse(text).expect("valid TOML");
467        assert_eq!(doc.newline(), Newline::CrLf);
468        assert_eq!(doc.render(), text);
469    }
470
471    /// Edited is a comparison against the parse, not the bytes, so that a
472    /// file with a byte order mark is not edited the moment it is opened;
473    /// and saving resets it.
474    #[test]
475    fn edited_follows_the_document_and_saving_resets_it() {
476        let mut doc = Document::parse("\u{feff}a = 1\r\n").expect("valid TOML");
477        assert!(!doc.edited());
478        doc.tree_mut()["a"] = toml_edit::value(2);
479        assert!(doc.edited());
480        doc.mark_saved();
481        assert!(!doc.edited());
482        assert_eq!(doc.render(), "\u{feff}a = 2\r\n");
483    }
484
485    /// One byte order mark is a fact about the file; two is not TOML. The
486    /// first version of `parse` cut the first off and let `toml_edit` cut the
487    /// second, and toml-test caught it.
488    #[test]
489    fn a_second_byte_order_mark_is_refused() {
490        assert!(Document::parse("\u{feff}a = 1\n").is_ok());
491        assert!(matches!(
492            Document::parse("\u{feff}\u{feff}a = 1\n"),
493            Err(Error::Toml(_))
494        ));
495    }
496
497    /// Typing into one row is one step, another row is another, and undo
498    /// and redo walk them in order. Without coalescing every keystroke would
499    /// be a step and undo would take a word back one letter at a time.
500    #[test]
501    fn changes_in_one_row_are_one_step() {
502        let mut doc = Document::parse("a = \"\"\nb = 0\n").expect("valid TOML");
503        let a = vec!["a".to_owned()];
504        let b = vec!["b".to_owned()];
505        for text in ["x", "xy", "xyz"] {
506            doc.tree_mut()["a"] = toml_edit::value(text);
507            assert!(doc.record(Some(&a)));
508        }
509        doc.tree_mut()["b"] = toml_edit::value(1);
510        assert!(doc.record(Some(&b)));
511        assert!(!doc.record(Some(&b)), "nothing changed");
512        assert_eq!(doc.render(), "a = \"xyz\"\nb = 1\n");
513
514        assert!(doc.undo());
515        assert_eq!(doc.render(), "a = \"xyz\"\nb = 0\n");
516        assert!(doc.undo());
517        assert_eq!(
518            doc.render(),
519            "a = \"\"\nb = 0\n",
520            "the word came back whole"
521        );
522        assert!(!doc.undo(), "nothing left to undo");
523
524        assert!(doc.redo());
525        assert_eq!(doc.render(), "a = \"xyz\"\nb = 0\n");
526        assert!(doc.redo());
527        assert_eq!(doc.render(), "a = \"xyz\"\nb = 1\n");
528        assert!(!doc.redo());
529    }
530
531    /// A change after an undo is a new branch: what was undone cannot be
532    /// redone over it. And a change nobody recorded before pressing undo is
533    /// recorded then, so it is undone rather than lost.
534    #[test]
535    fn a_change_after_an_undo_ends_the_redo_and_an_unrecorded_one_is_kept() {
536        let mut doc = Document::parse("a = 1\n").expect("valid TOML");
537        doc.tree_mut()["a"] = toml_edit::value(2);
538        doc.record(None);
539        assert!(doc.undo());
540        assert!(doc.can_redo());
541        doc.tree_mut()["a"] = toml_edit::value(3);
542        doc.record(None);
543        assert!(!doc.can_redo(), "a new change ended the branch");
544
545        doc.tree_mut()["a"] = toml_edit::value(4);
546        assert!(doc.undo(), "the unrecorded change is a step");
547        assert_eq!(doc.render(), "a = 3\n");
548        assert!(doc.redo());
549        assert_eq!(doc.render(), "a = 4\n");
550    }
551
552    /// Undo puts back the text, comments and layout included, and leaves
553    /// the file facts and the saved baseline alone: an undo past the save is
554    /// edited, an undo back to it is not.
555    #[test]
556    fn undo_restores_the_text_and_respects_the_baseline() {
557        let text = "\u{feff}# above\r\na = 1   # beside\r\n";
558        let mut doc = Document::parse(text).expect("valid TOML");
559        doc.tree_mut()["a"] = toml_edit::value(2);
560        doc.record(None);
561        doc.mark_saved();
562        doc.tree_mut()["a"] = toml_edit::value(3);
563        doc.record(None);
564        assert!(doc.edited());
565        assert!(doc.undo());
566        assert!(!doc.edited(), "back at what was saved");
567        assert!(doc.undo());
568        assert!(doc.edited(), "before what was saved");
569        assert_eq!(doc.render(), text);
570    }
571
572    /// Every shape of path lands on the lines it names: a root row, a row in
573    /// a table, a table's header, an array element, an array-of-tables
574    /// element's header, a key inside an inline table, and a value that runs
575    /// over several lines, whose range is all of them. A path naming nothing
576    /// is `None` rather than a wrong line.
577    #[test]
578    fn every_kind_of_path_lands_on_its_lines() {
579        let text = "\
580first = 1
581[types]
582count = 44
583list = [
584  1,
585  2,
586]
587inline = { a = 1, b = 2 }
588[[runs]]
589id = 1
590[[runs]]
591id = 2
592";
593        let doc = Document::parse(text).expect("valid TOML");
594        let lines = |parts: &[&str]| {
595            let path: Vec<String> = parts.iter().map(|p| (*p).to_owned()).collect();
596            doc.lines_of(&path)
597        };
598        assert_eq!(lines(&["first"]), Some(0..1));
599        assert_eq!(lines(&["types"]), Some(1..2));
600        assert_eq!(lines(&["types", "count"]), Some(2..3));
601        assert_eq!(lines(&["types", "list"]), Some(3..7));
602        assert_eq!(lines(&["types", "list", "[1]"]), Some(5..6));
603        assert_eq!(lines(&["types", "inline", "b"]), Some(7..8));
604        assert_eq!(lines(&["runs", "[1]"]), Some(10..11));
605        assert_eq!(lines(&["runs", "[1]", "id"]), Some(11..12));
606        assert_eq!(lines(&["absent"]), None);
607        assert_eq!(lines(&["types", "list", "[9]"]), None);
608        assert_eq!(lines(&[]), None);
609    }
610
611    /// A document saved and read back is the same bytes, byte order mark and
612    /// line endings included, and saving is what clears the edited mark. A
613    /// save that cannot happen leaves the file as it was and the mark set.
614    #[cfg(feature = "fs")]
615    #[test]
616    fn a_save_writes_the_render_and_a_failed_one_writes_nothing() {
617        let dir = std::env::temp_dir().join(format!("flyleaf-core-{}", std::process::id()));
618        std::fs::create_dir_all(&dir).unwrap();
619        let path = dir.join("doc.toml");
620        std::fs::write(&path, "\u{feff}a = 1\r\n").unwrap();
621
622        let mut doc = Document::from_path(&path).expect("reads");
623        doc.tree_mut()["a"] = toml_edit::value(2);
624        assert!(doc.edited());
625        doc.save_to(&path).expect("saves");
626        assert!(!doc.edited());
627        assert_eq!(
628            std::fs::read(&path).unwrap(),
629            "\u{feff}a = 2\r\n".as_bytes()
630        );
631        assert_eq!(
632            std::fs::read_dir(&dir).unwrap().count(),
633            1,
634            "the staged file is gone"
635        );
636
637        doc.tree_mut()["a"] = toml_edit::value(3);
638        let nowhere = dir.join("missing").join("doc.toml");
639        assert!(matches!(doc.save_to(&nowhere), Err(Error::Io(_))));
640        assert!(doc.edited(), "not marked saved");
641        assert_eq!(
642            std::fs::read(&path).unwrap(),
643            "\u{feff}a = 2\r\n".as_bytes()
644        );
645        std::fs::remove_dir_all(&dir).unwrap();
646    }
647
648    /// A saved file keeps the permissions it had, and a read-only file is
649    /// refused rather than replaced. A rename needs only the directory to be
650    /// writable, so without these a read-only fixture saved without a word
651    /// and came back mode 0600, which is how they were found.
652    #[cfg(all(feature = "fs", unix))]
653    #[test]
654    fn a_save_keeps_the_mode_and_refuses_a_read_only_file() {
655        use std::os::unix::fs::PermissionsExt as _;
656        let dir = std::env::temp_dir().join(format!("flyleaf-core-mode-{}", std::process::id()));
657        std::fs::create_dir_all(&dir).unwrap();
658        let path = dir.join("doc.toml");
659        std::fs::write(&path, "a = 1\n").unwrap();
660        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
661
662        let mut doc = Document::from_path(&path).expect("reads");
663        doc.tree_mut()["a"] = toml_edit::value(2);
664        doc.save_to(&path).expect("saves");
665        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
666        assert_eq!(mode, 0o640, "the mode the file had");
667
668        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o440)).unwrap();
669        doc.tree_mut()["a"] = toml_edit::value(3);
670        match doc.save_to(&path) {
671            Err(Error::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
672            other => panic!("{other:?}"),
673        }
674        assert!(doc.edited(), "not marked saved");
675        assert_eq!(std::fs::read_to_string(&path).unwrap(), "a = 2\n");
676        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
677        std::fs::remove_dir_all(&dir).unwrap();
678    }
679
680    /// Bytes that are not UTF-8 and text that is not TOML are two different
681    /// refusals, and the second carries the parser's location.
682    #[test]
683    fn the_two_refusals_are_told_apart() {
684        assert!(matches!(
685            Document::from_bytes(b"a = \"\xff\"\n"),
686            Err(Error::NotUtf8(_))
687        ));
688        match Document::from_bytes(b"a = \n") {
689            Err(Error::Toml(e)) => assert!(e.to_string().contains("line 1"), "{e}"),
690            other => panic!("{other:?}"),
691        }
692    }
693}