use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
use serde_json::json;
use crate::{
coverage_report::CoverageManifest,
integrity::ExplicitIntegrityInputs,
ruby_instrumenter::{
RUBY_PROBE_PLAN_VERSION, RUBY_PROBE_RECEIVER, RubyFilePlan, RubyProbePlan,
build_ruby_obligations,
},
source_discovery::{SourceScope, SourceScopeEntry, SourceScopeMode, SourceScopeStatus},
};
pub const UNPARSEABLE_LIMITATION: &str = "ruby-source-unparseable";
const EXCLUDED_DIRECTORIES: &[&str] = &[
".git",
".hg",
".svn",
".supercov",
".bundle",
"node_modules",
"vendor",
"tmp",
"log",
"coverage",
"config",
"db",
"bin",
"public",
"storage",
".yardoc",
"doc",
"pkg",
];
const TEST_DIRECTORIES: &[&str] = &["spec", "test", "tests", "features"];
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RubyFiles {
pub sources: Vec<String>,
pub tests: Vec<String>,
pub dependency_files: Vec<PathBuf>,
pub configuration_files: Vec<PathBuf>,
pub excluded: Vec<(String, &'static str)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedRubyProject {
pub root: PathBuf,
pub files: RubyFiles,
pub manifest: CoverageManifest,
pub plan: RubyProbePlan,
pub unparseable: Vec<(String, String)>,
}
fn is_test_path(relative: &str) -> Option<&'static str> {
let mut components = relative.split('/').peekable();
let mut file_name = "";
while let Some(component) = components.next() {
if components.peek().is_none() {
file_name = component;
break;
}
if TEST_DIRECTORIES.contains(&component) {
return Some("inside a test directory");
}
}
if file_name.ends_with("_spec.rb") || file_name.ends_with("_test.rb") {
return Some("test module by name");
}
if file_name.starts_with("test_") && file_name.ends_with(".rb") {
return Some("test module by name");
}
if matches!(
file_name,
"spec_helper.rb" | "rails_helper.rb" | "test_helper.rb"
) {
return Some("test helper");
}
None
}
fn walk(root: &Path, directory: &Path, files: &mut RubyFiles) -> Result<(), String> {
let mut entries = fs::read_dir(directory)
.map_err(|error| format!("{}: {error}", directory.display()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| error.to_string())?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
let name = entry
.file_name()
.into_string()
.map_err(|_| format!("Ruby project contains a non-UTF-8 path: {}", path.display()))?;
let file_type = entry.file_type().map_err(|error| error.to_string())?;
let relative = path
.strip_prefix(root)
.map_err(|_| format!("path escaped root: {}", path.display()))?
.to_string_lossy()
.replace('\\', "/");
if file_type.is_dir() {
if EXCLUDED_DIRECTORIES.contains(&name.as_str()) {
files
.excluded
.push((relative, "tooling, dependency or generated directory"));
continue;
}
walk(root, &path, files)?;
} else if file_type.is_file() {
match name.as_str() {
"Gemfile" | "Gemfile.lock" | ".ruby-version" | ".tool-versions" => {
files.dependency_files.push(PathBuf::from(&relative));
continue;
}
".rspec" | "Rakefile" | "config.ru" => {
files.configuration_files.push(PathBuf::from(&relative));
continue;
}
_ => {}
}
if name.ends_with(".gemspec") {
files.dependency_files.push(PathBuf::from(&relative));
continue;
}
if !name.ends_with(".rb") {
continue;
}
match is_test_path(&relative) {
Some(reason) => {
files.tests.push(relative.clone());
files.excluded.push((relative, reason));
}
None => files.sources.push(relative),
}
}
}
Ok(())
}
pub fn discover_ruby_files(root: &Path) -> Result<RubyFiles, String> {
let mut files = RubyFiles::default();
walk(root, root, &mut files)?;
files.sources.sort();
files.tests.sort();
files.dependency_files.sort();
files.configuration_files.sort();
Ok(files)
}
pub fn prepare_ruby_project(root: &Path) -> Result<PreparedRubyProject, String> {
let files = discover_ruby_files(root)?;
if files.sources.is_empty() && files.tests.is_empty() {
return Err(
"no Ruby source files were found under the project root; Supercov measures .rb files outside vendor, db, bin and test directories".into(),
);
}
let mut manifest = CoverageManifest {
unmeasured: Vec::new(),
decisions: Vec::new(),
points: Vec::new(),
branches: Vec::new(),
limitations: Vec::new(),
scope: None,
};
let mut plan_files = BTreeMap::<String, RubyFilePlan>::new();
let mut probes = BTreeMap::new();
let mut next_probe = 0u64;
let mut limitation_ids = BTreeSet::new();
let mut unparseable = Vec::new();
for relative in &files.sources {
let path = root.join(relative);
let source = fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?;
match build_ruby_obligations(relative, &source, &mut next_probe) {
Ok(obligations) => {
manifest.points.extend(obligations.manifest.points);
manifest.decisions.extend(obligations.manifest.decisions);
manifest.branches.extend(obligations.manifest.branches);
manifest.unmeasured.extend(obligations.manifest.unmeasured);
for item in obligations.manifest.limitations {
let id = item
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned();
if limitation_ids.insert(id) {
manifest.limitations.push(item);
}
}
plan_files.insert(relative.clone(), obligations.plan);
probes.extend(obligations.probes);
}
Err(error) => unparseable.push((relative.clone(), error.to_string())),
}
}
if let Some((file, reason)) = unparseable.first()
&& limitation_ids.insert(UNPARSEABLE_LIMITATION.into())
{
manifest.limitations.push(json!({
"id": UNPARSEABLE_LIMITATION,
"kind": "source-scope",
"file": file,
"line": 1,
"column": 0,
"source": "",
"reason": format!(
"{} source file(s) could not be parsed and carry no obligations; first: {file}: {reason}",
unparseable.len()
)
}));
}
manifest.unmeasured.sort();
manifest.unmeasured.dedup();
let mut entries = Vec::new();
for file in &files.sources {
let unparseable_file = unparseable.iter().any(|(path, _)| path == file);
entries.push(SourceScopeEntry {
file: file.clone(),
status: if unparseable_file {
SourceScopeStatus::Excluded
} else {
SourceScopeStatus::Included
},
reason: if unparseable_file {
"could not be parsed".into()
} else {
"Ruby application source".into()
},
package_root: None,
});
}
for (file, reason) in &files.excluded {
if file.ends_with(".rb") {
entries.push(SourceScopeEntry {
file: file.clone(),
status: SourceScopeStatus::Excluded,
reason: (*reason).into(),
package_root: None,
});
}
}
entries.sort_by(|left, right| left.file.cmp(&right.file));
manifest.scope = Some(
serde_json::to_value(SourceScope {
version: 1,
mode: SourceScopeMode::Automatic,
roots: vec![".".into()],
entries,
})
.map_err(|error| error.to_string())?,
);
let mut plan = RubyProbePlan {
version: RUBY_PROBE_PLAN_VERSION,
root: root.display().to_string(),
receiver: RUBY_PROBE_RECEIVER.into(),
files: plan_files,
probes,
probe_obligations: Vec::new(),
};
plan.probe_obligations = plan.probe_obligations();
Ok(PreparedRubyProject {
root: root.to_owned(),
plan,
manifest,
files,
unparseable,
})
}
pub fn ruby_integrity_inputs(files: &RubyFiles, command: &[String]) -> ExplicitIntegrityInputs {
let execution_configuration = command.join("\0").into_bytes();
ExplicitIntegrityInputs {
source_files: files.sources.iter().map(PathBuf::from).collect(),
test_files: files.tests.iter().map(PathBuf::from).collect(),
dependency_files: files.dependency_files.clone(),
configuration_files: files.configuration_files.clone(),
execution_configuration,
}
}
#[cfg(test)]
mod tests {
#[test]
fn the_ambient_environment_is_not_part_of_run_identity() {
let files = RubyFiles::default();
let command = ["rspec".to_owned(), "--format=progress".to_owned()];
let inputs = ruby_integrity_inputs(&files, &command);
assert_eq!(inputs.execution_configuration, b"rspec\0--format=progress");
}
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
fn fixture(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"supercov-ruby-project-{}-{nonce}-{name}",
std::process::id()
));
fs::create_dir_all(&root).unwrap();
root
}
fn write(root: &Path, relative: &str, contents: &str) {
let path = root.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
#[test]
fn separates_sources_tests_and_tooling() {
let root = fixture("discover");
write(&root, "Gemfile", "source 'https://rubygems.org'\n");
write(&root, ".rspec", "--require spec_helper\n");
write(&root, "lib/app.rb", "def f(a)\n a && 1\nend\n");
write(&root, "lib/app/version.rb", "VERSION = '1'\n");
write(&root, "spec/app_spec.rb", "RSpec.describe 'x' do end\n");
write(&root, "spec/spec_helper.rb", "");
write(&root, "test/app_test.rb", "");
write(&root, "vendor/bundle/gem.rb", "x = 1\n");
write(&root, "db/schema.rb", "x = 1\n");
write(&root, "broken/old.rb", "def (\n");
let project = prepare_ruby_project(&root).unwrap();
assert_eq!(
project.files.sources,
["broken/old.rb", "lib/app.rb", "lib/app/version.rb"]
);
assert_eq!(
project.files.tests,
[
"spec/app_spec.rb",
"spec/spec_helper.rb",
"test/app_test.rb"
]
);
assert_eq!(project.files.dependency_files, [PathBuf::from("Gemfile")]);
assert_eq!(project.files.configuration_files, [PathBuf::from(".rspec")]);
assert_eq!(project.unparseable.len(), 1);
assert!(project.plan.files.contains_key("lib/app.rb"));
assert!(!project.plan.files.contains_key("broken/old.rb"));
assert_eq!(project.manifest.limitations.len(), 1);
assert_eq!(
project.manifest.limitations[0]["id"],
UNPARSEABLE_LIMITATION
);
let keys = project.plan.probes.keys().copied().collect::<Vec<_>>();
let unique = keys.iter().collect::<BTreeSet<_>>();
assert_eq!(keys.len(), unique.len());
fs::remove_dir_all(root).unwrap();
}
}