forge_guard/doctor/
mod.rs1use crate::core::{ForgeGuardError, ProjectConfig};
4use serde::{Deserialize, Serialize};
5
6#[allow(dead_code)]
8pub struct Doctor {
9 config: ProjectConfig,
10 verbose: bool,
11 issues: Vec<DoctorIssue>,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DoctorIssue {
17 pub category: String,
18 pub severity: String,
19 pub message: String,
20 pub recommendation: Option<String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct DoctorReport {
26 pub healthy: bool,
27 pub issues: Vec<DoctorIssue>,
28 pub foundry_version: Option<String>,
29 pub solidity_version: Option<String>,
30 pub chain: String,
31 pub project_path: String,
32}
33
34impl Doctor {
35 pub fn new(config: &ProjectConfig, verbose: bool) -> Result<Self, ForgeGuardError> {
37 Ok(Self {
38 config: config.clone(),
39 verbose,
40 issues: Vec::new(),
41 })
42 }
43
44 pub fn check_foundry_version(&mut self) -> Result<String, ForgeGuardError> {
46 let output = std::process::Command::new("forge")
47 .arg("--version")
48 .output()
49 .map_err(|_| ForgeGuardError::Command("forge not found. Install Foundry: https://book.getfoundry.sh/getting-started/installation".into()))?;
50
51 if !output.status.success() {
52 return Err(ForgeGuardError::Command("forge not found. Install Foundry: https://book.getfoundry.sh/getting-started/installation".into()));
53 }
54
55 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
56 Ok(version)
57 }
58
59 pub fn check_solidity_version(&mut self) -> Result<String, ForgeGuardError> {
61 let output = std::process::Command::new("forge")
62 .args(["config", "--json"])
63 .output()
64 .map_err(|_| ForgeGuardError::Command("forge not found".into()))?;
65
66 let stdout = String::from_utf8_lossy(&output.stdout);
68 if let Ok(config) = serde_json::from_str::<serde_json::Value>(&stdout) {
69 if let Some(solc) = config.get("solc").and_then(|s| s.as_str()) {
70 return Ok(solc.to_string());
71 }
72 }
73
74 Ok("0.8.20+ (default)".into())
75 }
76
77 pub fn check_project_structure(&mut self) -> Result<Vec<String>, ForgeGuardError> {
79 let mut issues = Vec::new();
80 let root = &self.config.project_root;
81
82 if !root.join("foundry.toml").exists() && !root.join("foundry.toml").exists() {
84 issues.push("No foundry.toml found — run `forge init` to create one".into());
85 }
86
87 let src_dirs = &self.config.src_dirs;
89 for dir in src_dirs {
90 let dir_path = if dir.is_absolute() {
91 dir.clone()
92 } else {
93 root.join(dir)
94 };
95 if !dir_path.exists() {
96 issues.push(format!(
97 "Source directory not found: {}",
98 dir_path.display()
99 ));
100 }
101 }
102
103 Ok(issues)
104 }
105
106 pub fn check_dependencies(&mut self) -> Result<Vec<String>, ForgeGuardError> {
108 let mut issues = Vec::new();
109
110 let root = &self.config.project_root;
112 let foundry_toml = root.join("foundry.toml");
113 let remappings = root.join("remappings.txt");
114
115 if foundry_toml.exists() {
116 let content = std::fs::read_to_string(&foundry_toml)?;
117 if content.contains("openzeppelin-contracts@4.9") {
119 issues
120 .push("OpenZeppelin 4.9.x has known vulnerabilities — upgrade to 5.0+".into());
121 }
122 }
123
124 if !remappings.exists() {
125 issues.push(
126 "No remappings.txt found — consider adding one for dependency management".into(),
127 );
128 }
129
130 Ok(issues)
131 }
132
133 pub fn check_compiler_settings(&mut self) -> Result<Vec<String>, ForgeGuardError> {
135 let mut issues = Vec::new();
136 let root = &self.config.project_root;
137 let foundry_toml = root.join("foundry.toml");
138
139 if foundry_toml.exists() {
140 let content = std::fs::read_to_string(&foundry_toml)?;
141 if !content.contains("optimizer") {
143 issues.push(
144 "Compiler optimizer not configured — consider enabling for production".into(),
145 );
146 }
147 }
148
149 Ok(issues)
150 }
151
152 pub fn check_rpc_connectivity(&mut self, chain: &str) -> Result<(), ForgeGuardError> {
154 let rpc_url = std::env::var("ETH_RPC_URL")
155 .unwrap_or_else(|_| format!("https://{}.llamarpc.com", chain));
156
157 let output = std::process::Command::new("curl")
159 .args(["-s", "-o", "/dev/null", "-w", "%{http_code}", &rpc_url])
160 .output()
161 .map_err(|_| ForgeGuardError::Rpc("curl not available for RPC check".into()))?;
162
163 let status = String::from_utf8_lossy(&output.stdout);
164 match status.trim() {
165 "200" | "401" | "403" => Ok(()), _ => Err(ForgeGuardError::Rpc(format!(
167 "RPC endpoint unreachable: {} (HTTP {})",
168 rpc_url, status
169 ))),
170 }
171 }
172
173 pub fn check_security_config(&mut self) -> Result<Vec<String>, ForgeGuardError> {
175 let mut issues = Vec::new();
176 if self.config.strict {
177 issues.push("Strict mode enabled — deployment will fail on any finding".into());
178 }
179 Ok(issues)
180 }
181
182 pub fn generate_report(&self, healthy: bool) -> DoctorReport {
184 DoctorReport {
185 healthy,
186 issues: self.issues.clone(),
187 foundry_version: None,
188 solidity_version: None,
189 chain: self.config.chain.clone(),
190 project_path: self.config.project_root.to_string_lossy().to_string(),
191 }
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::core::ProjectConfig;
199
200 #[test]
201 fn test_doctor_creation() {
202 let config = ProjectConfig::default();
203 let doctor = Doctor::new(&config, false).unwrap();
204 assert!(!doctor.verbose);
205 assert!(doctor.issues.is_empty());
206 }
207
208 #[test]
209 fn test_doctor_verbose() {
210 let config = ProjectConfig::default();
211 let doctor = Doctor::new(&config, true).unwrap();
212 assert!(doctor.verbose);
213 }
214
215 #[test]
216 fn test_check_project_structure_no_foundry_toml() {
217 let config = ProjectConfig::default();
218 let mut doctor = Doctor::new(&config, false).unwrap();
219 let issues = doctor.check_project_structure().unwrap();
221 assert!(issues.is_empty() || issues.iter().any(|i| i.contains("foundry.toml")));
223 }
224
225 #[test]
226 fn test_generate_report() {
227 let config = ProjectConfig::default();
228 let doctor = Doctor::new(&config, false).unwrap();
229
230 let report = doctor.generate_report(true);
231 assert!(report.healthy);
232 assert_eq!(report.chain, "ethereum");
233 }
234
235 #[test]
236 fn test_generate_report_unhealthy() {
237 let config = ProjectConfig::default();
238 let doctor = Doctor::new(&config, false).unwrap();
239
240 let report = doctor.generate_report(false);
241 assert!(!report.healthy);
242 }
243
244 #[test]
245 fn test_doctor_issue_serialization() {
246 let issue = DoctorIssue {
247 category: "test".into(),
248 severity: "high".into(),
249 message: "Test issue".into(),
250 recommendation: Some("Fix it".into()),
251 };
252
253 let json = serde_json::to_string(&issue).unwrap();
254 assert!(json.contains("Test issue"));
255 assert!(json.contains("high"));
256
257 let deserialized: DoctorIssue = serde_json::from_str(&json).unwrap();
258 assert_eq!(deserialized.message, "Test issue");
259 }
260
261 #[test]
262 fn test_doctor_report_serialization() {
263 let issue = DoctorIssue {
264 category: "sec".into(),
265 severity: "medium".into(),
266 message: "Issue".into(),
267 recommendation: None,
268 };
269 let report = DoctorReport {
270 healthy: false,
271 issues: vec![issue],
272 foundry_version: Some("nightly".into()),
273 solidity_version: Some("0.8.20".into()),
274 chain: "base".into(),
275 project_path: "/project".into(),
276 };
277
278 let json = serde_json::to_string(&report).unwrap();
279 assert!(json.contains("nightly"));
280 assert!(json.contains("base"));
281
282 let deserialized: DoctorReport = serde_json::from_str(&json).unwrap();
283 assert!(!deserialized.healthy);
284 assert_eq!(deserialized.chain, "base");
285 }
286}