xbp 10.39.0

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

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

const LEDGER_VERSION: u32 = 2;

#[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, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TodoIssueStatus {
    #[default]
    Open,
    Done,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TodoEffortStats {
    /// Session-gap estimated coding time attributed to tracked paths while open.
    #[serde(default)]
    pub estimated_coding_seconds: u64,
    #[serde(default)]
    pub event_count: u64,
    #[serde(default)]
    pub commit_count: u64,
    #[serde(default)]
    pub added_lines: u64,
    #[serde(default)]
    pub removed_lines: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub first_activity_at: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_activity_at: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub computed_at: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoLedgerEntry {
    pub fingerprint: String,
    pub kind: String,
    /// Primary path as scanned (often package-relative).
    pub path: String,
    pub line: usize,
    pub text: String,
    /// Repo-relative paths watched for effort (git work tree paths).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub paths: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo_owner: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo_name: Option<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>,
    /// When the TODO was filed as an issue (ISO-8601).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub opened_at: Option<String>,
    /// When the linked issue was marked done/closed (ISO-8601).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub closed_at: Option<String>,
    #[serde(default)]
    pub status: TodoIssueStatus,
    #[serde(default)]
    pub effort: TodoEffortStats,
    #[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>,
}

impl TodoLedgerEntry {
    pub fn is_open(&self) -> bool {
        matches!(self.status, TodoIssueStatus::Open) && self.closed_at.is_none()
    }

    pub fn track_path(&mut self, path: &str) {
        let path = path.replace('\\', "/").trim().to_string();
        if path.is_empty() {
            return;
        }
        if !self.paths.iter().any(|p| p == &path) {
            self.paths.push(path);
        }
    }

    pub fn all_tracked_paths(&self) -> BTreeSet<String> {
        let mut set = BTreeSet::new();
        for p in &self.paths {
            let n = p.replace('\\', "/");
            if !n.is_empty() {
                set.insert(n);
            }
        }
        let primary = self.path.replace('\\', "/");
        if !primary.is_empty() {
            set.insert(primary);
        }
        set
    }
}

/// How the Linear issue was associated with this TODO.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum LinearLinkSource {
    /// Created by `xbp todos sync`.
    #[default]
    XbpCreate,
    /// Discovered from Linear↔GitHub auto-link (comment or attachment).
    AutoLink,
    /// Found via description fingerprint / search.
    Fingerprint,
}

#[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>,
    /// Optional provenance for duplicate purge / reconcile.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<LinearLinkSource>,
}

#[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("issues").join("ledger.json")
}

pub fn path_index_path(project_root: &Path) -> PathBuf {
    project_root
        .join(".xbp")
        .join("issues")
        .join("path-index.json")
}

pub fn legacy_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 = if ledger_path(project_root).exists() {
        ledger_path(project_root)
    } else {
        legacy_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;
    // Backfill paths from primary path for older ledgers.
    for entry in ledger.entries.values_mut() {
        if entry.paths.is_empty() && !entry.path.is_empty() {
            entry.paths.push(entry.path.replace('\\', "/"));
        }
        if entry.opened_at.is_none() {
            entry.opened_at = entry.created_at.clone();
        }
    }
    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()))?;
    save_path_index(project_root, ledger)?;
    Ok(())
}

/// Fast lookup: repo-relative path → fingerprints of open (and closed) issues.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TodoPathIndex {
    #[serde(default = "default_version")]
    pub version: u32,
    /// path → list of fingerprints
    #[serde(default)]
    pub paths: BTreeMap<String, Vec<String>>,
    /// open fingerprints only (for watch-time attribution)
    #[serde(default)]
    pub open_paths: BTreeMap<String, Vec<String>>,
}

pub fn save_path_index(project_root: &Path, ledger: &TodoLedger) -> Result<(), String> {
    let mut index = TodoPathIndex {
        version: LEDGER_VERSION,
        paths: BTreeMap::new(),
        open_paths: BTreeMap::new(),
    };
    for (fp, entry) in &ledger.entries {
        if entry.linear.is_none() && entry.github.is_none() {
            continue;
        }
        for path in entry.all_tracked_paths() {
            index
                .paths
                .entry(path.clone())
                .or_default()
                .push(fp.clone());
            if entry.is_open() {
                index.open_paths.entry(path).or_default().push(fp.clone());
            }
        }
    }
    for list in index.paths.values_mut() {
        list.sort();
        list.dedup();
    }
    for list in index.open_paths.values_mut() {
        list.sort();
        list.dedup();
    }
    let path = path_index_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(&index)
        .map_err(|e| format!("Failed to serialize path index: {e}"))?;
    fs::write(&path, content + "\n")
        .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
    Ok(())
}

pub fn load_path_index(project_root: &Path) -> Result<TodoPathIndex, String> {
    let path = if path_index_path(project_root).exists() {
        path_index_path(project_root)
    } else {
        project_root.join(".xbp").join("todo-path-index.json")
    };
    if !path.exists() {
        let ledger = load_ledger(project_root)?;
        save_path_index(project_root, &ledger)?;
        return load_path_index(project_root);
    }
    let content =
        fs::read_to_string(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
    serde_json::from_str(&content).map_err(|e| format!("Failed to parse {}: {e}", path.display()))
}

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

    #[test]
    fn roundtrip_ledger_with_paths_and_effort() {
        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();
        let mut entry = TodoLedgerEntry {
            fingerprint: "abc".into(),
            kind: "TODO".into(),
            path: "src/a.rs".into(),
            line: 1,
            text: "hello".into(),
            paths: vec!["crates/foo/src/a.rs".into()],
            repo_owner: Some("xylex-group".into()),
            repo_name: Some("athena".into()),
            linear: Some(LinearIssueRef {
                id: "id".into(),
                identifier: "XLX-1".into(),
                url: None,
                source: None,
            }),
            github: None,
            opened_at: Some("2026-01-01T00:00:00Z".into()),
            closed_at: None,
            status: TodoIssueStatus::Open,
            effort: TodoEffortStats {
                estimated_coding_seconds: 120,
                event_count: 3,
                ..Default::default()
            },
            created_at: None,
            updated_at: None,
        };
        entry.track_path("crates/foo/src/a.rs");
        entry.track_path("crates/foo/src/b.rs");
        ledger.entries.insert("abc".into(), entry);
        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"
        );
        assert!(loaded.entries["abc"]
            .paths
            .iter()
            .any(|p| p == "crates/foo/src/b.rs"));
        assert_eq!(loaded.entries["abc"].effort.estimated_coding_seconds, 120);

        let index = load_path_index(&dir).unwrap();
        assert!(index.open_paths.contains_key("crates/foo/src/b.rs"));
        assert!(dir.join(".xbp").join("issues").join("ledger.json").exists());
        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn reads_legacy_todo_ledger_and_writes_new_issue_ledger() {
        let stamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("xbp-legacy-todo-ledger-{stamp}"));
        fs::create_dir_all(dir.join(".xbp")).unwrap();
        fs::write(
            legacy_ledger_path(&dir),
            r#"{
  "version": 2,
  "entries": {
    "legacy": {
      "fingerprint": "legacy",
      "kind": "TODO",
      "path": "src/lib.rs",
      "line": 2,
      "text": "migrate me",
      "status": "open",
      "effort": {}
    }
  }
}
"#,
        )
        .unwrap();

        let ledger = load_ledger(&dir).unwrap();
        assert!(ledger.entries.contains_key("legacy"));
        save_ledger(&dir, &ledger).unwrap();
        assert!(ledger_path(&dir).exists());

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