crux_cli/
doctor.rs

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                &current_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                        &current_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                // TODO support shell having multiple cores
73                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                        &current_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
219/// Trim whitespace from end of line and ensure trailing newline
220fn ensure_trailing_newline(s: &str) -> String {
221    let mut s = s.trim_end().to_string();
222    s.push('\n');
223    s
224}
225
226/// files in second but not in first
227fn 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
237/// files in both first and second
238fn 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
248/// test if file is source code
249fn 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}