opencode-cloud 25.1.3

CLI for managing opencode as a persistent cloud service
Documentation
//! Disk usage reporting utilities.
//!
//! We intentionally fetch `/system/df` via raw HTTP instead of relying on
//! Bollard's `SystemDataUsageResponse` model. Docker API v1.52+ returns
//! different fields for disk usage than the models generated by Bollard,
//! which causes all values to deserialize as `None` and display as "unknown".
//! Parsing the JSON ourselves keeps the CLI accurate across Docker versions.

use anyhow::{Result, anyhow};
use opencode_cloud_core::docker::{DockerClient, DockerEndpoint};
use reqwest::Client;
use serde_json::Value;
use std::time::Duration;
use sysinfo::Disks;

#[derive(Clone, Copy)]
pub struct DiskUsageReport {
    pub images: Option<i64>,
    pub containers: Option<i64>,
    pub volumes: Option<i64>,
    pub build_cache: Option<i64>,
    pub total: Option<i64>,
}

#[derive(Clone, Copy)]
pub struct HostDiskReport {
    pub total: u64,
    pub available: u64,
    pub used: u64,
}

/// Format a disk usage report, optionally including a delta vs. a baseline.
pub fn format_disk_usage_report(
    stage: &str,
    report: DiskUsageReport,
    baseline: Option<DiskUsageReport>,
) -> Vec<String> {
    let mut lines = vec![
        format!("Docker disk usage ({stage}):"),
        format!("  Images:      {}", format_usage_value(report.images)),
        format!("  Containers:  {}", format_usage_value(report.containers)),
        format!("  Volumes:     {}", format_usage_value(report.volumes)),
        format!("  Build cache: {}", format_usage_value(report.build_cache)),
        format!("  Total:       {}", format_usage_value(report.total)),
    ];
    if let Some(delta) = format_delta_i64(report.total, baseline.and_then(|b| b.total)) {
        lines.push(format!("  Delta:       {delta}"));
    }
    lines
}

/// Format a host disk report, optionally including a delta vs. a baseline.
pub fn format_host_disk_report(
    stage: &str,
    report: HostDiskReport,
    baseline: Option<HostDiskReport>,
) -> Vec<String> {
    let mut lines = vec![
        format!("Host disk ({stage}):"),
        format!(
            "  Total:       {}",
            format_usage_value_u64(Some(report.total))
        ),
        format!(
            "  Available:   {}",
            format_usage_value_u64(Some(report.available))
        ),
        format!(
            "  Used:        {}",
            format_usage_value_u64(Some(report.used))
        ),
    ];
    if let Some(delta) = format_delta_u64(Some(report.used), baseline.map(|baseline| baseline.used))
    {
        lines.push(format!("  Delta used:  {delta}"));
    }
    lines
}

/// Fetch Docker disk usage from `/system/df` and build a report.
///
/// We bypass Bollard here because its `SystemDataUsageResponse` model does not
/// match the Docker API v1.52+ response shape, which otherwise yields "unknown"
/// values in the CLI.
pub async fn get_disk_usage_report(client: &DockerClient) -> Result<DiskUsageReport> {
    let data_usage = match fetch_system_df_json(client, true).await {
        Ok(payload) => payload,
        Err(err) => {
            tracing::debug!("Verbose /system/df failed, retrying without verbose: {err}");
            fetch_system_df_json(client, false).await?
        }
    };
    Ok(build_disk_usage_report(&data_usage))
}

/// Compute host disk report for local Docker; returns `None` for remote.
pub fn get_host_disk_report(client: &DockerClient) -> Result<Option<HostDiskReport>> {
    if client.is_remote() {
        return Ok(None);
    }
    let disks = Disks::new_with_refreshed_list();
    Ok(build_host_disk_report(&disks))
}

/// Format a possibly-negative byte count, returning "unknown" for negatives.
pub fn format_bytes_i64(value: i64) -> String {
    if value < 0 {
        return "unknown".to_string();
    }
    format_bytes_u64(value as u64)
}

