Skip to main content

fallow_config/
config_inputs.rs

1//! The files and directories that config resolution reads, other than the
2//! config file and its `extends` targets.
3//!
4//! A long-lived process, such as the language server, keeps a resolved config
5//! between runs. A snapshot of these inputs tells it when that config is out
6//! of date.
7
8use std::ffi::OsString;
9use std::path::{Path, PathBuf};
10
11use crate::{FallowConfig, external_plugin_source_files};
12
13/// The inputs that [`FallowConfig::resolve`] reads for one project root:
14/// the external plugin files, the rule pack files, and the directories that
15/// `autoDiscover` lists.
16#[derive(Clone, Debug, Default, PartialEq, Eq)]
17pub struct ConfigInputs {
18    root: PathBuf,
19    plugin_paths: Vec<String>,
20    rule_pack_paths: Vec<String>,
21    auto_discover_dirs: Vec<PathBuf>,
22}
23
24/// The content of [`ConfigInputs`] at one moment. Two snapshots that differ
25/// tell that a resolved config can be out of date.
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
27pub struct ConfigInputsSnapshot {
28    /// Each file with its content, or `None` when the file did not read.
29    files: Vec<(PathBuf, Option<Vec<u8>>)>,
30    /// Each directory with the sorted names of its child directories, or
31    /// `None` when the directory did not read.
32    dirs: Vec<(PathBuf, Option<Vec<OsString>>)>,
33}
34
35impl ConfigInputs {
36    /// The inputs of `config` for `root`. Take a [`Self::snapshot`] before
37    /// [`FallowConfig::resolve`] and one after it: when the two differ, an
38    /// input changed while resolution read it.
39    #[must_use]
40    pub fn new(root: &Path, config: &FallowConfig) -> Self {
41        Self {
42            root: root.to_path_buf(),
43            plugin_paths: config.plugins.clone(),
44            rule_pack_paths: config.rule_packs.clone(),
45            auto_discover_dirs: config.boundaries.auto_discover_dirs(root),
46        }
47    }
48
49    /// Read the current content of the inputs.
50    #[must_use]
51    pub fn snapshot(&self) -> ConfigInputsSnapshot {
52        let files = external_plugin_source_files(&self.root, &self.plugin_paths)
53            .into_iter()
54            .chain(self.rule_pack_paths.iter().map(|path| self.root.join(path)))
55            .map(|path| {
56                let content = std::fs::read(&path).ok();
57                (path, content)
58            })
59            .collect();
60        let dirs = self
61            .auto_discover_dirs
62            .iter()
63            .map(|dir| (dir.clone(), child_dir_names(dir)))
64            .collect();
65        ConfigInputsSnapshot { files, dirs }
66    }
67}
68
69fn child_dir_names(dir: &Path) -> Option<Vec<OsString>> {
70    let entries = std::fs::read_dir(dir).ok()?;
71    let mut names: Vec<OsString> = entries
72        .filter_map(Result::ok)
73        .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_dir()))
74        .map(|entry| entry.file_name())
75        .collect();
76    names.sort();
77    Some(names)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn inputs(root: &Path, config: &str) -> ConfigInputs {
85        ConfigInputs::new(root, &serde_json::from_str(config).unwrap())
86    }
87
88    #[test]
89    fn a_plugin_edit_or_a_new_plugin_file_changes_the_snapshot() {
90        let dir = tempfile::tempdir().unwrap();
91        let root = dir.path();
92        std::fs::create_dir_all(root.join(".fallow/plugins")).unwrap();
93        std::fs::write(root.join(".fallow/plugins/a.json"), r#"{"name":"a"}"#).unwrap();
94        let inputs = inputs(root, "{}");
95        let before = inputs.snapshot();
96        assert_eq!(before, inputs.snapshot());
97
98        std::fs::write(root.join(".fallow/plugins/a.json"), r#"{"name":"b"}"#).unwrap();
99        let edited = inputs.snapshot();
100        assert_ne!(before, edited, "an edit to a plugin file");
101
102        std::fs::write(root.join("fallow-plugin-c.toml"), "name = \"c\"").unwrap();
103        assert_ne!(edited, inputs.snapshot(), "a new root plugin file");
104    }
105
106    #[test]
107    fn configured_plugins_and_rule_packs_are_inputs_also_before_they_exist() {
108        let dir = tempfile::tempdir().unwrap();
109        let root = dir.path();
110        let inputs = inputs(
111            root,
112            r#"{"plugins":["tools/plugin.json"],"rulePacks":["policy.json"]}"#,
113        );
114        let before = inputs.snapshot();
115
116        std::fs::create_dir_all(root.join("tools")).unwrap();
117        std::fs::write(root.join("tools/plugin.json"), r#"{"name":"a"}"#).unwrap();
118        let with_plugin = inputs.snapshot();
119        assert_ne!(before, with_plugin, "a configured plugin file appeared");
120
121        std::fs::write(root.join("policy.json"), "{}").unwrap();
122        assert_ne!(with_plugin, inputs.snapshot(), "a rule pack appeared");
123    }
124
125    #[test]
126    fn a_new_child_of_an_auto_discover_dir_changes_the_snapshot() {
127        let dir = tempfile::tempdir().unwrap();
128        let root = dir.path();
129        std::fs::create_dir_all(root.join("src/features/auth")).unwrap();
130        let inputs = inputs(
131            root,
132            r#"{"boundaries":{"zones":[{"name":"features","patterns":[],"autoDiscover":["./src/features/"]}],"rules":[]}}"#,
133        );
134        let before = inputs.snapshot();
135
136        std::fs::write(root.join("src/features/index.ts"), "").unwrap();
137        assert_eq!(before, inputs.snapshot(), "a file is not a zone");
138        std::fs::create_dir_all(root.join("src/features/billing")).unwrap();
139        assert_ne!(before, inputs.snapshot(), "a new zone directory");
140    }
141
142    #[test]
143    fn a_preset_auto_discover_dir_is_an_input() {
144        let dir = tempfile::tempdir().unwrap();
145        let root = dir.path();
146        std::fs::create_dir_all(root.join("src/features/auth")).unwrap();
147        let inputs = inputs(root, r#"{"boundaries":{"preset":"bulletproof"}}"#);
148        let before = inputs.snapshot();
149
150        std::fs::create_dir_all(root.join("src/features/billing")).unwrap();
151        assert_ne!(before, inputs.snapshot(), "a new zone directory");
152    }
153}