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 fn noun(&self, kind: &str) -> Option<&'static str> {
25 let _ = kind;
26 None
27 }
28}
29
30pub 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
53fn 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}