/// Build a disk usage report from the raw `/system/df` JSON payload.
///
/// This parser supports both the legacy fields (`Images`, `Containers`, etc.)
/// and the newer aggregate usage fields (`ImageUsage`, `ContainerUsage`, etc.).
/// We do this because Bollard's generated models lag the Docker API schema.
fn build_disk_usage_report(data_usage: &Value) -> DiskUsageReport {
    let images =
        parse_verbose_total(data_usage, "ImageUsage").or_else(|| parse_layers_size(data_usage));
    let containers = parse_verbose_total(data_usage, "ContainerUsage")
        .or_else(|| sum_container_sizes(data_usage));
    let volumes =
        parse_verbose_total(data_usage, "VolumeUsage").or_else(|| sum_volume_sizes(data_usage));
    let build_cache = parse_verbose_total(data_usage, "BuildCacheUsage")
        .or_else(|| sum_build_cache_sizes(data_usage));

    let total = match (images, containers, volumes, build_cache) {
        (Some(images), Some(containers), Some(volumes), Some(build_cache)) => {
            Some(images + containers + volumes + build_cache)
        }
        _ => None,
    };

    DiskUsageReport {
        images,
        containers,
        volumes,
        build_cache,
        total,
    }
}

/// Build a host disk report from sysinfo disk data.
fn build_host_disk_report(disks: &Disks) -> Option<HostDiskReport> {
    if disks.list().is_empty() {
        return None;
    }
    let mut total = 0u64;
    let mut available = 0u64;
    for disk in disks.list() {
        total = total.saturating_add(disk.total_space());
        available = available.saturating_add(disk.available_space());
    }
    let used = total.saturating_sub(available);
    Some(HostDiskReport {
        total,
        available,
        used,
    })
}

/// Format a delta between two optional signed byte values.
fn format_delta_i64(after: Option<i64>, before: Option<i64>) -> Option<String> {
    let (after, before) = (after?, before?);
    let delta = after - before;
    let sign = if delta >= 0 { "+" } else { "-" };
    Some(format!("{sign}{}", format_bytes_u64(delta.unsigned_abs())))
}

/// Format a delta between two optional unsigned byte values.
fn format_delta_u64(after: Option<u64>, before: Option<u64>) -> Option<String> {
    let (after, before) = (after?, before?);
    if after >= before {
        Some(format!("+{}", format_bytes_u64(after - before)))
    } else {
        Some(format!("-{}", format_bytes_u64(before - after)))
    }
}

/// Format a byte count into a human-friendly string.
fn format_bytes_u64(value: u64) -> String {
    let units = ["B", "KB", "MB", "GB", "TB", "PB"];
    let mut size = value as f64;
    let mut index = 0usize;
    while size >= 1024.0 && index + 1 < units.len() {
        size /= 1024.0;
        index += 1;
    }
    if index == 0 {
        format!("{value} {}", units[index])
    } else {
        format!("{size:.2} {}", units[index])
    }
}

/// Format an optional signed byte value or "unknown".
fn format_usage_value(value: Option<i64>) -> String {
    value
        .map(format_bytes_i64)
        .unwrap_or_else(|| "unknown".to_string())
}

/// Format an optional unsigned byte value or "unknown".
fn format_usage_value_u64(value: Option<u64>) -> String {
    value
        .map(format_bytes_u64)
        .unwrap_or_else(|| "unknown".to_string())
}

/// Fetch `/system/df` as raw JSON for the current Docker client.
///
/// We use raw HTTP because Bollard's data-usage models don't match the Docker
/// API v1.52+ response shape, which would otherwise deserialize to `None`.
async fn fetch_system_df_json(client: &DockerClient, verbose: bool) -> Result<Value> {
    let http = build_reqwest_client(client.endpoint())?;
    let url = system_df_url(client.endpoint(), verbose)?;
    let response = http
        .get(url)
        .send()
        .await
        .map_err(|e| anyhow!("Failed to request /system/df: {e}"))?
        .error_for_status()
        .map_err(|e| anyhow!("Docker /system/df returned error: {e}"))?;
    response
        .json::<Value>()
        .await
        .map_err(|e| anyhow!("Failed to parse /system/df JSON: {e}"))
}

/// Build a reqwest client configured for the Docker endpoint.
///
/// We need custom transport (Unix socket vs HTTP) because we bypass Bollard
/// for `/system/df`. Unix sockets are only supported on Unix platforms.
fn build_reqwest_client(endpoint: &DockerEndpoint) -> Result<Client> {
    let mut builder = Client::builder().timeout(Duration::from_secs(30));
    match endpoint {
        DockerEndpoint::Unix(path) => {
            #[cfg(unix)]
            {
                builder = builder.unix_socket(path.clone());
            }
            #[cfg(not(unix))]
            {
                return Err(anyhow!(
                    "Unix socket Docker endpoints are not supported on this platform."
                ));
            }
        }
        DockerEndpoint::Http(_) => {}
    }
    builder
        .build()
        .map_err(|e| anyhow!("Failed to build HTTP client: {e}"))
}

