Skip to main content

forge_guard/doctor/
mod.rs

1//! Doctor module — analyzes project health and configuration.
2
3use crate::core::{ForgeGuardError, ProjectConfig};
4use anyhow::{Context, Result};
5use colored::*;
6use serde::{Deserialize, Serialize};
7
8/// Doctor performs comprehensive project health analysis.
9#[allow(dead_code)]
10pub struct Doctor {
11    config: ProjectConfig,
12    verbose: bool,
13    issues: Vec<DoctorIssue>,
14}
15
16/// An issue found during a health check.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct DoctorIssue {
19    pub category: String,
20    pub severity: String,
21    pub message: String,
22    pub recommendation: Option<String>,
23}
24
25/// The complete doctor report.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct DoctorReport {
28    pub healthy: bool,
29    pub issues: Vec<DoctorIssue>,
30    pub foundry_version: Option<String>,
31    pub solidity_version: Option<String>,
32    pub chain: String,
33    pub project_path: String,
34}
35
36impl Doctor {
37    /// Create a new doctor instance.
38    pub fn new(config: &ProjectConfig, verbose: bool) -> Result<Self, ForgeGuardError> {
39        Ok(Self {
40            config: config.clone(),
41            verbose,
42            issues: Vec::new(),
43        })
44    }
45
46    /// Check installed Foundry version.
47    pub fn check_foundry_version(&mut self) -> Result<String, ForgeGuardError> {
48        let output = std::process::Command::new("forge")
49            .arg("--version")
50            .output()
51            .map_err(|_| ForgeGuardError::Command("forge not found. Install Foundry: https://book.getfoundry.sh/getting-started/installation".into()))?;
52
53        if !output.status.success() {
54            return Err(ForgeGuardError::Command("forge not found. Install Foundry: https://book.getfoundry.sh/getting-started/installation".into()));
55        }
56
57        let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
58        Ok(version)
59    }
60
61    /// Check Solidity compiler version.
62    pub fn check_solidity_version(&mut self) -> Result<String, ForgeGuardError> {
63        let output = std::process::Command::new("forge")
64            .args(["config", "--json"])
65            .output()
66            .map_err(|_| ForgeGuardError::Command("forge not found".into()))?;
67
68        // Extract solc version from forge config
69        let stdout = String::from_utf8_lossy(&output.stdout);
70        if let Ok(config) = serde_json::from_str::<serde_json::Value>(&stdout) {
71            if let Some(solc) = config.get("solc").and_then(|s| s.as_str()) {
72                return Ok(solc.to_string());
73            }
74        }
75
76        Ok("0.8.20+ (default)".into())
77    }
78
79    /// Check project structure for common issues.
80    pub fn check_project_structure(&mut self) -> Result<Vec<String>, ForgeGuardError> {
81        let mut issues = Vec::new();
82        let root = &self.config.project_root;
83
84        // Check for foundry.toml
85        if !root.join("foundry.toml").exists() && !root.join("foundry.toml").exists() {
86            issues.push("No foundry.toml found — run `forge init` to create one".into());
87        }
88
89        // Check for src directory
90        let src_dirs = &self.config.src_dirs;
91        for dir in src_dirs {
92            let dir_path = if dir.is_absolute() {
93                dir.clone()
94            } else {
95                root.join(dir)
96            };
97            if !dir_path.exists() {
98                issues.push(format!(
99                    "Source directory not found: {}",
100                    dir_path.display()
101                ));
102            }
103        }
104
105        Ok(issues)
106    }
107
108    /// Check for vulnerable dependencies.
109    pub fn check_dependencies(&mut self) -> Result<Vec<String>, ForgeGuardError> {
110        let mut issues = Vec::new();
111
112        // Check for a foundry.toml or remappings
113        let root = &self.config.project_root;
114        let foundry_toml = root.join("foundry.toml");
115        let remappings = root.join("remappings.txt");
116
117        if foundry_toml.exists() {
118            let content = std::fs::read_to_string(&foundry_toml)?;
119            // Check for known vulnerable remappings
120            if content.contains("openzeppelin-contracts@4.9") {
121                issues
122                    .push("OpenZeppelin 4.9.x has known vulnerabilities — upgrade to 5.0+".into());
123            }
124        }
125
126        if !remappings.exists() {
127            issues.push(
128                "No remappings.txt found — consider adding one for dependency management".into(),
129            );
130        }
131
132        Ok(issues)
133    }
134
135    /// Check compiler settings for security issues.
136    pub fn check_compiler_settings(&mut self) -> Result<Vec<String>, ForgeGuardError> {
137        let mut issues = Vec::new();
138        let root = &self.config.project_root;
139        let foundry_toml = root.join("foundry.toml");
140
141        if foundry_toml.exists() {
142            let content = std::fs::read_to_string(&foundry_toml)?;
143            // Check for optimizations
144            if !content.contains("optimizer") {
145                issues.push(
146                    "Compiler optimizer not configured — consider enabling for production".into(),
147                );
148            }
149        }
150
151        Ok(issues)
152    }
153
154    /// Check RPC connectivity.
155    pub fn check_rpc_connectivity(&mut self, chain: &str) -> Result<(), ForgeGuardError> {
156        let rpc_url = std::env::var("ETH_RPC_URL")
157            .unwrap_or_else(|_| format!("https://{}.llamarpc.com", chain));
158
159        // Simple connectivity check via curl
160        let output = std::process::Command::new("curl")
161            .args(["-s", "-o", "/dev/null", "-w", "%{http_code}", &rpc_url])
162            .output()
163            .map_err(|_| ForgeGuardError::Rpc("curl not available for RPC check".into()))?;
164
165        let status = String::from_utf8_lossy(&output.stdout);
166        match status.trim() {
167            "200" | "401" | "403" => Ok(()), // RPC responded
168            _ => Err(ForgeGuardError::Rpc(format!(
169                "RPC endpoint unreachable: {} (HTTP {})",
170                rpc_url, status
171            ))),
172        }
173    }
174
175    /// Check security configuration.
176    pub fn check_security_config(&mut self) -> Result<Vec<String>, ForgeGuardError> {
177        let mut issues = Vec::new();
178        if self.config.strict {
179            issues.push("Strict mode enabled — deployment will fail on any finding".into());
180        }
181        Ok(issues)
182    }
183
184    /// Generate a complete doctor report.
185    pub fn generate_report(&self, healthy: bool) -> DoctorReport {
186        DoctorReport {
187            healthy,
188            issues: self.issues.clone(),
189            foundry_version: None,
190            solidity_version: None,
191            chain: self.config.chain.clone(),
192            project_path: self.config.project_root.to_string_lossy().to_string(),
193        }
194    }
195}
196
197/// Sync forge-guard.toml settings from foundry.toml.
198///
199/// Syncs `src`, `test`, `lib`, `remappings`, and `solc_version` into the
200/// audit config. `--dry-run` previews changes, `--diff` shows current state.
201pub fn sync_foundry_config(config: &ProjectConfig, dry_run: bool, show_diff: bool) -> Result<()> {
202    let root = &config.project_root;
203    let foundry_toml = root.join("foundry.toml");
204    let forge_guard_toml = root.join("forge-guard.toml");
205
206    if !foundry_toml.exists() {
207        anyhow::bail!("No foundry.toml found at {}", foundry_toml.display());
208    }
209
210    eprintln!("{} Syncing settings from foundry.toml...\n", "🔄".bold());
211
212    // Read foundry.toml
213    let foundry_content = std::fs::read_to_string(&foundry_toml)?;
214    let foundry_config: toml::Value = toml::from_str(&foundry_content)?;
215
216    // Resolve settings from [profile.default] or top-level
217    let profile = foundry_config.get("profile").and_then(|p| p.get("default"));
218
219    let src_dirs = profile
220        .and_then(|p| p.get("src").and_then(|v| v.as_str()))
221        .unwrap_or("src");
222    let test_dirs = profile
223        .and_then(|p| p.get("test").and_then(|v| v.as_str()))
224        .unwrap_or("test");
225    let lib_dirs: Vec<String> = profile
226        .and_then(|p| p.get("libs").and_then(|v| v.as_array()))
227        .map(|arr| {
228            arr.iter()
229                .filter_map(|v| v.as_str().map(String::from))
230                .collect()
231        })
232        .unwrap_or_else(|| vec!["lib".into()]);
233    let solc_version = foundry_config
234        .get("solc")
235        .or_else(|| profile.and_then(|p| p.get("solc")))
236        .and_then(|v| v.as_str());
237    let remappings: Vec<String> = foundry_config
238        .get("remappings")
239        .and_then(|v| v.as_array())
240        .map(|arr| {
241            arr.iter()
242                .filter_map(|v| v.as_str().map(String::from))
243                .collect()
244        })
245        .unwrap_or_default();
246
247    // Merge synced values into a TOML document, preserving any existing
248    // forge-guard.toml settings that were not sourced from foundry.toml
249    // (e.g. [security], [report], [cache] sections).
250    let mut cfg: toml::Value = if forge_guard_toml.exists() {
251        std::fs::read_to_string(&forge_guard_toml)
252            .ok()
253            .and_then(|c| toml::from_str(&c).ok())
254            .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()))
255    } else {
256        toml::Value::Table(toml::map::Map::new())
257    };
258
259    let table = cfg.as_table_mut().expect("toml table");
260    if let Some(version) = solc_version {
261        table.insert(
262            "solc_version".into(),
263            toml::Value::String(version.to_string()),
264        );
265    }
266    table.insert(
267        "src_dirs".into(),
268        toml::Value::Array(vec![toml::Value::String(src_dirs.to_string())]),
269    );
270    if test_dirs != "test" {
271        table.insert(
272            "test_dirs".into(),
273            toml::Value::Array(vec![toml::Value::String(test_dirs.to_string())]),
274        );
275    }
276    if !lib_dirs.is_empty() && lib_dirs != ["lib"] {
277        table.insert(
278            "lib_dirs".into(),
279            toml::Value::Array(
280                lib_dirs
281                    .iter()
282                    .map(|l| toml::Value::String(l.clone()))
283                    .collect(),
284            ),
285        );
286    }
287    if !remappings.is_empty() {
288        table.insert(
289            "remappings".into(),
290            toml::Value::Array(
291                remappings
292                    .iter()
293                    .map(|r| toml::Value::String(r.clone()))
294                    .collect(),
295            ),
296        );
297    }
298
299    let mut new_config =
300        String::from("# Auto-synced from foundry.toml by forge-guard doctor --sync\n\n");
301    new_config
302        .push_str(&toml::to_string_pretty(&cfg).context("Failed to serialize synced config")?);
303
304    // Show detected settings
305    eprintln!("   Detected settings to sync:");
306    eprintln!("{} src_dirs: {src_dirs}", "  •".cyan());
307    eprintln!("{} test_dirs: {test_dirs}", "  •".cyan());
308    if let Some(version) = solc_version {
309        eprintln!("{} solc_version: {version}", "  •".cyan());
310    }
311    if !remappings.is_empty() {
312        eprintln!("{} remappings: {} entries", "  •".cyan(), remappings.len());
313    }
314
315    if show_diff {
316        // Read current forge-guard.toml if it exists
317        let current = if forge_guard_toml.exists() {
318            std::fs::read_to_string(&forge_guard_toml)?
319        } else {
320            String::new()
321        };
322
323        eprintln!("\n{} Current forge-guard.toml:", "──".dimmed());
324        if current.is_empty() {
325            eprintln!("   (file does not exist)");
326        } else {
327            for line in current.lines() {
328                eprintln!("{}{}", "  ".dimmed(), line);
329            }
330        }
331        eprintln!("\n{} New forge-guard.toml (after sync):", "──".dimmed());
332        for line in new_config.lines() {
333            eprintln!("{}{}", "  +".green(), line);
334        }
335    }
336
337    if dry_run {
338        eprintln!(
339            "\n{} Dry run — no changes written to forge-guard.toml",
340            "ℹ️".yellow()
341        );
342        return Ok(());
343    }
344
345    std::fs::write(&forge_guard_toml, &new_config)
346        .with_context(|| format!("Failed to write {}", forge_guard_toml.display()))?;
347
348    eprintln!(
349        "\n{} forge-guard.toml synced from foundry.toml",
350        "✅".green().bold()
351    );
352
353    Ok(())
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::core::ProjectConfig;
360
361    #[test]
362    fn test_doctor_creation() {
363        let config = ProjectConfig::default();
364        let doctor = Doctor::new(&config, false).unwrap();
365        assert!(!doctor.verbose);
366        assert!(doctor.issues.is_empty());
367    }
368
369    #[test]
370    fn test_doctor_verbose() {
371        let config = ProjectConfig::default();
372        let doctor = Doctor::new(&config, true).unwrap();
373        assert!(doctor.verbose);
374    }
375
376    #[test]
377    fn test_check_project_structure_no_foundry_toml() {
378        let config = ProjectConfig::default();
379        let mut doctor = Doctor::new(&config, false).unwrap();
380        // Should not panic — just return issues about missing files
381        let issues = doctor.check_project_structure().unwrap();
382        // May or may not have issues depending on cwd
383        assert!(issues.is_empty() || issues.iter().any(|i| i.contains("foundry.toml")));
384    }
385
386    #[test]
387    fn test_generate_report() {
388        let config = ProjectConfig::default();
389        let doctor = Doctor::new(&config, false).unwrap();
390
391        let report = doctor.generate_report(true);
392        assert!(report.healthy);
393        assert_eq!(report.chain, "ethereum");
394    }
395
396    #[test]
397    fn test_generate_report_unhealthy() {
398        let config = ProjectConfig::default();
399        let doctor = Doctor::new(&config, false).unwrap();
400
401        let report = doctor.generate_report(false);
402        assert!(!report.healthy);
403    }
404
405    #[test]
406    fn test_doctor_issue_serialization() {
407        let issue = DoctorIssue {
408            category: "test".into(),
409            severity: "high".into(),
410            message: "Test issue".into(),
411            recommendation: Some("Fix it".into()),
412        };
413
414        let json = serde_json::to_string(&issue).unwrap();
415        assert!(json.contains("Test issue"));
416        assert!(json.contains("high"));
417
418        let deserialized: DoctorIssue = serde_json::from_str(&json).unwrap();
419        assert_eq!(deserialized.message, "Test issue");
420    }
421
422    #[test]
423    fn test_sync_foundry_config_no_toml_fails() {
424        let config = ProjectConfig::default();
425        let result = sync_foundry_config(&config, false, false);
426        assert!(result.is_err());
427        assert!(result.unwrap_err().to_string().contains("No foundry.toml"));
428    }
429
430    #[test]
431    fn test_sync_foundry_config_dry_run_without_toml_fails() {
432        let config = ProjectConfig::default();
433        let result = sync_foundry_config(&config, true, false);
434        assert!(result.is_err());
435        // Dry run should still check for foundry.toml existence
436        assert!(result.unwrap_err().to_string().contains("No foundry.toml"));
437    }
438
439    #[test]
440    fn test_sync_foundry_config_diff_without_toml_fails() {
441        let config = ProjectConfig::default();
442        let result = sync_foundry_config(&config, false, true);
443        assert!(result.is_err());
444    }
445
446    #[test]
447    fn test_sync_foundry_config_writes_real_values() {
448        let dir = tempfile::tempdir().unwrap();
449        std::fs::write(
450            dir.path().join("foundry.toml"),
451            "[profile.default]\n\
452             src = 'contracts'\n\
453             test = 'tests'\n\
454             libs = ['lib', 'vendor']\n\
455             solc = '0.8.23'\n",
456        )
457        .unwrap();
458
459        let config = ProjectConfig {
460            project_root: dir.path().to_path_buf(),
461            ..ProjectConfig::default()
462        };
463
464        sync_foundry_config(&config, false, false).unwrap();
465
466        let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
467        let reparsed: toml::Value = toml::from_str(&written).unwrap();
468        assert_eq!(reparsed["solc_version"].as_str(), Some("0.8.23"));
469        assert_eq!(reparsed["src_dirs"][0].as_str(), Some("contracts"));
470        assert_eq!(reparsed["test_dirs"][0].as_str(), Some("tests"));
471        let libs: Vec<&str> = reparsed["lib_dirs"]
472            .as_array()
473            .unwrap()
474            .iter()
475            .map(|v| v.as_str().unwrap())
476            .collect();
477        assert_eq!(libs, vec!["lib", "vendor"]);
478    }
479
480    #[test]
481    fn test_sync_foundry_config_dry_run_does_not_write() {
482        let dir = tempfile::tempdir().unwrap();
483        std::fs::write(
484            dir.path().join("foundry.toml"),
485            "[profile.default]\nsrc = 'contracts'\n",
486        )
487        .unwrap();
488
489        let config = ProjectConfig {
490            project_root: dir.path().to_path_buf(),
491            ..ProjectConfig::default()
492        };
493
494        sync_foundry_config(&config, true, false).unwrap();
495        assert!(
496            !dir.path().join("forge-guard.toml").exists(),
497            "Dry run must not write the config file"
498        );
499    }
500
501    #[test]
502    fn test_sync_foundry_config_syncs_remappings() {
503        let dir = tempfile::tempdir().unwrap();
504        std::fs::write(
505            dir.path().join("foundry.toml"),
506            "remappings = ['@oz/=lib/openzeppelin/', '@forge-std/=lib/forge-std/']\n",
507        )
508        .unwrap();
509
510        let config = ProjectConfig {
511            project_root: dir.path().to_path_buf(),
512            ..ProjectConfig::default()
513        };
514
515        sync_foundry_config(&config, false, false).unwrap();
516
517        let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
518        assert!(written.contains("@oz/=lib/openzeppelin/"));
519        assert!(written.contains("@forge-std/=lib/forge-std/"));
520        // The synced file must remain valid TOML (single remappings array)
521        let reparsed: toml::Value = toml::from_str(&written).unwrap();
522        let remappings = reparsed["remappings"].as_array().unwrap();
523        assert_eq!(remappings.len(), 2);
524    }
525
526    #[test]
527    fn test_sync_foundry_config_preserves_existing_settings() {
528        let dir = tempfile::tempdir().unwrap();
529        std::fs::write(
530            dir.path().join("foundry.toml"),
531            "[profile.default]\nsrc = 'contracts'\n",
532        )
533        .unwrap();
534        // Pre-existing forge-guard.toml with unrelated sections
535        std::fs::write(
536            dir.path().join("forge-guard.toml"),
537            "[security]\nenable_high = true\n\n[report]\ninclude_snippets = true\n",
538        )
539        .unwrap();
540
541        let config = ProjectConfig {
542            project_root: dir.path().to_path_buf(),
543            ..ProjectConfig::default()
544        };
545
546        sync_foundry_config(&config, false, false).unwrap();
547
548        let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
549        // Existing sections preserved
550        assert!(written.contains("[security]"));
551        assert!(written.contains("enable_high = true"));
552        assert!(written.contains("[report]"));
553        // Synced src_dirs applied
554        assert!(written.contains("src_dirs = [\"contracts\"]"));
555        // File is still valid TOML
556        assert!(toml::from_str::<toml::Value>(&written).is_ok());
557    }
558
559    #[test]
560    fn test_sync_foundry_config_defaults_when_keys_missing() {
561        let dir = tempfile::tempdir().unwrap();
562        std::fs::write(dir.path().join("foundry.toml"), "[profile.default]\n").unwrap();
563
564        let config = ProjectConfig {
565            project_root: dir.path().to_path_buf(),
566            ..ProjectConfig::default()
567        };
568
569        sync_foundry_config(&config, false, false).unwrap();
570
571        let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
572        let reparsed: toml::Value = toml::from_str(&written).unwrap();
573        // Defaults should still be written for src, but not the test/lib overrides
574        assert_eq!(reparsed["src_dirs"][0].as_str(), Some("src"));
575        assert!(reparsed.get("test_dirs").is_none());
576        assert!(reparsed.get("lib_dirs").is_none());
577    }
578
579    #[test]
580    fn test_doctor_report_serialization() {
581        let issue = DoctorIssue {
582            category: "sec".into(),
583            severity: "medium".into(),
584            message: "Issue".into(),
585            recommendation: None,
586        };
587        let report = DoctorReport {
588            healthy: false,
589            issues: vec![issue],
590            foundry_version: Some("nightly".into()),
591            solidity_version: Some("0.8.20".into()),
592            chain: "base".into(),
593            project_path: "/project".into(),
594        };
595
596        let json = serde_json::to_string(&report).unwrap();
597        assert!(json.contains("nightly"));
598        assert!(json.contains("base"));
599
600        let deserialized: DoctorReport = serde_json::from_str(&json).unwrap();
601        assert!(!deserialized.healthy);
602        assert_eq!(deserialized.chain, "base");
603    }
604}