Agendador de Tarefas (Cron) em Rust

Construa um agendador de tarefas tipo cron em Rust com parsing de expressoes, execucao async com tokio e captura de saida.

Automatizar a execucao de tarefas em intervalos regulares e uma necessidade fundamental em qualquer ambiente de producao. Desde backups periodicos ate limpeza de cache, um agendador de tarefas confiavel e indispensavel. Neste projeto, vamos construir um agendador de tarefas inspirado no cron, capaz de interpretar expressoes de agendamento, executar comandos de forma assincrona e capturar toda a saida.

Voce aprendera a trabalhar com parsing de expressoes temporais, execucao assincrona com tokio, gerenciamento de processos filhos e controle preciso de tempo.

O Que Vamos Construir

  • Parser de expressoes de agendamento simplificadas (estilo cron)
  • Execucao assincrona de comandos com tokio
  • Captura de stdout e stderr dos processos
  • Arquivo de configuracao em TOML para definir tarefas
  • Log de execucoes com timestamp e status
  • Suporte a multiplas tarefas simultaneas

Estrutura do Projeto

task-scheduler/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── parser.rs
    ├── executor.rs
    └── scheduler.rs

Configurando o Projeto

cargo new task-scheduler
cd task-scheduler

Edite o Cargo.toml:

[package]
name = "task-scheduler"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
chrono = "0.4"
toml = "0.8"
serde = { version = "1.0", features = ["derive"] }
colored = "2.1"

Passo 1: Parser de Expressoes de Agendamento

Vamos implementar um parser simplificado que suporta expressoes como */5 (a cada 5 minutos), 30 (no minuto 30) e * (todo minuto). Crie src/parser.rs:

use chrono::{Local, Timelike};

/// Representa uma expressao de agendamento com campos para minuto e hora.
#[derive(Debug, Clone)]
pub struct Expressao {
    pub minuto: CampoTempo,
    pub hora: CampoTempo,
}

/// Cada campo pode ser: qualquer valor, um valor fixo ou um intervalo.
#[derive(Debug, Clone)]
pub enum CampoTempo {
    /// Corresponde a qualquer valor (*)
    Qualquer,
    /// Corresponde a um valor exato (ex: 30)
    Exato(u32),
    /// Corresponde a um intervalo (ex: */5 = a cada 5 unidades)
    Intervalo(u32),
}

impl CampoTempo {
    /// Parseia uma string em um CampoTempo.
    pub fn parsear(s: &str) -> Result<Self, String> {
        let s = s.trim();

        if s == "*" {
            return Ok(CampoTempo::Qualquer);
        }

        if let Some(intervalo) = s.strip_prefix("*/") {
            let valor: u32 = intervalo
                .parse()
                .map_err(|_| format!("Intervalo invalido: {}", s))?;
            if valor == 0 {
                return Err("Intervalo nao pode ser zero".to_string());
            }
            return Ok(CampoTempo::Intervalo(valor));
        }

        let valor: u32 = s
            .parse()
            .map_err(|_| format!("Valor invalido: {}", s))?;
        Ok(CampoTempo::Exato(valor))
    }

    /// Verifica se o valor atual corresponde a esta expressao.
    pub fn corresponde(&self, valor: u32) -> bool {
        match self {
            CampoTempo::Qualquer => true,
            CampoTempo::Exato(v) => *v == valor,
            CampoTempo::Intervalo(intervalo) => valor % intervalo == 0,
        }
    }
}

impl Expressao {
    /// Parseia uma expressao no formato "minuto hora" (ex: "*/5 *").
    pub fn parsear(expressao: &str) -> Result<Self, String> {
        let partes: Vec<&str> = expressao.split_whitespace().collect();

        if partes.len() != 2 {
            return Err(format!(
                "Expressao deve ter 2 campos (minuto hora), recebeu: '{}'",
                expressao
            ));
        }

        Ok(Expressao {
            minuto: CampoTempo::parsear(partes[0])?,
            hora: CampoTempo::parsear(partes[1])?,
        })
    }

