forge-guard 0.3.6

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! VS Code extension config generator — `forge-guard ci --vscode`.
//!
//! Generates `.vscode/settings.json`, `.vscode/tasks.json`, and
//! `.vscode/extensions.json` for Solidity developers:
//!
//! * **settings.json** — Solidity validation rules (compiler version pin,
//!   remappings for the language server, solhint linter) plus audit-on-save
//!   integration for `.sol` files via the Run on Save extension.
//! * **tasks.json** — a `forge-guard: audit` build task whose output is parsed
//!   into the VS Code Problems panel by problem matchers that map
//!   forge-guard's severity levels (CRITICAL/HIGH → error, MEDIUM → warning,
//!   LOW/INFO → info).
//! * **extensions.json** — recommended extensions for the above.

use serde_json::{json, Value};

/// Generate `.vscode/settings.json`.
///
/// `solc_version` and `remappings` come from the project's forge-guard.toml
/// (synced from foundry.toml via `doctor --sync`) when available.
pub fn generate_settings(solc_version: Option<&str>, remappings: &[String]) -> String {
    let mut settings = serde_json::Map::new();

    // ── Custom Solidity validation rules (juanblanco.solidity) ──
    settings.insert("solidity.validation.disable".into(), json!(false));
    if let Some(version) = solc_version.filter(|v| !v.is_empty()) {
        settings.insert(
            "solidity.validation.requiredCompilerVersion".into(),
            json!(version),
        );
    }
    settings.insert("solidity.linter".into(), json!("solhint"));
    if !remappings.is_empty() {
        let remaps: Vec<Value> = remappings.iter().map(|r| json!(r)).collect();
        settings.insert("solidity.remappings".into(), Value::Array(remaps));
    }

    // ── Audit-on-save for Solidity files (emeraldwalk.runonsave) ──
    settings.insert(
        "emeraldwalk.runonsave".into(),
        json!({
            "commands": [
                {
                    "match": "\\.sol$",
                    "isAsync": true,
                    "cmd": "forge-guard audit --offline"
                }
            ]
        }),
    );

    serde_json::to_string_pretty(&Value::Object(settings)).expect("settings serialize") + "\n"
}

/// Generate `.vscode/tasks.json` with the audit task and problem matchers.
pub fn generate_tasks() -> String {
    // forge-guard's terminal finding format:
    //
    //   [CRITICAL] Reentrancy vulnerability
    //        📄 src/MyContract.sol:42
    //
    // Each matcher targets one severity class; the 📄 line (shared by all)
    // carries the file/line. VS Code walks the pattern list per problem, so a
    // finding matches exactly one severity matcher and then the file line.
    let file_line_pattern = json!({
        "regexp": "^\\s*📄 (.+):(\\d+)$",
        "file": 1,
        "line": 2
    });

    let matcher = |severity: &str, severity_regex: &str| {
        json!({
            "owner": "forge-guard",
            "severity": severity,
            "fileLocation": ["relative", "${workspaceFolder}"],
            "pattern": [
                {
                    "regexp": severity_regex,
                    "message": 2
                },
                file_line_pattern.clone()
            ]
        })
    };

    let tasks = json!({
        "version": "2.0.0",
        "tasks": [
            {
                "label": "forge-guard: audit",
                "type": "shell",
                "command": "forge-guard",
                "args": ["audit", "--offline"],
                "group": { "kind": "build", "isDefault": true },
                "presentation": {
                    "reveal": "always",
                    "panel": "dedicated",
                    "clear": true
                },
                "problemMatcher": [
                    matcher("error", "^\\s*\\[(CRITICAL|HIGH)\\] (.+)$"),
                    matcher("warning", "^\\s*\\[(MEDIUM)\\] (.+)$"),
                    matcher("info", "^\\s*\\[(LOW|INFO)\\] (.+)$")
                ]
            }
        ]
    });

    serde_json::to_string_pretty(&tasks).expect("tasks serialize") + "\n"
}

/// Generate `.vscode/extensions.json` with recommended extensions.
pub fn generate_extensions() -> String {
    let extensions = json!({
        "recommendations": [
            "juanblanco.solidity",
            "emeraldwalk.runonsave",
            "tamasfe.even-better-toml"
        ]
    });
    serde_json::to_string_pretty(&extensions).expect("extensions serialize") + "\n"
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_settings_contains_validation_rules() {
        let settings = generate_settings(
            Some("0.8.23"),
            &["@openzeppelin/=lib/openzeppelin-contracts/".into()],
        );
        let parsed: Value = serde_json::from_str(&settings).unwrap();
        assert_eq!(parsed["solidity.validation.disable"], json!(false));
        assert_eq!(
            parsed["solidity.validation.requiredCompilerVersion"],
            json!("0.8.23")
        );
        assert_eq!(parsed["solidity.linter"], json!("solhint"));
        assert_eq!(
            parsed["solidity.remappings"][0],
            json!("@openzeppelin/=lib/openzeppelin-contracts/")
        );
    }

    #[test]
    fn test_settings_omits_unknown_compiler_version() {
        let settings = generate_settings(None, &[]);
        let parsed: Value = serde_json::from_str(&settings).unwrap();
        assert!(parsed
            .get("solidity.validation.requiredCompilerVersion")
            .is_none());
        assert!(parsed.get("solidity.remappings").is_none());
    }

    #[test]
    fn test_settings_has_audit_on_save() {
        let settings = generate_settings(None, &[]);
        let parsed: Value = serde_json::from_str(&settings).unwrap();
        let run_on_save = &parsed["emeraldwalk.runonsave"];
        assert_eq!(run_on_save["commands"][0]["match"], json!("\\.sol$"));
        assert_eq!(
            run_on_save["commands"][0]["cmd"],
            json!("forge-guard audit --offline")
        );
    }

    #[test]
    fn test_tasks_has_audit_task_with_matchers() {
        let tasks = generate_tasks();
        let parsed: Value = serde_json::from_str(&tasks).unwrap();
        let task = &parsed["tasks"][0];
        assert_eq!(task["label"], json!("forge-guard: audit"));
        assert_eq!(task["command"], json!("forge-guard"));
        assert_eq!(task["group"]["isDefault"], json!(true));

        let matchers = task["problemMatcher"].as_array().unwrap();
        assert_eq!(matchers.len(), 3);
        assert_eq!(matchers[0]["severity"], json!("error"));
        assert_eq!(matchers[1]["severity"], json!("warning"));
        assert_eq!(matchers[2]["severity"], json!("info"));
        // error matcher covers CRITICAL and HIGH
        let re = matchers[0]["pattern"][0]["regexp"].as_str().unwrap();
        assert!(re.contains("CRITICAL") && re.contains("HIGH"));
        // every matcher parses the 📄 file:line line
        for m in matchers {
            let file_pattern = &m["pattern"][1];
            assert_eq!(file_pattern["file"], json!(1));
            assert_eq!(file_pattern["line"], json!(2));
        }
    }

    #[test]
    fn test_extensions_recommendations() {
        let extensions = generate_extensions();
        let parsed: Value = serde_json::from_str(&extensions).unwrap();
        let recs = parsed["recommendations"].as_array().unwrap();
        assert!(recs.iter().any(|r| r == &json!("juanblanco.solidity")));
        assert!(recs.iter().any(|r| r == &json!("emeraldwalk.runonsave")));
    }
}