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;