Skip to main content

rig_harness/
settings.rs

1//! The user's settings for plugins: `RIG_HOME/settings.toml`, one table
2//! per plugin. A plugin names its table and a type that reads it, and gets
3//! that type as a resource, its `Default` where the user set nothing. A
4//! table that does not read as the type leaves the default and says why
5//! at startup, as does a file that is not TOML.
6//!
7//! A plugin may let a project override its table
8//! ([`add_project_setting`](AppSettingsExt::add_project_setting)): the same
9//! table of the project's `.rig/settings.toml`, merged key by key over the
10//! user's when the app starts. Only tables declared that way are read from
11//! the project, so a cloned repository cannot change any other setting.
12//!
13//! ```
14//! use rig_harness::prelude::*;
15//! use serde::Deserialize;
16//!
17//! /// `[confirm]` in `settings.toml`.
18//! #[derive(Resource, Deserialize, Default)]
19//! #[serde(default, deny_unknown_fields)]
20//! struct Confirm {
21//!     patterns: Vec<String>,
22//! }
23//!
24//! let mut app = App::new();
25//! // A test's settings; the agent reads the user's file.
26//! app.insert_resource(Settings::from_toml("[confirm]\npatterns = [\"rm -rf\"]"));
27//! app.add_setting::<Confirm>("confirm");
28//! assert_eq!(app.world().resource::<Confirm>().patterns, ["rm -rf"]);
29//! ```
30
31use std::io::ErrorKind;
32use std::path::{Path, PathBuf};
33
34use bevy_app::prelude::*;
35use bevy_ecs::prelude::*;
36use bevy_log::warn;
37use serde::de::DeserializeOwned;
38
39use crate::services::{ProjectRoot, RigHome};
40use crate::workspace::ProjectWorkspace;
41
42/// A settings file's tables, and what was wrong with it. Each problem
43/// names the file.
44#[derive(Resource, Clone, Debug)]
45pub struct Settings {
46    file: String,
47    tables: toml::Table,
48    problems: Vec<String>,
49}
50
51impl Default for Settings {
52    /// No settings, as from an empty `settings.toml`.
53    fn default() -> Self {
54        Self::empty("settings.toml".to_owned())
55    }
56}
57
58impl Settings {
59    /// The settings `text` of `settings.toml` holds; a text that is not
60    /// TOML holds none and says why.
61    pub fn from_toml(text: &str) -> Self {
62        Self::parse("settings.toml".to_owned(), text)
63    }
64
65    /// The settings in the file at `path`; none when it does not exist.
66    /// Its problems start with `path`.
67    pub fn read(path: &Path) -> Self {
68        let file = path.display().to_string();
69        match std::fs::read_to_string(path) {
70            Ok(text) => Self::parse(file, &text),
71            Err(failure) if failure.kind() == ErrorKind::NotFound => Self::empty(file),
72            Err(failure) => {
73                let problem = format!("{file}: could not read: {failure}");
74                Self {
75                    problems: vec![problem],
76                    ..Self::empty(file)
77                }
78            }
79        }
80    }
81
82    /// No settings, from `file`.
83    fn empty(file: String) -> Self {
84        Self {
85            file,
86            tables: toml::Table::new(),
87            problems: Vec::new(),
88        }
89    }
90
91    /// The settings `text` of `file` holds.
92    fn parse(file: String, text: &str) -> Self {
93        match text.parse::<toml::Table>() {
94            Ok(tables) => Self {
95                tables,
96                ..Self::empty(file)
97            },
98            Err(failure) => {
99                let problem = format!("{file}: not TOML: {failure}");
100                Self {
101                    problems: vec![problem],
102                    ..Self::empty(file)
103                }
104            }
105        }
106    }
107
108    /// The `[section]` table as a `T`: `T::default()` without one, and the
109    /// reason when it does not read as `T`.
110    pub fn get<T: DeserializeOwned + Default>(&self, section: &str) -> Result<T, String> {
111        let Some(table) = self.tables.get(section) else {
112            return Ok(T::default());
113        };
114        table
115            .clone()
116            .try_into()
117            .map_err(|failure| format!("{} [{section}]: {failure}", self.file))
118    }
119
120    /// What was wrong with the file or with a table a plugin read, to show
121    /// the user.
122    pub fn problems(&self) -> &[String] {
123        &self.problems
124    }
125}
126
127/// Reading settings while the app is built.
128pub trait AppSettingsExt {
129    /// Inserts `T` from the `[section]` table of the [`Settings`], read
130    /// from `RIG_HOME/settings.toml` the first time (unless the app
131    /// inserted some, as a test does). A table that does not read as `T`
132    /// inserts `T::default()` and is one of [`Settings::problems`].
133    fn add_setting<T: Resource + DeserializeOwned + Default>(&mut self, section: &str)
134    -> &mut Self;
135
136    /// As [`add_setting`](Self::add_setting), then, when the app starts
137    /// ([`PreStartup`]), the same table of the project's
138    /// `.rig/settings.toml`, read through the [`ProjectWorkspace`] under the
139    /// [`ProjectRoot`], merged key by key over the user's: so read `T` in
140    /// systems, not while building. A project file that is not TOML, or a
141    /// merged table that does not read as `T`, keeps the user's value and
142    /// is one of [`Settings::problems`].
143    ///
144    /// ```
145    /// use rig_harness::prelude::*;
146    /// use serde::Deserialize;
147    ///
148    /// /// `[large-change]`, which a project may tighten.
149    /// #[derive(Resource, Deserialize, Default)]
150    /// #[serde(default, deny_unknown_fields)]
151    /// struct Limits {
152    ///     lines: usize,
153    /// }
154    ///
155    /// let mut app = App::new();
156    /// app.insert_resource(Settings::from_toml("[large-change]\nlines = 400"));
157    /// app.add_project_setting::<Limits>("large-change");
158    /// assert_eq!(app.world().resource::<Limits>().lines, 400);
159    /// ```
160    fn add_project_setting<T: Resource + DeserializeOwned + Default>(
161        &mut self,
162        section: &str,
163    ) -> &mut Self;
164}
165
166impl AppSettingsExt for App {
167    fn add_setting<T: Resource + DeserializeOwned + Default>(
168        &mut self,
169        section: &str,
170    ) -> &mut Self {
171        let world = self.world_mut();
172        if !world.contains_resource::<Settings>() {
173            let settings = Settings::read(&RigHome::of(world).settings());
174            world.insert_resource(settings);
175        }
176        let mut settings = world.resource_mut::<Settings>();
177        let value = settings.get::<T>(section).unwrap_or_else(|problem| {
178            warn!("{problem}");
179            settings.problems.push(problem);
180            T::default()
181        });
182        self.insert_resource(value)
183    }
184
185    fn add_project_setting<T: Resource + DeserializeOwned + Default>(
186        &mut self,
187        section: &str,
188    ) -> &mut Self {
189        self.add_setting::<T>(section);
190        if !self.world().contains_resource::<ProjectSettings>() {
191            self.init_resource::<ProjectSettings>()
192                .add_systems(PreStartup, read_project_settings);
193        }
194        self.world_mut()
195            .resource_mut::<ProjectSettings>()
196            .0
197            .push((section.to_owned(), insert_merged::<T>));
198        self
199    }
200}
201
202/// The tables a project may override, each with how it inserts its value.
203#[derive(Resource, Default)]
204struct ProjectSettings(Vec<(String, Insert)>);
205
206/// Inserts a setting from its merged table, or says why it cannot.
207type Insert = fn(&mut World, toml::Value) -> Result<(), String>;
208
209/// Inserts `T` read from `table`.
210fn insert_merged<T: Resource + DeserializeOwned + Default>(
211    world: &mut World,
212    table: toml::Value,
213) -> Result<(), String> {
214    let value = table
215        .try_into::<T>()
216        .map_err(|failure| failure.to_string())?;
217    world.insert_resource(value);
218    Ok(())
219}
220
221/// Reads the project's `.rig/settings.toml` and inserts each table a
222/// plugin let it override, merged over the user's.
223fn read_project_settings(world: &mut World) {
224    let root = world
225        .get_resource::<ProjectRoot>()
226        .map_or_else(|| ProjectRoot::default().0, |root| root.0.clone());
227    let workspace = world
228        .get_resource::<ProjectWorkspace>()
229        .cloned()
230        .unwrap_or_default();
231    let path: PathBuf = root.join(".rig").join("settings.toml");
232    let file = path.display().to_string();
233    let project = match workspace.0.read(&path, rig_tools::MAX_FILE_BYTES) {
234        Ok(contents) => Settings::parse(file, &String::from_utf8_lossy(&contents.bytes)),
235        Err(failure) if failure.kind() == ErrorKind::NotFound => return,
236        Err(failure) => Settings {
237            problems: vec![format!("{file}: could not read: {failure}")],
238            ..Settings::empty(file)
239        },
240    };
241    let declared = world
242        .get_resource::<ProjectSettings>()
243        .map(|it| it.0.clone())
244        .unwrap_or_default();
245    let mut problems = project.problems.clone();
246    for (section, insert) in declared {
247        let Some(over) = project.tables.get(&section) else {
248            continue;
249        };
250        let user = world
251            .get_resource::<Settings>()
252            .and_then(|settings| settings.tables.get(&section).cloned());
253        let merged = match user {
254            Some(user) => merge(user, over.clone()),
255            None => over.clone(),
256        };
257        if let Err(failure) = insert(world, merged) {
258            problems.push(format!("{} [{section}]: {failure}", project.file));
259        }
260    }
261    for problem in &problems {
262        warn!("{problem}");
263    }
264    if let Some(mut settings) = world.get_resource_mut::<Settings>() {
265        settings.problems.extend(problems);
266    }
267}
268
269/// `over` merged key by key onto `base`: a table's keys one by one, a
270/// table inside it the same way, anything else replaced.
271fn merge(base: toml::Value, over: toml::Value) -> toml::Value {
272    match (base, over) {
273        (toml::Value::Table(mut base), toml::Value::Table(over)) => {
274            for (key, value) in over {
275                let merged = match base.remove(&key) {
276                    Some(was) => merge(was, value),
277                    None => value,
278                };
279                base.insert(key, merged);
280            }
281            toml::Value::Table(base)
282        }
283        (_, over) => over,
284    }
285}
286
287#[cfg(test)]
288mod tests;