1use std::{
2 collections::BTreeMap,
3 env, fs,
4 path::{Path, PathBuf},
5};
6
7use anyhow::{bail, Result};
8use ignore::Walk;
9use ramhorns::Template;
10
11use crate::{
12 diff,
13 template::{Context, CoreContext, ShellContext},
14 workspace,
15};
16
17const SOURCE_CODE_EXTENSIONS: [&str; 9] =
18 ["rs", "kt", "swift", "ts", "js", "tsx", "jsx", "html", "css"];
19
20type FileMap = BTreeMap<PathBuf, String>;
21
22pub fn doctor(
23 template_dir: &Path,
24 path: Option<&Path>,
25 verbosity: u8,
26 include_source_code: bool,
27) -> Result<()> {
28 let workspace = workspace::read_config()?;
29 let current_dir = &env::current_dir()?;
30 let template_root = current_dir.join(template_dir).canonicalize()?;
31
32 for core in workspace.cores.values() {
33 let (do_core, do_typegen) = match path {
34 Some(path) => (path == core.source, Some(path) == core.type_gen.as_deref()),
35 None => (true, true),
36 };
37
38 if do_core {
39 compare(
40 ¤t_dir.join(&core.source),
41 &template_root.join("shared"),
42 &Context::Core(CoreContext::new(&workspace, core)),
43 verbosity,
44 include_source_code,
45 )?;
46 }
47
48 if do_typegen {
49 if let Some(type_gen) = &core.type_gen {
50 let templates_typegen = template_root.join("shared_types");
51 if templates_typegen.exists() {
52 compare(
53 ¤t_dir.join(type_gen),
54 &templates_typegen,
55 &Context::Core(CoreContext::new(&workspace, core)),
56 verbosity,
57 include_source_code,
58 )?;
59 }
60 }
61 }
62 }
63
64 if let Some(shells) = &workspace.shells {
65 for (name, shell) in shells {
66 let do_shell = match path {
67 Some(path) => path == shell.source,
68 None => true,
69 };
70
71 if do_shell {
72 if shell.cores.len() > 1 {
74 eprintln!("Warning: shell {name} has multiple cores, only checking first",);
75 }
76 let core = workspace
77 .cores
78 .get(&shell.cores[0])
79 .expect("core not in workspace");
80 let template_root =
81 template_root.join(shell.template.as_deref().unwrap_or(Path::new(&name)));
82 if template_root.exists() {
83 compare(
84 ¤t_dir.join(&shell.source),
85 &template_root,
86 &Context::Shell(ShellContext::new(&workspace, core, shell)),
87 verbosity,
88 include_source_code,
89 )?;
90 }
91 }
92 }
93 }
94
95 workspace::write_config(&workspace)
96}
97
98fn compare(
99 root: &Path,
100 template_root: &Path,
101 context: &Context,
102 verbosity: u8,
103 include_source_code: bool,
104) -> Result<(), anyhow::Error> {
105 println!(
106 "{:-<80}\nActual: {}\nDesired: {}",
107 "",
108 root.display(),
109 template_root.display()
110 );
111 let (actual, desired) =
112 &read_files(root, template_root, context, verbosity, include_source_code)?;
113 missing(actual, desired);
114 common(actual, desired);
115 Ok(())
116}
117
118fn read_files(
119 root: &Path,
120 template_root: &Path,
121 context: &Context,
122 verbosity: u8,
123 include_source_code: bool,
124) -> Result<(FileMap, FileMap)> {
125 validate_path(root)?;
126 validate_path(template_root)?;
127
128 let mut actual = FileMap::new();
129 for entry in Walk::new(root).filter_map(Result::ok) {
130 if entry.file_type().expect("should have a file type").is_dir() {
131 continue;
132 }
133 let path = entry.path();
134 if !include_source_code && is_source_code(path) {
135 continue;
136 }
137 let path_display = path.display();
138 if verbosity > 0 {
139 println!("Reading: {path_display}");
140 }
141
142 match fs::read_to_string(path) {
143 Ok(contents) => {
144 let relative = path.strip_prefix(root)?.to_path_buf();
145 actual.insert(relative, ensure_trailing_newline(&contents));
146 }
147 Err(e) => match e.kind() {
148 std::io::ErrorKind::InvalidData => {
149 if verbosity > 0 {
150 println!("Warning, cannot read: {path_display}, {e}");
151 }
152 }
153 _ => bail!("Error reading: {path_display}, {e}"),
154 },
155 };
156 }
157
158 let mut desired = FileMap::new();
159 for entry in Walk::new(template_root).filter_map(Result::ok) {
160 if entry.file_type().expect("should have a file type").is_dir() {
161 continue;
162 }
163 let path = entry.path();
164 if !include_source_code && is_source_code(path) {
165 continue;
166 }
167 let path_display = path.display();
168 if verbosity > 0 {
169 println!("Reading: {path_display}");
170 }
171
172 let template = fs::read_to_string(path)?;
173 let template = Template::new(template).unwrap();
174
175 let rendered = match context {
176 Context::Core(context) => template.render(context),
177 Context::Shell(context) => template.render(context),
178 };
179 let rendered = ensure_trailing_newline(&rendered);
180
181 let relative = path.strip_prefix(template_root)?.to_path_buf();
182 desired.insert(relative, rendered);
183 }
184
185 Ok((actual, desired))
186}
187
188fn validate_path(path: &Path) -> Result<()> {
189 if !path.exists() {
190 bail!("{} does not exist", path.display());
191 }
192 if !path.is_absolute() {
193 bail!("{} is not an absolute path", path.display());
194 }
195 Ok(())
196}
197
198fn missing(actual: &FileMap, desired: &FileMap) {
199 let missing = difference(actual, desired);
200 if missing.is_empty() {
201 println!("No missing files");
202 } else {
203 println!("Missing files:");
204 for file_name in missing {
205 println!(" {}", file_name.to_string_lossy());
206 }
207 println!();
208 }
209}
210
211fn common(actual: &FileMap, desired: &FileMap) {
212 for file_name in &intersection(actual, desired) {
213 let desired = desired.get(file_name).expect("file not in map");
214 let actual = actual.get(file_name).expect("file not in map");
215 diff::show(file_name, desired, actual);
216 }
217}
218
219fn ensure_trailing_newline(s: &str) -> String {
221 let mut s = s.trim_end().to_string();
222 s.push('\n');
223 s
224}
225
226fn difference(first: &FileMap, second: &FileMap) -> Vec<PathBuf> {
228 let mut missing = Vec::new();
229 for k in second.keys() {
230 if !first.contains_key(k) {
231 missing.push(k.clone());
232 }
233 }
234 missing
235}
236
237fn intersection(first: &FileMap, second: &FileMap) -> Vec<PathBuf> {
239 let mut common = Vec::new();
240 for k in first.keys() {
241 if second.contains_key(k) {
242 common.push(k.clone());
243 }
244 }
245 common
246}
247
248fn is_source_code(path: &Path) -> bool {
250 if let Some(ext) = path.extension() {
251 if let Some(ext) = ext.to_str() {
252 return SOURCE_CODE_EXTENSIONS.contains(&ext);
253 }
254 }
255 false
256}
257
258#[cfg(test)]
259mod test {
260 use super::*;
261
262 #[test]
263 fn test_ensure_trailing_newline() {
264 assert_eq!(ensure_trailing_newline("hello\n"), "hello\n");
265 assert_eq!(ensure_trailing_newline("hello\n \t"), "hello\n");
266 assert_eq!(ensure_trailing_newline("hello\n\n "), "hello\n");
267 }
268
269 #[test]
270 fn test_find_missing_files() {
271 let mut actual_map = FileMap::new();
272 actual_map.insert(PathBuf::from("foo"), "foo".to_string());
273
274 let mut desired_map = FileMap::new();
275 desired_map.insert(PathBuf::from("foo"), "foo".to_string());
276 desired_map.insert(PathBuf::from("bar"), "bar".to_string());
277
278 let expected = vec![PathBuf::from("bar")];
279 let actual = difference(&actual_map, &desired_map);
280 assert_eq!(expected, actual);
281 }
282
283 #[test]
284 fn test_find_common_files() {
285 let mut actual_map = FileMap::new();
286 actual_map.insert(PathBuf::from("foo"), "foo".to_string());
287
288 let mut desired_map = FileMap::new();
289 desired_map.insert(PathBuf::from("foo"), "foo".to_string());
290 desired_map.insert(PathBuf::from("bar"), "bar".to_string());
291
292 let expected = vec![PathBuf::from("foo")];
293 let actual = intersection(&actual_map, &desired_map);
294 assert_eq!(expected, actual);
295 }
296
297 #[test]
298 fn test_is_source_code() {
299 assert!(is_source_code(Path::new("foo.rs")));
300 assert!(is_source_code(Path::new("foo.kt")));
301 assert!(is_source_code(Path::new("foo.swift")));
302 assert!(is_source_code(Path::new("foo.ts")));
303 assert!(is_source_code(Path::new("foo.js")));
304 assert!(is_source_code(Path::new("foo.tsx")));
305 assert!(is_source_code(Path::new("foo.jsx")));
306 assert!(is_source_code(Path::new("foo.html")));
307 assert!(is_source_code(Path::new("foo.css")));
308
309 assert!(!is_source_code(Path::new("foo.txt")));
310 assert!(!is_source_code(Path::new("foo")));
311 }
312}