Skip to main content

forge_guard/doctor/
mod.rs

1//! Doctor module — analyzes project health and configuration.
2
3use crate::core::{ForgeGuardError, ProjectConfig};
4use serde::{Deserialize, Serialize};
5
6/// Doctor performs comprehensive project health analysis.
7#[allow(dead_code)]
8pub struct Doctor {
9    config: ProjectConfig,
10    verbose: bool,
11    issues: Vec<DoctorIssue>,
12}
13
14/// An issue found during a health check.
15#[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/// The complete doctor report.
24#[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    /// Create a new doctor instance.
36    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    /// Check installed Foundry version.
45    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    /// Check Solidity compiler version.
60    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        // Extract solc version from forge config
67        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    /// Check project structure for common issues.
78    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        // Check for foundry.toml
83        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        // Check for src directory
88        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    /// Check for vulnerable dependencies.
107    pub fn check_dependencies(&mut self) -> Result<Vec<String>, ForgeGuardError> {
108        let mut issues = Vec::new();
109
110        // Check for a foundry.toml or remappings
111        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            // Check for known vulnerable remappings
118            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    /// Check compiler settings for security issues.
134    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            // Check for optimizations
142            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    /// Check RPC connectivity.
153    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        // Simple connectivity check via curl
158        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(()), // RPC responded
166            _ => Err(ForgeGuardError::Rpc(format!(
167                "RPC endpoint unreachable: {} (HTTP {})",
168                rpc_url, status
169            ))),
170        }
171    }
172
173    /// Check security configuration.
174    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    /// Generate a complete doctor report.
183    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        // Should not panic — just return issues about missing files
220        let issues = doctor.check_project_structure().unwrap();
221        // May or may not have issues depending on cwd
222        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}