use crate::core::{ForgeGuardError, ProjectConfig};
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
pub struct Doctor {
config: ProjectConfig,
verbose: bool,
issues: Vec<DoctorIssue>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorIssue {
pub category: String,
pub severity: String,
pub message: String,
pub recommendation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorReport {
pub healthy: bool,
pub issues: Vec<DoctorIssue>,
pub foundry_version: Option<String>,
pub solidity_version: Option<String>,
pub chain: String,
pub project_path: String,
}
impl Doctor {
pub fn new(config: &ProjectConfig, verbose: bool) -> Result<Self, ForgeGuardError> {
Ok(Self {
config: config.clone(),
verbose,
issues: Vec::new(),
})
}
pub fn check_foundry_version(&mut self) -> Result<String, ForgeGuardError> {
let output = std::process::Command::new("forge")
.arg("--version")
.output()
.map_err(|_| ForgeGuardError::Command("forge not found. Install Foundry: https://book.getfoundry.sh/getting-started/installation".into()))?;
if !output.status.success() {
return Err(ForgeGuardError::Command("forge not found. Install Foundry: https://book.getfoundry.sh/getting-started/installation".into()));
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(version)
}
pub fn check_solidity_version(&mut self) -> Result<String, ForgeGuardError> {
let output = std::process::Command::new("forge")
.args(["config", "--json"])
.output()
.map_err(|_| ForgeGuardError::Command("forge not found".into()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(config) = serde_json::from_str::<serde_json::Value>(&stdout) {
if let Some(solc) = config.get("solc").and_then(|s| s.as_str()) {
return Ok(solc.to_string());
}
}
Ok("0.8.20+ (default)".into())
}
pub fn check_project_structure(&mut self) -> Result<Vec<String>, ForgeGuardError> {
let mut issues = Vec::new();
let root = &self.config.project_root;
if !root.join("foundry.toml").exists() && !root.join("foundry.toml").exists() {
issues.push("No foundry.toml found — run `forge init` to create one".into());
}
let src_dirs = &self.config.src_dirs;
for dir in src_dirs {
let dir_path = if dir.is_absolute() {
dir.clone()
} else {
root.join(dir)
};
if !dir_path.exists() {
issues.push(format!(
"Source directory not found: {}",
dir_path.display()
));
}
}
Ok(issues)
}
pub fn check_dependencies(&mut self) -> Result<Vec<String>, ForgeGuardError> {
let mut issues = Vec::new();
let root = &self.config.project_root;
let foundry_toml = root.join("foundry.toml");
let remappings = root.join("remappings.txt");
if foundry_toml.exists() {
let content = std::fs::read_to_string(&foundry_toml)?;
if content.contains("openzeppelin-contracts@4.9") {
issues
.push("OpenZeppelin 4.9.x has known vulnerabilities — upgrade to 5.0+".into());
}
}
if !remappings.exists() {
issues.push(
"No remappings.txt found — consider adding one for dependency management".into(),
);
}
Ok(issues)
}
pub fn check_compiler_settings(&mut self) -> Result<Vec<String>, ForgeGuardError> {
let mut issues = Vec::new();
let root = &self.config.project_root;
let foundry_toml = root.join("foundry.toml");
if foundry_toml.exists() {
let content = std::fs::read_to_string(&foundry_toml)?;
if !content.contains("optimizer") {
issues.push(
"Compiler optimizer not configured — consider enabling for production".into(),
);
}
}
Ok(issues)
}
pub fn check_rpc_connectivity(&mut self, chain: &str) -> Result<(), ForgeGuardError> {
let rpc_url = std::env::var("ETH_RPC_URL")
.unwrap_or_else(|_| format!("https://{}.llamarpc.com", chain));
let output = std::process::Command::new("curl")
.args(["-s", "-o", "/dev/null", "-w", "%{http_code}", &rpc_url])
.output()
.map_err(|_| ForgeGuardError::Rpc("curl not available for RPC check".into()))?;
let status = String::from_utf8_lossy(&output.stdout);
match status.trim() {
"200" | "401" | "403" => Ok(()), _ => Err(ForgeGuardError::Rpc(format!(
"RPC endpoint unreachable: {} (HTTP {})",
rpc_url, status
))),
}
}
pub fn check_security_config(&mut self) -> Result<Vec<String>, ForgeGuardError> {
let mut issues = Vec::new();
if self.config.strict {
issues.push("Strict mode enabled — deployment will fail on any finding".into());
}
Ok(issues)
}
pub fn generate_report(&self, healthy: bool) -> DoctorReport {
DoctorReport {
healthy,
issues: self.issues.clone(),
foundry_version: None,
solidity_version: None,
chain: self.config.chain.clone(),
project_path: self.config.project_root.to_string_lossy().to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ProjectConfig;
#[test]
fn test_doctor_creation() {
let config = ProjectConfig::default();
let doctor = Doctor::new(&config, false).unwrap();
assert!(!doctor.verbose);
assert!(doctor.issues.is_empty());
}
#[test]
fn test_doctor_verbose() {
let config = ProjectConfig::default();
let doctor = Doctor::new(&config, true).unwrap();
assert!(doctor.verbose);
}
#[test]
fn test_check_project_structure_no_foundry_toml() {
let config = ProjectConfig::default();
let mut doctor = Doctor::new(&config, false).unwrap();
let issues = doctor.check_project_structure().unwrap();
assert!(issues.is_empty() || issues.iter().any(|i| i.contains("foundry.toml")));
}
#[test]
fn test_generate_report() {
let config = ProjectConfig::default();
let doctor = Doctor::new(&config, false).unwrap();
let report = doctor.generate_report(true);
assert!(report.healthy);
assert_eq!(report.chain, "ethereum");
}
#[test]
fn test_generate_report_unhealthy() {
let config = ProjectConfig::default();
let doctor = Doctor::new(&config, false).unwrap();
let report = doctor.generate_report(false);
assert!(!report.healthy);
}
#[test]
fn test_doctor_issue_serialization() {
let issue = DoctorIssue {
category: "test".into(),
severity: "high".into(),
message: "Test issue".into(),
recommendation: Some("Fix it".into()),
};
let json = serde_json::to_string(&issue).unwrap();
assert!(json.contains("Test issue"));
assert!(json.contains("high"));
let deserialized: DoctorIssue = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.message, "Test issue");
}
#[test]
fn test_doctor_report_serialization() {
let issue = DoctorIssue {
category: "sec".into(),
severity: "medium".into(),
message: "Issue".into(),
recommendation: None,
};
let report = DoctorReport {
healthy: false,
issues: vec![issue],
foundry_version: Some("nightly".into()),
solidity_version: Some("0.8.20".into()),
chain: "base".into(),
project_path: "/project".into(),
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("nightly"));
assert!(json.contains("base"));
let deserialized: DoctorReport = serde_json::from_str(&json).unwrap();
assert!(!deserialized.healthy);
assert_eq!(deserialized.chain, "base");
}
}