forge-guard 0.1.0

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! Doctor module — analyzes project health and configuration.

use crate::core::{ForgeGuardError, ProjectConfig};
use serde::{Deserialize, Serialize};

/// Doctor performs comprehensive project health analysis.
#[allow(dead_code)]
pub struct Doctor {
    config: ProjectConfig,
    verbose: bool,
    issues: Vec<DoctorIssue>,
}

/// An issue found during a health check.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorIssue {
    pub category: String,
    pub severity: String,
    pub message: String,
    pub recommendation: Option<String>,
}

/// The complete doctor report.
#[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 {
    /// Create a new doctor instance.
    pub fn new(config: &ProjectConfig, verbose: bool) -> Result<Self, ForgeGuardError> {
        Ok(Self {
            config: config.clone(),
            verbose,
            issues: Vec::new(),
        })
    }

    /// Check installed Foundry version.
    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)
    }

    /// Check Solidity compiler 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()))?;

        // Extract solc version from forge config
        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())
    }

    /// Check project structure for common issues.
    pub fn check_project_structure(&mut self) -> Result<Vec<String>, ForgeGuardError> {
        let mut issues = Vec::new();
        let root = &self.config.project_root;

        // Check for foundry.toml
        if !root.join("foundry.toml").exists() && !root.join("foundry.toml").exists() {
            issues.push("No foundry.toml found — run `forge init` to create one".into());
        }

        // Check for src directory
        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)
    }

    /// Check for vulnerable dependencies.
    pub fn check_dependencies(&mut self) -> Result<Vec<String>, ForgeGuardError> {
        let mut issues = Vec::new();

        // Check for a foundry.toml or remappings
        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)?;
            // Check for known vulnerable remappings
            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)
    }

    /// Check compiler settings for security 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)?;
            // Check for optimizations
            if !content.contains("optimizer") {
                issues.push(
                    "Compiler optimizer not configured — consider enabling for production".into(),
                );
            }
        }

        Ok(issues)
    }

    /// Check RPC connectivity.
    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));

        // Simple connectivity check via curl
        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(()), // RPC responded
            _ => Err(ForgeGuardError::Rpc(format!(
                "RPC endpoint unreachable: {} (HTTP {})",
                rpc_url, status
            ))),
        }
    }

    /// Check security configuration.
    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)
    }

    /// Generate a complete doctor report.
    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();
        // Should not panic — just return issues about missing files
        let issues = doctor.check_project_structure().unwrap();
        // May or may not have issues depending on cwd
        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");
    }
}