Skip to main content

asbuilt_core/
frontend.rs

1//! The front-end contract and the survey that runs every front-end
2//! that recognizes a root, merges their models, and applies the config.
3
4use std::path::Path;
5
6use crate::config::Config;
7use crate::error::{Error, Result};
8use crate::externals;
9use crate::model::Model;
10
11/// One language's reader. `asbuilt-rust` is the first.
12pub trait Frontend {
13    /// The front-end's name, which is also its `asbuilt.toml` table
14    /// (`rust` reads `[rust]`).
15    fn name(&self) -> &str;
16    /// Whether `root` is a code base this front-end reads, decided from
17    /// marker files (`Cargo.toml`, `package.json`, ...).
18    fn detect(&self, root: &Path) -> bool;
19    fn analyze(&self, root: &Path, config: &Config) -> Result<Model>;
20}
21
22/// The model of `root`: every front-end that detects it, merged, with
23/// the config's externals applied and ids checked for collisions.
24pub fn survey(root: &Path, config: &Config, frontends: &[&dyn Frontend]) -> Result<Model> {
25    let mut model = Model::default();
26    let mut detected = false;
27    for frontend in frontends {
28        if !frontend.detect(root) {
29            continue;
30        }
31        detected = true;
32        model.merge(frontend.analyze(root, config)?);
33    }
34    if !detected {
35        return Err(Error::NoFrontend {
36            root: root.to_path_buf(),
37        });
38    }
39    externals::apply(&mut model, config)?;
40    model.validate()?;
41    check_theme_kinds(&model, config)?;
42    Ok(model)
43}
44
45/// Every `[theme]` key names a kind the model has.
46fn check_theme_kinds(model: &Model, config: &Config) -> Result<()> {
47    let have: std::collections::BTreeSet<&str> =
48        model.elements.iter().map(|e| e.kind.keyword()).collect();
49    for kind in config.theme.keys() {
50        if !have.contains(kind.as_str()) {
51            return Err(Error::UnknownThemeKind {
52                kind: kind.clone(),
53                have: have.iter().copied().collect::<Vec<_>>().join(", "),
54            });
55        }
56    }
57    Ok(())
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use crate::model::{Element, ElementKind};
64    use std::path::PathBuf;
65
66    struct Fake {
67        name: &'static str,
68        detects: bool,
69        elements: Vec<&'static str>,
70        fails: bool,
71    }
72
73    impl Frontend for Fake {
74        fn name(&self) -> &str {
75            self.name
76        }
77        fn detect(&self, _: &Path) -> bool {
78            self.detects
79        }
80        fn analyze(&self, _: &Path, _: &Config) -> Result<Model> {
81            if self.fails {
82                return Err(Error::Frontend {
83                    frontend: self.name.into(),
84                    source: "boom".into(),
85                });
86            }
87            Ok(Model {
88                elements: self
89                    .elements
90                    .iter()
91                    .map(|id| Element {
92                        id: vec![id.to_string()],
93                        kind: ElementKind::Container,
94                        title: id.to_string(),
95                        description: None,
96                        technology: None,
97                        path: None,
98                        tags: vec![],
99                    })
100                    .collect(),
101                ..Default::default()
102            })
103        }
104    }
105
106    fn fake(name: &'static str, detects: bool, elements: &[&'static str]) -> Fake {
107        Fake {
108            name,
109            detects,
110            elements: elements.to_vec(),
111            fails: false,
112        }
113    }
114
115    #[test]
116    fn no_front_end_recognizing_the_root_is_an_error_naming_it() {
117        let none = fake("rust", false, &["a"]);
118        match survey(Path::new("/some/root"), &Config::default(), &[&none]) {
119            Err(Error::NoFrontend { root }) => assert_eq!(root, PathBuf::from("/some/root")),
120            other => panic!("expected NoFrontend, got {other:?}"),
121        }
122    }
123
124    #[test]
125    fn a_theme_key_naming_no_kind_in_the_model_is_an_error_naming_it_and_the_kinds_there_are() {
126        let rust = fake("rust", true, &["a"]);
127        let config: Config = "[theme]\nbrowser = \"#000000\"\n".parse().unwrap();
128        match survey(Path::new("/some/root"), &config, &[&rust]) {
129            Err(Error::UnknownThemeKind { kind, have }) => {
130                assert_eq!(kind, "browser");
131                assert_eq!(have, "container");
132            }
133            other => panic!("expected UnknownThemeKind, got {other:?}"),
134        }
135        let config: Config = "[theme]\ncontainer = \"#000000\"\n".parse().unwrap();
136        assert!(survey(Path::new("/some/root"), &config, &[&rust]).is_ok());
137    }
138
139    #[test]
140    fn every_detecting_front_end_contributes_and_the_result_is_normalized() {
141        let a = fake("a", true, &["zeta"]);
142        let b = fake("b", true, &["alpha"]);
143        let skipped = fake("c", false, &["never"]);
144        let model = survey(Path::new("."), &Config::default(), &[&a, &skipped, &b]).unwrap();
145        let ids: Vec<String> = model.elements.iter().map(|e| e.id.join(".")).collect();
146        assert_eq!(ids, ["alpha", "zeta"]);
147    }
148
149    #[test]
150    fn the_config_externals_are_applied_to_the_merged_model() {
151        let a = fake("a", true, &["app"]);
152        let config: Config = "[[externals]]\nid = \"driver\"\nkind = \"process\"\ntitle = \"D\"\n\n[[externals.relations]]\nfrom = \"app\"\ntitle = \"spawns\"\n"
153            .parse()
154            .unwrap();
155        let model = survey(Path::new("."), &config, &[&a]).unwrap();
156        assert!(model.element(&["driver".to_string()]).is_some());
157        assert_eq!(model.relations.len(), 1);
158    }
159
160    #[test]
161    fn a_front_end_error_is_returned_as_is() {
162        let broken = Fake {
163            name: "rust",
164            detects: true,
165            elements: vec![],
166            fails: true,
167        };
168        match survey(Path::new("."), &Config::default(), &[&broken]) {
169            Err(Error::Frontend { frontend, .. }) => assert_eq!(frontend, "rust"),
170            other => panic!("expected Frontend, got {other:?}"),
171        }
172    }
173
174    #[test]
175    fn an_external_whose_id_is_a_surveyed_crate_is_a_duplicate() {
176        let a = fake("a", true, &["app"]);
177        let config: Config = "[[externals]]\nid = \"app\"\nkind = \"process\"\ntitle = \"D\"\n"
178            .parse()
179            .unwrap();
180        assert!(matches!(
181            survey(Path::new("."), &config, &[&a]),
182            Err(Error::DuplicateId { .. })
183        ));
184    }
185
186    #[test]
187    fn colliding_ids_across_front_ends_are_an_error() {
188        let a = fake("a", true, &["my-app"]);
189        let b = fake("b", true, &["my_app"]);
190        assert!(matches!(
191            survey(Path::new("."), &Config::default(), &[&a, &b]),
192            Err(Error::DuplicateId { .. })
193        ));
194    }
195}