Skip to main content

qframe/document/
mod.rs

1//! An application's own data file: a TOML document with a declared shape, which can hold arrays
2//! of tables and is never repaired behind the application's back.
3//!
4//! ```toml
5//! id = "api"
6//! name = "Payments API"
7//! created = "2026-09-18"
8//!
9//! [[profile]]
10//! name = "review"
11//! added = "2026-09-18"
12//!
13//! [[profile]]
14//! name = "nightly"
15//! ```
16//!
17//! This is not [`Settings`](crate::storage::Settings). Settings are the user's preferences: they
18//! fall back to defaults, they self-heal, and a value they cannot store is dropped. A document is
19//! the application's own file — a project, a profile, a record, a list of sources. It has no
20//! defaults, it holds tables and arrays of tables, and **nothing is ever written back**: reading
21//! a broken document reports it, and no `.bak` file is left behind either. Saving stays the
22//! application's own step, with [`atomic_write`](crate::storage::atomic_write).
23//!
24//! A [`Shape`] declares what the document holds. Reading gives a [`Document`]: the part that
25//! could be read, and a located [`Diagnostic`] for everything that could not.
26//!
27//! - A key of the declared type is read.
28//! - A key the shape does not declare is a warning and is skipped.
29//! - A value of another type is skipped: an error when the key is required, a warning when it is
30//!   optional.
31//! - A required key that is not there at all is an error, reported where its table starts.
32//! - A syntax error is an error, and the rest of the file is still read.
33//!
34//! Reading gives the valid part of a broken document rather than nothing, for the same reason
35//! every other loader in the framework does: a file the user wrote by hand is usually wrong in
36//! one place, and an application that can still name a project with one unreadable profile is
37//! more use than one that opens nothing. The application decides what to do: the diagnostics say
38//! what is wrong, and a required key that is missing reads as `None`.
39//!
40//! ```
41//! use qframe::document::{Document, Shape, ValueKind};
42//!
43//! let profile = Shape::new().required("name", ValueKind::text()).optional("added", ValueKind::text());
44//! let shape = Shape::new()
45//!     .required("id", ValueKind::text())
46//!     .optional("name", ValueKind::text())
47//!     .entries("profile", profile);
48//!
49//! let text = "id = \"api\"\nname = \"Payments API\"\n\n[[profile]]\nname = \"review\"\n\n[[profile]]\n";
50//! let document = Document::parse("project.qcode", text, &shape);
51//! assert_eq!(document.root().text("id"), Some("api"));
52//! let names: Vec<&str> = document.root().entries("profile").iter().filter_map(|e| e.text("name")).collect();
53//! assert_eq!(names, vec!["review"]);
54//! assert_eq!(
55//!     document.diagnostics()[0].to_string(),
56//!     "project.qcode:7:1: error: `profile[1].name` is required and missing"
57//! );
58//! ```
59
60mod read;
61mod shape;
62
63pub use shape::{Shape, ValueKind};
64
65use std::fs;
66use std::io;
67use std::path::Path;
68
69use crate::diagnostics::{Diagnostic, Location};
70use crate::doc::Doc;
71
72/// A value a document holds, in the type its [`Shape`] declared.
73#[derive(Debug, Clone, PartialEq, Eq)]
74enum Stored {
75    Text(String),
76    Integer(i64),
77    Flag(bool),
78}
79
80/// One table of a document, read against its [`Shape`]: the values it holds, the tables inside
81/// it and its arrays of tables.
82///
83/// Every reader answers `None` (or no entries) for a key that was missing or unreadable, so a
84/// broken document is read exactly as far as it is readable.
85#[derive(Debug, Clone, Default, PartialEq, Eq)]
86pub struct Table {
87    /// Each value in file order, with the place its key and the value itself were written.
88    values: Vec<(String, Stored, Location, Location)>,
89    tables: Vec<(String, Table)>,
90    arrays: Vec<(String, Vec<Table>)>,
91}
92
93impl Table {
94    /// The text under `key`, declared with [`ValueKind::text`] or [`ValueKind::choice`].
95    #[must_use]
96    pub fn text(&self, key: &str) -> Option<&str> {
97        match self.stored(key) {
98            Some(Stored::Text(text)) => Some(text),
99            _ => None,
100        }
101    }
102
103    /// The number under `key`, declared with [`ValueKind::integer`].
104    #[must_use]
105    pub fn integer(&self, key: &str) -> Option<i64> {
106        match self.stored(key) {
107            Some(Stored::Integer(number)) => Some(*number),
108            _ => None,
109        }
110    }
111
112    /// The `true` or `false` under `key`, declared with [`ValueKind::flag`].
113    #[must_use]
114    pub fn flag(&self, key: &str) -> Option<bool> {
115        match self.stored(key) {
116            Some(Stored::Flag(flag)) => Some(*flag),
117            _ => None,
118        }
119    }
120
121    /// The table `key` holds, declared with [`Shape::table`].
122    #[must_use]
123    pub fn table(&self, key: &str) -> Option<&Table> {
124        self.tables.iter().find(|(name, _)| name == key).map(|(_, table)| table)
125    }
126
127    /// The entries `key` holds, declared with [`Shape::entries`]; empty when the document lists
128    /// none. The order is the file's own.
129    #[must_use]
130    pub fn entries(&self, key: &str) -> &[Table] {
131        self.arrays.iter().find(|(name, _)| name == key).map_or(&[], |(_, entries)| entries.as_slice())
132    }
133
134    /// Where `key` was written, so an application can report a problem only it can see — a name
135    /// no file system accepts, a date the calendar does not have — at the place the user wrote.
136    #[must_use]
137    pub fn location(&self, key: &str) -> Option<&Location> {
138        self.values.iter().find(|(name, _, _, _)| name == key).map(|(_, _, at, _)| at)
139    }
140
141    /// Where the value of `key` was written: `8` in `mode = "halb"` is the column of `"halb"`,
142    /// not of `mode`. This is the place the document's own diagnostics point at when a value
143    /// has the wrong type or is not one of its choices, so an application that reports its own
144    /// findings here lands on the same column. `None` when the key was missing or unreadable.
145    #[must_use]
146    pub fn value_location(&self, key: &str) -> Option<&Location> {
147        self.values.iter().find(|(name, _, _, _)| name == key).map(|(_, _, _, at)| at)
148    }
149
150    fn stored(&self, key: &str) -> Option<&Stored> {
151        self.values.iter().find(|(name, _, _, _)| name == key).map(|(_, value, _, _)| value)
152    }
153}
154
155/// A document read against a [`Shape`]: the part that could be read, and a diagnostic for
156/// everything that could not.
157///
158/// Reading never writes, never repairs and never panics. See the [module
159/// documentation](self) for what each kind of problem becomes.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct Document {
162    root: Table,
163    diagnostics: Vec<Diagnostic>,
164}
165
166impl Document {
167    /// Reads TOML `text` as the document `shape` describes, reporting problems against `file`.
168    #[must_use]
169    pub fn parse(file: &str, text: &str, shape: &Shape) -> Self {
170        let doc = Doc::new(file, text);
171        let (root, mut diagnostics) = doc.parse_recoverable();
172        // A key missing from the whole document is reported at its first character.
173        let start = doc.locate(&(0..0));
174        let root = read::table(&doc, &root, shape, "", &start, &mut diagnostics);
175        Self { root, diagnostics }
176    }
177
178    /// Reads the document at `path`, reporting problems against the file's name.
179    ///
180    /// # Errors
181    ///
182    /// Returns the I/O error when the file cannot be read. A file that is not there is one of
183    /// those errors, not an empty document: a data file the application has not written yet and
184    /// one it wrote empty mean different things, and only the application knows which it expects.
185    pub fn open(path: impl AsRef<Path>, shape: &Shape) -> io::Result<Self> {
186        let path = path.as_ref();
187        let text = fs::read_to_string(path)?;
188        let name = path.file_name().and_then(|name| name.to_str());
189        Ok(match name {
190            Some(name) => Self::parse(name, &text, shape),
191            None => Self::parse(&path.display().to_string(), &text, shape),
192        })
193    }
194
195    /// The document's root table.
196    #[must_use]
197    pub fn root(&self) -> &Table {
198        &self.root
199    }
200
201    /// Every problem found while reading, in the order they were found.
202    #[must_use]
203    pub fn diagnostics(&self) -> &[Diagnostic] {
204        &self.diagnostics
205    }
206
207    /// Whether nothing at all was wrong with the document.
208    #[must_use]
209    pub fn is_clean(&self) -> bool {
210        self.diagnostics.is_empty()
211    }
212}
213
214#[cfg(test)]
215mod tests;