use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ProjectError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Already initialized: .checkmate/ exists in {0}")]
AlreadyInitialized(PathBuf),
#[error("Not initialized: no .checkmate/ found. Run 'cm init' first.")]
NotInitialized,
#[error("Invalid base URL: {0}")]
InvalidUrl(String),
}
#[derive(Debug, Clone)]
pub struct CheckmateProject {
pub root: PathBuf,
pub checkmate_dir: PathBuf,
pub config_path: PathBuf,
pub tests_dir: PathBuf,
pub runs_dir: PathBuf,
pub hooks_dir: PathBuf,
}
impl CheckmateProject {
pub fn discover() -> Option<Self> {
let checkmate_dir = find_checkmate_dir()?;
let checkmate_dir = follow_redirect(&checkmate_dir);
Self::from_checkmate_dir(checkmate_dir)
}
fn from_checkmate_dir(checkmate_dir: PathBuf) -> Option<Self> {
let root = checkmate_dir.parent()?.to_path_buf();
Some(Self {
config_path: checkmate_dir.join("config.toml"),
tests_dir: checkmate_dir.join("tests"),
runs_dir: checkmate_dir.join("runs"),
hooks_dir: checkmate_dir.join("hooks"),
checkmate_dir,
root,
})
}
pub fn exists_at(path: &Path) -> bool {
path.join(".checkmate").is_dir()
}
pub fn init(root: &Path, base_url: &str, stealth: bool) -> Result<Self, ProjectError> {
if Self::exists_at(root) {
return Err(ProjectError::AlreadyInitialized(root.to_path_buf()));
}
let base_url = normalize_url(base_url)?;
let checkmate_dir = root.join(".checkmate");
let tests_dir = checkmate_dir.join("tests");
let runs_dir = checkmate_dir.join("runs");
let hooks_dir = checkmate_dir.join("hooks");
fs::create_dir_all(&tests_dir)?;
fs::create_dir_all(&runs_dir)?;
fs::create_dir_all(&hooks_dir)?;
let config_path = checkmate_dir.join("config.toml");
let config_content = generate_config(&base_url);
fs::write(&config_path, config_content)?;
let runs_gitignore = runs_dir.join(".gitignore");
fs::write(&runs_gitignore, "# Run history is local by default\n*.jsonl\n")?;
if stealth {
add_to_git_exclude(root, ".checkmate/")?;
}
Ok(Self {
root: root.to_path_buf(),
checkmate_dir,
config_path,
tests_dir,
runs_dir,
hooks_dir,
})
}
pub fn runs_file(&self) -> PathBuf {
self.runs_dir.join("runs.jsonl")
}
}
pub fn find_checkmate_dir() -> Option<PathBuf> {
let mut current = std::env::current_dir().ok()?;
loop {
let checkmate_dir = current.join(".checkmate");
if checkmate_dir.is_dir() {
return Some(checkmate_dir);
}
if !current.pop() {
return None;
}
}
}
pub fn follow_redirect(checkmate_dir: &Path) -> PathBuf {
let redirect_file = checkmate_dir.join("redirect");
if redirect_file.exists() {
if let Ok(target) = fs::read_to_string(&redirect_file) {
let target = target.trim();
if !target.is_empty() {
if let Some(parent) = checkmate_dir.parent() {
let resolved = parent.join(target);
if resolved.is_dir() {
return resolved;
}
}
}
}
}
checkmate_dir.to_path_buf()
}
fn normalize_url(url: &str) -> Result<String, ProjectError> {
let url = url.trim();
if url.is_empty() {
return Err(ProjectError::InvalidUrl("URL cannot be empty".to_string()));
}
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(ProjectError::InvalidUrl(format!(
"URL must start with http:// or https://, got: {}",
url
)));
}
Ok(url.trim_end_matches('/').to_string())
}
fn generate_config(base_url: &str) -> String {
format!(
r#"# Checkmate Configuration
# Documentation: cm docs
[env]
base_url = "{}"
timeout_ms = 5000
[defaults]
fail_fast = false
expect_status = 200
"#,
base_url
)
}
fn add_to_git_exclude(repo_root: &Path, pattern: &str) -> Result<(), ProjectError> {
let git_dir = repo_root.join(".git");
if !git_dir.is_dir() {
return Ok(());
}
let info_dir = git_dir.join("info");
fs::create_dir_all(&info_dir)?;
let exclude_path = info_dir.join("exclude");
if exclude_path.exists() {
let content = fs::read_to_string(&exclude_path)?;
if content.lines().any(|line| line.trim() == pattern) {
return Ok(()); }
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&exclude_path)?;
writeln!(file, "\n# Added by Checkmate (stealth mode)")?;
writeln!(file, "{}", pattern)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_normalize_url() {
assert_eq!(
normalize_url("http://localhost:8080/").unwrap(),
"http://localhost:8080"
);
assert_eq!(
normalize_url(" https://api.example.com ").unwrap(),
"https://api.example.com"
);
assert!(normalize_url("").is_err());
assert!(normalize_url("not-a-url").is_err());
}
#[test]
fn test_project_init() {
let temp_dir = env::temp_dir().join(format!("checkmate-test-{}", nanoid::nanoid!(6)));
fs::create_dir_all(&temp_dir).unwrap();
let project = CheckmateProject::init(&temp_dir, "http://localhost:8080", false).unwrap();
assert!(project.checkmate_dir.exists());
assert!(project.tests_dir.exists());
assert!(project.runs_dir.exists());
assert!(project.hooks_dir.exists());
assert!(project.config_path.exists());
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_already_initialized() {
let temp_dir = env::temp_dir().join(format!("checkmate-test-{}", nanoid::nanoid!(6)));
fs::create_dir_all(&temp_dir).unwrap();
CheckmateProject::init(&temp_dir, "http://localhost:8080", false).unwrap();
let result = CheckmateProject::init(&temp_dir, "http://localhost:8080", false);
assert!(matches!(result, Err(ProjectError::AlreadyInitialized(_))));
fs::remove_dir_all(&temp_dir).ok();
}
}