use std::collections::{HashMap, hash_map::Entry};
use std::fs::{File, OpenOptions, create_dir, read_to_string, remove_dir_all, rename, write};
use std::io::{BufWriter, Write};
use std::path::Path;
use std::thread::sleep;
use std::time::Duration;
use camino::{Utf8Path, Utf8PathBuf};
use fs2::FileExt;
use jiff::Timestamp;
use path_slash::PathExt;
use serde::Serialize;
use tracing::{info, trace};
use crate::outcome::{LabOutcome, SummaryOutcome};
use crate::{Context, Mutant, Result, Scenario, ScenarioOutcome, check_interrupted};
const OUTDIR_NAME: &str = "mutants.out";
const ROTATED_NAME: &str = "mutants.out.old";
const LOCK_JSON: &str = "lock.json";
const LOCK_POLL: Duration = Duration::from_millis(100);
static CAUGHT_TXT: &str = "caught.txt";
static PREVIOUSLY_CAUGHT_TXT: &str = "previously_caught.txt";
static UNVIABLE_TXT: &str = "unviable.txt";
#[derive(Debug, Serialize)]
struct LockFile {
cargo_mutants_version: String,
start_time: Timestamp,
hostname: String,
username: String,
}
impl LockFile {
fn new() -> LockFile {
LockFile {
cargo_mutants_version: crate::VERSION.to_string(),
start_time: Timestamp::now(),
hostname: whoami::fallible::hostname().unwrap_or_default(),
username: whoami::username(),
}
}
pub fn acquire_lock(output_dir: &Path) -> Result<File> {
let lock_path = output_dir.join(LOCK_JSON);
let mut lock_file = File::options()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.context("open or create lock.json in existing directory")?;
let mut first = true;
while let Err(err) = lock_file.try_lock_exclusive() {
if first {
info!(
"Waiting for lock on {} ...: {err}",
lock_path.to_slash_lossy()
);
first = false;
}
check_interrupted()?;
sleep(LOCK_POLL);
}
lock_file.set_len(0)?;
lock_file
.write_all(serde_json::to_string_pretty(&LockFile::new())?.as_bytes())
.context("write lock.json")?;
Ok(lock_file)
}
}
#[derive(Debug)]
#[allow(clippy::module_name_repetitions)]
pub struct OutputDir {
path: Utf8PathBuf,
#[allow(unused)] lock_file: File,
missed_list: File,
caught_list: File,
timeout_list: File,
unviable_list: File,
pub lab_outcome: LabOutcome,
used_log_names: HashMap<String, usize>,
}
impl OutputDir {
pub fn new(in_dir: &Utf8Path) -> Result<OutputDir> {
if !in_dir.exists() {
create_dir(in_dir)
.with_context(|| format!("create output parent directory {in_dir:?}"))?;
}
let output_dir = in_dir.join(OUTDIR_NAME);
if output_dir.exists() {
LockFile::acquire_lock(output_dir.as_ref())?;
let rotated = in_dir.join(ROTATED_NAME);
if rotated.exists() {
remove_dir_all(&rotated).with_context(|| format!("remove {:?}", &rotated))?;
}
rename(&output_dir, &rotated)
.with_context(|| format!("move {:?} to {:?}", &output_dir, &rotated))?;
}
create_dir(&output_dir)
.with_context(|| format!("create output directory {:?}", &output_dir))?;
let lock_file = LockFile::acquire_lock(output_dir.as_std_path())
.context("create lock.json lock file")?;
let log_dir = output_dir.join("log");
create_dir(&log_dir).with_context(|| format!("create log directory {:?}", &log_dir))?;
let diff_dir = output_dir.join("diff");
create_dir(diff_dir).context("create diff dir")?;
let mut list_file_options = OpenOptions::new();
list_file_options.create(true).append(true);
let missed_list = list_file_options
.open(output_dir.join("missed.txt"))
.context("create missed.txt")?;
let caught_list = list_file_options
.open(output_dir.join(CAUGHT_TXT))
.context("create caught.txt")?;
let unviable_list = list_file_options
.open(output_dir.join(UNVIABLE_TXT))
.context("create unviable.txt")?;
let timeout_list = list_file_options
.open(output_dir.join("timeout.txt"))
.context("create timeout.txt")?;
Ok(OutputDir {
path: output_dir,
lab_outcome: LabOutcome::new(Timestamp::now()),
lock_file,
missed_list,
caught_list,
timeout_list,
unviable_list,
used_log_names: HashMap::new(),
})
}
pub fn start_scenario(&mut self, scenario: &Scenario) -> Result<ScenarioOutput> {
let scenario_name = match scenario {
Scenario::Baseline => "baseline".into(),
Scenario::Mutant(mutant) => mutant.log_file_name_base(),
};
let basename = match self.used_log_names.entry(scenario_name.clone()) {
Entry::Occupied(mut e) => {
let index = e.get_mut();
*index += 1;
format!("{scenario_name}_{index:03}")
}
Entry::Vacant(e) => {
e.insert(0);
scenario_name
}
};
ScenarioOutput::new(&self.path, scenario, &basename)
}
#[allow(unused)]
pub fn path(&self) -> &Utf8Path {
&self.path
}
fn write_lab_outcome(&self) -> Result<()> {
serde_json::to_writer_pretty(
BufWriter::new(File::create(self.path.join("outcomes.json"))?),
&self.lab_outcome,
)
.context("write outcomes.json")
}
pub fn add_scenario_outcome(&mut self, scenario_outcome: &ScenarioOutcome) -> Result<()> {
self.lab_outcome.add(scenario_outcome.to_owned());
self.write_lab_outcome()?;
let scenario = &scenario_outcome.scenario;
if let Scenario::Mutant(mutant) = scenario {
let file = match scenario_outcome.summary() {
SummaryOutcome::MissedMutant => &mut self.missed_list,
SummaryOutcome::CaughtMutant => &mut self.caught_list,
SummaryOutcome::Timeout => &mut self.timeout_list,
SummaryOutcome::Unviable => &mut self.unviable_list,
_ => return Ok(()),
};
writeln!(file, "{}", mutant.name(true)).context("write to list file")?;
}
Ok(())
}
pub fn open_debug_log(&self) -> Result<File> {
let debug_log_path = self.path.join("debug.log");
OpenOptions::new()
.create(true)
.append(true)
.open(&debug_log_path)
.with_context(|| format!("open {debug_log_path}"))
}
pub fn write_mutants_list(&self, mutants: &[Mutant]) -> Result<()> {
write(
self.path.join("mutants.json"),
crate::list::mutants_to_json_string(mutants),
)
.context("write mutants.json")
}
pub fn finish(mut self) -> Result<LabOutcome> {
self.lab_outcome.end_time = Some(Timestamp::now());
self.write_lab_outcome()?;
Ok(self.lab_outcome)
}
pub fn write_previously_caught(&self, caught: &[String]) -> Result<()> {
let path = self.path.join(PREVIOUSLY_CAUGHT_TXT);
let mut b = String::with_capacity(caught.iter().map(|l| l.len() + 1).sum());
for l in caught {
b.push_str(l);
b.push('\n');
}
File::options()
.create_new(true)
.write(true)
.open(&path)
.and_then(|mut f| f.write_all(b.as_bytes()))
.with_context(|| format!("Write {path:?}"))
}
}
pub fn load_previously_caught(output_parent_dir: &Utf8Path) -> Result<Vec<String>> {
let mut r = Vec::new();
for filename in [CAUGHT_TXT, UNVIABLE_TXT, PREVIOUSLY_CAUGHT_TXT] {
let p = output_parent_dir.join(OUTDIR_NAME).join(filename);
trace!(?p, "read previously caught");
if p.is_file() {
r.extend(
read_to_string(&p)
.with_context(|| format!("Read previously caught mutants from {p:?}"))?
.lines()
.map(str::to_string),
);
}
}
Ok(r)
}
#[allow(clippy::module_name_repetitions)]
pub struct ScenarioOutput {
pub output_dir: Utf8PathBuf,
log_path: Utf8PathBuf,
pub log_file: File,
pub diff_path: Option<Utf8PathBuf>,
}
impl ScenarioOutput {
fn new(output_dir: &Utf8Path, scenario: &Scenario, basename: &str) -> Result<Self> {
let log_path = Utf8PathBuf::from(format!("log/{basename}.log"));
let log_file = File::options()
.append(true)
.create_new(true)
.read(true)
.open(output_dir.join(&log_path))?;
let diff_path = if scenario.is_mutant() {
Some(Utf8PathBuf::from(format!("diff/{basename}.diff")))
} else {
None
};
let mut scenario_output = Self {
output_dir: output_dir.to_owned(),
log_path,
log_file,
diff_path,
};
scenario_output.message(&scenario.to_string())?;
Ok(scenario_output)
}
pub fn log_path(&self) -> &Utf8Path {
&self.log_path
}
pub fn write_diff(&mut self, diff: &str) -> Result<()> {
self.message(&format!("mutation diff:\n{diff}"))?;
let diff_path = self.diff_path.as_ref().expect("should know the diff path");
write(self.output_dir.join(diff_path), diff.as_bytes())
.with_context(|| format!("write diff to {diff_path}"))
}
pub fn open_log_read(&self) -> Result<File> {
let path = self.output_dir.join(&self.log_path);
OpenOptions::new()
.read(true)
.open(&path)
.with_context(|| format!("reopen {path} for read"))
}
pub fn open_log_append(&self) -> Result<File> {
let path = self.output_dir.join(&self.log_path);
OpenOptions::new()
.append(true)
.open(&path)
.with_context(|| format!("reopen {path} for append"))
}
pub fn message(&mut self, message: &str) -> Result<()> {
write!(self.log_file, "\n*** {message}\n").context("write message to log")
}
}
pub fn clean_filename(s: &str) -> String {
s.replace('/', "__")
.chars()
.map(|c| match c {
'\\' | ' ' | ':' | '<' | '>' | '?' | '*' | '|' | '"' => '_',
c => c,
})
.collect::<String>()
}
#[cfg(test)]
mod test {
use std::fs::write;
use indoc::indoc;
use itertools::Itertools;
use pretty_assertions::assert_eq;
use tempfile::{TempDir, tempdir};
use super::*;
use crate::workspace::Workspace;
fn minimal_source_tree() -> TempDir {
let tmp = tempdir().unwrap();
let path = tmp.path();
write(
path.join("Cargo.toml"),
indoc! { br#"
# enough for a test
[package]
name = "cargo-mutants-minimal-test-tree"
version = "0.0.0"
"#
},
)
.unwrap();
create_dir(path.join("src")).unwrap();
write(path.join("src/lib.rs"), b"fn foo() {}").unwrap();
tmp
}
fn list_recursive(path: &Path) -> Vec<String> {
walkdir::WalkDir::new(path)
.sort_by_file_name()
.into_iter()
.map(|entry| {
entry
.unwrap()
.path()
.strip_prefix(path)
.unwrap()
.to_slash_lossy()
.to_string()
})
.collect_vec()
}
#[test]
fn clean_filename_removes_special_characters() {
assert_eq!(
clean_filename("1/2\\3:4<5>6?7*8|9\"0"),
"1__2_3_4_5_6_7_8_9_0"
);
}
#[test]
fn create_output_dir() {
let tmp = minimal_source_tree();
let tmp_path: &Utf8Path = tmp.path().try_into().unwrap();
let workspace = Workspace::open(tmp_path).unwrap();
let output_dir = OutputDir::new(workspace.root()).unwrap();
assert_eq!(
list_recursive(tmp.path()),
&[
"",
"Cargo.toml",
"mutants.out",
"mutants.out/caught.txt",
"mutants.out/diff",
"mutants.out/lock.json",
"mutants.out/log",
"mutants.out/missed.txt",
"mutants.out/timeout.txt",
"mutants.out/unviable.txt",
"src",
"src/lib.rs",
]
);
assert_eq!(output_dir.path(), workspace.root().join("mutants.out"));
assert!(output_dir.path().join("lock.json").is_file());
}
#[test]
fn rotate() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8Path::from_path(temp_dir.path()).unwrap();
let mut output_dir = OutputDir::new(temp_dir_path).unwrap();
let scenario_output = output_dir.start_scenario(&Scenario::Baseline).unwrap();
assert!(temp_dir_path.join("mutants.out/log/baseline.log").is_file());
drop(output_dir); drop(scenario_output);
let mut output_dir = OutputDir::new(temp_dir_path).unwrap();
output_dir.start_scenario(&Scenario::Baseline).unwrap();
assert!(
temp_dir
.path()
.join("mutants.out.old/log/baseline.log")
.is_file()
);
assert!(
temp_dir
.path()
.join("mutants.out/log/baseline.log")
.is_file()
);
drop(output_dir);
let mut output_dir = OutputDir::new(temp_dir_path).unwrap();
output_dir.start_scenario(&Scenario::Baseline).unwrap();
assert!(
temp_dir
.path()
.join("mutants.out/log/baseline.log")
.is_file()
);
assert!(
temp_dir
.path()
.join("mutants.out.old/log/baseline.log")
.is_file()
);
assert!(
temp_dir
.path()
.join("mutants.out.old/log/baseline.log")
.is_file()
);
}
#[test]
fn track_previously_caught() {
let temp_dir = TempDir::new().unwrap();
let parent = Utf8Path::from_path(temp_dir.path()).unwrap();
let example = "src/process.rs:213:9: replace ProcessStatus::is_success -> bool with true
src/process.rs:248:5: replace get_command_output -> Result<String> with Ok(String::new())
";
assert!(
load_previously_caught(parent)
.expect("load succeeds")
.is_empty()
);
let output_dir = OutputDir::new(parent).unwrap();
assert!(
load_previously_caught(parent)
.expect("load succeeds")
.is_empty()
);
write(parent.join("mutants.out/caught.txt"), example.as_bytes()).unwrap();
let previously_caught = load_previously_caught(parent).expect("load succeeds");
assert_eq!(
previously_caught.iter().collect_vec(),
example.lines().collect_vec()
);
drop(output_dir);
let output_dir = OutputDir::new(parent).unwrap();
output_dir
.write_previously_caught(&previously_caught)
.unwrap();
assert_eq!(
read_to_string(parent.join("mutants.out/caught.txt")).expect("read caught.txt"),
""
);
assert!(parent.join("mutants.out/previously_caught.txt").is_file());
let now = load_previously_caught(parent).expect("load succeeds");
assert_eq!(now.iter().collect_vec(), example.lines().collect_vec());
}
}