use crate::core::{ForgeGuardError, ProjectConfig};
use anyhow::{Context, Result};
use colored::*;
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(),
}
}
}
pub fn sync_foundry_config(config: &ProjectConfig, dry_run: bool, show_diff: bool) -> Result<()> {
let root = &config.project_root;
let foundry_toml = root.join("foundry.toml");
let forge_guard_toml = root.join("forge-guard.toml");
if !foundry_toml.exists() {
anyhow::bail!("No foundry.toml found at {}", foundry_toml.display());
}
eprintln!("{} Syncing settings from foundry.toml...\n", "🔄".bold());
let foundry_content = std::fs::read_to_string(&foundry_toml)?;
let foundry_config: toml::Value = toml::from_str(&foundry_content)?;
let profile = foundry_config.get("profile").and_then(|p| p.get("default"));
let src_dirs = profile
.and_then(|p| p.get("src").and_then(|v| v.as_str()))
.unwrap_or("src");
let test_dirs = profile
.and_then(|p| p.get("test").and_then(|v| v.as_str()))
.unwrap_or("test");
let lib_dirs: Vec<String> = profile
.and_then(|p| p.get("libs").and_then(|v| v.as_array()))
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_else(|| vec!["lib".into()]);
let solc_version = foundry_config
.get("solc")
.or_else(|| profile.and_then(|p| p.get("solc")))
.and_then(|v| v.as_str());
let remappings: Vec<String> = foundry_config
.get("remappings")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let mut cfg: toml::Value = if forge_guard_toml.exists() {
std::fs::read_to_string(&forge_guard_toml)
.ok()
.and_then(|c| toml::from_str(&c).ok())
.unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()))
} else {
toml::Value::Table(toml::map::Map::new())
};
let table = cfg.as_table_mut().expect("toml table");
if let Some(version) = solc_version {
table.insert(
"solc_version".into(),
toml::Value::String(version.to_string()),
);
}
table.insert(
"src_dirs".into(),
toml::Value::Array(vec![toml::Value::String(src_dirs.to_string())]),
);
if test_dirs != "test" {
table.insert(
"test_dirs".into(),
toml::Value::Array(vec![toml::Value::String(test_dirs.to_string())]),
);
}
if !lib_dirs.is_empty() && lib_dirs != ["lib"] {
table.insert(
"lib_dirs".into(),
toml::Value::Array(
lib_dirs
.iter()
.map(|l| toml::Value::String(l.clone()))
.collect(),
),
);
}
if !remappings.is_empty() {
table.insert(
"remappings".into(),
toml::Value::Array(
remappings
.iter()
.map(|r| toml::Value::String(r.clone()))
.collect(),
),
);
}
let mut new_config =
String::from("# Auto-synced from foundry.toml by forge-guard doctor --sync\n\n");
new_config
.push_str(&toml::to_string_pretty(&cfg).context("Failed to serialize synced config")?);
eprintln!(" Detected settings to sync:");
eprintln!("{} src_dirs: {src_dirs}", " •".cyan());
eprintln!("{} test_dirs: {test_dirs}", " •".cyan());
if let Some(version) = solc_version {
eprintln!("{} solc_version: {version}", " •".cyan());
}
if !remappings.is_empty() {
eprintln!("{} remappings: {} entries", " •".cyan(), remappings.len());
}
if show_diff {
let current = if forge_guard_toml.exists() {
std::fs::read_to_string(&forge_guard_toml)?
} else {
String::new()
};
eprintln!("\n{} Current forge-guard.toml:", "──".dimmed());
if current.is_empty() {
eprintln!(" (file does not exist)");
} else {
for line in current.lines() {
eprintln!("{}{}", " ".dimmed(), line);
}
}
eprintln!("\n{} New forge-guard.toml (after sync):", "──".dimmed());
for line in new_config.lines() {
eprintln!("{}{}", " +".green(), line);
}
}
if dry_run {
eprintln!(
"\n{} Dry run — no changes written to forge-guard.toml",
"ℹ️".yellow()
);
return Ok(());
}
std::fs::write(&forge_guard_toml, &new_config)
.with_context(|| format!("Failed to write {}", forge_guard_toml.display()))?;
eprintln!(
"\n{} forge-guard.toml synced from foundry.toml",
"✅".green().bold()
);
Ok(())
}
#[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_sync_foundry_config_no_toml_fails() {
let config = ProjectConfig::default();
let result = sync_foundry_config(&config, false, false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No foundry.toml"));
}
#[test]
fn test_sync_foundry_config_dry_run_without_toml_fails() {
let config = ProjectConfig::default();
let result = sync_foundry_config(&config, true, false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No foundry.toml"));
}
#[test]
fn test_sync_foundry_config_diff_without_toml_fails() {
let config = ProjectConfig::default();
let result = sync_foundry_config(&config, false, true);
assert!(result.is_err());
}
#[test]
fn test_sync_foundry_config_writes_real_values() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("foundry.toml"),
"[profile.default]\n\
src = 'contracts'\n\
test = 'tests'\n\
libs = ['lib', 'vendor']\n\
solc = '0.8.23'\n",
)
.unwrap();
let config = ProjectConfig {
project_root: dir.path().to_path_buf(),
..ProjectConfig::default()
};
sync_foundry_config(&config, false, false).unwrap();
let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
let reparsed: toml::Value = toml::from_str(&written).unwrap();
assert_eq!(reparsed["solc_version"].as_str(), Some("0.8.23"));
assert_eq!(reparsed["src_dirs"][0].as_str(), Some("contracts"));
assert_eq!(reparsed["test_dirs"][0].as_str(), Some("tests"));
let libs: Vec<&str> = reparsed["lib_dirs"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(libs, vec!["lib", "vendor"]);
}
#[test]
fn test_sync_foundry_config_dry_run_does_not_write() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("foundry.toml"),
"[profile.default]\nsrc = 'contracts'\n",
)
.unwrap();
let config = ProjectConfig {
project_root: dir.path().to_path_buf(),
..ProjectConfig::default()
};
sync_foundry_config(&config, true, false).unwrap();
assert!(
!dir.path().join("forge-guard.toml").exists(),
"Dry run must not write the config file"
);
}
#[test]
fn test_sync_foundry_config_syncs_remappings() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("foundry.toml"),
"remappings = ['@oz/=lib/openzeppelin/', '@forge-std/=lib/forge-std/']\n",
)
.unwrap();
let config = ProjectConfig {
project_root: dir.path().to_path_buf(),
..ProjectConfig::default()
};
sync_foundry_config(&config, false, false).unwrap();
let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
assert!(written.contains("@oz/=lib/openzeppelin/"));
assert!(written.contains("@forge-std/=lib/forge-std/"));
let reparsed: toml::Value = toml::from_str(&written).unwrap();
let remappings = reparsed["remappings"].as_array().unwrap();
assert_eq!(remappings.len(), 2);
}
#[test]
fn test_sync_foundry_config_preserves_existing_settings() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("foundry.toml"),
"[profile.default]\nsrc = 'contracts'\n",
)
.unwrap();
std::fs::write(
dir.path().join("forge-guard.toml"),
"[security]\nenable_high = true\n\n[report]\ninclude_snippets = true\n",
)
.unwrap();
let config = ProjectConfig {
project_root: dir.path().to_path_buf(),
..ProjectConfig::default()
};
sync_foundry_config(&config, false, false).unwrap();
let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
assert!(written.contains("[security]"));
assert!(written.contains("enable_high = true"));
assert!(written.contains("[report]"));
assert!(written.contains("src_dirs = [\"contracts\"]"));
assert!(toml::from_str::<toml::Value>(&written).is_ok());
}
#[test]
fn test_sync_foundry_config_defaults_when_keys_missing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("foundry.toml"), "[profile.default]\n").unwrap();
let config = ProjectConfig {
project_root: dir.path().to_path_buf(),
..ProjectConfig::default()
};
sync_foundry_config(&config, false, false).unwrap();
let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
let reparsed: toml::Value = toml::from_str(&written).unwrap();
assert_eq!(reparsed["src_dirs"][0].as_str(), Some("src"));
assert!(reparsed.get("test_dirs").is_none());
assert!(reparsed.get("lib_dirs").is_none());
}
#[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");
}
}