forge-guard 0.1.8

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! `forge-guard watch` — watch files for changes and re-audit.

use super::WatchArgs;
use anyhow::Result;
use colored::*;
use std::path::PathBuf;
use std::time::{Duration, Instant};

/// Watch source files for changes and automatically re-audit.
pub fn run(args: &WatchArgs) -> Result<()> {
    eprintln!("{}", "👁️  Forge Guard — Watch Mode".bold());
    eprintln!("   Watching: {}", args.dirs);
    eprintln!("   Debounce: {}ms", args.debounce_ms);

    let watch_dirs: Vec<PathBuf> = args
        .dirs
        .split(',')
        .map(|s| {
            let p = PathBuf::from(s.trim());
            if p.is_relative() {
                args.shared.project.join(p)
            } else {
                p
            }
        })
        .collect();

    // Verify directories exist
    for dir in &watch_dirs {
        if !dir.exists() {
            anyhow::bail!("Directory does not exist: {}", dir.display());
        }
    }

    eprintln!(
        "\n{}",
        "   Watching for changes... (Ctrl+C to stop)".dimmed()
    );

    // Simple polling watch loop
    let debounce = Duration::from_millis(args.debounce_ms);
    let poll_interval = Duration::from_millis(200);

    let mut last_mod = get_last_modification(&watch_dirs)?;
    let mut last_trigger = Instant::now();

    loop {
        std::thread::sleep(poll_interval);

        let current_mod = get_last_modification(&watch_dirs)?;
        if current_mod > last_mod && last_trigger.elapsed() >= debounce {
            eprintln!("\n{}", "🔄 Change detected, re-auditing...".bold());
            let audit_args = super::AuditArgs {
                shared: super::SharedFlags {
                    chain: args.shared.chain.clone(),
                    project: args.shared.project.clone(),
                    json: args.shared.json,
                    markdown: args.shared.markdown,
                    html: args.shared.html,
                    strict: args.shared.strict,
                    offline: args.shared.offline,
                    production: args.shared.production,
                    report: args.shared.report,
                    parallelism: args.shared.parallelism,
                },
                full: args.full,
                quick: false,
                summary: false,
                exploit: args.full,
                gas: args.full,
                all_chains: false,
                sources: args.dirs.clone(),
                exclude: args.exclude.clone(),
                ai: false,
                ai_provider: "openai".into(),
                ai_model: "gpt-4".into(),
                ai_api_key: None,
                ollama_endpoint: None,
                ai_full: false,
            };

            if let Err(e) = super::audit::run(&audit_args) {
                eprintln!("{} Audit error: {}", "⚠️".yellow(), e);
            }

            last_mod = current_mod;
            last_trigger = Instant::now();
            eprintln!(
                "\n{}",
                "   Waiting for changes... (Ctrl+C to stop)".dimmed()
            );
        }
    }
}

