1use crate::paths::AgentsHome;
30use serde::Serialize;
31use serde_json::Value;
32use std::collections::HashSet;
33use std::path::{Path, PathBuf};
34
35#[derive(Debug, Serialize, PartialEq)]
38pub struct DigestSummary {
39 pub session: String,
40 pub since: String,
41 pub commits: usize,
42 pub pr_number: Option<u64>,
43 pub pr_url: Option<String>,
44 pub pr_state: Option<String>,
45 pub ci: Option<String>,
46 pub reviewed: bool,
47 pub blocked_episodes: usize,
48 pub last_block_reason: Option<String>,
49 pub resolved: bool,
50 pub cost_usd: f64,
51 pub state: String,
54 pub skipped_lines: usize,
56 pub lines: Vec<String>,
59}
60
61fn field<'a>(v: &'a Value, key: &str) -> Option<&'a Value> {
66 v.get("data")
67 .and_then(|d| d.get(key))
68 .or_else(|| v.get(key))
69}
70
71fn event_kind(v: &Value) -> Option<&str> {
74 v.get("type")
75 .and_then(|t| t.as_str())
76 .or_else(|| v.get("kind").and_then(|k| k.as_str()))
77}
78
79fn event_ts(v: &Value) -> &str {
81 v.get("ts").and_then(|t| t.as_str()).unwrap_or("")
82}
83
84#[derive(Clone, Copy)]
90enum Since<'a> {
91 Str(&'a str),
92 Epoch(u64),
93}
94
95impl Since<'_> {
96 fn includes(&self, ts: &str) -> bool {
99 match self {
100 Since::Str(s) => ts >= *s,
101 Since::Epoch(e) => to_epoch_lenient(ts).is_none_or(|secs| secs >= *e),
102 }
103 }
104
105 fn label(&self) -> String {
106 match self {
107 Since::Str(s) => s.to_string(),
108 Since::Epoch(e) => format!("epoch:{e}"),
109 }
110 }
111}
112
113fn to_epoch_lenient(ts: &str) -> Option<u64> {
121 crate::state::rfc3339_like_to_secs(ts).or_else(|| {
122 let secs = ts.get(..19)?;
123 crate::state::rfc3339_like_to_secs(&format!("{secs}Z"))
124 })
125}
126
127fn event_session_in(v: &Value, set: &HashSet<&str>) -> bool {
130 field(v, "session_id")
131 .and_then(|s| s.as_str())
132 .is_some_and(|s| set.contains(s))
133}
134
135pub fn fold(events_raw: &str, ledger_raw: &str, selector: &str, since: &str) -> DigestSummary {
139 fold_since(events_raw, ledger_raw, selector, Since::Str(since))
140}
141
142fn fold_since(
143 events_raw: &str,
144 ledger_raw: &str,
145 selector: &str,
146 since: Since<'_>,
147) -> DigestSummary {
148 let ledger = resolve_from_ledger(ledger_raw, selector, since);
153 let mut session_ids: HashSet<&str> = ledger.session_ids.iter().map(String::as_str).collect();
154 session_ids.insert(selector);
155
156 let mut skipped_lines = 0usize;
157 let mut commit_shas: Vec<String> = Vec::new(); let mut pr_state: Option<String> = None;
159 let mut ci: Option<String> = None;
160 let mut reviewed = false;
161 let mut blocked_episodes = 0usize;
162 let mut last_block_reason: Option<String> = None;
163 let mut resolved = false;
164 let mut terminated = false;
165 let mut last_decision_blocked = false;
166 let mut seen_lines: HashSet<&str> = HashSet::new();
171
172 for line in events_raw.lines() {
173 if line.trim().is_empty() {
174 continue;
175 }
176 let Ok(v) = serde_json::from_str::<Value>(line) else {
177 skipped_lines += 1;
178 continue;
179 };
180 if !event_session_in(&v, &session_ids) {
181 continue;
182 }
183 if !since.includes(event_ts(&v)) {
184 continue;
185 }
186 if !seen_lines.insert(line) {
187 continue; }
189 match event_kind(&v) {
190 Some("loop_check") => {
191 if let Some(fp) = field(&v, "fingerprint").and_then(|f| f.as_str()) {
192 let head = fp.split('|').next().unwrap_or("");
194 if !head.is_empty() && head != "none" {
195 if commit_shas.last().map(String::as_str) != Some(head) {
196 commit_shas.push(head.to_string());
197 }
198 }
199 }
200 if let Some(s) = field(&v, "pr_state").and_then(|s| s.as_str()) {
201 if s != "none" {
202 pr_state = Some(s.to_string());
203 }
204 }
205 if let Some(c) = field(&v, "ci").and_then(|c| c.as_str()) {
206 if c != "none" {
207 ci = Some(c.to_string());
208 }
209 }
210 if let Some(r) = field(&v, "reviewed").and_then(|r| r.as_bool()) {
211 reviewed = r;
212 }
213 let decision = field(&v, "decision").and_then(|d| d.as_str());
214 if decision == Some("block") {
215 if !last_decision_blocked {
222 blocked_episodes += 1;
223 resolved = false;
224 }
225 last_decision_blocked = true;
226 let ci_blocking = field(&v, "ci")
231 .and_then(|c| c.as_str())
232 .filter(|c| c.starts_with("FAILURE") || *c == "PENDING");
233 last_block_reason = ci_blocking
234 .or_else(|| field(&v, "intent").and_then(|i| i.as_str()))
235 .filter(|r| *r != "none")
236 .map(str::to_string);
237 } else if decision == Some("allow") {
238 if last_decision_blocked {
239 resolved = true;
240 }
241 last_decision_blocked = false;
242 }
243 }
244 Some("termination") | Some("session_finalized") | Some("node_closed") => {
245 terminated = true;
246 }
247 _ => {}
248 }
249 }
250
251 let commits = commit_shas.len().saturating_sub(1);
254
255 let state = if terminated {
256 "done"
257 } else if last_decision_blocked {
258 "blocked"
259 } else {
260 "working"
261 }
262 .to_string();
263
264 let mut summary = DigestSummary {
265 session: selector.to_string(),
266 since: since.label(),
267 commits,
268 pr_number: ledger.pr_number,
269 pr_url: ledger.pr_url,
270 pr_state,
271 ci,
272 reviewed,
273 blocked_episodes,
274 last_block_reason,
275 resolved,
276 cost_usd: ledger.cost_usd,
277 state,
278 skipped_lines,
279 lines: Vec::new(),
280 };
281 summary.lines = render_lines(&summary);
282 summary
283}
284
285#[derive(Default)]
287struct LedgerFold {
288 session_ids: HashSet<String>,
291 pr_number: Option<u64>,
292 pr_url: Option<String>,
293 cost_usd: f64,
294}
295
296fn basename(path: &str) -> &str {
299 path.rsplit('/').next().unwrap_or(path)
300}
301
302fn ledger_entry_matches(entry: &Value, selector: &str) -> bool {
308 if entry.get("session_id").and_then(|s| s.as_str()) == Some(selector) {
309 return true;
310 }
311 if entry
312 .get("sessions")
313 .and_then(|s| s.as_array())
314 .is_some_and(|arr| arr.iter().any(|s| s.as_str() == Some(selector)))
315 {
316 return true;
317 }
318 for key in ["graph_node_id", "title"] {
319 if entry.get(key).and_then(|v| v.as_str()) == Some(selector) {
320 return true;
321 }
322 }
323 for key in ["worktree", "root_path"] {
324 if entry
325 .get(key)
326 .and_then(|v| v.as_str())
327 .is_some_and(|p| basename(p) == selector)
328 {
329 return true;
330 }
331 }
332 false
333}
334
335fn resolve_from_ledger(ledger_raw: &str, selector: &str, since: Since<'_>) -> LedgerFold {
339 let mut out = LedgerFold::default();
340 let Ok(root) = serde_json::from_str::<Value>(ledger_raw) else {
341 return out;
342 };
343 let Some(entries) = root
345 .get("entries")
346 .and_then(|e| e.as_array())
347 .or_else(|| root.as_array())
348 else {
349 return out;
350 };
351
352 for entry in entries {
353 if !ledger_entry_matches(entry, selector) {
354 continue;
355 }
356 if let Some(sid) = entry.get("session_id").and_then(|s| s.as_str()) {
360 out.session_ids.insert(sid.to_string());
361 }
362 if let Some(arr) = entry.get("sessions").and_then(|s| s.as_array()) {
363 for s in arr.iter().filter_map(|s| s.as_str()) {
364 out.session_ids.insert(s.to_string());
365 }
366 }
367 let ts = ["timestamp", "completed", "ts"]
369 .iter()
370 .find_map(|k| entry.get(*k).and_then(|v| v.as_str()))
371 .unwrap_or("");
372 if !since.includes(ts) {
373 continue;
374 }
375 if let Some(c) = entry.get("cost_usd").and_then(|c| c.as_f64()) {
376 out.cost_usd += c;
377 }
378 if let Some(n) = entry.get("pr_number").and_then(|n| n.as_u64()) {
379 out.pr_number = Some(n);
380 }
381 if let Some(u) = entry.get("pr_url").and_then(|u| u.as_str()) {
382 out.pr_url = Some(u.to_string());
383 }
384 }
385 out
386}
387
388fn is_empty_digest(s: &DigestSummary) -> bool {
393 s.commits == 0
394 && s.pr_number.is_none()
395 && s.blocked_episodes == 0
396 && s.cost_usd == 0.0
397 && s.state != "done"
398 && s.state != "blocked"
399}
400
401fn render_lines(s: &DigestSummary) -> Vec<String> {
404 if is_empty_digest(s) {
405 return Vec::new();
406 }
407 let mut lines = Vec::new();
408
409 if s.blocked_episodes > 0 {
410 let reason = s.last_block_reason.as_deref().unwrap_or("gate");
411 let resolution = if s.resolved { "resolved" } else { "UNRESOLVED" };
412 let plural = if s.blocked_episodes == 1 { "" } else { "s" };
413 lines.push(format!(
414 "! {} block{plural} (last: {reason}) - {resolution}",
415 s.blocked_episodes
416 ));
417 }
418
419 match (s.pr_number, s.pr_state.as_deref()) {
420 (Some(n), state) => {
421 let state = state.unwrap_or("open");
422 let ci = s.ci.as_deref().unwrap_or("?");
423 let review = if s.reviewed { "reviewed" } else { "unreviewed" };
424 lines.push(format!("PR #{n} {state} - CI {ci} - {review}"));
425 }
426 (None, _) => lines.push("no PR yet".to_string()),
427 }
428
429 let commit_word = if s.commits == 1 { "commit" } else { "commits" };
430 lines.push(format!(
431 "{} {commit_word} - ${:.2} - state: {}",
432 s.commits, s.cost_usd, s.state
433 ));
434
435 lines
436}
437
438fn default_sources(home: &AgentsHome) -> (Vec<PathBuf>, PathBuf) {
442 let fno_dir = home
443 .root()
444 .parent()
445 .map(Path::to_path_buf)
446 .unwrap_or_else(|| PathBuf::from(".fno"));
447 let global_events = fno_dir.join("events.jsonl");
448 let project_events = PathBuf::from(".fno").join("events.jsonl");
449 let ledger = fno_dir.join("ledger.json");
450 (vec![project_events, global_events], ledger)
451}
452
453struct DigestArgs {
454 session: String,
455 since: String,
456 since_epoch: Option<u64>,
459 json: bool,
460 events_override: Vec<PathBuf>,
461 ledger_override: Option<PathBuf>,
462}
463
464fn parse_args(rest: &[String]) -> Result<DigestArgs, String> {
465 let mut session: Option<String> = None;
466 let mut since = String::new();
467 let mut since_epoch: Option<u64> = None;
468 let mut json = false;
469 let mut events_override: Vec<PathBuf> = Vec::new();
470 let mut ledger_override: Option<PathBuf> = None;
471
472 let mut it = expand_eq(rest).into_iter();
473 while let Some(a) = it.next() {
474 match a.as_str() {
475 "--session" => session = it.next(),
476 "--since" => since = it.next().ok_or("--since needs a value")?,
477 "--since-epoch" => {
478 since_epoch = Some(
479 it.next()
480 .and_then(|v| v.parse::<u64>().ok())
481 .ok_or("--since-epoch needs a non-negative integer")?,
482 )
483 }
484 "--json" | "-J" => json = true,
485 "--events" => {
487 events_override.push(PathBuf::from(it.next().ok_or("--events needs a path")?))
488 }
489 "--ledger" => {
490 ledger_override = Some(PathBuf::from(it.next().ok_or("--ledger needs a path")?))
491 }
492 other => return Err(format!("unknown digest flag: {other}")),
493 }
494 }
495 let session = match session {
496 Some(s) if !s.is_empty() => s,
497 _ => return Err("digest needs --session".into()),
498 };
499 Ok(DigestArgs {
500 session,
501 since,
502 since_epoch,
503 json,
504 events_override,
505 ledger_override,
506 })
507}
508
509fn expand_eq(rest: &[String]) -> Vec<String> {
511 let mut out = Vec::with_capacity(rest.len());
512 for a in rest {
513 if let Some(eq) = a.find('=') {
514 if a.starts_with("--") && eq > 2 {
515 out.push(a[..eq].to_string());
516 out.push(a[eq + 1..].to_string());
517 continue;
518 }
519 }
520 out.push(a.clone());
521 }
522 out
523}
524
525pub async fn run_digest(rest: &[String], home: &AgentsHome) -> i32 {
528 let args = match parse_args(rest) {
529 Ok(a) => a,
530 Err(msg) => {
531 eprintln!("fno-agents: {msg}");
532 return 2;
533 }
534 };
535
536 let (default_events, default_ledger) = default_sources(home);
537 let event_paths = if args.events_override.is_empty() {
538 default_events
539 } else {
540 args.events_override
541 };
542 let ledger_path = args.ledger_override.unwrap_or(default_ledger);
543
544 let mut events_raw = String::new();
545 for p in &event_paths {
546 if let Ok(content) = std::fs::read_to_string(p) {
547 events_raw.push_str(&content);
548 if !content.ends_with('\n') {
549 events_raw.push('\n');
550 }
551 }
552 }
553 let ledger_raw = std::fs::read_to_string(&ledger_path).unwrap_or_default();
554
555 let since = match args.since_epoch {
556 Some(e) => Since::Epoch(e),
557 None => Since::Str(&args.since),
558 };
559 let summary = fold_since(&events_raw, &ledger_raw, &args.session, since);
560
561 if args.json {
562 println!(
563 "{}",
564 serde_json::to_string(&summary).expect("serializing an owned value never fails")
565 );
566 } else {
567 for line in &summary.lines {
568 println!("{line}");
569 }
570 }
571 0
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 fn loop_check(
580 ts: &str,
581 session: &str,
582 head: &str,
583 pr: &str,
584 ci: &str,
585 reviewed: bool,
586 decision: &str,
587 ) -> String {
588 format!(
589 r#"{{"ts":"{ts}","type":"loop_check","source":"hook","data":{{"session_id":"{session}","fingerprint":"{head}|{pr}|{ci}|none","pr_state":"{pr}","ci":"{ci}","reviewed":{reviewed},"decision":"{decision}","intent":"promise"}}}}"#
590 )
591 }
592
593 #[test]
594 fn happy_names_pr_blocked_and_cost() {
595 let events = [
598 loop_check(
599 "2026-07-03T01:00:00Z",
600 "sess-A",
601 "base",
602 "none",
603 "PENDING",
604 false,
605 "block",
606 ),
607 loop_check(
608 "2026-07-03T02:00:00Z",
609 "sess-A",
610 "c1",
611 "OPEN",
612 "PENDING",
613 false,
614 "block",
615 ),
616 loop_check(
617 "2026-07-03T03:00:00Z",
618 "sess-A",
619 "c2",
620 "OPEN",
621 "SUCCESS",
622 true,
623 "allow",
624 ),
625 ]
626 .join("\n");
627 let ledger = r#"{"entries":[{"session_id":"sess-A","cost_usd":1.5,"pr_number":42,"pr_url":"https://x/pull/42","completed":"2026-07-03T03:00:00Z"}]}"#;
628
629 let d = fold(&events, ledger, "sess-A", "2026-07-03T00:00:00Z");
630 assert_eq!(d.pr_number, Some(42), "PR named");
631 assert_eq!(d.commits, 2, "base->c1->c2 = 2 commits");
632 assert_eq!(d.blocked_episodes, 1);
634 assert!(d.resolved, "a later allow resolved the block");
635 assert_eq!(d.cost_usd, 1.5);
636 assert!(d.reviewed);
637 assert_eq!(d.skipped_lines, 0);
638 assert!(
640 d.lines.iter().any(|l| l.contains("#42")),
641 "lines name the PR: {:?}",
642 d.lines
643 );
644 assert!(
645 d.lines.iter().any(|l| l.contains("block")),
646 "lines name the block"
647 );
648 }
649
650 #[test]
651 fn corrupt_line_is_skipped_and_counted() {
652 let events = [
653 loop_check(
654 "2026-07-03T02:00:00Z",
655 "sess-A",
656 "c1",
657 "OPEN",
658 "SUCCESS",
659 true,
660 "allow",
661 ),
662 "{ this is not valid json".to_string(),
663 "".to_string(),
664 ]
665 .join("\n");
666 let d = fold(&events, "", "sess-A", "");
667 assert_eq!(d.skipped_lines, 1, "one bad line, blank line not counted");
668 assert_eq!(d.pr_state.as_deref(), Some("OPEN"));
669 }
670
671 #[test]
672 fn since_in_future_is_empty() {
673 let events = loop_check(
674 "2026-07-03T02:00:00Z",
675 "sess-A",
676 "c1",
677 "OPEN",
678 "SUCCESS",
679 true,
680 "allow",
681 );
682 let d = fold(&events, "", "sess-A", "2099-01-01T00:00:00Z");
683 assert_eq!(d.commits, 0);
684 assert_eq!(d.blocked_episodes, 0);
685 assert!(d.pr_state.is_none());
686 assert_eq!(d.cost_usd, 0.0);
687 assert!(
690 d.lines.is_empty(),
691 "empty digest renders nothing: {:?}",
692 d.lines
693 );
694 }
695
696 #[test]
697 fn other_session_ignored() {
698 let events = [
699 loop_check(
700 "2026-07-03T02:00:00Z",
701 "sess-A",
702 "c1",
703 "OPEN",
704 "SUCCESS",
705 true,
706 "allow",
707 ),
708 loop_check(
709 "2026-07-03T02:00:00Z",
710 "sess-B",
711 "z9",
712 "MERGED",
713 "SUCCESS",
714 true,
715 "allow",
716 ),
717 ]
718 .join("\n");
719 let d = fold(&events, "", "sess-A", "");
720 assert_eq!(
721 d.pr_state.as_deref(),
722 Some("OPEN"),
723 "sess-B's MERGED must not leak in"
724 );
725 }
726
727 #[test]
728 fn cost_matches_sessions_array_membership() {
729 let ledger = r#"[{"sessions":["uuid-1","sess-A"],"cost_usd":23.45,"pr_number":7,"pr_url":"https://x/pull/7","completed":"2026-07-03T03:00:00Z"}]"#;
731 let l = resolve_from_ledger(ledger, "sess-A", Since::Str(""));
732 assert_eq!(l.pr_number, Some(7));
733 assert_eq!(l.pr_url.as_deref(), Some("https://x/pull/7"));
734 assert_eq!(l.cost_usd, 23.45);
735 }
736
737 #[test]
738 fn ledger_cost_respects_since() {
739 let ledger = r#"[{"session_id":"sess-A","cost_usd":9.0,"timestamp":"2026-07-01T00:00:00Z"},{"session_id":"sess-A","cost_usd":1.0,"timestamp":"2026-07-03T00:00:00Z"}]"#;
740 let l = resolve_from_ledger(ledger, "sess-A", Since::Str("2026-07-02T00:00:00Z"));
741 assert_eq!(
742 l.cost_usd, 1.0,
743 "the pre-since entry is excluded from the delta"
744 );
745 }
746
747 #[test]
748 fn node_id_selector_resolves_via_ledger_to_events() {
749 let ledger = r#"[{"graph_node_id":"x-4e2d","worktree":"/w/footnote/x-4e2d","session_id":"20260703T-abc","cost_usd":2.0,"pr_number":99,"pr_url":"https://x/pull/99","completed":"2026-07-03T03:00:00Z"}]"#;
753 let events = loop_check(
754 "2026-07-03T02:00:00Z",
755 "20260703T-abc",
756 "c1",
757 "OPEN",
758 "SUCCESS",
759 true,
760 "block",
761 );
762 let d = fold(&events, ledger, "x-4e2d", "");
763 assert_eq!(
764 d.pr_number,
765 Some(99),
766 "PR resolved from the ledger by node id"
767 );
768 assert_eq!(d.cost_usd, 2.0);
769 assert_eq!(
770 d.pr_state.as_deref(),
771 Some("OPEN"),
772 "events found via the resolved session id"
773 );
774 assert_eq!(d.blocked_episodes, 1);
775 }
776
777 #[test]
778 fn mirrored_events_are_folded_once() {
779 let one = loop_check(
782 "2026-07-03T01:00:00Z",
783 "s",
784 "a",
785 "OPEN",
786 "FAILURE:x",
787 false,
788 "block",
789 );
790 let events = [one.clone(), one].join("\n");
791 let d = fold(&events, "", "s", "");
792 assert_eq!(d.blocked_episodes, 1, "the duplicate line is folded once");
793 }
794
795 #[test]
796 fn repeated_block_rows_are_one_episode() {
797 let events = [
799 loop_check(
800 "2026-07-03T01:00:00Z",
801 "s",
802 "a",
803 "OPEN",
804 "FAILURE:x",
805 false,
806 "block",
807 ),
808 loop_check(
809 "2026-07-03T01:05:00Z",
810 "s",
811 "a",
812 "OPEN",
813 "FAILURE:x",
814 false,
815 "block",
816 ),
817 loop_check(
818 "2026-07-03T01:10:00Z",
819 "s",
820 "a",
821 "OPEN",
822 "FAILURE:x",
823 false,
824 "block",
825 ),
826 ]
827 .join("\n");
828 let d = fold(&events, "", "s", "");
829 assert_eq!(
830 d.blocked_episodes, 1,
831 "three block rows, one unresolved spell"
832 );
833 }
834
835 #[test]
836 fn ledger_epoch_filter_parses_non_z_microsecond_ts() {
837 let ledger =
840 r#"[{"session_id":"s","cost_usd":5.0,"completed":"2026-07-01T00:00:00.123456"}]"#;
841 let after = crate::state::rfc3339_like_to_secs("2026-07-02T00:00:00Z").unwrap();
842 let l = resolve_from_ledger(ledger, "s", Since::Epoch(after));
843 assert_eq!(
844 l.cost_usd, 0.0,
845 "the pre-window microsecond-ts row is excluded"
846 );
847 }
848
849 #[test]
850 fn reblock_after_resolve_reads_unresolved() {
851 let events = [
855 loop_check(
856 "2026-07-03T01:00:00Z",
857 "s",
858 "a",
859 "OPEN",
860 "FAILURE:x",
861 false,
862 "block",
863 ),
864 loop_check(
865 "2026-07-03T02:00:00Z",
866 "s",
867 "b",
868 "OPEN",
869 "SUCCESS",
870 true,
871 "allow",
872 ),
873 loop_check(
874 "2026-07-03T03:00:00Z",
875 "s",
876 "c",
877 "OPEN",
878 "FAILURE:y",
879 false,
880 "block",
881 ),
882 ]
883 .join("\n");
884 let d = fold(&events, "", "s", "");
885 assert_eq!(d.blocked_episodes, 2);
886 assert!(!d.resolved, "the current (last) block is unresolved");
887 assert_eq!(d.state, "blocked");
888 assert!(d.lines.iter().any(|l| l.contains("UNRESOLVED")));
889 }
890
891 #[test]
892 fn since_epoch_scopes_to_the_absence_window() {
893 let events = [
896 loop_check(
897 "2026-07-03T01:00:00Z",
898 "sess-A",
899 "old",
900 "OPEN",
901 "PENDING",
902 false,
903 "block",
904 ),
905 loop_check(
906 "2026-07-03T05:00:00Z",
907 "sess-A",
908 "new",
909 "OPEN",
910 "SUCCESS",
911 true,
912 "allow",
913 ),
914 ]
915 .join("\n");
916 let detach = crate::state::rfc3339_like_to_secs("2026-07-03T03:00:00Z").unwrap();
918 let d = fold_since(&events, "", "sess-A", Since::Epoch(detach));
919 assert_eq!(
920 d.blocked_episodes, 0,
921 "the pre-detach block is out of window"
922 );
923 assert!(d.reviewed, "the in-window allow still counts");
924 assert!(d.since.starts_with("epoch:"));
925 }
926
927 #[test]
928 fn worktree_basename_selector_matches() {
929 let entry: Value =
930 serde_json::from_str(r#"{"worktree":"/Users/x/conductor/workspaces/footnote/x-4e2d"}"#)
931 .unwrap();
932 assert!(ledger_entry_matches(&entry, "x-4e2d"));
933 assert!(!ledger_entry_matches(&entry, "footnote"));
934 }
935}