Monitor de Alteracoes em Arquivos

Construa um monitor de arquivos em Rust que detecta mudancas e executa acoes automaticamente, como recompilacao e testes.

Durante o desenvolvimento, recompilar manualmente apos cada alteracao e tedioso e improdutivo. Ferramentas como nodemon, watchexec e cargo-watch resolvem isso monitorando o sistema de arquivos e executando acoes automaticamente. Neste projeto, vamos construir nosso proprio monitor de alteracoes em arquivos em Rust, com suporte a padroes glob, debouncing para evitar execucoes duplicadas e acoes customizaveis.

Este projeto ensina conceitos importantes de interacao com o sistema de arquivos, programacao orientada a eventos e controle fino de temporalidade.

O Que Vamos Construir

  • Monitoramento recursivo de diretorios
  • Filtragem por padroes glob (ex: *.rs, *.toml)
  • Debouncing configuravel para evitar execucoes repetidas
  • Execucao de comandos customizaveis ao detectar mudancas
  • Ignorar diretorios especificos (ex: target/, .git/)
  • Saida informativa com caminho do arquivo modificado

Estrutura do Projeto

file-watcher/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── filtro.rs
    └── executor.rs

Configurando o Projeto

cargo new file-watcher
cd file-watcher

Edite o Cargo.toml:

[package]
name = "file-watcher"
version = "0.1.0"
edition = "2021"

[dependencies]
notify = "6.1"
glob-match = "0.2"
clap = { version = "4.5", features = ["derive"] }
colored = "2.1"

Passo 1: Filtro de Arquivos

O modulo de filtro decide quais mudancas devem disparar acoes e quais devem ser ignoradas. Crie src/filtro.rs:

use std::path::Path;

/// Configuracao de filtragem para o monitor.
pub struct ConfigFiltro {
    /// Padroes glob para incluir (ex: "*.rs")
    pub padroes_incluir: Vec<String>,
    /// Diretorios para ignorar (ex: "target", ".git")
    pub diretorios_ignorar: Vec<String>,
}

impl ConfigFiltro {
    pub fn new(padroes: Vec<String>, ignorar: Vec<String>) -> Self {
        ConfigFiltro {
            padroes_incluir: padroes,
            diretorios_ignorar: ignorar,
        }
    }

    /// Verifica se um caminho deve ser monitorado.
    pub fn deve_processar(&self, caminho: &Path) -> bool {
        // Verificar se esta em um diretorio ignorado
        let caminho_str = caminho.to_string_lossy();
        for dir_ignorado in &self.diretorios_ignorar {
            if caminho_str.contains(&format!("/{}/", dir_ignorado))
                || caminho_str.contains(&format!("\\{}\\", dir_ignorado))
                || caminho_str.starts_with(&format!("{}/", dir_ignorado))
            {
                return false;
            }
        }

        // Se nao ha padroes de inclusao, aceitar tudo
        if self.padroes_incluir.is_empty() {
            return true;
        }

        // Verificar se o arquivo corresponde a algum padrao
        let nome = caminho
            .file_name()
            .unwrap_or_default()
            .to_string_lossy();

        for padrao in &self.padroes_incluir {
            if corresponde_glob(&nome, padrao) {
                return true;
            }
        }

        false
    }
}

/// Implementacao simples de matching de padroes glob.
/// Suporta '*' como curinga para qualquer sequencia de caracteres.
fn corresponde_glob(texto: &str, padrao: &str) -> bool {
    if padrao == "*" {
        return true;
    }

    // Padrao tipo "*.rs" -- verificar extensao
    if let Some(extensao) = padrao.strip_prefix("*.") {
        return texto.ends_with(&format!(".{}", extensao));
    }

    // Padrao tipo "Cargo.*" -- verificar prefixo
    if let Some(prefixo) = padrao.strip_suffix(".*") {
        return texto.starts_with(prefixo) && texto.contains('.');
    }

    // Correspondencia exata
    texto == padrao
}

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

    #[test]
    fn teste_filtro_extensao_rs() {
        let filtro = ConfigFiltro::new(
            vec!["*.rs".to_string()],
            vec!["target".to_string()],
        );
        assert!(filtro.deve_processar(&PathBuf::from("src/main.rs")));
        assert!(!filtro.deve_processar(&PathBuf::from("src/main.py")));
    }

    #[test]
    fn teste_ignorar_diretorio() {
        let filtro = ConfigFiltro::new(
            vec!["*.rs".to_string()],
            vec!["target".to_string()],
        );
        assert!(!filtro.deve_processar(&PathBuf::from("target/debug/main.rs")));
    }

    #[test]
    fn teste_glob_simples() {
        assert!(corresponde_glob("main.rs", "*.rs"));
        assert!(!corresponde_glob("main.py", "*.rs"));
        assert!(corresponde_glob("qualquer.txt", "*"));
        assert!(corresponde_glob("Cargo.toml", "Cargo.*"));
    }
}

