xbp 10.35.1

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum ReleaseLedgerStatus {
    #[default]
    Planned,
    InProgress,
    Completed,
    Failed,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum ReleaseLedgerStepStatus {
    #[default]
    Pending,
    Running,
    Completed,
    Failed,
    Skipped,
    VerifiedRemote,
}

impl ReleaseLedgerStepStatus {
    pub(crate) fn is_terminal_success(&self) -> bool {
        matches!(self, Self::Completed | Self::Skipped | Self::VerifiedRemote)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ReleaseLedgerStep {
    #[serde(default)]
    pub(crate) status: ReleaseLedgerStepStatus,
    #[serde(default)]
    pub(crate) started_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub(crate) completed_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub(crate) last_error: Option<String>,
    #[serde(default)]
    pub(crate) details: serde_json::Value,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub(crate) struct ReleaseVersionTarget {
    pub(crate) path: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub(crate) struct ReleasePublishTarget {
    pub(crate) kind: String,
    pub(crate) manifest_path: String,
    #[serde(default)]
    pub(crate) package_name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ReleaseBaselineRecord {
    pub(crate) source: String,
    #[serde(default)]
    pub(crate) reference: Option<String>,
    #[serde(default)]
    pub(crate) tag: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ReleaseSelectionRecord {
    #[serde(default)]
    pub(crate) changed_files: Vec<String>,
    #[serde(default)]
    pub(crate) version_targets: Vec<ReleaseVersionTarget>,
    #[serde(default)]
    pub(crate) publish_targets: Vec<ReleasePublishTarget>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ReleaseLedger {
    pub(crate) scope_kind: String,
    pub(crate) scope_label: String,
    pub(crate) scope_slug: String,
    pub(crate) version: String,
    pub(crate) tag_name: String,
    #[serde(default)]
    pub(crate) branch_name: Option<String>,
    #[serde(default)]
    pub(crate) baseline: ReleaseBaselineRecord,
    #[serde(default)]
    pub(crate) selection: ReleaseSelectionRecord,
    #[serde(default)]
    pub(crate) status: ReleaseLedgerStatus,
    #[serde(default)]
    pub(crate) steps: BTreeMap<String, ReleaseLedgerStep>,
    #[serde(default)]
    pub(crate) created_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub(crate) updated_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub(crate) completed_at: Option<DateTime<Utc>>,
}

impl ReleaseLedger {
    pub(crate) fn step(&self, name: &str) -> Option<&ReleaseLedgerStep> {
        self.steps.get(name)
    }

    pub(crate) fn step_is_done(&self, name: &str) -> bool {
        self.step(name)
            .map(|step| step.status.is_terminal_success())
            .unwrap_or(false)
    }
}

pub(crate) fn release_ledger_scope_slug(scope_kind: &str, scope_label: &str) -> String {
    format!("{}-{}", slug(scope_kind), slug(scope_label))
}

pub(crate) fn release_ledger_path(project_root: &Path, scope_slug: &str, version: &str) -> PathBuf {
    project_root
        .join(".xbp")
        .join("releases")
        .join(scope_slug)
        .join(format!("{version}.yaml"))
}

pub(crate) fn load_release_ledger(path: &Path) -> Result<Option<ReleaseLedger>, String> {
    if !path.exists() {
        return Ok(None);
    }
    let content = fs::read_to_string(path).map_err(|error| {
        format!(
            "Failed to read release ledger {}: {}",
            path.display(),
            error
        )
    })?;
    serde_yaml::from_str(&content).map(Some).map_err(|error| {
        format!(
            "Failed to parse release ledger {}: {}",
            path.display(),
            error
        )
    })
}

pub(crate) fn save_release_ledger(path: &Path, ledger: &ReleaseLedger) -> Result<(), String> {
    let parent = path.parent().ok_or_else(|| {
        format!(
            "Release ledger path {} does not have a parent directory.",
            path.display()
        )
    })?;
    fs::create_dir_all(parent).map_err(|error| {
        format!(
            "Failed to create release ledger directory {}: {}",
            parent.display(),
            error
        )
    })?;
    let content = serde_yaml::to_string(ledger)
        .map_err(|error| format!("Failed to serialize release ledger: {}", error))?;
    fs::write(path, content).map_err(|error| {
        format!(
            "Failed to write release ledger {}: {}",
            path.display(),
            error
        )
    })
}

pub(crate) fn find_latest_successful_release_ledger(
    project_root: &Path,
    scope_slug: &str,
) -> Result<Option<ReleaseLedger>, String> {
    let root = project_root.join(".xbp").join("releases").join(scope_slug);
    let entries = match fs::read_dir(&root) {
        Ok(entries) => entries,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => {
            return Err(format!(
                "Failed to inspect release ledgers in {}: {}",
                root.display(),
                error
            ))
        }
    };

    let mut ledgers = entries
        .filter_map(Result::ok)
        .filter_map(|entry| {
            let path = entry.path();
            if path.extension().and_then(|value| value.to_str()) != Some("yaml") {
                return None;
            }
            match load_release_ledger(&path) {
                Ok(Some(ledger)) if ledger.status == ReleaseLedgerStatus::Completed => {
                    Some(Ok(ledger))
                }
                Ok(_) => None,
                Err(error) => Some(Err(error)),
            }
        })
        .collect::<Result<Vec<_>, _>>()?;
    ledgers.sort_by(|left, right| left.version.cmp(&right.version));
    Ok(ledgers.pop())
}

pub(crate) fn find_latest_unfinished_release_ledger(
    project_root: &Path,
    scope_slug: &str,
) -> Result<Option<ReleaseLedger>, String> {
    let root = project_root.join(".xbp").join("releases").join(scope_slug);
    let entries = match fs::read_dir(&root) {
        Ok(entries) => entries,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => {
            return Err(format!(
                "Failed to inspect release ledgers in {}: {}",
                root.display(),
                error
            ))
        }
    };

    let mut ledgers = entries
        .filter_map(Result::ok)
        .filter_map(|entry| {
            let path = entry.path();
            if path.extension().and_then(|value| value.to_str()) != Some("yaml") {
                return None;
            }
            match load_release_ledger(&path) {
                Ok(Some(ledger))
                    if ledger.status != ReleaseLedgerStatus::Completed
                        && ledger.status != ReleaseLedgerStatus::Failed =>
                {
                    Some(Ok(ledger))
                }
                Ok(Some(ledger)) if ledger.status == ReleaseLedgerStatus::Failed => {
                    Some(Ok(ledger))
                }
                Ok(_) => None,
                Err(error) => Some(Err(error)),
            }
        })
        .collect::<Result<Vec<_>, _>>()?;
    ledgers.sort_by(|left, right| left.version.cmp(&right.version));
    Ok(ledgers.pop())
}

fn slug(value: &str) -> String {
    let mut slug = value
        .trim()
        .chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() {
                character.to_ascii_lowercase()
            } else {
                '-'
            }
        })
        .collect::<String>();
    while slug.contains("--") {
        slug = slug.replace("--", "-");
    }
    slug.trim_matches('-').to_string()
}

#[cfg(test)]
mod tests {
    use super::{
        find_latest_successful_release_ledger, find_latest_unfinished_release_ledger,
        load_release_ledger, release_ledger_path, release_ledger_scope_slug, save_release_ledger,
        ReleaseBaselineRecord, ReleaseLedger, ReleaseLedgerStatus, ReleaseLedgerStep,
        ReleaseLedgerStepStatus, ReleaseSelectionRecord,
    };
    use std::collections::BTreeMap;
    use std::fs;
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_dir(label: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("time")
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("xbp-release-ledger-{label}-{nanos}"));
        fs::create_dir_all(&dir).expect("create temp dir");
        dir
    }

    fn sample_ledger(version: &str, status: ReleaseLedgerStatus) -> ReleaseLedger {
        ReleaseLedger {
            scope_kind: "repository".to_string(),
            scope_label: "xbp".to_string(),
            scope_slug: "repository-xbp".to_string(),
            version: version.to_string(),
            tag_name: format!("v{version}"),
            branch_name: Some(format!("releases/{version}-xbp")),
            baseline: ReleaseBaselineRecord {
                source: "github-tag".to_string(),
                reference: Some("v10.29.0".to_string()),
                tag: Some("v10.29.0".to_string()),
            },
            selection: ReleaseSelectionRecord::default(),
            status,
            steps: BTreeMap::from([(
                "publish_packages".to_string(),
                ReleaseLedgerStep {
                    status: ReleaseLedgerStepStatus::Completed,
                    ..ReleaseLedgerStep::default()
                },
            )]),
            created_at: None,
            updated_at: None,
            completed_at: None,
        }
    }

    #[test]
    fn release_ledger_scope_slug_normalizes_scope_and_label() {
        assert_eq!(
            release_ledger_scope_slug("Service", "Athena/API"),
            "service-athena-api"
        );
    }

    #[test]
    fn release_ledger_path_uses_repo_local_release_directory() {
        let root = PathBuf::from("/tmp/xbp");
        let path = release_ledger_path(&root, "repository-xbp", "10.30.0");
        assert_eq!(
            path,
            root.join(".xbp")
                .join("releases")
                .join("repository-xbp")
                .join("10.30.0.yaml")
        );
    }

    #[test]
    fn save_and_load_release_ledger_round_trip() {
        let dir = temp_dir("round-trip");
        let path = release_ledger_path(&dir, "repository-xbp", "10.30.0");
        let ledger = sample_ledger("10.30.0", ReleaseLedgerStatus::InProgress);

        save_release_ledger(&path, &ledger).expect("save release ledger");
        let loaded = load_release_ledger(&path)
            .expect("load release ledger")
            .expect("ledger");

        assert_eq!(loaded.version, "10.30.0");
        assert_eq!(loaded.tag_name, "v10.30.0");
        assert_eq!(loaded.status, ReleaseLedgerStatus::InProgress);

        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn find_latest_release_ledgers_prefers_highest_matching_version() {
        let dir = temp_dir("latest-ledgers");
        let scope_slug = "repository-xbp";

        save_release_ledger(
            &release_ledger_path(&dir, scope_slug, "10.29.0"),
            &sample_ledger("10.29.0", ReleaseLedgerStatus::Completed),
        )
        .expect("save completed ledger");
        save_release_ledger(
            &release_ledger_path(&dir, scope_slug, "10.30.0"),
            &sample_ledger("10.30.0", ReleaseLedgerStatus::Failed),
        )
        .expect("save failed ledger");
        save_release_ledger(
            &release_ledger_path(&dir, scope_slug, "10.31.0"),
            &sample_ledger("10.31.0", ReleaseLedgerStatus::InProgress),
        )
        .expect("save unfinished ledger");

        let latest_success = find_latest_successful_release_ledger(&dir, scope_slug)
            .expect("latest successful ledger")
            .expect("successful ledger");
        assert_eq!(latest_success.version, "10.29.0");

        let latest_unfinished = find_latest_unfinished_release_ledger(&dir, scope_slug)
            .expect("latest unfinished ledger")
            .expect("unfinished ledger");
        assert_eq!(latest_unfinished.version, "10.31.0");

        let _ = fs::remove_dir_all(dir);
    }
}