use std::path::{Path, PathBuf};
use serde::Serialize;
use super::undo::InMemoryRollback;
use crate::error::{Error, Result};
use crate::extension;
#[derive(Debug, Clone, Serialize)]
pub struct ValidationResult {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
pub rolled_back: bool,
pub files_checked: usize,
}
impl ValidationResult {
fn skipped(files_checked: usize) -> Self {
Self {
success: true,
command: None,
output: None,
rolled_back: false,
files_checked,
}
}
fn passed(command: String, files_checked: usize) -> Self {
Self {
success: true,
command: Some(command),
output: None,
rolled_back: false,
files_checked,
}
}
fn failed(command: String, output: String, rolled_back: bool, files_checked: usize) -> Self {
Self {
success: false,
command: Some(command),
output: Some(output),
rolled_back,
files_checked,
}
}
}
pub fn validate_write(
root: &Path,
changed_files: &[PathBuf],
rollback: &InMemoryRollback,
) -> Result<ValidationResult> {
if changed_files.is_empty() {
return Ok(ValidationResult::skipped(0));
}
let validate_command = match resolve_validate_command(root, changed_files) {
Some(cmd) => cmd,
None => {
return Ok(ValidationResult::skipped(changed_files.len()));
}
};
crate::log_status!(
"validate",
"Running post-write validation: {}",
validate_command
);
let output = std::process::Command::new("sh")
.args(["-c", &validate_command])
.current_dir(root)
.output()
.map_err(|e| {
Error::internal_io(
format!("Failed to run validation command: {}", e),
Some("validate_write".to_string()),
)
})?;
if output.status.success() {
crate::log_status!("validate", "Validation passed");
return Ok(ValidationResult::passed(
validate_command,
changed_files.len(),
));
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let error_output = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else {
stderr.trim().to_string()
};
let truncated: String = error_output
.lines()
.rev()
.take(30)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
crate::log_status!(
"validate",
"Validation FAILED — rolling back {} file(s)",
rollback.len()
);
rollback.restore_all();
crate::log_status!("validate", "Rollback complete");
Ok(ValidationResult::failed(
validate_command,
truncated,
true,
changed_files.len(),
))
}
pub fn validate_only(root: &Path, changed_files: &[PathBuf]) -> Result<ValidationResult> {
if changed_files.is_empty() {
return Ok(ValidationResult::skipped(0));
}
let validate_command = match resolve_validate_command(root, changed_files) {
Some(cmd) => cmd,
None => return Ok(ValidationResult::skipped(changed_files.len())),
};
let output = std::process::Command::new("sh")
.args(["-c", &validate_command])
.current_dir(root)
.output()
.map_err(|e| {
Error::internal_io(
format!("Failed to run validation command: {}", e),
Some("validate_only".to_string()),
)
})?;
if output.status.success() {
Ok(ValidationResult::passed(
validate_command,
changed_files.len(),
))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let error_output = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else {
stderr.trim().to_string()
};
Ok(ValidationResult::failed(
validate_command,
error_output,
false,
changed_files.len(),
))
}
}
fn resolve_validate_command(root: &Path, changed_files: &[PathBuf]) -> Option<String> {
let extensions: Vec<String> = changed_files
.iter()
.filter_map(|f| {
f.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_string())
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
for ext in &extensions {
if let Some(manifest) = find_extension_with_validate(ext) {
let ext_path = manifest.extension_path.as_deref()?;
let script_rel = manifest.validate_script()?;
let script_path = std::path::Path::new(ext_path).join(script_rel);
if script_path.exists() {
return Some(format!(
"sh {}",
crate::engine::shell::quote_path(&script_path.to_string_lossy())
));
}
}
}
resolve_builtin_validate_command(root)
}
fn find_extension_with_validate(file_ext: &str) -> Option<extension::ExtensionManifest> {
extension::load_all_extensions().ok().and_then(|manifests| {
manifests
.into_iter()
.find(|m| m.handles_file_extension(file_ext) && m.validate_script().is_some())
})
}
fn resolve_builtin_validate_command(root: &Path) -> Option<String> {
if root.join("Cargo.toml").exists() {
return Some("cargo check 2>&1".to_string());
}
if root.join("tsconfig.json").exists() {
return Some("npx tsc --noEmit 2>&1".to_string());
}
if root.join("go.mod").exists() {
return Some("go vet ./... 2>&1".to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn resolve_builtin_for_rust_project() {
let dir = TempDir::new().expect("temp dir");
fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"t\"").unwrap();
let cmd = resolve_builtin_validate_command(dir.path());
assert_eq!(cmd, Some("cargo check 2>&1".to_string()));
}
#[test]
fn resolve_builtin_for_typescript_project() {
let dir = TempDir::new().expect("temp dir");
fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
let cmd = resolve_builtin_validate_command(dir.path());
assert_eq!(cmd, Some("npx tsc --noEmit 2>&1".to_string()));
}
#[test]
fn resolve_builtin_for_go_project() {
let dir = TempDir::new().expect("temp dir");
fs::write(dir.path().join("go.mod"), "module example.com/t").unwrap();
let cmd = resolve_builtin_validate_command(dir.path());
assert_eq!(cmd, Some("go vet ./... 2>&1".to_string()));
}
#[test]
fn resolve_builtin_returns_none_for_unknown() {
let dir = TempDir::new().expect("temp dir");
let cmd = resolve_builtin_validate_command(dir.path());
assert!(cmd.is_none());
}
#[test]
fn validation_result_skipped_is_success() {
let result = ValidationResult::skipped(5);
assert!(result.success);
assert!(!result.rolled_back);
assert!(result.command.is_none());
}
#[test]
fn validate_write_with_no_files_is_success() {
let dir = TempDir::new().expect("temp dir");
let rollback = InMemoryRollback::new();
let result = validate_write(dir.path(), &[], &rollback).expect("should succeed");
assert!(result.success);
assert_eq!(result.files_checked, 0);
}
#[test]
fn validate_write_rolls_back_on_failure() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"validate-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/lib.rs"), "pub fn good() {}\n").unwrap();
let mut rollback = InMemoryRollback::new();
let lib_path = root.join("src/lib.rs");
rollback.capture(&lib_path);
fs::write(&lib_path, "pub fn broken( {}\n").unwrap();
let changed = vec![lib_path.clone()];
let result = validate_write(root, &changed, &rollback).expect("should not error");
assert!(!result.success, "validation should fail for broken code");
assert!(result.rolled_back, "should have rolled back");
assert!(result.output.is_some(), "should have compiler output");
let content = fs::read_to_string(&lib_path).unwrap();
assert_eq!(content, "pub fn good() {}\n", "file should be restored");
}
}