O filtro usa padroes glob simplificados para decidir quais arquivos merecem atencao. Diretorios como target/ e .git/ sao ignorados por padrao para evitar loops infinitos de recompilacao.

Passo 2: Executor de Comandos

O executor roda o comando especificado pelo usuario ao detectar uma mudanca. Crie src/executor.rs:

use colored::*;
use std::process::Command;
use std::time::Instant;

/// Executa um comando no shell e exibe o resultado formatado.
pub fn executar(comando: &str) -> bool {
    println!(
        "\n{} {}",
        ">>>".green().bold(),
        comando.white().bold()
    );
    println!("{}", "-".repeat(60).dimmed());

    let inicio = Instant::now();

    let resultado = Command::new("sh")
        .arg("-c")
        .arg(comando)
        .status();

    let duracao = inicio.elapsed();

    println!("{}", "-".repeat(60).dimmed());

    match resultado {
        Ok(status) => {
            if status.success() {
                println!(
                    "{} Concluido com sucesso em {:.2}s\n",
                    "[OK]".green().bold(),
                    duracao.as_secs_f64()
                );
                true
            } else {
                println!(
                    "{} Falhou com codigo {} em {:.2}s\n",
                    "[ERRO]".red().bold(),
                    status.code().unwrap_or(-1),
                    duracao.as_secs_f64()
                );
                false
            }
        }
        Err(e) => {
            println!(
                "{} Erro ao executar comando: {}\n",
                "[ERRO]".red().bold(),
                e
            );
            false
        }
    }
}

O executor invoca o comando via shell (sh -c) para suportar pipelines e redirecionamentos. A duracao da execucao e exibida para ajudar a identificar comandos lentos.

Passo 3: Juntando Tudo no main.rs

Agora vamos implementar o loop principal com o sistema de notificacao e debouncing. Crie src/main.rs:

mod executor;
mod filtro;

use clap::Parser;
use colored::*;
use filtro::ConfigFiltro;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::{Duration, Instant};

/// Monitor de alteracoes em arquivos com execucao automatica de comandos
#[derive(Parser)]
#[command(name = "file-watcher")]
#[command(about = "Monitora arquivos e executa comandos ao detectar mudancas")]
struct Argumentos {
    /// Diretorio para monitorar (padrao: diretorio atual)
    #[arg(short, long, default_value = ".")]
    diretorio: PathBuf,

    /// Comando para executar ao detectar mudancas
    #[arg(short, long)]
    comando: String,

    /// Padroes glob para filtrar arquivos (ex: "*.rs")
    #[arg(short, long)]
    padrao: Vec<String>,

