rustploy 0.1.0

Rustploy PaaS daemon — lightweight self-hosted deployment platform
//! `Command::Prune*`: limpeza manual de containers, volumes, imagens, build
//! cache e redes; as versões `*_core` são reaproveitadas pela limpeza
//! automática.

use crate::api::AppState;
use bollard::{
    container::PruneContainersOptions, image::PruneImagesOptions, network::PruneNetworksOptions,
    volume::PruneVolumesOptions,
};
use shared::Response as RpResponse;

/// Resultado tipado de uma limpeza — o denominador comum entre o botão manual
/// (embrulhado em `RpResponse::PruneResult` por cada função pública deste
/// arquivo) e a limpeza automática (`crate::maintenance`, que chama
/// `*_core` diretamente e embrulha em `shared::DockerCleanupResourceResult`).
pub struct PruneStat {
    pub count: u32,
    pub reclaimed_bytes: u64,
}

pub async fn prune_containers(state: AppState) -> RpResponse {
    match prune_containers_core(&state).await {
        Ok(s) => RpResponse::PruneResult {
            count: s.count,
            reclaimed_bytes: s.reclaimed_bytes,
        },
        Err(e) => RpResponse::err("DockerError", e),
    }
}

pub(crate) async fn prune_containers_core(state: &AppState) -> Result<PruneStat, String> {
    let result = state
        .docker
        .inner
        .prune_containers(None::<PruneContainersOptions<String>>)
        .await;
    // Removing containers changes image in-use counts and network attachments.
    state.docker_cache.df.invalidate().await;
    state.docker_cache.networks.invalidate().await;
    match result {
        Ok(r) => Ok(PruneStat {
            count: r.containers_deleted.map(|v| v.len() as u32).unwrap_or(0),
            reclaimed_bytes: r.space_reclaimed.unwrap_or(0) as u64,
        }),
        Err(e) => Err(e.to_string()),
    }
}

/// `all=true` mirrors `docker volume prune --all`: considers every unused
/// volume, not just anonymous ones. The `all` filter isn't in bollard
/// 0.17.1's documented list for this endpoint, but `filters` is a plain
/// map serialized straight to JSON, so the Engine API (1.42+) still honors it.
pub async fn prune_volumes(state: AppState, all: bool) -> RpResponse {
    match prune_volumes_core(&state, all).await {
        Ok(s) => RpResponse::PruneResult {
            count: s.count,
            reclaimed_bytes: s.reclaimed_bytes,
        },
        Err(e) => RpResponse::err("DockerError", e),
    }
}

pub(crate) async fn prune_volumes_core(state: &AppState, all: bool) -> Result<PruneStat, String> {
    let mut filters = std::collections::HashMap::new();
    if all {
        filters.insert("all", vec!["true"]);
    }
    let result = state
        .docker
        .inner
        .prune_volumes(Some(PruneVolumesOptions { filters }))
        .await;
    state.docker_cache.df.invalidate().await;
    match result {
        Ok(r) => Ok(PruneStat {
            count: r.volumes_deleted.map(|v| v.len() as u32).unwrap_or(0),
            reclaimed_bytes: r.space_reclaimed.unwrap_or(0) as u64,
        }),
        Err(e) => Err(e.to_string()),
    }
}

/// `all=true` mirrors `docker image prune -a`: removes every image unused by
/// any container, not just dangling/untagged ones (`dangling=true` is
/// Docker's own default when the filter is omitted).
pub async fn prune_images(state: AppState, all: bool) -> RpResponse {
    match prune_images_core(&state, all).await {
        Ok(s) => RpResponse::PruneResult {
            count: s.count,
            reclaimed_bytes: s.reclaimed_bytes,
        },
        Err(e) => RpResponse::err("DockerError", e),
    }
}

pub(crate) async fn prune_images_core(state: &AppState, all: bool) -> Result<PruneStat, String> {
    let mut filters = std::collections::HashMap::new();
    if all {
        filters.insert("dangling", vec!["false"]);
    }
    let result = state
        .docker
        .inner
        .prune_images(Some(PruneImagesOptions { filters }))
        .await;
    state.docker_cache.df.invalidate().await;
    match result {
        Ok(r) => Ok(PruneStat {
            count: r.images_deleted.map(|v| v.len() as u32).unwrap_or(0),
            reclaimed_bytes: r.space_reclaimed.unwrap_or(0) as u64,
        }),
        Err(e) => Err(e.to_string()),
    }
}

/// Usa `docker builder prune -f` via subprocess — a API REST do BuildKit
/// não está exposta pelo bollard 0.17.
pub async fn prune_build_cache(_state: AppState) -> RpResponse {
    match prune_build_cache_core().await {
        Ok(s) => RpResponse::PruneResult {
            count: s.count,
            reclaimed_bytes: s.reclaimed_bytes,
        },
        Err(e) => RpResponse::err("DockerError", e),
    }
}

pub(crate) async fn prune_build_cache_core() -> Result<PruneStat, String> {
    let output = tokio::process::Command::new("docker")
        .args(["builder", "prune", "-f"])
        .output()
        .await;

    match output {
        Ok(out) if out.status.success() => {
            // Tenta extrair "Total reclaimed space: X.XXX MB" da saída
            let text = String::from_utf8_lossy(&out.stdout);
            let reclaimed_bytes = parse_reclaimed_space(&text);
            Ok(PruneStat {
                count: 0,
                reclaimed_bytes,
            })
        }
        Ok(out) => Err(String::from_utf8_lossy(&out.stderr).trim().to_string()),
        Err(e) => Err(e.to_string()),
    }
}

fn parse_reclaimed_space(output: &str) -> u64 {
    for line in output.lines() {
        let line = line.trim();
        if let Some(rest) = line.strip_prefix("Total reclaimed space:") {
            let rest = rest.trim();
            // exemplos: "1.234 GB", "512 MB", "0B"
            if let Some((num, unit)) = rest.split_once(char::is_whitespace) {
                let n: f64 = num.parse().unwrap_or(0.0);
                return match unit.to_uppercase().as_str() {
                    "GB" | "GIB" => (n * 1_000_000_000.0) as u64,
                    "MB" | "MIB" => (n * 1_000_000.0) as u64,
                    "KB" | "KIB" => (n * 1_000.0) as u64,
                    _ => n as u64,
                };
            }
        }
    }
    0
}

pub async fn prune_networks(state: AppState) -> RpResponse {
    match prune_networks_core(&state).await {
        Ok(s) => RpResponse::PruneResult {
            count: s.count,
            reclaimed_bytes: s.reclaimed_bytes,
        },
        Err(e) => RpResponse::err("DockerError", e),
    }
}

pub(crate) async fn prune_networks_core(state: &AppState) -> Result<PruneStat, String> {
    let result = state
        .docker
        .inner
        .prune_networks(None::<PruneNetworksOptions<String>>)
        .await;
    state.docker_cache.networks.invalidate().await;
    match result {
        Ok(r) => Ok(PruneStat {
            count: r.networks_deleted.map(|v| v.len() as u32).unwrap_or(0),
            reclaimed_bytes: 0,
        }),
        Err(e) => Err(e.to_string()),
    }
}