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);
}
}