    /// Verifica se a expressao corresponde ao horario atual.
    pub fn deve_executar_agora(&self) -> bool {
        let agora = Local::now();
        self.minuto.corresponde(agora.minute())
            && self.hora.corresponde(agora.hour())
    }
}

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

    #[test]
    fn teste_parsear_qualquer() {
        let campo = CampoTempo::parsear("*").unwrap();
        assert!(campo.corresponde(0));
        assert!(campo.corresponde(30));
    }

    #[test]
    fn teste_parsear_intervalo() {
        let campo = CampoTempo::parsear("*/5").unwrap();
        assert!(campo.corresponde(0));
        assert!(campo.corresponde(15));
        assert!(!campo.corresponde(3));
    }

    #[test]
    fn teste_parsear_exato() {
        let campo = CampoTempo::parsear("30").unwrap();
        assert!(campo.corresponde(30));
        assert!(!campo.corresponde(15));
    }

    #[test]
    fn teste_expressao_completa() {
        let expr = Expressao::parsear("*/10 *").unwrap();
        assert!(expr.minuto.corresponde(0));
        assert!(expr.minuto.corresponde(20));
    }
}

O parser converte expressoes de texto em estruturas que podem ser avaliadas contra o horario atual. Isso permite verificar a cada minuto se uma tarefa deve ser executada.

Passo 2: Executor de Comandos

O executor e responsavel por rodar os comandos e capturar sua saida. Crie src/executor.rs:

use chrono::Local;
use colored::*;
use std::process::Stdio;
use tokio::process::Command;

/// Resultado da execucao de um comando.
#[derive(Debug)]
pub struct ResultadoExecucao {
    pub comando: String,
    pub codigo_saida: Option<i32>,
    pub stdout: String,
    pub stderr: String,
    pub duracao_ms: u128,
    pub sucesso: bool,
}

/// Executa um comando de forma assincrona e captura toda a saida.
pub async fn executar_comando(comando: &str) -> ResultadoExecucao {
    let inicio = std::time::Instant::now();
    let agora = Local::now().format("%Y-%m-%d %H:%M:%S");

    println!(
        "{} {} Executando: {}",
        agora.to_string().dimmed(),
        "[EXEC]".cyan(),
        comando.white().bold()
    );

    let resultado = Command::new("sh")
        .arg("-c")
        .arg(comando)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await;

    let duracao = inicio.elapsed().as_millis();

    match resultado {
        Ok(saida) => {
            let stdout = String::from_utf8_lossy(&saida.stdout).to_string();
            let stderr = String::from_utf8_lossy(&saida.stderr).to_string();
            let codigo = saida.status.code();
            let sucesso = saida.status.success();

            if sucesso {
                println!(
                    "{} {} Concluido em {}ms (codigo: {})",
                    Local::now().format("%Y-%m-%d %H:%M:%S").to_string().dimmed(),
                    "[  OK]".green(),
                    duracao,
                    codigo.unwrap_or(-1)
                );
            } else {
                println!(
                    "{} {} Falhou em {}ms (codigo: {})",
                    Local::now().format("%Y-%m-%d %H:%M:%S").to_string().dimmed(),
                    "[ERRO]".red().bold(),
                    duracao,
                    codigo.unwrap_or(-1)
                );
            }

            if !stdout.is_empty() {
                for linha in stdout.lines() {
                    println!("  {} {}", "|".dimmed(), linha);
                }
            }

            if !stderr.is_empty() {
                for linha in stderr.lines() {
                    println!("  {} {}", "|".red(), linha);
                }
            }

            ResultadoExecucao {
                comando: comando.to_string(),
                codigo_saida: codigo,
                stdout,
                stderr,
                duracao_ms: duracao,
                sucesso,
            }
        }
        Err(e) => {
            println!(
                "{} {} Erro ao executar: {}",
                Local::now().format("%Y-%m-%d %H:%M:%S").to_string().dimmed(),
                "[ERRO]".red().bold(),
                e
            );

            ResultadoExecucao {
                comando: comando.to_string(),
                codigo_saida: None,
                stdout: String::new(),
                stderr: e.to_string(),
                duracao_ms: duracao,
                sucesso: false,
            }
        }
    }
}