/// Build the `/system/df` URL for the current endpoint.
///
/// For Unix sockets we use `http://localhost` as the base; for remote tunnels
/// we use the HTTP base URL. This keeps raw calls aligned with the Bollard
/// connection while avoiding mismatched response models.
fn system_df_url(endpoint: &DockerEndpoint, verbose: bool) -> Result<String> {
    let base = match endpoint {
        DockerEndpoint::Unix(_) => "http://localhost".to_string(),
        DockerEndpoint::Http(base) => base.trim_end_matches('/').to_string(),
    };
    let suffix = if verbose { "?verbose=true" } else { "" };
    Ok(format!("{base}/system/df{suffix}"))
}

/// Parse `TotalSize` from the verbose disk-usage response.
fn parse_verbose_total(data_usage: &Value, key: &str) -> Option<i64> {
    data_usage
        .get(key)
        .and_then(|usage| usage.get("TotalSize"))
        .and_then(|value| value.as_i64())
        .filter(|value| *value >= 0)
}

/// Parse the legacy `LayersSize` value for images.
fn parse_layers_size(data_usage: &Value) -> Option<i64> {
    data_usage
        .get("LayersSize")
        .and_then(|value| value.as_i64())
        .filter(|value| *value >= 0)
}

/// Sum legacy container sizes from `Containers[].SizeRw`.
fn sum_container_sizes(data_usage: &Value) -> Option<i64> {
    sum_array_sizes(data_usage, "Containers", &["SizeRw"])
}

/// Sum legacy volume sizes from `Volumes[].UsageData.Size`.
fn sum_volume_sizes(data_usage: &Value) -> Option<i64> {
    sum_array_sizes(data_usage, "Volumes", &["UsageData", "Size"])
}

/// Sum legacy build cache sizes from `BuildCache[].Size`.
fn sum_build_cache_sizes(data_usage: &Value) -> Option<i64> {
    sum_array_sizes(data_usage, "BuildCache", &["Size"])
}

/// Sum sizes from a legacy array response using a JSON field path.
fn sum_array_sizes(data_usage: &Value, key: &str, path: &[&str]) -> Option<i64> {
    let array = data_usage.get(key)?.as_array()?;
    let mut total = 0i64;
    let mut seen = false;
    for item in array {
        let mut cursor = item;
        let mut missing = false;
        for segment in path {
            match cursor.get(*segment) {
                Some(next) => cursor = next,
                None => {
                    missing = true;
                    break;
                }
            }
        }
        if missing {
            continue;
        }
        if let Some(value) = cursor.as_i64().filter(|value| *value >= 0) {
            total = total.saturating_add(value);
            seen = true;
        }
    }
    seen.then_some(total)
}

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

    /// Ensure we parse verbose disk-usage fields.
    #[test]
    fn build_disk_usage_report_parses_verbose_fields() {
        let payload = json!({
            "ImageUsage": { "TotalSize": 10 },
            "ContainerUsage": { "TotalSize": 20 },
            "VolumeUsage": { "TotalSize": 30 },
            "BuildCacheUsage": { "TotalSize": 40 }
        });
        let report = build_disk_usage_report(&payload);
        assert_eq!(report.images, Some(10));
        assert_eq!(report.containers, Some(20));
        assert_eq!(report.volumes, Some(30));
        assert_eq!(report.build_cache, Some(40));
        assert_eq!(report.total, Some(100));
    }

    /// Ensure we parse legacy disk-usage fields when verbose data is missing.
    #[test]
    fn build_disk_usage_report_parses_legacy_fields() {
        let payload = json!({
            "LayersSize": 5,
            "Containers": [{ "SizeRw": 2 }, { "SizeRw": 3 }],
            "Volumes": [{ "UsageData": { "Size": 7 } }],
            "BuildCache": [{ "Size": 11 }, { "Size": 13 }]
        });
        let report = build_disk_usage_report(&payload);
        assert_eq!(report.images, Some(5));
        assert_eq!(report.containers, Some(5));
        assert_eq!(report.volumes, Some(7));
        assert_eq!(report.build_cache, Some(24));
        assert_eq!(report.total, Some(41));
    }
}