xbp 10.38.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Project-local ledger for idempotent TODO → issue mapping.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

const LEDGER_VERSION: u32 = 1;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TodoLedger {
    #[serde(default = "default_version")]
    pub version: u32,
    #[serde(default)]
    pub entries: BTreeMap<String, TodoLedgerEntry>,
}

fn default_version() -> u32 {
    LEDGER_VERSION
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoLedgerEntry {
    pub fingerprint: String,
    pub kind: String,
    pub path: String,
    pub line: usize,
    pub text: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub linear: Option<LinearIssueRef>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub github: Option<GithubIssueRef>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub created_at: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub updated_at: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinearIssueRef {
    pub id: String,
    pub identifier: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GithubIssueRef {
    pub number: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
}

pub fn ledger_path(project_root: &Path) -> PathBuf {
    project_root.join(".xbp").join("todo-issues.json")
}

pub fn load_ledger(project_root: &Path) -> Result<TodoLedger, String> {
    let path = ledger_path(project_root);
    if !path.exists() {
        return Ok(TodoLedger {
            version: LEDGER_VERSION,
            entries: BTreeMap::new(),
        });
    }
    let content = fs::read_to_string(&path)
        .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
    let mut ledger: TodoLedger = serde_json::from_str(&content)
        .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
    ledger.version = LEDGER_VERSION;
    Ok(ledger)
}

pub fn save_ledger(project_root: &Path, ledger: &TodoLedger) -> Result<(), String> {
    let path = ledger_path(project_root);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
    }
    let content = serde_json::to_string_pretty(ledger)
        .map_err(|e| format!("Failed to serialize todo ledger: {e}"))?;
    fs::write(&path, content + "\n")
        .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn roundtrip_ledger() {
        let stamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("xbp-todo-ledger-{stamp}"));
        fs::create_dir_all(dir.join(".xbp")).unwrap();
        let mut ledger = TodoLedger::default();
        ledger.entries.insert(
            "abc".into(),
            TodoLedgerEntry {
                fingerprint: "abc".into(),
                kind: "TODO".into(),
                path: "src/a.rs".into(),
                line: 1,
                text: "hello".into(),
                linear: Some(LinearIssueRef {
                    id: "id".into(),
                    identifier: "XLX-1".into(),
                    url: None,
                }),
                github: None,
                created_at: None,
                updated_at: None,
            },
        );
        save_ledger(&dir, &ledger).unwrap();
        let loaded = load_ledger(&dir).unwrap();
        assert_eq!(loaded.entries.len(), 1);
        assert_eq!(
            loaded.entries["abc"].linear.as_ref().unwrap().identifier,
            "XLX-1"
        );
        let _ = fs::remove_dir_all(dir);
    }
}