O executor usa tokio::process::Command para rodar comandos sem bloquear a thread principal. Isso permite que multiplas tarefas sejam executadas simultaneamente sem interferir no loop principal do agendador.

Passo 3: Agendador Principal

O scheduler coordena a verificacao de horarios e o disparo das tarefas. Crie src/scheduler.rs:

use crate::executor;
use crate::parser::Expressao;
use serde::Deserialize;
use std::path::Path;

/// Configuracao de uma tarefa individual.
#[derive(Debug, Deserialize, Clone)]
pub struct ConfigTarefa {
    pub nome: String,
    pub agenda: String,
    pub comando: String,
}

/// Configuracao completa do agendador.
#[derive(Debug, Deserialize)]
pub struct Configuracao {
    #[serde(rename = "tarefa")]
    pub tarefas: Vec<ConfigTarefa>,
}

/// Representa uma tarefa pronta para execucao com sua expressao parseada.
#[derive(Debug, Clone)]
pub struct Tarefa {
    pub nome: String,
    pub expressao: Expressao,
    pub comando: String,
}

/// Carrega as tarefas de um arquivo TOML de configuracao.
pub fn carregar_configuracao(caminho: &Path) -> Result<Vec<Tarefa>, Box<dyn std::error::Error>> {
    let conteudo = std::fs::read_to_string(caminho)?;
    let config: Configuracao = toml::from_str(&conteudo)?;

    let mut tarefas = Vec::new();
    for cfg in config.tarefas {
        let expressao = Expressao::parsear(&cfg.agenda)
            .map_err(|e| format!("Erro na tarefa '{}': {}", cfg.nome, e))?;
        tarefas.push(Tarefa {
            nome: cfg.nome,
            expressao,
            comando: cfg.comando,
        });
    }

    Ok(tarefas)
}

/// Verifica quais tarefas devem ser executadas e as dispara.
pub async fn verificar_e_executar(tarefas: &[Tarefa]) {
    let mut handles = Vec::new();

    for tarefa in tarefas {
        if tarefa.expressao.deve_executar_agora() {
            let comando = tarefa.comando.clone();
            let nome = tarefa.nome.clone();

            let handle = tokio::spawn(async move {
                println!("\n--- Tarefa: {} ---", nome);
                let resultado = executor::executar_comando(&comando).await;
                (nome, resultado)
            });

            handles.push(handle);
        }
    }

    for handle in handles {
        if let Ok((nome, resultado)) = handle.await {
            if !resultado.sucesso {
                eprintln!("Tarefa '{}' falhou com codigo {:?}", nome, resultado.codigo_saida);
            }
        }
    }
}

O agendador le as tarefas de um arquivo TOML, parseia suas expressoes e verifica a cada minuto quais devem ser disparadas. Tarefas que coincidem sao executadas de forma concorrente usando tokio::spawn.

Passo 4: Juntando Tudo no main.rs

Crie src/main.rs:

mod executor;
mod parser;
mod scheduler;

