testing_conventions/
location.rs1use std::collections::HashSet;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14
15const PY_EXTENSION: &str = "py";
17const TEST_STEM_SUFFIX: &str = "_test";
19const PACKAGE_MARKER: &str = "__init__.py";
21
22pub fn missing_unit_tests(root: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
29 let mut python_files = Vec::new();
30 collect_python_files(root.as_ref(), &mut python_files)?;
31
32 let present: HashSet<&Path> = python_files.iter().map(PathBuf::as_path).collect();
35
36 let mut orphans: Vec<PathBuf> = Vec::new();
37 for source in &python_files {
38 if is_test_file(source) || is_exempt(source) {
39 continue;
40 }
41 if !present.contains(expected_test_path(source).as_path()) {
42 orphans.push(source.clone());
43 }
44 }
45 orphans.sort();
46 Ok(orphans)
47}
48
49fn collect_python_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
51 let entries =
52 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
53 for entry in entries {
54 let path = entry
55 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
56 .path();
57 if path.is_dir() {
58 collect_python_files(&path, out)?;
59 } else if is_python_source(&path) {
60 out.push(path);
61 }
62 }
63 Ok(())
64}
65
66fn is_python_source(path: &Path) -> bool {
68 path.extension().and_then(|ext| ext.to_str()) == Some(PY_EXTENSION)
69}
70
71fn is_test_file(path: &Path) -> bool {
73 stem_of(path).ends_with(TEST_STEM_SUFFIX)
74}
75
76fn is_exempt(path: &Path) -> bool {
78 path.file_name().and_then(|name| name.to_str()) == Some(PACKAGE_MARKER)
79}
80
81fn expected_test_path(source: &Path) -> PathBuf {
83 source.with_file_name(format!(
84 "{}{}.{}",
85 stem_of(source),
86 TEST_STEM_SUFFIX,
87 PY_EXTENSION
88 ))
89}
90
91fn stem_of(path: &Path) -> String {
93 path.file_stem()
94 .map(|stem| stem.to_string_lossy().into_owned())
95 .unwrap_or_default()
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn recognizes_python_sources_by_extension() {
104 assert!(is_python_source(Path::new("a.py")));
105 assert!(is_python_source(Path::new("pkg/widget.py")));
106 assert!(!is_python_source(Path::new("a.pyi")));
107 assert!(!is_python_source(Path::new("a.txt")));
108 assert!(!is_python_source(Path::new("README")));
109 }
110
111 #[test]
112 fn recognizes_test_files_by_stem_suffix() {
113 assert!(is_test_file(Path::new("widget_test.py")));
114 assert!(is_test_file(Path::new("pkg/helper_test.py")));
115 assert!(!is_test_file(Path::new("widget.py")));
116 assert!(!is_test_file(Path::new("pkg/helper.py")));
117 }
118
119 #[test]
120 fn exempts_the_package_marker() {
121 assert!(is_exempt(Path::new("__init__.py")));
122 assert!(is_exempt(Path::new("pkg/__init__.py")));
123 assert!(!is_exempt(Path::new("conftest.py")));
124 assert!(!is_exempt(Path::new("widget.py")));
125 }
126
127 #[test]
128 fn expected_test_path_is_the_colocated_twin() {
129 assert_eq!(
130 expected_test_path(Path::new("pkg/widget.py")),
131 PathBuf::from("pkg/widget_test.py")
132 );
133 assert_eq!(
134 expected_test_path(Path::new("widget.py")),
135 PathBuf::from("widget_test.py")
136 );
137 }
138}