1use 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;
34pub const MAX_TERMINAL_INDEX_ITEMS: usize = 100;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct LedgerRecordDocument {
41 pub record: LedgerRecord,
42 pub body: String,
43 pub path: PathBuf,
44}
45
46#[async_trait::async_trait]
52pub trait LedgerScheduleBridge: Send + Sync {
53 async fn sync_record_schedules(&self, record: &LedgerRecord) -> Result<Vec<String>, String>;
56
57 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#[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#[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#[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 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 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}