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 {
#[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,
pub path: String,
pub line: usize,
pub text: String,
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opened_at: Option<String>,
#[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
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum LinearLinkSource {
#[default]
XbpCreate,
AutoLink,
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>,
#[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;
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(())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TodoPathIndex {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub paths: BTreeMap<String, Vec<String>>,
#[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);
}
}