use std::fs;
use crate::check::Check;
use crate::project::Project;
use crate::types::{CheckGroup, CheckLayer, CheckResult, CheckStatus};
const WRITE_KEYWORDS: &[&str] = &[
"--write",
"--delete",
"--create",
"--update",
"--remove",
"--deploy",
"--install",
"--push",
"\"write\"",
"\"delete\"",
"\"create\"",
"\"update\"",
"\"remove\"",
"\"deploy\"",
"\"install\"",
"\"push\"",
];
const DRY_RUN_PATTERNS: &[&str] = &["dry-run", "dry_run", "dryrun"];
pub struct DryRunCheck;
impl Check for DryRunCheck {
fn id(&self) -> &str {
"p5-dry-run"
}
fn group(&self) -> CheckGroup {
CheckGroup::P5
}
fn layer(&self) -> CheckLayer {
CheckLayer::Project
}
fn applicable(&self, project: &Project) -> bool {
project.path.is_dir() && project.language.is_some()
}
fn run(&self, project: &Project) -> anyhow::Result<CheckResult> {
let parsed = project.parsed_files();
let mut has_write_commands = false;
let mut has_dry_run = false;
for (_path, parsed_file) in parsed.iter() {
let source = &parsed_file.source;
if !has_write_commands {
has_write_commands = WRITE_KEYWORDS.iter().any(|kw| source.contains(kw));
}
if !has_dry_run {
has_dry_run = DRY_RUN_PATTERNS.iter().any(|pat| source.contains(pat));
}
if has_write_commands && has_dry_run {
break;
}
}
if !has_write_commands {
if let Some(manifest) = &project.manifest_path {
if let Ok(content) = fs::read_to_string(manifest) {
has_write_commands = WRITE_KEYWORDS.iter().any(|kw| content.contains(kw));
}
}
}
let status = if !has_write_commands {
CheckStatus::Skip("No write/mutate commands detected".into())
} else if has_dry_run {
CheckStatus::Pass
} else {
CheckStatus::Warn("Write/mutate commands detected but no --dry-run flag found".into())
};
Ok(CheckResult {
id: self.id().to_string(),
label: "Dry-run flag for write operations".into(),
group: self.group(),
layer: self.layer(),
status,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::project::{Language, ParsedFile};
fn temp_dir(suffix: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"anc-dryrun-{suffix}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time after UNIX epoch")
.as_nanos(),
));
std::fs::create_dir_all(&dir).expect("create test dir");
dir
}
fn make_project(dir: &std::path::Path, files: &[(&str, &str)]) -> Project {
let src = dir.join("src");
std::fs::create_dir_all(&src).expect("create src dir");
let mut parsed = HashMap::new();
for (name, content) in files {
let path = src.join(name);
std::fs::write(&path, content).expect("write test source file");
parsed.insert(
path,
ParsedFile {
source: content.to_string(),
},
);
}
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.expect("write Cargo.toml");
Project {
path: dir.to_path_buf(),
language: Some(Language::Rust),
binary_paths: vec![],
manifest_path: Some(dir.join("Cargo.toml")),
runner: None,
include_tests: false,
parsed_files: OnceLock::from(parsed),
}
}
#[test]
fn applicable_when_language_detected() {
let dir = temp_dir("applicable");
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.expect("write Cargo.toml");
let project = Project::discover(&dir).expect("discover test project");
assert!(DryRunCheck.applicable(&project));
}
#[test]
fn not_applicable_without_language() {
let dir = temp_dir("no-lang");
let project = Project::discover(&dir).expect("discover test project");
assert!(!DryRunCheck.applicable(&project));
}
#[test]
fn skip_when_no_write_commands() {
let dir = temp_dir("no-write");
let project = make_project(
&dir,
&[(
"main.rs",
r#"
fn main() {
let args = Cli::parse();
println!("reading data");
}
"#,
)],
);
let result = DryRunCheck.run(&project).expect("run check");
assert!(matches!(result.status, CheckStatus::Skip(_)));
}
#[test]
fn pass_when_dry_run_present() {
let dir = temp_dir("pass");
let project = make_project(
&dir,
&[(
"cli.rs",
r#"
#[derive(Parser)]
struct Cli {
#[arg(long = "delete")]
delete: bool,
#[arg(long = "dry-run")]
dry_run: bool,
}
"#,
)],
);
let result = DryRunCheck.run(&project).expect("run check");
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn warn_when_write_without_dry_run() {
let dir = temp_dir("warn");
let project = make_project(
&dir,
&[(
"cli.rs",
r#"
#[derive(Parser)]
struct Cli {
#[arg(long = "delete")]
delete: bool,
#[arg(long)]
force: bool,
}
"#,
)],
);
let result = DryRunCheck.run(&project).expect("run check");
assert!(matches!(result.status, CheckStatus::Warn(_)));
if let CheckStatus::Warn(evidence) = &result.status {
assert!(evidence.contains("dry-run"));
}
}
#[test]
fn detects_write_from_multiple_keywords() {
for keyword in &[
"--deploy",
"--install",
"--push",
"\"create\"",
"\"update\"",
] {
let dir = temp_dir(&format!("kw-{}", keyword.replace('"', "")));
let source = format!("let flag = \"{keyword}\";");
let project = make_project(&dir, &[("main.rs", &source)]);
let result = DryRunCheck.run(&project).expect("run check");
assert!(
!matches!(result.status, CheckStatus::Skip(_)),
"keyword {keyword} should be detected as a write command"
);
}
}
#[test]
fn pass_with_dry_run_underscore_variant() {
let dir = temp_dir("underscore");
let project = make_project(
&dir,
&[(
"cli.rs",
r#"
struct Cli {
#[arg(long = "delete")]
delete: bool,
#[arg(long)]
dry_run: bool,
}
"#,
)],
);
let result = DryRunCheck.run(&project).expect("run check");
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn metadata_is_correct() {
let check = DryRunCheck;
assert_eq!(check.id(), "p5-dry-run");
assert_eq!(check.group(), CheckGroup::P5);
assert_eq!(check.layer(), CheckLayer::Project);
}
}