agent_doc_element_backlog/
guard_policy.rs1use crate::backlog::{
9 DroppedBacklogReport, MalformedTrackedItemRef, ShadowPendingReport,
10 detect_dropped_from_history_with_extra_current_ids, detect_shadow_open_items,
11 format_dropped_refs, format_shadow_refs, malformed_tracked_item_interruption_message,
12 malformed_tracked_item_refs_in_components,
13};
14use agent_doc_element::element;
15use anyhow::Result;
16use std::collections::HashSet;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum BacklogGuardOutcome {
20 Pass,
21 Warn(Vec<String>),
22 Interrupt(String),
23}
24
25pub fn shadow_backlog_guard(doc: &str) -> Result<BacklogGuardOutcome> {
26 Ok(shadow_backlog_report_guard(&detect_shadow_open_items(doc)?))
27}
28
29pub fn shadow_backlog_report_guard(report: &ShadowPendingReport) -> BacklogGuardOutcome {
30 if !report.shadow_only.is_empty() {
31 return BacklogGuardOutcome::Interrupt(format!(
32 "[session-check] INTERRUPTED: open backlog item(s) exist only outside live agent:backlog: {}. Re-run preflight/repair after restoring them to the live backlog or marking them complete",
33 format_shadow_refs(&report.shadow_only)
34 ));
35 }
36 if !report.duplicated_in_live_backlog.is_empty() {
37 return BacklogGuardOutcome::Warn(vec![format!(
38 "[session-check] warning: open backlog item(s) also appear outside live agent:backlog: {}",
39 format_shadow_refs(&report.duplicated_in_live_backlog)
40 )]);
41 }
42 BacklogGuardOutcome::Pass
43}
44
45pub fn malformed_tracked_item_guard(
46 content: &str,
47 components: &[element::Component],
48) -> BacklogGuardOutcome {
49 let refs = malformed_tracked_item_reference_strings(malformed_tracked_item_refs_in_components(
50 content, components,
51 ));
52 if refs.is_empty() {
53 return BacklogGuardOutcome::Pass;
54 }
55 BacklogGuardOutcome::Interrupt(malformed_tracked_item_interruption_message(&refs))
56}
57
58pub fn malformed_tracked_item_reference_strings(
59 refs: impl IntoIterator<Item = MalformedTrackedItemRef>,
60) -> Vec<String> {
61 refs.into_iter().map(|item| item.reference()).collect()
62}
63
64pub fn dropped_from_history_guard(
65 current_doc: &str,
66 baseline_doc: &str,
67 done_ids: &HashSet<String>,
68 extra_current_ids: &HashSet<String>,
69) -> Result<BacklogGuardOutcome> {
70 Ok(dropped_from_history_report_guard(
71 &detect_dropped_from_history_with_extra_current_ids(
72 current_doc,
73 baseline_doc,
74 done_ids,
75 extra_current_ids,
76 )?,
77 ))
78}
79
80pub fn dropped_from_history_report_guard(report: &DroppedBacklogReport) -> BacklogGuardOutcome {
81 if report.dropped.is_empty() {
82 return BacklogGuardOutcome::Pass;
83 }
84 BacklogGuardOutcome::Interrupt(format!(
85 "[session-check] INTERRUPTED: open backlog item(s) from recent history are completely absent from the document: {}. Restore them to the live backlog, move them to icebox, or mark them done",
86 format_dropped_refs(&report.dropped)
87 ))
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use crate::backlog::{
94 ShadowPendingItem, detect_dropped_from_history, format_dropped_refs, format_shadow_refs,
95 };
96
97 #[test]
98 fn format_shadow_refs_joins_references() {
99 let refs = format_shadow_refs(&[
100 ShadowPendingItem {
101 id: "one1".to_string(),
102 text: "first".to_string(),
103 line: 7,
104 },
105 ShadowPendingItem {
106 id: "two2".to_string(),
107 text: "second".to_string(),
108 line: 9,
109 },
110 ]);
111
112 assert_eq!(refs, "#one1 (line 7), #two2 (line 9)");
113 }
114
115 #[test]
116 fn shadow_guard_interrupts_shadow_only_items() {
117 let doc = concat!(
118 "<!-- agent:backlog -->\n",
119 "- [ ] [#live1] Keep in backlog\n",
120 "<!-- /agent:backlog -->\n\n",
121 "<!-- parked copy\n",
122 "- [ ] [#lost1] Drifted out of backlog\n",
123 "-->\n"
124 );
125
126 let outcome = shadow_backlog_guard(doc).unwrap();
127
128 assert!(matches!(outcome, BacklogGuardOutcome::Interrupt(message)
129 if message.contains("#lost1") && message.contains("only outside live agent:backlog")));
130 }
131
132 #[test]
133 fn shadow_guard_warns_duplicate_shadow_items() {
134 let doc = concat!(
135 "<!-- agent:backlog -->\n",
136 "- [ ] [#live1] Keep in backlog\n",
137 "<!-- /agent:backlog -->\n\n",
138 "<!-- parked copy\n",
139 "- [ ] [#live1] Keep in backlog\n",
140 "-->\n"
141 );
142
143 let outcome = shadow_backlog_guard(doc).unwrap();
144
145 assert!(matches!(outcome, BacklogGuardOutcome::Warn(lines)
146 if lines.len() == 1 && lines[0].contains("#live1") && lines[0].contains("also appear")));
147 }
148
149 #[test]
150 fn malformed_guard_interrupts_with_projected_refs() {
151 let doc = concat!(
152 "<!-- agent:backlog -->\n",
153 "_- [ ] [#bad1] damaged backlog\n",
154 "<!-- /agent:backlog -->\n"
155 );
156 let components = element::parse(doc).unwrap();
157
158 let outcome = malformed_tracked_item_guard(doc, &components);
159
160 assert!(matches!(outcome, BacklogGuardOutcome::Interrupt(message)
161 if message.contains("backlog #bad1 (line 1)") && message.contains("malformed tracked checklist item")));
162 }
163
164 #[test]
165 fn dropped_history_guard_interrupts_with_missing_ids() {
166 let baseline = concat!(
167 "<!-- agent:backlog -->\n",
168 "- [ ] [#gone1] Was open in baseline\n",
169 "<!-- /agent:backlog -->\n"
170 );
171 let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
172
173 let outcome =
174 dropped_from_history_guard(current, baseline, &HashSet::new(), &HashSet::new())
175 .unwrap();
176
177 assert!(matches!(outcome, BacklogGuardOutcome::Interrupt(message)
178 if message.contains("#gone1") && message.contains("recent history")));
179 }
180
181 #[test]
182 fn dropped_refs_match_backlog_detection_projection() {
183 let baseline = concat!(
184 "<!-- agent:backlog -->\n",
185 "- [ ] [#gone1] Was open in baseline\n",
186 "- [ ] [#gone2] Also open in baseline\n",
187 "<!-- /agent:backlog -->\n"
188 );
189 let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
190 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
191
192 assert_eq!(format_dropped_refs(&report.dropped), "#gone1, #gone2");
193 }
194}