1use std::path::Path;
5
6use crate::config::Config;
7use crate::error::{Error, Result};
8use crate::externals;
9use crate::model::Model;
10
11pub trait Frontend {
13 fn name(&self) -> &str;
16 fn detect(&self, root: &Path) -> bool;
19 fn analyze(&self, root: &Path, config: &Config) -> Result<Model>;
20}
21
22pub 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
45fn 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}