1use std::{
9 collections::{BTreeMap, BTreeSet},
10 fs,
11 path::{Path, PathBuf},
12};
13
14use serde_json::json;
15
16use crate::{
17 coverage_report::CoverageManifest,
18 integrity::ExplicitIntegrityInputs,
19 ruby_instrumenter::{
20 RUBY_PROBE_PLAN_VERSION, RUBY_PROBE_RECEIVER, RubyFilePlan, RubyProbePlan,
21 build_ruby_obligations,
22 },
23 source_discovery::{SourceScope, SourceScopeEntry, SourceScopeMode, SourceScopeStatus},
24};
25
26pub const UNPARSEABLE_LIMITATION: &str = "ruby-source-unparseable";
27
28const EXCLUDED_DIRECTORIES: &[&str] = &[
33 ".git",
34 ".hg",
35 ".svn",
36 ".supercov",
37 ".bundle",
38 "node_modules",
39 "vendor",
40 "tmp",
41 "log",
42 "coverage",
43 "config",
44 "db",
45 "bin",
46 "public",
47 "storage",
48 ".yardoc",
49 "doc",
50 "pkg",
51];
52
53const TEST_DIRECTORIES: &[&str] = &["spec", "test", "tests", "features"];
54
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub struct RubyFiles {
57 pub sources: Vec<String>,
59 pub tests: Vec<String>,
61 pub dependency_files: Vec<PathBuf>,
62 pub configuration_files: Vec<PathBuf>,
63 pub excluded: Vec<(String, &'static str)>,
64}
65
66#[derive(Debug, Clone, PartialEq)]
67pub struct PreparedRubyProject {
68 pub root: PathBuf,
69 pub files: RubyFiles,
70 pub manifest: CoverageManifest,
71 pub plan: RubyProbePlan,
72 pub unparseable: Vec<(String, String)>,
73}
74
75fn is_test_path(relative: &str) -> Option<&'static str> {
76 let mut components = relative.split('/').peekable();
77 let mut file_name = "";
78 while let Some(component) = components.next() {
79 if components.peek().is_none() {
80 file_name = component;
81 break;
82 }
83 if TEST_DIRECTORIES.contains(&component) {
84 return Some("inside a test directory");
85 }
86 }
87 if file_name.ends_with("_spec.rb") || file_name.ends_with("_test.rb") {
88 return Some("test module by name");
89 }
90 if file_name.starts_with("test_") && file_name.ends_with(".rb") {
91 return Some("test module by name");
92 }
93 if matches!(
94 file_name,
95 "spec_helper.rb" | "rails_helper.rb" | "test_helper.rb"
96 ) {
97 return Some("test helper");
98 }
99 None
100}
101
102fn walk(root: &Path, directory: &Path, files: &mut RubyFiles) -> Result<(), String> {
103 let mut entries = fs::read_dir(directory)
104 .map_err(|error| format!("{}: {error}", directory.display()))?
105 .collect::<Result<Vec<_>, _>>()
106 .map_err(|error| error.to_string())?;
107 entries.sort_by_key(fs::DirEntry::file_name);
108 for entry in entries {
109 let path = entry.path();
110 let name = entry
111 .file_name()
112 .into_string()
113 .map_err(|_| format!("Ruby project contains a non-UTF-8 path: {}", path.display()))?;
114 let file_type = entry.file_type().map_err(|error| error.to_string())?;
115 let relative = path
116 .strip_prefix(root)
117 .map_err(|_| format!("path escaped root: {}", path.display()))?
118 .to_string_lossy()
119 .replace('\\', "/");
120 if file_type.is_dir() {
121 if EXCLUDED_DIRECTORIES.contains(&name.as_str()) {
122 files
123 .excluded
124 .push((relative, "tooling, dependency or generated directory"));
125 continue;
126 }
127 walk(root, &path, files)?;
128 } else if file_type.is_file() {
129 match name.as_str() {
130 "Gemfile" | "Gemfile.lock" | ".ruby-version" | ".tool-versions" => {
131 files.dependency_files.push(PathBuf::from(&relative));
132 continue;
133 }
134 ".rspec" | "Rakefile" | "config.ru" => {
135 files.configuration_files.push(PathBuf::from(&relative));
136 continue;
137 }
138 _ => {}
139 }
140 if name.ends_with(".gemspec") {
141 files.dependency_files.push(PathBuf::from(&relative));
142 continue;
143 }
144 if !name.ends_with(".rb") {
145 continue;
146 }
147 match is_test_path(&relative) {
148 Some(reason) => {
149 files.tests.push(relative.clone());
150 files.excluded.push((relative, reason));
151 }
152 None => files.sources.push(relative),
153 }
154 }
155 }
157 Ok(())
158}
159
160pub fn discover_ruby_files(root: &Path) -> Result<RubyFiles, String> {
161 let mut files = RubyFiles::default();
162 walk(root, root, &mut files)?;
163 files.sources.sort();
164 files.tests.sort();
165 files.dependency_files.sort();
166 files.configuration_files.sort();
167 Ok(files)
168}
169
170pub fn prepare_ruby_project(
171 root: &Path,
172 roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
173) -> Result<PreparedRubyProject, String> {
174 let mut files = discover_ruby_files(root)?;
175 match roots {
176 Some(roots) => {
177 roots.narrow(&mut files.sources, &mut files.excluded, String::as_str);
178 roots.refuse_if_empty(files.sources.len(), "Ruby")?;
179 }
180 None => crate::source_discovery::leave_out_what_lies_beside(
181 &mut files.sources,
182 &mut files.excluded,
183 ),
184 }
185 if files.sources.is_empty() && files.tests.is_empty() {
186 return Err(
187 "no Ruby source files were found under the project root; Supercov measures .rb files outside vendor, db, bin and test directories".into(),
188 );
189 }
190 let mut manifest = CoverageManifest {
191 unmeasured: Vec::new(),
192 decisions: Vec::new(),
193 points: Vec::new(),
194 branches: Vec::new(),
195 limitations: Vec::new(),
196 scope: None,
197 assertion_sites: Vec::new(),
198 };
199 let mut plan_files = BTreeMap::<String, RubyFilePlan>::new();
200 let mut probes = BTreeMap::new();
201 let mut next_probe = 0u64;
202 let mut limitation_ids = BTreeSet::new();
203 let mut unparseable = Vec::new();
204 for relative in &files.sources {
205 let path = root.join(relative);
206 let source = fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?;
207 match build_ruby_obligations(relative, &source, &mut next_probe) {
208 Ok(obligations) => {
209 manifest.points.extend(obligations.manifest.points);
210 manifest.decisions.extend(obligations.manifest.decisions);
211 manifest.branches.extend(obligations.manifest.branches);
212 manifest.unmeasured.extend(obligations.manifest.unmeasured);
213 for item in obligations.manifest.limitations {
214 let id = item
215 .get("id")
216 .and_then(serde_json::Value::as_str)
217 .unwrap_or_default()
218 .to_owned();
219 if limitation_ids.insert(id) {
220 manifest.limitations.push(item);
221 }
222 }
223 plan_files.insert(relative.clone(), obligations.plan);
224 probes.extend(obligations.probes);
225 }
226 Err(error) => unparseable.push((relative.clone(), error.to_string())),
227 }
228 }
229 if let Some((file, reason)) = unparseable.first()
230 && limitation_ids.insert(UNPARSEABLE_LIMITATION.into())
231 {
232 manifest.limitations.push(json!({
233 "id": UNPARSEABLE_LIMITATION,
234 "kind": "source-scope",
235 "file": file,
236 "line": 1,
237 "column": 0,
238 "source": "",
239 "reason": format!(
240 "{} source file(s) could not be parsed and carry no obligations; first: {file}: {reason}",
241 unparseable.len()
242 )
243 }));
244 }
245 manifest.unmeasured.sort();
246 manifest.unmeasured.dedup();
247 let mut entries = Vec::new();
248 for file in &files.sources {
249 let unparseable_file = unparseable.iter().any(|(path, _)| path == file);
250 entries.push(SourceScopeEntry {
251 file: file.clone(),
252 status: if unparseable_file {
253 SourceScopeStatus::Excluded
254 } else {
255 SourceScopeStatus::Included
256 },
257 reason: if unparseable_file {
258 "could not be parsed".into()
259 } else {
260 "Ruby application source".into()
261 },
262 package_root: None,
263 });
264 }
265 for (file, reason) in &files.excluded {
266 if file.ends_with(".rb") {
267 entries.push(SourceScopeEntry {
268 file: file.clone(),
269 status: SourceScopeStatus::Excluded,
270 reason: (*reason).into(),
271 package_root: None,
272 });
273 }
274 }
275 entries.sort_by(|left, right| left.file.cmp(&right.file));
276 manifest.scope = Some(
277 serde_json::to_value(SourceScope {
278 version: 1,
279 mode: if roots.is_some() {
280 SourceScopeMode::Explicit
281 } else {
282 SourceScopeMode::Automatic
283 },
284 roots: match roots {
285 Some(roots) => roots.local_roots().map_err(|error| error.to_string())?,
286 None => vec![".".into()],
287 },
288 entries,
289 })
290 .map_err(|error| error.to_string())?,
291 );
292 let mut plan = RubyProbePlan {
293 version: RUBY_PROBE_PLAN_VERSION,
294 root: root.display().to_string(),
295 receiver: RUBY_PROBE_RECEIVER.into(),
296 files: plan_files,
297 probes,
298 probe_obligations: Vec::new(),
299 };
300 plan.probe_obligations = plan.probe_obligations();
301 Ok(PreparedRubyProject {
302 root: root.to_owned(),
303 plan,
304 manifest,
305 files,
306 unparseable,
307 })
308}
309
310pub fn ruby_integrity_inputs(files: &RubyFiles, command: &[String]) -> ExplicitIntegrityInputs {
311 let execution_configuration = command.join("\0").into_bytes();
312 ExplicitIntegrityInputs {
313 source_files: files.sources.iter().map(PathBuf::from).collect(),
314 test_files: files.tests.iter().map(PathBuf::from).collect(),
315 dependency_files: files.dependency_files.clone(),
316 configuration_files: files.configuration_files.clone(),
317 execution_configuration,
318 }
319}
320
321#[cfg(test)]
322mod tests {
323
324 #[test]
325 fn the_ambient_environment_is_not_part_of_run_identity() {
326 let files = RubyFiles::default();
331 let command = ["rspec".to_owned(), "--format=progress".to_owned()];
332 let inputs = ruby_integrity_inputs(&files, &command);
333 assert_eq!(inputs.execution_configuration, b"rspec\0--format=progress");
334 }
335 use std::time::{SystemTime, UNIX_EPOCH};
336
337 use super::*;
338
339 fn fixture(name: &str) -> PathBuf {
340 let nonce = SystemTime::now()
341 .duration_since(UNIX_EPOCH)
342 .unwrap()
343 .as_nanos();
344 let root = std::env::temp_dir().join(format!(
345 "supercov-ruby-project-{}-{nonce}-{name}",
346 std::process::id()
347 ));
348 fs::create_dir_all(&root).unwrap();
349 root
350 }
351
352 fn write(root: &Path, relative: &str, contents: &str) {
353 let path = root.join(relative);
354 fs::create_dir_all(path.parent().unwrap()).unwrap();
355 fs::write(path, contents).unwrap();
356 }
357
358 fn explicit_roots(root: &Path, roots: &[&str]) -> crate::source_discovery::ExplicitSourceRoots {
359 let roots = roots
360 .iter()
361 .map(|root| (*root).to_owned())
362 .collect::<Vec<_>>();
363 crate::source_discovery::ExplicitSourceRoots::resolve(root, &roots).unwrap()
364 }
365
366 #[test]
367 fn benchmarks_and_examples_beside_the_gem_are_not_measured_unless_named() {
368 let root = fixture("beside");
371 write(&root, "Gemfile", "source 'https://rubygems.org'\n");
372 write(&root, "lib/app.rb", "def f(a)\n a && 1\nend\n");
373 write(&root, "lib/app/examples.rb", "def g\n 1\nend\n");
374 write(&root, "benchmark/template_bench.rb", "require 'app'\n");
375 write(&root, "examples/usage.rb", "require 'app'\n");
376
377 let project = prepare_ruby_project(&root, None).unwrap();
378 assert_eq!(
379 project.files.sources,
380 ["lib/app.rb", "lib/app/examples.rb"],
381 "{:?}",
382 project.files
383 );
384 assert!(
385 project
386 .files
387 .excluded
388 .contains(&("benchmark/template_bench.rb".into(), "benchmark")),
389 "{:?}",
390 project.files.excluded
391 );
392 let named =
393 prepare_ruby_project(&root, Some(&explicit_roots(&root, &["examples"]))).unwrap();
394 assert_eq!(named.files.sources, ["examples/usage.rb"]);
395 fs::remove_dir_all(root).ok();
396 }
397
398 #[test]
399 fn explicit_roots_narrow_ruby_to_the_code_they_name() {
400 let root = fixture("explicit-roots");
403 write(&root, "Gemfile", "source 'https://rubygems.org'\n");
404 write(&root, "lib/app.rb", "def f(a)\n a && 1\nend\n");
405 write(&root, "third_party/dep.rb", "def g\n 1\nend\n");
406
407 let project = prepare_ruby_project(&root, Some(&explicit_roots(&root, &["lib"]))).unwrap();
408 assert_eq!(project.files.sources, ["lib/app.rb"], "{:?}", project.files);
409 assert!(
410 project
411 .files
412 .excluded
413 .contains(&("third_party/dep.rb".into(), "outside explicit source roots")),
414 "{:?}",
415 project.files.excluded
416 );
417 let scope: crate::source_discovery::SourceScope =
418 serde_json::from_value(project.manifest.scope.clone().unwrap()).unwrap();
419 assert_eq!(
420 scope.mode,
421 crate::source_discovery::SourceScopeMode::Explicit
422 );
423 assert_eq!(scope.roots, ["lib"]);
424
425 let project =
427 prepare_ruby_project(&root, Some(&explicit_roots(&root, &["third_party/dep.rb"])))
428 .unwrap();
429 assert_eq!(project.files.sources, ["third_party/dep.rb"]);
430 fs::remove_dir_all(root).ok();
431 }
432
433 #[test]
434 fn separates_sources_tests_and_tooling() {
435 let root = fixture("discover");
436 write(&root, "Gemfile", "source 'https://rubygems.org'\n");
437 write(&root, ".rspec", "--require spec_helper\n");
438 write(&root, "lib/app.rb", "def f(a)\n a && 1\nend\n");
439 write(&root, "lib/app/version.rb", "VERSION = '1'\n");
440 write(&root, "spec/app_spec.rb", "RSpec.describe 'x' do end\n");
441 write(&root, "spec/spec_helper.rb", "");
442 write(&root, "test/app_test.rb", "");
443 write(&root, "vendor/bundle/gem.rb", "x = 1\n");
444 write(&root, "db/schema.rb", "x = 1\n");
445 write(&root, "broken/old.rb", "def (\n");
446 let project = prepare_ruby_project(&root, None).unwrap();
447 assert_eq!(
448 project.files.sources,
449 ["broken/old.rb", "lib/app.rb", "lib/app/version.rb"]
450 );
451 assert_eq!(
452 project.files.tests,
453 [
454 "spec/app_spec.rb",
455 "spec/spec_helper.rb",
456 "test/app_test.rb"
457 ]
458 );
459 assert_eq!(project.files.dependency_files, [PathBuf::from("Gemfile")]);
460 assert_eq!(project.files.configuration_files, [PathBuf::from(".rspec")]);
461 assert_eq!(project.unparseable.len(), 1);
462 assert!(project.plan.files.contains_key("lib/app.rb"));
463 assert!(!project.plan.files.contains_key("broken/old.rb"));
464 assert_eq!(project.manifest.limitations.len(), 1);
465 assert_eq!(
466 project.manifest.limitations[0]["id"],
467 UNPARSEABLE_LIMITATION
468 );
469 let keys = project.plan.probes.keys().copied().collect::<Vec<_>>();
471 let unique = keys.iter().collect::<BTreeSet<_>>();
472 assert_eq!(keys.len(), unique.len());
473 fs::remove_dir_all(root).unwrap();
474 }
475}