    /// Diretorios para ignorar
    #[arg(short, long, default_values_t = vec![
        "target".to_string(),
        ".git".to_string(),
        "node_modules".to_string(),
    ])]
    ignorar: Vec<String>,

    /// Tempo de debounce em milissegundos
    #[arg(long, default_value = "500")]
    debounce: u64,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Argumentos::parse();

    let filtro = ConfigFiltro::new(args.padrao.clone(), args.ignorar.clone());

    println!("{}", "=== Monitor de Arquivos ===".green().bold());
    println!("Diretorio: {}", args.diretorio.display());
    println!("Comando:   {}", args.comando.cyan());

    if !args.padrao.is_empty() {
        println!(
            "Padroes:   {}",
            args.padrao.join(", ").yellow()
        );
    }

    println!(
        "Ignorando: {}",
        args.ignorar.join(", ").dimmed()
    );
    println!(
        "Debounce:  {}ms",
        args.debounce
    );
    println!(
        "\n{}\n",
        "Aguardando mudancas... (Ctrl+C para sair)".dimmed()
    );

    // Canal para receber eventos de modificacao
    let (tx, rx) = mpsc::channel::<PathBuf>();

    let mut watcher = RecommendedWatcher::new(
        move |resultado: Result<Event, notify::Error>| {
            if let Ok(evento) = resultado {
                match evento.kind {
                    EventKind::Modify(_) | EventKind::Create(_) => {
                        for caminho in evento.paths {
                            let _ = tx.send(caminho);
                        }
                    }
                    _ => {}
                }
            }
        },
        Config::default(),
    )?;

    // Iniciar monitoramento recursivo
    watcher.watch(&args.diretorio, RecursiveMode::Recursive)?;

    let debounce = Duration::from_millis(args.debounce);
    let mut ultima_execucao = Instant::now() - debounce; // Permitir execucao imediata

    loop {
        match rx.recv() {
            Ok(caminho) => {
                // Verificar se o arquivo passa no filtro
                if !filtro.deve_processar(&caminho) {
                    continue;
                }

                // Debouncing: ignorar se executou ha pouco tempo
                let agora = Instant::now();
                if agora.duration_since(ultima_execucao) < debounce {
                    // Drenar eventos acumulados
                    while rx.try_recv().is_ok() {}
                    continue;
                }

                // Drenar eventos acumulados antes de executar
                std::thread::sleep(Duration::from_millis(50));
                while rx.try_recv().is_ok() {}

                let nome_arquivo = caminho
                    .file_name()
                    .unwrap_or_default()
                    .to_string_lossy();

                println!(
                    "{} Mudanca detectada: {}",
                    "[WATCH]".blue().bold(),
                    nome_arquivo.yellow()
                );

                executor::executar(&args.comando);
                ultima_execucao = Instant::now();
            }
            Err(e) => {
                eprintln!("Erro no canal de eventos: {}", e);
                break;
            }
        }
    }

    Ok(())
}

O loop principal recebe notificacoes de mudancas, aplica o filtro e implementa debouncing para evitar que salvar o mesmo arquivo varias vezes rapidamente dispare multiplas execucoes. O pequeno atraso de 50ms antes de drenar eventos acumulados garante que mudancas simultaneas em multiplos arquivos sejam agrupadas em uma unica execucao.

Como Executar

# Compilar o projeto
cargo build --release

# Monitorar arquivos Rust e recompilar automaticamente
cargo run -- --diretorio . --comando "cargo build" --padrao "*.rs" --padrao "*.toml"

# Monitorar e rodar testes automaticamente
cargo run -- -d src/ -c "cargo test" -p "*.rs"

# Monitorar um projeto web e recarregar
cargo run -- -d ./static -c "echo 'Arquivos atualizados!'" -p "*.html" -p "*.css" -p "*.js"

# Com debounce customizado de 1 segundo
cargo run -- -d . -c "cargo build" -p "*.rs" --debounce 1000

Saida esperada:

=== Monitor de Arquivos ===
Diretorio: .
Comando:   cargo build
Padroes:   *.rs, *.toml
Ignorando: target, .git, node_modules
Debounce:  500ms

Aguardando mudancas... (Ctrl+C para sair)

[WATCH] Mudanca detectada: main.rs

>>> cargo build
------------------------------------------------------------
   Compiling file-watcher v0.1.0 (/home/dev/file-watcher)
    Finished dev [unoptimized + debuginfo] target(s) in 1.23s
------------------------------------------------------------
[OK] Concluido com sucesso em 1.25s

Desafios para Expandir

  1. Substituicao de variaveis: Permita usar {{arquivo}} e {{diretorio}} no comando, substituindo pelo caminho do arquivo que mudou e seu diretorio pai.
  2. Arquivo de configuracao: Crie suporte a um arquivo .watcherrc.toml na raiz do projeto com configuracoes predefinidas para evitar digitar argumentos longos.
  3. Modo silencioso com notificacao: Adicione um modo que suprime a saida do comando e mostra apenas notificacoes de sucesso/falha usando notify-rust para notificacoes de desktop.
  4. Restart automatico de processos: Implemente um modo --restart que mantém o processo rodando e o reinicia quando detecta mudancas, util para servidores de desenvolvimento.
  5. Historico de mudancas: Registre um log de todas as mudancas detectadas e execucoes realizadas em um arquivo, incluindo timestamps e resultados.

Veja Tambem