use chrono::Local;
use colored::*;
use std::path::PathBuf;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args: Vec<String> = std::env::args().collect();

    let caminho_config = if args.len() > 1 {
        PathBuf::from(&args[1])
    } else {
        PathBuf::from("tarefas.toml")
    };

    if !caminho_config.exists() {
        eprintln!("Arquivo de configuracao nao encontrado: {}", caminho_config.display());
        eprintln!("\nCrie um arquivo '{}' com o seguinte formato:", caminho_config.display());
        eprintln!(
            r#"
[[tarefa]]
nome = "Verificar disco"
agenda = "*/5 *"
comando = "df -h | head -5"

[[tarefa]]
nome = "Data atual"
agenda = "* *"
comando = "date"
"#
        );
        std::process::exit(1);
    }

    println!("{}", "=== Agendador de Tarefas Rust ===".green().bold());
    println!(
        "Inicio: {}\n",
        Local::now().format("%Y-%m-%d %H:%M:%S")
    );

    let tarefas = scheduler::carregar_configuracao(&caminho_config)?;

    println!("Tarefas carregadas:");
    for (i, tarefa) in tarefas.iter().enumerate() {
        println!(
            "  {}. {} - agenda: '{}' -> {}",
            i + 1,
            tarefa.nome.cyan(),
            format!("{:?}", tarefa.expressao).dimmed(),
            tarefa.comando
        );
    }
    println!(
        "\nAgendador ativo. Verificando a cada 60 segundos...\n{}",
        "Pressione Ctrl+C para encerrar.".dimmed()
    );

    // Executar verificacao imediata
    scheduler::verificar_e_executar(&tarefas).await;

    // Loop principal: verificar a cada 60 segundos
    let mut intervalo = tokio::time::interval(tokio::time::Duration::from_secs(60));
    loop {
        intervalo.tick().await;
        scheduler::verificar_e_executar(&tarefas).await;
    }
}

O ponto de entrada carrega o arquivo de configuracao, exibe as tarefas registradas e entra em um loop infinito que verifica a cada 60 segundos se alguma tarefa deve ser executada.

Como Executar

Primeiro, crie o arquivo de configuracao tarefas.toml na raiz do projeto:

[[tarefa]]
nome = "Data e hora"
agenda = "* *"
comando = "date '+%Y-%m-%d %H:%M:%S'"

[[tarefa]]
nome = "Uso de memoria"
agenda = "*/2 *"
comando = "free -h | head -3"

[[tarefa]]
nome = "Espaco em disco"
agenda = "*/5 *"
comando = "df -h / | tail -1"

[[tarefa]]
nome = "Processos ativos"
agenda = "0 *"
comando = "ps aux | wc -l"

Agora compile e execute:

# Compilar
cargo build --release

# Executar com arquivo padrao (tarefas.toml)
cargo run

# Executar com arquivo especifico
cargo run -- meu_agendamento.toml

Saida esperada:

=== Agendador de Tarefas Rust ===
Inicio: 2026-01-15 10:30:00

Tarefas carregadas:
  1. Data e hora - agenda: '* *' -> date '+%Y-%m-%d %H:%M:%S'
  2. Uso de memoria - agenda: '*/2 *' -> free -h | head -3
  3. Espaco em disco - agenda: '*/5 *' -> df -h / | tail -1
  4. Processos ativos - agenda: '0 *' -> ps aux | wc -l

Agendador ativo. Verificando a cada 60 segundos...
Pressione Ctrl+C para encerrar.

--- Tarefa: Data e hora ---
2026-01-15 10:30:00 [EXEC] Executando: date '+%Y-%m-%d %H:%M:%S'
2026-01-15 10:30:00 [  OK] Concluido em 5ms (codigo: 0)
  | 2026-01-15 10:30:00

--- Tarefa: Uso de memoria ---
2026-01-15 10:30:00 [EXEC] Executando: free -h | head -3
2026-01-15 10:30:00 [  OK] Concluido em 8ms (codigo: 0)
  |               total        used        free
  | Mem:          16Gi       4.2Gi        11Gi

Desafios para Expandir

  1. Expressoes cron completas: Expanda o parser para suportar os 5 campos do cron real (minuto, hora, dia do mes, mes, dia da semana) incluindo listas e faixas como 1,15 e 9-17.
  2. Persistencia de historico: Salve o historico de execucoes em um arquivo SQLite usando a crate rusqlite, permitindo consultar execucoes passadas e estatisticas.
  3. Notificacoes de falha: Implemente envio de alertas por webhook ou email quando uma tarefa falhar, utilizando a crate reqwest para chamadas HTTP.
  4. Timeout por tarefa: Adicione um campo timeout na configuracao que cancele tarefas que excedam o tempo limite usando tokio::time::timeout.
  5. Interface web: Crie uma API REST simples com axum para visualizar tarefas agendadas, historico de execucoes e disparar execucoes manuais.

Veja Tambem