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