xbp 10.40.1

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use chrono::Utc;
use colored::Colorize;

use crate::strategies::XbpConfig;

use super::types::DeployRunRecord;

fn history_dir(project_root: &Path, config: &XbpConfig) -> PathBuf {
    let rel = config
        .deploy
        .as_ref()
        .and_then(|d| d.history_dir.as_deref())
        .filter(|s| !s.trim().is_empty())
        .unwrap_or(".xbp/deployments");
    project_root.join(rel)
}

fn lock_path(project_root: &Path, config: &XbpConfig) -> PathBuf {
    let rel = config
        .deploy
        .as_ref()
        .and_then(|d| d.lock_file.as_deref())
        .filter(|s| !s.trim().is_empty())
        .unwrap_or(".xbp/deploy-lock.json");
    project_root.join(rel)
}

pub fn write_history_record(
    project_root: &Path,
    config: &XbpConfig,
    record: &DeployRunRecord,
) -> Result<(), String> {
    let dir = history_dir(project_root, config);
    fs::create_dir_all(&dir).map_err(|e| format!("create history dir: {e}"))?;

    let stamp = record.timestamp.format("%Y%m%dT%H%M%SZ");
    let safe_target = record
        .target
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
        .collect::<String>();
    let path = dir.join(format!("{stamp}-{safe_target}-{}.json", record.env));
    let body = serde_json::to_string_pretty(record).map_err(|e| e.to_string())?;
    fs::write(&path, body).map_err(|e| format!("write history {}: {e}", path.display()))?;

    // Update lock snapshot (last successful or last attempt)
    let lock = serde_json::json!({
        "updated_at": Utc::now().to_rfc3339(),
        "target": record.target,
        "env": record.env,
        "ok": record.ok,
        "history_file": path.file_name().and_then(|s| s.to_str()),
    });
    let lock_file = lock_path(project_root, config);
    if let Some(parent) = lock_file.parent() {
        let _ = fs::create_dir_all(parent);
    }
    fs::write(
        &lock_file,
        serde_json::to_string_pretty(&lock).map_err(|e| e.to_string())?,
    )
    .map_err(|e| format!("write lock {}: {e}", lock_file.display()))?;

    println!(
        "{} {}",
        "History".bright_cyan().bold(),
        path.display().to_string().bright_white()
    );
    Ok(())
}

pub fn list_history(
    project_root: &Path,
    config: &XbpConfig,
    target: &str,
    env: &str,
    limit: usize,
) -> Result<(), String> {
    let dir = history_dir(project_root, config);
    if !dir.exists() {
        println!(
            "{}",
            format!("No deploy history under {}", dir.display()).dimmed()
        );
        return Ok(());
    }

    let mut entries: Vec<PathBuf> = fs::read_dir(&dir)
        .map_err(|e| e.to_string())?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
        .collect();
    entries.sort();
    entries.reverse();

    let mut shown = 0usize;
    for path in entries {
        if shown >= limit {
            break;
        }
        let raw = fs::read_to_string(&path).map_err(|e| e.to_string())?;
        let record: DeployRunRecord =
            serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", path.display()))?;
        if target != "all" && !target.is_empty() && record.target != target && !record.services.iter().any(|s| s == target)
        {
            continue;
        }
        if !env.is_empty() && record.env != env {
            // still show if env filter is production default and record matches target only
        }
        let status = if record.ok {
            "ok".bright_green().to_string()
        } else {
            "fail".bright_red().to_string()
        };
        println!(
            "{}  {}  {}  {}  {}",
            record.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
            status,
            record.target.bright_white(),
            record.env.bright_yellow(),
            record.services.join(",")
        );
        shown += 1;
    }

    if shown == 0 {
        println!("{}", "No matching deploy history entries.".dimmed());
    }
    Ok(())
}