fn get_last_modification(dirs: &[PathBuf]) -> Result<std::time::SystemTime> {
    let mut latest = std::time::UNIX_EPOCH;
    for dir in dirs {
        for entry in walkdir::WalkDir::new(dir)
            .into_iter()
            .filter_map(|e| e.ok())
        {
            if entry.file_type().is_file() {
                if let Ok(metadata) = entry.metadata() {
                    if let Ok(modified) = metadata.modified() {
                        if modified > latest {
                            latest = modified;
                        }
                    }
                }
            }
        }
    }
    Ok(latest)
}

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

    #[test]
    fn test_watch_arg_defaults() {
        let args = WatchArgs {
            shared: super::super::SharedFlags {
                chain: "ethereum".into(),
                project: std::path::PathBuf::from("."),
                json: false,
                markdown: false,
                html: false,
                strict: false,
                offline: false,
                production: false,
                report: false,
                parallelism: 4,
            },
            dirs: "src".into(),
            debounce_ms: 500,
            exclude: None,
            full: false,
        };
        assert_eq!(args.dirs, "src");
        assert_eq!(args.debounce_ms, 500);
        assert!(args.exclude.is_none());
        assert!(!args.full);
    }

    #[test]
    fn test_watch_with_custom_dirs() {
        let args = WatchArgs {
            shared: super::super::SharedFlags::default(),
            dirs: "src,test-contracts,lib".into(),
            debounce_ms: 1000,
            exclude: Some("*.test.sol".into()),
            full: true,
        };
        assert_eq!(args.dirs, "src,test-contracts,lib");
        assert_eq!(args.debounce_ms, 1000);
        assert_eq!(args.exclude.as_deref(), Some("*.test.sol"));
        assert!(args.full);
    }

    #[test]
    fn test_watch_dir_splitting() {
        let dirs_str = "src, contracts, lib";
        let dirs: Vec<PathBuf> = dirs_str
            .split(',')
            .map(|s| PathBuf::from(s.trim()))
            .collect();
        assert_eq!(dirs.len(), 3);
        assert_eq!(dirs[0], PathBuf::from("src"));
        assert_eq!(dirs[1], PathBuf::from("contracts"));
        assert_eq!(dirs[2], PathBuf::from("lib"));
    }

    #[test]
    fn test_watch_dir_relative_to_project() {
        let project = PathBuf::from("/tmp/my-project");
        let dir_str = "src";
        let dir_relative = PathBuf::from(dir_str.trim());
        let resolved = if dir_relative.is_relative() {
            project.join(dir_relative)
        } else {
            dir_relative
        };
        assert_eq!(resolved, PathBuf::from("/tmp/my-project/src"));
    }

    #[test]
    fn test_watch_dir_absolute_stays_absolute() {
        let project = PathBuf::from("/tmp/my-project");
        let dir_str = "/custom/src";
        let dir_path = PathBuf::from(dir_str.trim());
        let resolved = if dir_path.is_relative() {
            project.join(dir_path)
        } else {
            dir_path
        };
        assert_eq!(resolved, PathBuf::from("/custom/src"));
    }

    #[test]
    fn test_get_last_modification_nonexistent_dir() {
        let dirs = vec![PathBuf::from("/nonexistent/path")];
        let result = get_last_modification(&dirs);
        assert!(result.is_ok());
        let time = result.unwrap();
        // Should return UNIX_EPOCH for empty/nonexistent
        assert_eq!(time, std::time::UNIX_EPOCH);
    }

    #[test]
    fn test_watch_debounce_duration() {
        let debounce_ms: u64 = 500;
        let debounce = Duration::from_millis(debounce_ms);
        assert_eq!(debounce.as_millis(), 500);
    }

    #[test]
    fn test_watch_poll_interval() {
        let poll = Duration::from_millis(200);
        assert_eq!(poll.as_millis(), 200);
    }

    #[test]
    fn test_watch_audit_args_construction() {
        // Test the construction of audit args within watch
        let watch_args = WatchArgs {
            shared: super::super::SharedFlags {
                chain: "base".into(),
                project: PathBuf::from("."),
                json: false,
                markdown: false,
                html: false,
                strict: false,
                offline: false,
                production: false,
                report: false,
                parallelism: 4,
            },
            dirs: "src".into(),
            debounce_ms: 500,
            exclude: None,
            full: true,
        };

        let audit_args = super::super::AuditArgs {
            shared: super::super::SharedFlags {
                chain: watch_args.shared.chain.clone(),
                project: watch_args.shared.project.clone(),
                json: watch_args.shared.json,
                markdown: watch_args.shared.markdown,
                html: watch_args.shared.html,
                strict: watch_args.shared.strict,
                offline: watch_args.shared.offline,
                production: watch_args.shared.production,
                report: watch_args.shared.report,
                parallelism: watch_args.shared.parallelism,
            },
            full: watch_args.full,
            quick: false,
            summary: false,
            exploit: watch_args.full,
            gas: watch_args.full,
            all_chains: false,
            sources: watch_args.dirs.clone(),
            exclude: watch_args.exclude.clone(),
            ai: false,
            ai_provider: "openai".into(),
            ai_model: "gpt-4".into(),
            ai_api_key: None,
            ollama_endpoint: None,
            ai_full: false,
        };

        assert_eq!(audit_args.shared.chain, "base");
        assert!(audit_args.full);
        assert!(audit_args.exploit);
        assert!(audit_args.gas);
        assert_eq!(audit_args.sources, "src");
    }
}