Skip to main content

care_script/
lib.rs

1use anyhow::{bail, Result};
2use log::debug;
3use thiserror::Error;
4
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug)]
9#[cfg_attr(test, derive(Default))]
10pub struct Script {
11    pub shadow_dir: PathBuf,
12    pub ignores: Vec<String>,
13    pub effectors: Effectors,
14    pub paths: PathContentMap,
15}
16
17pub type Effectors = BTreeMap<String, Vec<String>>;
18pub type PathContentMap = BTreeMap<String, String>;
19
20impl Script {
21    pub fn parse_ncl_file(ncl_path: &Path) -> Result<Self> {
22        let mut toml = parse_ncl::from_file(&ncl_path)?;
23        let ncl_parent = if let Some(p) = ncl_path.parent() {
24            p.to_owned()
25        } else {
26            PathBuf::from(".")
27        };
28        Self::parse_toml(&mut toml, &ncl_parent)
29    }
30
31    fn parse_toml(toml: &mut toml::Table, base_dir: &Path) -> Result<Self> {
32        // println!("PARSED: {toml:?}");
33
34        // Extract `shadow_dir` from toml
35        // TODO[LATER]: use serde instead to extract, maybe
36        let raw_shadow_dir = if let Some(dir) = toml.remove("shadow_dir") {
37            let toml::Value::String(dir) = dir else {
38                bail!("Expected 'shadow_dir' to be text, got: {dir:?}");
39            };
40            dir
41        } else {
42            ".".to_string()
43        };
44        // If raw_shadow_dir is absolute, join will ignore base_dir.
45        let shadow_dir = base_dir.join(&raw_shadow_dir);
46        debug!("SHAD: {shadow_dir:?} (from {raw_shadow_dir:?})");
47
48        let mut ignores = Vec::<String>::new();
49        if let Some(raw_ignores) = toml.remove("ignores") {
50            let toml::Value::Array(raw_ignores) = raw_ignores else {
51                bail!("Expected 'ignores' to be array, got: {raw_ignores:?}");
52            };
53            for (i, v) in raw_ignores.into_iter().enumerate() {
54                let toml::Value::String(s) = v else {
55                    bail!("Unexpected type of ignores[{i}], want String, got: {v:?}");
56                };
57                ignores.push(s);
58            }
59        }
60
61        // Extract `effectors` from toml
62        // TODO[LATER]: use serde instead to extract, maybe
63        let Some(raw_effectors) = toml.remove("effectors") else {
64            bail!("Missing 'effectors' in stdin");
65        };
66        let toml::Value::Table(raw_effectors) = raw_effectors else {
67            bail!("Expected 'effectors' to be table, got: {raw_effectors:?}");
68        };
69
70        // Extract `tree` from toml
71        // TODO[LATER]: use serde instead to extract, maybe
72        let Some(raw_tree) = toml.remove("tree") else {
73            bail!("Missing 'tree' in stdin");
74        };
75        let toml::Value::Table(raw_tree) = raw_tree else {
76            bail!("Expected 'tree' to be table, got: {raw_tree:?}");
77        };
78
79        // Convert effectors to a simple map
80        // TODO[LATER]: preserve original order
81        let mut effectors = Effectors::new();
82        for (k, v) in raw_effectors {
83            let toml::Value::String(s) = v else {
84                bail!("Unexpected type of effector {k:?}, want String, got: {v:?}");
85            };
86            effectors.insert(k, s.split_whitespace().map(str::to_string).collect());
87        }
88        debug!("HANDL: {effectors:?}");
89
90        // Convert tree to paths map
91        let mut paths = PathContentMap::new();
92        let mut todo = vec![(String::new(), raw_tree)];
93        loop {
94            let Some((parent, subtree)) = todo.pop() else {
95                break;
96            };
97            for (key, value) in subtree {
98                let path = parent.clone() + &key;
99                match value {
100                    toml::Value::String(s) => {
101                        paths.insert(path, s);
102                    }
103                    toml::Value::Table(t) => {
104                        todo.push((path + "/", t));
105                    }
106                    _ => {
107                        bail!("Unexpected type of value at {path:?} in tree: {value}");
108                    }
109                }
110            }
111        }
112        // for (k, v) in &paths {
113        //     let n = v.len();
114        //     println!(" * {k:?} = {n}");
115        // }
116
117        Ok(Script {
118            shadow_dir: shadow_dir.into(),
119            ignores,
120            effectors,
121            paths,
122        })
123    }
124
125    pub fn validate(&self) -> ValidationResult {
126        use ValidationError::*;
127        // TODO: instead, canonicalize path & detect diff, to also find `/../` etc.
128        // TODO: also, find dupes in paths, incl. case-insensitively
129        fn path_error_of(p: &String) -> Option<ValidationError> {
130            if p.ends_with("/") {
131                return Some(TrailingSlashInPath(p.clone()));
132            } else if p.contains("//") {
133                return Some(DoubleSlashInPath(p.clone()));
134            } else if p.contains("/../") {
135                return Some(DoubleDotInPath(p.clone()));
136            }
137            None
138        }
139        if let Some(err) = self.paths.keys().flat_map(path_error_of).next() {
140            return Err(err);
141        }
142        Ok(())
143    }
144
145    pub fn ignores_path(&self, path: &str) -> bool {
146        let first_segment_of_path = path.split('/').next().unwrap();
147        self.ignores.iter().any(|ign| ign == first_segment_of_path)
148    }
149}
150
151#[derive(Error, Debug)]
152#[cfg_attr(test, derive(PartialEq))]
153pub enum ValidationError {
154    #[error("path `{0}` contains double slash `//`")]
155    DoubleSlashInPath(String),
156    #[error("path `{0}` ends with a slash `/`")]
157    TrailingSlashInPath(String),
158    #[error("path `{0}` contains double dot `/../`")]
159    DoubleDotInPath(String),
160}
161
162type ValidationResult = std::result::Result<(), ValidationError>;
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn s(string: &str) -> String {
169        string.to_string()
170    }
171
172    #[test]
173    fn validate_problems_in_paths() {
174        // validate a Script built with given paths
175        fn vsp<'a>(paths: impl IntoIterator<Item = &'a str>) -> ValidationResult {
176            Script {
177                paths: paths
178                    .into_iter()
179                    .map(|s| (s.to_string(), "".to_string()))
180                    .collect(),
181                ..<_>::default()
182            }
183            .validate()
184        }
185
186        use ValidationError::*;
187        assert_eq!(vsp(["a/../b"]), Err(DoubleDotInPath(s("a/../b"))));
188        assert_eq!(vsp(["foo//bar"]), Err(DoubleSlashInPath(s("foo//bar"))));
189        assert_eq!(
190            vsp(["foo/bar//baz"]),
191            Err(DoubleSlashInPath(s("foo/bar//baz")))
192        );
193        assert_eq!(
194            vsp(["ok_a/ok_b", "foo/bar//baz"]),
195            Err(DoubleSlashInPath(s("foo/bar//baz")))
196        );
197        assert_eq!(
198            vsp(["ok_a/ok_b", "foo/bar/"]),
199            Err(TrailingSlashInPath(s("foo/bar/")))
200        );
201        assert_eq!(
202            vsp(["ok_a/ok_b", "foo/"]),
203            Err(TrailingSlashInPath(s("foo/")))
204        );
205    }
206}