Skip to main content

bamboo_memory/ledger_store/
mod.rs

1//! Ledger store — persistence for prospective-memory records.
2//!
3//! The ledger is the structured, future-facing counterpart to the durable
4//! memory store: one markdown file (YAML frontmatter + body) per
5//! [`LedgerRecord`], plus derived, rebuildable indexes (`by_time.json`,
6//! `by_status.json`) and human-readable views (`AGENDA.md`, `TODO.md`), all
7//! under the dedicated `{data_dir}/ledger/v1` area. It deliberately mirrors
8//! `memory_store`'s conventions: atomic writes, per-scope write locks, an
9//! append-only audit log, and indexes that are always regenerable from the
10//! record documents.
11
12use std::collections::BTreeMap;
13use std::io;
14use std::path::PathBuf;
15
16use bamboo_domain::ledger::{LedgerRecord, LedgerScope, RecordKind, RecordStatus};
17use bamboo_domain::TaskPriority;
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20
21pub mod paths;
22pub mod store;
23
24pub use paths::LedgerPathResolver;
25pub use store::{AgendaItem, AgendaSnapshot, LedgerStore, RecordFilter};
26
27pub const BY_TIME_INDEX_FILE: &str = "by_time.json";
28pub const BY_STATUS_INDEX_FILE: &str = "by_status.json";
29pub const AGENDA_VIEW_FILE: &str = "AGENDA.md";
30pub const TODO_VIEW_FILE: &str = "TODO.md";
31pub const AUDIT_LOG_FILE: &str = "audit.jsonl";
32
33pub const MAX_RECORD_TITLE_LEN: usize = 200;
34/// Terminal records kept per status bucket in `by_status.json`; the documents
35/// themselves are never pruned, only the derived index is capped.
36pub const MAX_TERMINAL_INDEX_ITEMS: usize = 100;
37
38/// A ledger record document as stored on disk.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct LedgerRecordDocument {
41    pub record: LedgerRecord,
42    pub body: String,
43    pub path: PathBuf,
44}
45
46/// Port through which ledger writers keep real schedules in step with a
47/// record's `remind_at`/`recurrence` times. Implemented by the server over its
48/// schedule store; the tool layer and background maintenance both talk to this
49/// trait so neither ever sees the scheduler directly. Absent in embedded/test
50/// setups (records still persist, reminders just don't fire).
51#[async_trait::async_trait]
52pub trait LedgerScheduleBridge: Send + Sync {
53    /// Make the managed schedules match the record's current `remind_at` and
54    /// `recurrence`. Returns the full set of schedule ids the record now owns.
55    async fn sync_record_schedules(&self, record: &LedgerRecord) -> Result<Vec<String>, String>;
56
57    /// Delete/disable managed schedules (the record went terminal or lost its
58    /// times).
59    async fn release_schedules(&self, schedule_ids: &[String]) -> Result<(), String>;
60}
61
62pub fn validate_record_id(record_id: &str) -> io::Result<&str> {
63    let trimmed = record_id.trim();
64    if trimmed.is_empty() {
65        return Err(io::Error::new(
66            io::ErrorKind::InvalidInput,
67            "record id cannot be empty",
68        ));
69    }
70    if !trimmed
71        .chars()
72        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
73    {
74        return Err(io::Error::new(
75            io::ErrorKind::InvalidInput,
76            "record id must contain only alphanumeric, dash, or underscore characters",
77        ));
78    }
79    Ok(trimmed)
80}
81
82pub fn validate_record_title(title: &str) -> io::Result<&str> {
83    let trimmed = title.trim();
84    if trimmed.is_empty() {
85        return Err(io::Error::new(
86            io::ErrorKind::InvalidInput,
87            "record title cannot be empty",
88        ));
89    }
90    if trimmed.chars().count() > MAX_RECORD_TITLE_LEN {
91        return Err(io::Error::new(
92            io::ErrorKind::InvalidInput,
93            format!("record title too long (max {} chars)", MAX_RECORD_TITLE_LEN),
94        ));
95    }
96    Ok(trimmed)
97}
98
99pub fn parse_record_document(content: &str) -> io::Result<(LedgerRecord, String)> {
100    let trimmed = content.trim_start_matches('\u{feff}');
101    let Some(rest) = trimmed.strip_prefix("---\n") else {
102        return Err(io::Error::new(
103            io::ErrorKind::InvalidData,
104            "missing frontmatter start marker",
105        ));
106    };
107    let Some(end_idx) = rest.find("\n---\n") else {
108        return Err(io::Error::new(
109            io::ErrorKind::InvalidData,
110            "missing frontmatter end marker",
111        ));
112    };
113    let yaml = &rest[..end_idx];
114    let body = &rest[end_idx + "\n---\n".len()..];
115    let record: LedgerRecord = serde_yaml::from_str(yaml).map_err(|error| {
116        io::Error::new(
117            io::ErrorKind::InvalidData,
118            format!("failed to parse ledger record frontmatter: {error}"),
119        )
120    })?;
121    Ok((record, body.trim().to_string()))
122}
123
124pub fn render_record_document(record: &LedgerRecord, body: &str) -> io::Result<String> {
125    let yaml = serde_yaml::to_string(record).map_err(|error| {
126        io::Error::new(
127            io::ErrorKind::InvalidData,
128            format!("failed to serialize ledger record frontmatter: {error}"),
129        )
130    })?;
131    Ok(format!("---\n{}---\n\n{}\n", yaml, body.trim()))
132}
133
134/// `by_time.json`: non-terminal records that have a time anchor, ascending by
135/// anchor — the source for agenda queries and prompt injection.
136#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137pub struct TimeIndex {
138    pub generated_at: String,
139    pub items: Vec<TimeIndexItem>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct TimeIndexItem {
144    pub id: String,
145    pub title: String,
146    pub kind: RecordKind,
147    pub status: RecordStatus,
148    pub priority: TaskPriority,
149    pub anchor_at: DateTime<Utc>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub due_at: Option<DateTime<Utc>>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub starts_at: Option<DateTime<Utc>>,
154}
155
156/// `by_status.json`: records bucketed by status. Non-terminal buckets are
157/// complete; terminal buckets keep only the most recent
158/// [`MAX_TERMINAL_INDEX_ITEMS`] entries (counts stay exact).
159#[derive(Debug, Clone, Serialize, Deserialize, Default)]
160pub struct StatusIndex {
161    pub generated_at: String,
162    pub buckets: BTreeMap<String, Vec<StatusIndexItem>>,
163    pub counts: BTreeMap<String, usize>,
164    pub total: usize,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct StatusIndexItem {
169    pub id: String,
170    pub title: String,
171    pub kind: RecordKind,
172    pub priority: TaskPriority,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub parent_id: Option<String>,
175    pub updated_at: DateTime<Utc>,
176}
177
178/// Append-only audit entry, one JSONL line per ledger mutation.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct LedgerAuditEntry {
181    pub timestamp: String,
182    pub action: String,
183    pub scope: LedgerScope,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub project_key: Option<String>,
186    pub record_id: String,
187    pub summary: String,
188}
189
190pub fn build_time_index(records: &[LedgerRecord], generated_at: DateTime<Utc>) -> TimeIndex {
191    let mut items: Vec<TimeIndexItem> = records
192        .iter()
193        .filter(|record| !record.status.is_terminal())
194        .filter_map(|record| {
195            record.time_anchor().map(|anchor_at| TimeIndexItem {
196                id: record.id.clone(),
197                title: record.title.clone(),
198                kind: record.kind.clone(),
199                status: record.status,
200                priority: record.priority.clone(),
201                anchor_at,
202                due_at: record.time.due_at,
203                starts_at: record.time.starts_at,
204            })
205        })
206        .collect();
207    items.sort_by(|left, right| {
208        left.anchor_at
209            .cmp(&right.anchor_at)
210            .then_with(|| left.id.cmp(&right.id))
211    });
212    TimeIndex {
213        generated_at: generated_at.to_rfc3339(),
214        items,
215    }
216}
217
218pub fn build_status_index(records: &[LedgerRecord], generated_at: DateTime<Utc>) -> StatusIndex {
219    let mut buckets: BTreeMap<String, Vec<StatusIndexItem>> = BTreeMap::new();
220    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
221    for record in records {
222        let key = record.status.as_str().to_string();
223        *counts.entry(key.clone()).or_default() += 1;
224        buckets.entry(key).or_default().push(StatusIndexItem {
225            id: record.id.clone(),
226            title: record.title.clone(),
227            kind: record.kind.clone(),
228            priority: record.priority.clone(),
229            parent_id: record.relations.parent_id.clone(),
230            updated_at: record.updated_at,
231        });
232    }
233    let total = records.len();
234    for (status_key, items) in buckets.iter_mut() {
235        items.sort_by(|left, right| {
236            right
237                .updated_at
238                .cmp(&left.updated_at)
239                .then_with(|| left.id.cmp(&right.id))
240        });
241        let is_terminal = RecordStatus::parse(status_key).is_some_and(RecordStatus::is_terminal);
242        if is_terminal {
243            items.truncate(MAX_TERMINAL_INDEX_ITEMS);
244        }
245    }
246    StatusIndex {
247        generated_at: generated_at.to_rfc3339(),
248        buckets,
249        counts,
250        total,
251    }
252}
253
254pub fn build_todo_markdown_view(records: &[LedgerRecord]) -> String {
255    let mut out = String::from("# Ledger — Open Records\n\n");
256    let open: Vec<&LedgerRecord> = records
257        .iter()
258        .filter(|record| !record.status.is_terminal())
259        .collect();
260    if open.is_empty() {
261        out.push_str("_(empty)_\n");
262        return out;
263    }
264
265    // Roots first (no parent, or parent not among the open set), children
266    // indented beneath — a record tree with one root reads as a plan.
267    let open_ids: std::collections::HashSet<&str> =
268        open.iter().map(|record| record.id.as_str()).collect();
269    let mut children: BTreeMap<&str, Vec<&LedgerRecord>> = BTreeMap::new();
270    let mut roots: Vec<&LedgerRecord> = Vec::new();
271    for record in &open {
272        match record
273            .relations
274            .parent_id
275            .as_deref()
276            .filter(|parent| open_ids.contains(parent))
277        {
278            Some(parent) => children.entry(parent).or_default().push(record),
279            None => roots.push(record),
280        }
281    }
282
283    fn push_line(out: &mut String, record: &LedgerRecord, depth: usize) {
284        let anchor = record
285            .time_anchor()
286            .map(|at| format!(" — {}", at.format("%Y-%m-%d %H:%M UTC")))
287            .unwrap_or_default();
288        out.push_str(&format!(
289            "{}- `{}` [{}/{}] {}{}\n",
290            "  ".repeat(depth),
291            record.id,
292            record.kind.as_str(),
293            record.status.as_str(),
294            record.title,
295            anchor,
296        ));
297    }
298
299    fn push_tree(
300        out: &mut String,
301        record: &LedgerRecord,
302        children: &BTreeMap<&str, Vec<&LedgerRecord>>,
303        depth: usize,
304    ) {
305        push_line(out, record, depth);
306        // Depth cap guards against a pathological parent cycle in hand-edited files.
307        if depth >= 8 {
308            return;
309        }
310        if let Some(kids) = children.get(record.id.as_str()) {
311            for kid in kids {
312                push_tree(out, kid, children, depth + 1);
313            }
314        }
315    }
316
317    for root in roots {
318        push_tree(&mut out, root, &children, 0);
319    }
320    out
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use chrono::TimeZone;
327
328    fn record(id: &str, status: RecordStatus, due_hour: Option<u32>) -> LedgerRecord {
329        let mut record = LedgerRecord::new(id, RecordKind::Todo, format!("record {id}"));
330        record.status = status;
331        record.time.due_at =
332            due_hour.map(|hour| Utc.with_ymd_and_hms(2026, 7, 20, hour, 0, 0).unwrap());
333        record
334    }
335
336    #[test]
337    fn record_document_round_trips() {
338        let source = record("rec_1", RecordStatus::Open, Some(9));
339        let rendered = render_record_document(&source, "Bring the old passport.\n").unwrap();
340        let (parsed, body) = parse_record_document(&rendered).unwrap();
341        assert_eq!(parsed, source);
342        assert_eq!(body, "Bring the old passport.");
343    }
344
345    #[test]
346    fn time_index_sorts_by_anchor_and_skips_terminal_and_undated() {
347        let records = vec![
348            record("rec_late", RecordStatus::Open, Some(15)),
349            record("rec_early", RecordStatus::InProgress, Some(8)),
350            record("rec_done", RecordStatus::Done, Some(6)),
351            record("rec_undated", RecordStatus::Open, None),
352        ];
353        let index = build_time_index(&records, Utc::now());
354        let ids: Vec<&str> = index.items.iter().map(|item| item.id.as_str()).collect();
355        assert_eq!(ids, vec!["rec_early", "rec_late"]);
356    }
357
358    #[test]
359    fn status_index_counts_stay_exact_when_terminal_bucket_is_capped() {
360        let mut records: Vec<LedgerRecord> = (0..(MAX_TERMINAL_INDEX_ITEMS + 5))
361            .map(|idx| record(&format!("rec_{idx}"), RecordStatus::Done, None))
362            .collect();
363        records.push(record("rec_open", RecordStatus::Open, None));
364
365        let index = build_status_index(&records, Utc::now());
366        assert_eq!(index.counts["done"], MAX_TERMINAL_INDEX_ITEMS + 5);
367        assert_eq!(index.buckets["done"].len(), MAX_TERMINAL_INDEX_ITEMS);
368        assert_eq!(index.buckets["open"].len(), 1);
369        assert_eq!(index.total, MAX_TERMINAL_INDEX_ITEMS + 6);
370    }
371
372    #[test]
373    fn todo_view_nests_children_under_open_parents() {
374        let mut parent = record("rec_trip", RecordStatus::Open, None);
375        parent.title = "Plan the trip".to_string();
376        let mut child = record("rec_flight", RecordStatus::Open, Some(6));
377        child.relations.parent_id = Some("rec_trip".to_string());
378        let done = record("rec_done", RecordStatus::Done, None);
379
380        let view = build_todo_markdown_view(&[parent, child, done]);
381        assert!(view.contains("- `rec_trip`"));
382        assert!(view.contains("  - `rec_flight`"));
383        assert!(!view.contains("rec_done"));
384    }
385
386    #[test]
387    fn validate_record_id_rejects_path_characters() {
388        assert!(validate_record_id("rec_ok-1").is_ok());
389        assert!(validate_record_id("../escape").is_err());
390        assert!(validate_record_id("a/b").is_err());
391        assert!(validate_record_id("  ").is_err());
392    }
393}