1use anyhow::{Result, bail};
69use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
70use std::collections::HashMap;
71
72pub const BACKLOG_COMPONENT: &str = "backlog";
74
75pub const BACKLOG_ALIAS: &str = "pending";
77
78pub const BACKLOG_DONE_COMPONENT: &str = "done";
80
81pub const REVIEW_COMPONENT: &str = "review";
83
84pub const ICEBOX_COMPONENT: &str = "icebox";
86
87const EXCHANGE_COMPONENT: &str = "exchange";
89
90pub fn is_backlog_component(name: &str) -> bool {
93 name == BACKLOG_COMPONENT || name == BACKLOG_ALIAS
94}
95
96pub fn is_backlog_done_component(name: &str) -> bool {
98 name == BACKLOG_DONE_COMPONENT
99}
100
101pub fn is_review_component(name: &str) -> bool {
103 name == REVIEW_COMPONENT
104}
105
106pub fn is_icebox_component(name: &str) -> bool {
108 name == ICEBOX_COMPONENT
109}
110
111pub fn is_tracked_work_component(name: &str) -> bool {
115 is_backlog_component(name) || is_review_component(name) || is_icebox_component(name)
116}
117
118pub fn strip_backlog_patch_attr(doc: &str) -> String {
125 let mut result = doc.to_string();
126 let mut warned = false;
127
128 for name in &[BACKLOG_COMPONENT, BACKLOG_ALIAS] {
129 let prefix = format!("<!-- agent:{} ", name);
130 while let Some(start) = result.find(&prefix) {
131 let tag_start = start;
132 let rest = &result[start + prefix.len()..];
133 let Some(end_rel) = rest.find("-->") else {
134 break;
135 };
136 let attrs_text = &rest[..end_rel];
137 let tag_end = start + prefix.len() + end_rel + 3; let tokens: Vec<&str> = attrs_text.split_whitespace().collect();
140 let has_patch_or_mode = tokens.iter().any(|t| {
141 t.split_once('=')
142 .map(|(k, _)| k == "patch" || k == "mode")
143 .unwrap_or(false)
144 });
145
146 if !has_patch_or_mode {
147 break;
148 }
149
150 if !warned {
151 eprintln!(
152 "[deprecation] patch/mode attribute on agent:{} is deprecated and will be stripped — \
153 the binary owns backlog mutations via --pending-* flags",
154 name
155 );
156 warned = true;
157 }
158
159 let remaining: Vec<&str> = tokens
160 .iter()
161 .filter(|t| {
162 t.split_once('=')
163 .map(|(k, _)| k != "patch" && k != "mode")
164 .unwrap_or(true)
165 })
166 .copied()
167 .collect();
168
169 let new_tag = if remaining.is_empty() {
170 format!("<!-- agent:{} -->", name)
171 } else {
172 format!("<!-- agent:{} {} -->", name, remaining.join(" "))
173 };
174
175 result.replace_range(tag_start..tag_end, &new_tag);
176 }
177 }
178
179 result
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct Component {
188 pub name: String,
189 pub attrs: HashMap<String, String>,
191 pub open_start: usize,
193 pub open_end: usize,
195 pub close_start: usize,
197 pub close_end: usize,
199}
200
201impl Component {
202 #[allow(dead_code)] pub fn content<'a>(&self, doc: &'a str) -> &'a str {
205 &doc[self.open_end..self.close_start]
206 }
207
208 pub fn patch_mode(&self) -> Option<&str> {
212 self.attrs
213 .get("patch")
214 .map(|s| s.as_str())
215 .or_else(|| self.attrs.get("mode").map(|s| s.as_str()))
216 }
217
218 pub fn replace_content(&self, doc: &str, new_content: &str) -> String {
221 let mut result = String::with_capacity(doc.len() + new_content.len());
222 result.push_str(&doc[..self.open_end]);
223 result.push_str(new_content);
224 result.push_str(&doc[self.close_start..]);
225 result
226 }
227
228 pub fn append_with_caret(
235 &self,
236 doc: &str,
237 content: &str,
238 caret_offset: Option<usize>,
239 ) -> String {
240 let existing = &doc[self.open_end..self.close_start];
241 if append_patch_already_present(existing, content) {
242 return doc.to_string();
243 }
244
245 if let Some(caret) = caret_offset {
246 if caret > self.open_end && caret <= self.close_start {
248 let insert_at = doc[..caret]
250 .rfind('\n')
251 .map(|i| i + 1)
252 .unwrap_or(self.open_end);
253
254 let insert_at = insert_at.max(self.open_end);
256 let prior = &doc[self.open_end..insert_at];
257
258 let mut result = String::with_capacity(doc.len() + content.len() + 1);
259 result.push_str(&doc[..insert_at]);
260 if needs_exchange_heading_separator(&self.name, prior, content) {
261 result.push('\n');
262 }
263 result.push_str(content.trim_end());
264 result.push('\n');
265 result.push_str(&doc[insert_at..]);
266 return result;
267 }
268 }
269
270 let mut result = String::with_capacity(doc.len() + content.len() + 1);
272 result.push_str(&doc[..self.open_end]);
273 let trimmed_existing = existing.trim_end();
274 result.push_str(trimmed_existing);
275 if needs_exchange_heading_separator(&self.name, trimmed_existing, content) {
276 result.push('\n');
277 }
278 result.push('\n');
279 result.push_str(content.trim_end());
280 result.push('\n');
281 result.push_str(&doc[self.close_start..]);
282 result
283 }
284
285 pub fn append_with_boundary(&self, doc: &str, content: &str, boundary_id: &str) -> String {
291 let boundary_marker = format!("<!-- agent:boundary:{} -->", boundary_id);
292 let content_region = &doc[self.open_end..self.close_start];
293 if append_patch_already_present(content_region, content) {
294 return doc.to_string();
295 }
296 let code_ranges = find_code_ranges(doc);
297
298 let mut search_from = 0;
300 let found_pos = loop {
301 match content_region[search_from..].find(&boundary_marker) {
302 Some(rel_pos) => {
303 let abs_pos = self.open_end + search_from + rel_pos;
304 if code_ranges
305 .iter()
306 .any(|&(cs, ce)| abs_pos >= cs && abs_pos < ce)
307 {
308 search_from += rel_pos + boundary_marker.len();
310 continue;
311 }
312 break Some(abs_pos);
313 }
314 None => break None,
315 }
316 };
317
318 if let Some(abs_pos) = found_pos {
319 let line_start = doc[..abs_pos]
321 .rfind('\n')
322 .map(|i| i + 1)
323 .unwrap_or(self.open_end)
324 .max(self.open_end);
325
326 let marker_end = abs_pos + boundary_marker.len();
328 let line_end = if marker_end < self.close_start
329 && doc.as_bytes().get(marker_end) == Some(&b'\n')
330 {
331 marker_end + 1
332 } else {
333 marker_end
334 };
335 let line_end = line_end.min(self.close_start);
336
337 let new_id = crate::id::new_boundary_id();
341 let new_marker = crate::id::format_boundary_marker(&new_id);
342 let mut result = String::with_capacity(doc.len() + content.len() + new_marker.len());
343 let prior = &doc[self.open_end..line_start];
344 result.push_str(&doc[..line_start]);
345 if needs_exchange_heading_separator(&self.name, prior, content) {
346 result.push('\n');
347 }
348 result.push_str(content.trim_end());
349 result.push('\n');
350 result.push_str(&new_marker);
351 result.push('\n');
352 result.push_str(&doc[line_end..]);
353 return result;
354 }
355
356 self.append_with_caret(doc, content, None)
358 }
359}
360
361pub fn converge_queue_auto(doc: &str, want_auto: bool) -> Option<String> {
377 let components = parse(doc).ok()?;
378 let queue = components.iter().find(|c| c.name == "queue")?;
379 let raw_tag = &doc[queue.open_start..queue.open_end];
380 let new_tag = set_auto_in_queue_tag(raw_tag, want_auto);
381 if new_tag == *raw_tag {
382 return None;
383 }
384 let mut result = String::with_capacity(doc.len());
385 result.push_str(&doc[..queue.open_start]);
386 result.push_str(&new_tag);
387 result.push_str(&doc[queue.open_end..]);
388 Some(result)
389}
390
391fn set_auto_in_queue_tag(tag: &str, want_auto: bool) -> String {
395 let Some((mut tokens, tail)) = queue_tag_tokens(tag) else {
396 return tag.to_string();
397 };
398 tokens = tokens
399 .into_iter()
400 .map(|token| normalize_queue_tag_token(&token))
401 .collect();
402 let has_auto = tokens.iter().any(|token| token == "auto");
403 if want_auto == has_auto {
404 return format!("<!-- {} -->{}", tokens.join(" "), tail);
405 }
406 if want_auto {
407 let insert_at = if tokens.is_empty() { 0 } else { 1 };
410 tokens.insert(insert_at, "auto".to_string());
411 } else {
412 tokens.retain(|token| token != "auto");
413 }
414 format!("<!-- {} -->{}", tokens.join(" "), tail)
415}
416
417fn queue_tag_tokens(tag: &str) -> Option<(Vec<String>, &str)> {
418 let close_idx = tag.find("-->")?;
419 let core = &tag[..close_idx + 3];
420 let tail = &tag[close_idx + 3..];
421 let inner = core
422 .trim_start_matches("<!--")
423 .trim_end_matches("-->")
424 .trim();
425 Some((split_marker_tokens(inner), tail))
426}
427
428fn split_marker_tokens(inner: &str) -> Vec<String> {
429 let mut tokens = Vec::new();
430 let mut current = String::new();
431 let mut quote: Option<char> = None;
432 for ch in inner.chars() {
433 if ch.is_whitespace() && quote.is_none() {
434 if !current.is_empty() {
435 tokens.push(std::mem::take(&mut current));
436 }
437 continue;
438 }
439 if matches!(ch, '"' | '\'') {
440 if quote == Some(ch) {
441 quote = None;
442 } else if quote.is_none() {
443 quote = Some(ch);
444 }
445 }
446 current.push(ch);
447 }
448 if !current.is_empty() {
449 tokens.push(current);
450 }
451 tokens
452}
453
454fn normalize_queue_tag_token(token: &str) -> String {
455 let Some((key, value)) = token.split_once('=') else {
456 return token.to_string();
457 };
458 if is_queue_boolean_attr(key) && value.eq_ignore_ascii_case("true") {
459 return key.to_string();
460 }
461 if key == "preset"
462 && let Some(stripped) = strip_malformed_true_suffix(value)
463 {
464 return format!("{key}={stripped}");
465 }
466 token.to_string()
467}
468
469fn is_queue_boolean_attr(key: &str) -> bool {
470 matches!(key, "auto" | "priority" | "go" | "start" | "stop")
471}
472
473fn strip_malformed_true_suffix(value: &str) -> Option<&str> {
474 let stripped = value.strip_suffix("=true")?;
475 if (stripped.starts_with('"') && stripped.ends_with('"'))
476 || (stripped.starts_with('\'') && stripped.ends_with('\''))
477 {
478 Some(stripped)
479 } else {
480 None
481 }
482}
483
484fn is_valid_name(name: &str) -> bool {
486 if name.is_empty() {
487 return false;
488 }
489 let first = name.as_bytes()[0];
490 if !first.is_ascii_alphanumeric() {
491 return false;
492 }
493 name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
494}
495
496fn append_patch_already_present(existing: &str, content: &str) -> bool {
497 let patch = normalize_append_patch_content(content);
498 if patch.is_empty() {
499 return false;
500 }
501 let existing_norm = normalize_append_patch_content(existing);
502 if existing_norm.contains(&patch) {
503 return true;
504 }
505 patch_is_typing_snapshot_of(&existing_norm, &patch)
518}
519
520fn patch_is_typing_snapshot_of(existing: &str, patch: &str) -> bool {
526 let e: Vec<&str> = existing.lines().collect();
527 let p: Vec<&str> = patch.lines().collect();
528 if p.is_empty() || p.len() > e.len() {
529 return false;
530 }
531 'outer: for start in 0..=(e.len() - p.len()) {
532 let mut saw_prefix_divergence = false;
533 for (j, pl) in p.iter().enumerate() {
534 let el = e[start + j];
535 if pl == &el {
536 continue;
537 }
538 if !pl.is_empty() && el.starts_with(pl) {
541 saw_prefix_divergence = true;
542 continue;
543 }
544 continue 'outer;
545 }
546 if saw_prefix_divergence {
547 return true;
548 }
549 }
550 false
551}
552
553fn needs_exchange_heading_separator(component_name: &str, prior: &str, content: &str) -> bool {
554 component_name == EXCHANGE_COMPONENT
555 && !prior.trim().is_empty()
556 && !prior.ends_with("\n\n")
557 && starts_with_response_heading(content)
558}
559
560fn starts_with_response_heading(content: &str) -> bool {
561 content.trim_start().starts_with("### Re:")
562}
563
564fn normalize_append_patch_content(content: &str) -> String {
565 content
566 .lines()
567 .filter_map(|line| {
568 let trimmed = line.trim();
569 if trimmed.starts_with("<!-- agent:boundary:") && trimmed.ends_with(" -->") {
570 None
571 } else if markdown_heading_has_head_marker(line) {
572 Some(strip_user_prompt_prefix(line.trim_end_matches(" (HEAD)")))
573 } else {
574 Some(strip_user_prompt_prefix(line))
575 }
576 })
577 .collect::<Vec<_>>()
578 .join("\n")
579 .trim()
580 .to_string()
581}
582
583fn strip_user_prompt_prefix(line: &str) -> String {
594 let trimmed_start = line.trim_start();
595 if let Some(rest) = trimmed_start.strip_prefix("❯ ") {
596 rest.to_string()
597 } else if let Some(rest) = trimmed_start.strip_prefix('❯') {
598 rest.trim_start().to_string()
599 } else {
600 line.to_string()
601 }
602}
603
604fn markdown_heading_has_head_marker(line: &str) -> bool {
605 let trimmed = line.trim_start();
606 let hashes = trimmed.bytes().take_while(|b| *b == b'#').count();
607 (1..=6).contains(&hashes)
608 && trimmed.as_bytes().get(hashes) == Some(&b' ')
609 && line.ends_with(" (HEAD)")
610}
611
612pub fn is_agent_marker(comment_text: &str) -> bool {
616 let trimmed = comment_text.trim();
617 if let Some(rest) = trimmed.strip_prefix("/agent:") {
618 is_valid_name(rest)
619 } else if let Some(rest) = trimmed.strip_prefix("agent:") {
620 let name_part = rest.split_whitespace().next().unwrap_or("");
622 is_valid_name(name_part)
623 } else {
624 false
625 }
626}
627
628fn bounded_preview(doc: &str, start: usize, max_bytes: usize) -> &str {
629 debug_assert!(doc.is_char_boundary(start));
630 let mut end = doc.len().min(start.saturating_add(max_bytes));
631 while end > start && !doc.is_char_boundary(end) {
632 end -= 1;
633 }
634 &doc[start..end]
635}
636
637pub fn parse_attrs(attr_text: &str) -> HashMap<String, String> {
642 let mut attrs = HashMap::new();
643 for token in attr_text.split_whitespace() {
644 if let Some((key, value)) = token.split_once('=') {
645 if !key.is_empty() && !value.is_empty() {
646 attrs.insert(key.to_string(), value.to_string());
647 }
648 } else if !token.is_empty() {
649 attrs.insert(token.to_string(), String::new());
650 }
651 }
652 attrs
653}
654
655pub fn find_code_ranges(doc: &str) -> Vec<(usize, usize)> {
661 let t = std::time::Instant::now();
662 let mut ranges = Vec::new();
663 let parser = Parser::new_ext(doc, Options::empty());
664 let mut iter = parser.into_offset_iter();
665 while let Some((event, range)) = iter.next() {
666 match event {
667 Event::Code(_) => {
669 ranges.push((range.start, range.end));
670 }
671 Event::Start(Tag::CodeBlock(_)) => {
673 let block_start = range.start;
674 let mut block_end = range.end;
675 for (inner_event, inner_range) in iter.by_ref() {
676 block_end = inner_range.end;
677 if matches!(inner_event, Event::End(TagEnd::CodeBlock)) {
678 break;
679 }
680 }
681 ranges.push((block_start, block_end));
682 }
683 _ => {}
684 }
685 }
686 let elapsed = t.elapsed().as_millis();
687 if elapsed > 0 {
688 eprintln!("[perf] find_code_ranges: {}ms", elapsed);
689 }
690 ranges
691}
692
693pub fn find_quoted_ranges(doc: &str) -> Vec<(usize, usize)> {
709 let mut ranges = Vec::new();
710 let mut offset = 0usize;
711 for raw_line in doc.split_inclusive('\n') {
712 let line_start = offset;
713 offset += raw_line.len();
714 let bytes = raw_line.as_bytes();
715 let line_content_len = raw_line.trim_end_matches(['\n', '\r']).len();
716 let mut i = 0usize;
717 while i < bytes.len() {
718 if bytes[i] != b'"' {
719 i += 1;
720 continue;
721 }
722 let span_start = line_start + i;
723 let mut j = i + 1;
724 let mut closed = false;
725 while j < bytes.len() {
726 match bytes[j] {
727 b'\\' => {
728 j += 2;
729 continue;
730 }
731 b'"' => {
732 closed = true;
733 break;
734 }
735 b'\n' | b'\r' => break,
736 _ => j += 1,
737 }
738 }
739 if closed {
740 ranges.push((span_start, line_start + j + 1));
741 i = j + 1;
742 } else {
743 ranges.push((span_start, line_start + line_content_len));
746 break;
747 }
748 }
749 }
750 ranges
751}
752
753pub fn find_non_agent_html_comment_ranges(doc: &str) -> Vec<(usize, usize)> {
762 let code_ranges = find_code_ranges(doc);
763 let quoted_ranges = find_quoted_ranges(doc);
764 let in_code = |pos: usize| {
765 code_ranges
766 .iter()
767 .chain(quoted_ranges.iter())
768 .any(|&(start, end)| pos >= start && pos < end)
769 };
770
771 let bytes = doc.as_bytes();
772 let len = bytes.len();
773 let mut ranges = Vec::new();
774 let mut pos = 0usize;
775 while pos + 4 <= len {
776 if &bytes[pos..pos + 4] != b"<!--" {
777 pos += 1;
778 continue;
779 }
780 if in_code(pos) {
781 pos += 4;
782 continue;
783 }
784 let Some(end) = find_comment_end(bytes, pos + 4) else {
785 let inner = &doc[pos + 4..];
786 if !is_agent_marker(inner) && !inner.trim_start().starts_with("agent:boundary:") {
787 ranges.push((pos, len));
788 break;
789 }
790 pos += 4;
791 continue;
792 };
793 let inner = &doc[pos + 4..end - 3];
794 if !is_agent_marker(inner) && !inner.trim().starts_with("agent:boundary:") {
795 ranges.push((pos, end));
796 }
797 pos = end;
798 }
799 ranges
800}
801
802pub fn parse(doc: &str) -> Result<Vec<Component>> {
808 let bytes = doc.as_bytes();
809 let len = bytes.len();
810 let code_ranges = find_code_ranges(doc);
811 let quoted_ranges = find_quoted_ranges(doc);
812 let mut templates: Vec<Component> = Vec::new();
813 let mut stack: Vec<(String, HashMap<String, String>, usize, usize)> = Vec::new();
815 let mut pos = 0;
816
817 while pos + 4 <= len {
818 if &bytes[pos..pos + 4] != b"<!--" {
820 pos += 1;
821 continue;
822 }
823
824 if code_ranges
827 .iter()
828 .chain(quoted_ranges.iter())
829 .any(|&(start, end)| pos >= start && pos < end)
830 {
831 pos += 4;
832 continue;
833 }
834
835 let peek_start = pos + 4;
840 let peek = bounded_preview(doc, peek_start, 20);
841 let peek_trimmed = peek.trim_start();
842 if !peek_trimmed.starts_with("agent:") && !peek_trimmed.starts_with("/agent:") {
843 pos += 1;
844 continue;
845 }
846
847 let marker_start = pos;
848
849 let close = match find_comment_end(bytes, pos + 4) {
851 Some(c) => c,
852 None => {
853 pos += 4;
854 continue;
855 }
856 };
857
858 let inner = &doc[marker_start + 4..close - 3]; let trimmed = inner.trim();
861
862 let mut marker_end = close;
864 if marker_end < len && bytes[marker_end] == b'\n' {
865 marker_end += 1;
866 }
867
868 if let Some(name) = trimmed.strip_prefix("/agent:") {
869 if !is_valid_name(name) {
871 bail!("invalid component name: '{}'", name);
872 }
873 match stack.pop() {
874 Some((open_name, open_attrs, open_start, open_end)) => {
875 if open_name != name {
876 bail!(
877 "mismatched component: opened '{}' but closed '{}'",
878 open_name,
879 name
880 );
881 }
882 templates.push(Component {
883 name: name.to_string(),
884 attrs: open_attrs,
885 open_start,
886 open_end,
887 close_start: marker_start,
888 close_end: marker_end,
889 });
890 }
891 None => bail!(
892 "closing marker <!-- /agent:{} --> without matching open",
893 name
894 ),
895 }
896 } else if let Some(rest) = trimmed.strip_prefix("agent:") {
897 if rest.starts_with("boundary:") {
899 pos = close;
900 continue;
901 }
902 let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
904 let name = parts.next().unwrap_or("");
905 let attr_text = parts.next().unwrap_or("");
906 if !is_valid_name(name) {
907 bail!("invalid component name: '{}'", name);
908 }
909 let attrs = parse_attrs(attr_text);
910 stack.push((name.to_string(), attrs, marker_start, marker_end));
911 }
912
913 pos = close;
914 }
915
916 if let Some((name, _, _, _)) = stack.last() {
917 bail!(
918 "unclosed component: <!-- agent:{} --> without matching close",
919 name
920 );
921 }
922
923 templates.sort_by_key(|t| t.open_start);
924 Ok(templates)
925}
926
927const SINGLETON_COMPONENT_NAMES: &[&str] = &[
931 EXCHANGE_COMPONENT,
932 "status",
933 "queue",
934 BACKLOG_COMPONENT,
935 REVIEW_COMPONENT,
936 ICEBOX_COMPONENT,
937 BACKLOG_DONE_COMPONENT,
938];
939
940fn canonical_singleton_name(name: &str) -> Option<&'static str> {
944 if is_backlog_component(name) {
945 return Some(BACKLOG_COMPONENT);
946 }
947 SINGLETON_COMPONENT_NAMES
948 .iter()
949 .copied()
950 .find(|&s| s == name)
951}
952
953fn marker_has_unterminated_quote(marker: &str) -> bool {
958 let mut open = false;
959 let mut escaped = false;
960 for ch in marker.chars() {
961 if escaped {
962 escaped = false;
963 continue;
964 }
965 match ch {
966 '\\' => escaped = true,
967 '"' => open = !open,
968 _ => {}
969 }
970 }
971 open
972}
973
974fn byte_in_ranges(offset: usize, ranges: &[(usize, usize)]) -> bool {
975 ranges
976 .iter()
977 .any(|&(start, end)| offset >= start && offset < end)
978}
979
980pub fn malformed_agent_comment_reason(doc: &str) -> Option<String> {
985 let code_ranges = find_code_ranges(doc);
986 let quoted_ranges = find_quoted_ranges(doc);
987 let mut offset = 0usize;
988 for (line_idx, raw_line) in doc.split_inclusive('\n').enumerate() {
989 let line = raw_line.trim_end_matches(['\n', '\r']);
990 let leading = line.len() - line.trim_start_matches([' ', '\t']).len();
991 let marker_start = offset + leading;
992 let trimmed = &line[leading..];
993 offset += raw_line.len();
994
995 if !trimmed.starts_with("<!--") {
996 continue;
997 }
998 if byte_in_ranges(marker_start, &code_ranges)
999 || byte_in_ranges(marker_start, "ed_ranges)
1000 {
1001 continue;
1002 }
1003 let inner = trimmed["<!--".len()..].trim_start();
1004 if (inner.starts_with("agent:") || inner.starts_with("/agent:")) && !trimmed.contains("-->")
1005 {
1006 let preview = trimmed.chars().take(80).collect::<String>();
1007 return Some(format!(
1008 "malformed_agent_comment:line{}:{preview}",
1009 line_idx + 1
1010 ));
1011 }
1012 }
1013 None
1014}
1015
1016pub fn structural_corruption_reason(doc: &str) -> Option<String> {
1027 if let Some(reason) = malformed_agent_comment_reason(doc) {
1028 return Some(reason);
1029 }
1030
1031 let components = match parse(doc) {
1032 Ok(c) => c,
1033 Err(e) => return Some(format!("parse_error: {e}")),
1034 };
1035
1036 let mut counts: HashMap<&str, usize> = HashMap::new();
1038 for c in &components {
1039 if let Some(canonical) = canonical_singleton_name(&c.name) {
1040 *counts.entry(canonical).or_insert(0) += 1;
1041 }
1042 }
1043 let mut dupes: Vec<(&str, usize)> = counts
1044 .iter()
1045 .filter(|&(_, &n)| n > 1)
1046 .map(|(&k, &n)| (k, n))
1047 .collect();
1048 if !dupes.is_empty() {
1049 dupes.sort();
1050 let detail = dupes
1051 .iter()
1052 .map(|(k, n)| format!("{k}={n}"))
1053 .collect::<Vec<_>>()
1054 .join(",");
1055 return Some(format!("duplicate_singleton_component:{detail}"));
1056 }
1057
1058 for c in &components {
1060 let marker = &doc[c.open_start..c.open_end];
1061 if marker_has_unterminated_quote(marker) {
1062 return Some(format!("malformed_attr:{}", c.name));
1063 }
1064 }
1065
1066 None
1067}
1068
1069pub fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
1071 let len = bytes.len();
1072 let mut i = start;
1073 while i + 3 <= len {
1074 if &bytes[i..i + 3] == b"-->" {
1075 return Some(i + 3);
1076 }
1077 i += 1;
1078 }
1079 None
1080}
1081
1082pub fn strip_comments(content: &str) -> String {
1095 let code_ranges = find_code_ranges(content);
1096 let in_code = |pos: usize| {
1097 code_ranges
1098 .iter()
1099 .any(|&(start, end)| pos >= start && pos < end)
1100 };
1101
1102 let mut result = String::with_capacity(content.len());
1103 let bytes = content.as_bytes();
1104 let len = bytes.len();
1105 let mut pos = 0;
1106
1107 while pos < len {
1108 if bytes[pos] == b'['
1110 && !in_code(pos)
1111 && is_line_start_at(bytes, pos)
1112 && let Some(end) = match_link_ref_comment_at(bytes, pos)
1113 {
1114 pos = end;
1115 continue;
1116 }
1117
1118 if pos + 4 <= len
1120 && &bytes[pos..pos + 4] == b"<!--"
1121 && !in_code(pos)
1122 && let Some((end, inner)) = match_html_comment_at(content, pos)
1123 {
1124 if is_agent_marker(inner) {
1125 result.push_str(&content[pos..end]);
1127 pos = end;
1128 } else {
1129 let mut skip_to = end;
1131 if is_line_start_at(bytes, pos) && skip_to < len && bytes[skip_to] == b'\n' {
1132 skip_to += 1;
1133 }
1134 pos = skip_to;
1135 }
1136 continue;
1137 }
1138
1139 result.push(content[pos..].chars().next().unwrap());
1140 pos += content[pos..].chars().next().unwrap().len_utf8();
1141 }
1142
1143 result
1144}
1145
1146fn is_line_start_at(bytes: &[u8], pos: usize) -> bool {
1148 pos == 0 || bytes[pos - 1] == b'\n'
1149}
1150
1151fn match_link_ref_comment_at(bytes: &[u8], pos: usize) -> Option<usize> {
1153 let prefix = b"[//]: # (";
1154 let len = bytes.len();
1155 if pos + prefix.len() > len || &bytes[pos..pos + prefix.len()] != prefix {
1156 return None;
1157 }
1158 let mut i = pos + prefix.len();
1159 while i < len && bytes[i] != b')' && bytes[i] != b'\n' {
1160 i += 1;
1161 }
1162 if i < len && bytes[i] == b')' {
1163 i += 1;
1164 if i < len && bytes[i] == b'\n' {
1165 i += 1;
1166 }
1167 Some(i)
1168 } else {
1169 None
1170 }
1171}
1172
1173fn match_html_comment_at(content: &str, pos: usize) -> Option<(usize, &str)> {
1175 let bytes = content.as_bytes();
1176 let len = bytes.len();
1177 let mut i = pos + 4;
1178 while i + 3 <= len {
1179 if &bytes[i..i + 3] == b"-->" {
1180 let inner = &content[pos + 4..i];
1181 return Some((i + 3, inner));
1182 }
1183 i += 1;
1184 }
1185 None
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use super::*;
1191
1192 #[test]
1193 fn single_range() {
1194 let doc = "before\n<!-- agent:status -->\nHello\n<!-- /agent:status -->\nafter\n";
1195 let ranges = parse(doc).unwrap();
1196 assert_eq!(ranges.len(), 1);
1197 assert_eq!(ranges[0].name, "status");
1198 assert_eq!(ranges[0].content(doc), "Hello\n");
1199 }
1200
1201 #[test]
1202 fn nested_ranges() {
1203 let doc = "\
1204<!-- agent:outer -->
1205<!-- agent:inner -->
1206content
1207<!-- /agent:inner -->
1208<!-- /agent:outer -->
1209";
1210 let ranges = parse(doc).unwrap();
1211 assert_eq!(ranges.len(), 2);
1212 assert_eq!(ranges[0].name, "outer");
1214 assert_eq!(ranges[1].name, "inner");
1215 assert_eq!(ranges[1].content(doc), "content\n");
1216 }
1217
1218 #[test]
1219 fn siblings() {
1220 let doc = "\
1221<!-- agent:a -->
1222alpha
1223<!-- /agent:a -->
1224<!-- agent:b -->
1225beta
1226<!-- /agent:b -->
1227";
1228 let ranges = parse(doc).unwrap();
1229 assert_eq!(ranges.len(), 2);
1230 assert_eq!(ranges[0].name, "a");
1231 assert_eq!(ranges[0].content(doc), "alpha\n");
1232 assert_eq!(ranges[1].name, "b");
1233 assert_eq!(ranges[1].content(doc), "beta\n");
1234 }
1235
1236 #[test]
1237 fn no_ranges() {
1238 let doc = "# Just a document\n\nWith no range templates.\n";
1239 let ranges = parse(doc).unwrap();
1240 assert!(ranges.is_empty());
1241 }
1242
1243 #[test]
1244 fn unmatched_open_error() {
1245 let doc = "<!-- agent:orphan -->\nContent\n";
1246 let err = parse(doc).unwrap_err();
1247 assert!(err.to_string().contains("unclosed component"));
1248 }
1249
1250 #[test]
1251 fn unmatched_close_error() {
1252 let doc = "Content\n<!-- /agent:orphan -->\n";
1253 let err = parse(doc).unwrap_err();
1254 assert!(err.to_string().contains("without matching open"));
1255 }
1256
1257 #[test]
1258 fn mismatched_names_error() {
1259 let doc = "<!-- agent:foo -->\n<!-- /agent:bar -->\n";
1260 let err = parse(doc).unwrap_err();
1261 assert!(err.to_string().contains("mismatched"));
1262 }
1263
1264 #[test]
1265 fn invalid_name() {
1266 let doc = "<!-- agent:-bad -->\n<!-- /agent:-bad -->\n";
1267 let err = parse(doc).unwrap_err();
1268 assert!(err.to_string().contains("invalid component name"));
1269 }
1270
1271 #[test]
1272 fn name_validation() {
1273 assert!(is_valid_name("status"));
1274 assert!(is_valid_name("my-section"));
1275 assert!(is_valid_name("a1"));
1276 assert!(is_valid_name("A"));
1277 assert!(!is_valid_name(""));
1278 assert!(!is_valid_name("-bad"));
1279 assert!(!is_valid_name("has space"));
1280 assert!(!is_valid_name("has_underscore"));
1281 }
1282
1283 #[test]
1284 fn content_extraction() {
1285 let doc = "<!-- agent:x -->\nfoo\nbar\n<!-- /agent:x -->\n";
1286 let ranges = parse(doc).unwrap();
1287 assert_eq!(ranges[0].content(doc), "foo\nbar\n");
1288 }
1289
1290 #[test]
1291 fn replace_roundtrip() {
1292 let doc = "before\n<!-- agent:s -->\nold\n<!-- /agent:s -->\nafter\n";
1293 let ranges = parse(doc).unwrap();
1294 let new_doc = ranges[0].replace_content(doc, "new\n");
1295 assert_eq!(
1296 new_doc,
1297 "before\n<!-- agent:s -->\nnew\n<!-- /agent:s -->\nafter\n"
1298 );
1299 let ranges2 = parse(&new_doc).unwrap();
1301 assert_eq!(ranges2.len(), 1);
1302 assert_eq!(ranges2[0].content(&new_doc), "new\n");
1303 }
1304
1305 #[test]
1306 fn is_agent_marker_yes() {
1307 assert!(is_agent_marker(" agent:status "));
1308 assert!(is_agent_marker("/agent:status"));
1309 assert!(is_agent_marker("agent:my-thing"));
1310 assert!(is_agent_marker(" /agent:A1 "));
1311 }
1312
1313 #[test]
1314 fn is_agent_marker_no() {
1315 assert!(!is_agent_marker("just a comment"));
1316 assert!(!is_agent_marker("agent:"));
1317 assert!(!is_agent_marker("/agent:"));
1318 assert!(!is_agent_marker("agent:-bad"));
1319 assert!(!is_agent_marker("some agent:fake stuff"));
1320 }
1321
1322 #[test]
1323 fn regular_comments_ignored() {
1324 let doc = "<!-- just a comment -->\n<!-- agent:x -->\ndata\n<!-- /agent:x -->\n";
1325 let ranges = parse(doc).unwrap();
1326 assert_eq!(ranges.len(), 1);
1327 assert_eq!(ranges[0].name, "x");
1328 }
1329
1330 #[test]
1331 fn regular_comments_with_multibyte_preview_boundary_ignored() {
1332 let doc = "\
1333<!-- 123456789012345678❯ ordinary comment -->
1334<!-- agent:x -->
1335data
1336<!-- /agent:x -->
1337";
1338 let ranges = parse(doc).unwrap();
1339 assert_eq!(ranges.len(), 1);
1340 assert_eq!(ranges[0].name, "x");
1341 }
1342
1343 #[test]
1344 fn multiline_comment_ignored() {
1345 let doc = "\
1346<!--
1347multi
1348line
1349comment
1350-->
1351<!-- agent:s -->
1352content
1353<!-- /agent:s -->
1354";
1355 let ranges = parse(doc).unwrap();
1356 assert_eq!(ranges.len(), 1);
1357 assert_eq!(ranges[0].name, "s");
1358 }
1359
1360 #[test]
1361 fn empty_content() {
1362 let doc = "<!-- agent:empty --><!-- /agent:empty -->\n";
1363 let ranges = parse(doc).unwrap();
1364 assert_eq!(ranges.len(), 1);
1365 assert_eq!(ranges[0].content(doc), "");
1366 }
1367
1368 #[test]
1369 fn markers_in_fenced_code_block_ignored() {
1370 let doc = "\
1371<!-- agent:real -->
1372content
1373<!-- /agent:real -->
1374```markdown
1375<!-- agent:fake -->
1376this is just an example
1377<!-- /agent:fake -->
1378```
1379";
1380 let ranges = parse(doc).unwrap();
1381 assert_eq!(ranges.len(), 1);
1382 assert_eq!(ranges[0].name, "real");
1383 }
1384
1385 #[test]
1386 fn markers_in_inline_code_ignored() {
1387 let doc = "\
1388Use `<!-- agent:example -->` markers for components.
1389<!-- agent:real -->
1390content
1391<!-- /agent:real -->
1392";
1393 let ranges = parse(doc).unwrap();
1394 assert_eq!(ranges.len(), 1);
1395 assert_eq!(ranges[0].name, "real");
1396 }
1397
1398 #[test]
1399 fn markers_in_tilde_fence_ignored() {
1400 let doc = "\
1401<!-- agent:x -->
1402data
1403<!-- /agent:x -->
1404~~~
1405<!-- agent:y -->
1406example
1407<!-- /agent:y -->
1408~~~
1409";
1410 let ranges = parse(doc).unwrap();
1411 assert_eq!(ranges.len(), 1);
1412 assert_eq!(ranges[0].name, "x");
1413 }
1414
1415 #[test]
1416 fn markers_in_indented_fenced_code_block_ignored() {
1417 let doc = "\
1419<!-- agent:exchange -->
1420Content here.
1421<!-- /agent:exchange -->
1422
1423 ```markdown
1424 <!-- agent:fake -->
1425 demo without closing tag
1426 ```
1427";
1428 let ranges = parse(doc).unwrap();
1429 assert_eq!(ranges.len(), 1);
1430 assert_eq!(ranges[0].name, "exchange");
1431 }
1432
1433 #[test]
1434 fn indented_fence_inside_component_ignored() {
1435 let doc = "\
1437<!-- agent:exchange -->
1438Here's how to set up:
1439
1440 ```markdown
1441 <!-- agent:status -->
1442 Your status here
1443 ```
1444
1445Done explaining.
1446<!-- /agent:exchange -->
1447";
1448 let ranges = parse(doc).unwrap();
1449 assert_eq!(ranges.len(), 1);
1450 assert_eq!(ranges[0].name, "exchange");
1451 }
1452
1453 #[test]
1454 fn deeply_indented_fence_ignored() {
1455 let doc = "\
1457<!-- agent:x -->
1458ok
1459<!-- /agent:x -->
1460 ```
1461 <!-- agent:y -->
1462 inside fence
1463 ```
1464";
1465 let ranges = parse(doc).unwrap();
1466 assert_eq!(ranges.len(), 1);
1467 assert_eq!(ranges[0].name, "x");
1468 }
1469
1470 #[test]
1471 fn indented_fence_code_ranges_detected() {
1472 let doc = "before\n ```\n code\n ```\nafter\n";
1473 let ranges = find_code_ranges(doc);
1474 assert_eq!(ranges.len(), 1);
1475 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1476 }
1477
1478 #[test]
1479 fn code_ranges_detected() {
1480 let doc = "before\n```\ncode\n```\nafter `inline` end\n";
1481 let ranges = find_code_ranges(doc);
1482 assert_eq!(ranges.len(), 2);
1483 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1485 assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
1487 }
1488
1489 #[test]
1490 fn code_ranges_double_backtick() {
1491 let doc = "text `` `<!--` `` more\n";
1493 let ranges = find_code_ranges(doc);
1494 assert_eq!(ranges.len(), 1);
1495 let span = &doc[ranges[0].0..ranges[0].1];
1496 assert!(
1497 span.contains("<!--"),
1498 "double-backtick span should contain <!--: {:?}",
1499 span
1500 );
1501 }
1502
1503 #[test]
1504 fn code_ranges_double_backtick_does_not_match_single() {
1505 let doc = "text `` foo ` bar `` end\n";
1507 let ranges = find_code_ranges(doc);
1508 assert_eq!(ranges.len(), 1);
1509 let span = &doc[ranges[0].0..ranges[0].1];
1510 assert_eq!(span, "`` foo ` bar ``");
1511 }
1512
1513 #[test]
1514 fn double_backtick_comment_before_agent_marker() {
1515 let doc = "\
1517<!-- agent:exchange -->\n\
1518text `` `<!--` `` description\n\
1519new content here\n\
1520<!-- /agent:exchange -->\n";
1521 let components = parse(doc).unwrap();
1522 assert_eq!(components.len(), 1);
1523 assert_eq!(components[0].name, "exchange");
1524 assert!(components[0].content(doc).contains("new content here"));
1525 }
1526
1527 #[test]
1528 fn quoted_agent_marker_in_prose_is_not_parsed() {
1529 let doc = "\
1533<!-- agent:exchange -->\n\
1534Operator note: \"<!-- agent:queue -->\" attributes were dropped on compaction.\n\
1535new content here\n\
1536<!-- /agent:exchange -->\n";
1537 let components = parse(doc).unwrap();
1538 assert_eq!(components.len(), 1);
1539 assert_eq!(components[0].name, "exchange");
1540 assert!(components[0].content(doc).contains("new content here"));
1541 assert!(
1542 components[0]
1543 .content(doc)
1544 .contains("attributes were dropped")
1545 );
1546 }
1547
1548 #[test]
1549 fn quoted_id_token_in_prose_is_content() {
1550 let doc = "\
1552<!-- agent:exchange -->\n\
1553The operator asked to clear \"#advance-review\" but no such line exists.\n\
1554<!-- /agent:exchange -->\n";
1555 let components = parse(doc).unwrap();
1556 assert_eq!(components.len(), 1);
1557 assert!(components[0].content(doc).contains("#advance-review"));
1558 }
1559
1560 #[test]
1561 fn fenced_agent_marker_in_prose_is_not_parsed() {
1562 let doc = "\
1565<!-- agent:exchange -->\n\
1566```\n\
1567<!-- agent:queue -->\n\
1568```\n\
1569new content here\n\
1570<!-- /agent:exchange -->\n";
1571 let components = parse(doc).unwrap();
1572 assert_eq!(components.len(), 1);
1573 assert_eq!(components[0].name, "exchange");
1574 assert!(components[0].content(doc).contains("new content here"));
1575 }
1576
1577 #[test]
1578 fn real_marker_with_quoted_preset_attr_still_parses() {
1579 let doc = "\
1583<!-- agent:queue preset=\"#spec-test-build-install-commit-push\" priority go -->\n\
1584- :pushpin: do [#thing]\n\
1585<!-- /agent:queue -->\n";
1586 let components = parse(doc).unwrap();
1587 assert_eq!(components.len(), 1);
1588 assert_eq!(components[0].name, "queue");
1589 assert!(
1592 components[0]
1593 .attrs
1594 .get("preset")
1595 .is_some_and(|v| v.contains("#spec-test-build-install-commit-push")),
1596 "preset attr should survive quote-masking, got {:?}",
1597 components[0].attrs.get("preset")
1598 );
1599 }
1600
1601 #[test]
1602 fn find_quoted_ranges_is_same_line_and_fail_safe() {
1603 let doc = "a \"bc\" d\n\"ef\n";
1605 let ranges = find_quoted_ranges(doc);
1606 assert_eq!(ranges.len(), 2);
1609 assert_eq!(&doc[ranges[0].0..ranges[0].1], "\"bc\"");
1610 assert_eq!(&doc[ranges[1].0..ranges[1].1], "\"ef");
1611 }
1612
1613 #[test]
1616 fn parse_component_with_mode_attr() {
1617 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1618 let components = parse(doc).unwrap();
1619 assert_eq!(components.len(), 1);
1620 assert_eq!(components[0].name, "exchange");
1621 assert_eq!(
1622 components[0].attrs.get("mode").map(|s| s.as_str()),
1623 Some("append")
1624 );
1625 assert_eq!(components[0].content(doc), "Content\n");
1626 }
1627
1628 #[test]
1629 fn parse_component_with_multiple_attrs() {
1630 let doc = "<!-- agent:log mode=prepend timestamp=true -->\nData\n<!-- /agent:log -->\n";
1631 let components = parse(doc).unwrap();
1632 assert_eq!(components.len(), 1);
1633 assert_eq!(components[0].name, "log");
1634 assert_eq!(
1635 components[0].attrs.get("mode").map(|s| s.as_str()),
1636 Some("prepend")
1637 );
1638 assert_eq!(
1639 components[0].attrs.get("timestamp").map(|s| s.as_str()),
1640 Some("true")
1641 );
1642 }
1643
1644 #[test]
1645 fn parse_component_no_attrs_backward_compat() {
1646 let doc = "<!-- agent:status -->\nOK\n<!-- /agent:status -->\n";
1647 let components = parse(doc).unwrap();
1648 assert_eq!(components.len(), 1);
1649 assert_eq!(components[0].name, "status");
1650 assert!(components[0].attrs.is_empty());
1651 }
1652
1653 #[test]
1654 fn is_agent_marker_with_attrs() {
1655 assert!(is_agent_marker(" agent:exchange mode=append "));
1656 assert!(is_agent_marker("agent:status mode=replace"));
1657 assert!(is_agent_marker("agent:log mode=prepend timestamp=true"));
1658 }
1659
1660 #[test]
1661 fn closing_tag_unchanged_with_attrs() {
1662 let doc = "<!-- agent:status mode=replace -->\n- [x] Done\n<!-- /agent:status -->\n";
1664 let components = parse(doc).unwrap();
1665 assert_eq!(components.len(), 1);
1666 let new_doc = components[0].replace_content(doc, "- [ ] Todo\n");
1667 assert!(new_doc.contains("<!-- agent:status mode=replace -->"));
1668 assert!(new_doc.contains("<!-- /agent:status -->"));
1669 assert!(new_doc.contains("- [ ] Todo"));
1670 }
1671
1672 #[test]
1673 fn parse_component_with_patch_attr() {
1674 let doc = "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n";
1675 let components = parse(doc).unwrap();
1676 assert_eq!(components.len(), 1);
1677 assert_eq!(components[0].name, "exchange");
1678 assert_eq!(components[0].patch_mode(), Some("append"));
1679 assert_eq!(components[0].content(doc), "Content\n");
1680 }
1681
1682 #[test]
1683 fn patch_attr_takes_precedence_over_mode() {
1684 let doc = "<!-- agent:exchange patch=replace mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1685 let components = parse(doc).unwrap();
1686 assert_eq!(components[0].patch_mode(), Some("replace"));
1687 }
1688
1689 #[test]
1690 fn mode_attr_backward_compat() {
1691 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1692 let components = parse(doc).unwrap();
1693 assert_eq!(components[0].patch_mode(), Some("append"));
1694 }
1695
1696 #[test]
1697 fn no_patch_or_mode_attr() {
1698 let doc = "<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
1699 let components = parse(doc).unwrap();
1700 assert_eq!(components[0].patch_mode(), None);
1701 }
1702
1703 #[test]
1706 fn single_backtick_component_tag_ignored() {
1707 let doc = "\
1709Use `<!-- agent:pending -->` to mark pending sections.
1710<!-- agent:real -->
1711content
1712<!-- /agent:real -->
1713";
1714 let components = parse(doc).unwrap();
1715 assert_eq!(components.len(), 1);
1716 assert_eq!(components[0].name, "real");
1717 }
1718
1719 #[test]
1720 fn double_backtick_component_tag_ignored() {
1721 let doc = "\
1723Use ``<!-- agent:pending -->`` to mark pending sections.
1724<!-- agent:real -->
1725content
1726<!-- /agent:real -->
1727";
1728 let components = parse(doc).unwrap();
1729 assert_eq!(components.len(), 1);
1730 assert_eq!(components[0].name, "real");
1731 }
1732
1733 #[test]
1734 fn component_tags_not_in_backticks_still_work() {
1735 let doc = "\
1737<!-- agent:a -->
1738alpha
1739<!-- /agent:a -->
1740<!-- agent:b patch=append -->
1741beta
1742<!-- /agent:b -->
1743";
1744 let components = parse(doc).unwrap();
1745 assert_eq!(components.len(), 2);
1746 assert_eq!(components[0].name, "a");
1747 assert_eq!(components[1].name, "b");
1748 assert_eq!(components[1].patch_mode(), Some("append"));
1749 }
1750
1751 #[test]
1752 fn mixed_backtick_and_real_tags() {
1753 let doc = "\
1755Here is an example: `<!-- agent:fake -->` and ``<!-- /agent:fake -->``.
1756<!-- agent:real -->
1757real content
1758<!-- /agent:real -->
1759Another example: `<!-- agent:also-fake patch=replace -->` is just documentation.
1760";
1761 let components = parse(doc).unwrap();
1762 assert_eq!(components.len(), 1);
1763 assert_eq!(components[0].name, "real");
1764 assert_eq!(components[0].content(doc), "real content\n");
1765 }
1766
1767 #[test]
1768 fn inline_code_mid_line_with_surrounding_text_ignored() {
1769 let doc = "\
1772Wrap markers like `<!-- agent:status -->` in backticks to show them literally.
1773<!-- agent:real -->
1774actual content
1775<!-- /agent:real -->
1776";
1777 let components = parse(doc).unwrap();
1778 assert_eq!(components.len(), 1);
1779 assert_eq!(components[0].name, "real");
1780 assert_eq!(components[0].content(doc), "actual content\n");
1781 }
1782
1783 #[test]
1784 fn parse_attrs_unit() {
1785 let attrs = parse_attrs("mode=append");
1786 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1787
1788 let attrs = parse_attrs("mode=replace timestamp=true");
1789 assert_eq!(attrs.len(), 2);
1790
1791 let attrs = parse_attrs("");
1792 assert!(attrs.is_empty());
1793
1794 let attrs = parse_attrs("mode=append broken novalue=");
1796 assert_eq!(attrs.len(), 2);
1797 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1798 assert_eq!(attrs.get("broken").map(|s| s.as_str()), Some(""));
1799
1800 let attrs = parse_attrs("auto");
1802 assert_eq!(attrs.len(), 1);
1803 assert!(attrs.contains_key("auto"));
1804 }
1805
1806 #[test]
1807 fn append_with_boundary_skips_code_block() {
1808 let boundary_id = "real-uuid";
1811 let doc = format!(
1812 "<!-- agent:exchange patch=append -->\n\
1813 user prompt\n\
1814 ```\n\
1815 <!-- agent:boundary:{boundary_id} -->\n\
1816 ```\n\
1817 more user text\n\
1818 <!-- agent:boundary:{boundary_id} -->\n\
1819 <!-- /agent:exchange -->\n"
1820 );
1821 let components = parse(&doc).unwrap();
1822 let comp = &components[0];
1823 let result =
1824 comp.append_with_boundary(&doc, "### Re: Response\n\nContent here.", boundary_id);
1825
1826 assert!(result.contains("### Re: Response"));
1829 assert!(result.contains("more user text"));
1830 assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
1832 assert!(!result.contains(&format!(
1834 "more user text\n<!-- agent:boundary:{boundary_id} -->\n<!-- /agent:exchange -->"
1835 )));
1836 }
1837
1838 #[test]
1839 fn append_with_boundary_no_code_block() {
1840 let boundary_id = "simple-uuid";
1842 let doc = format!(
1843 "<!-- agent:exchange patch=append -->\n\
1844 user prompt\n\
1845 <!-- agent:boundary:{boundary_id} -->\n\
1846 <!-- /agent:exchange -->\n"
1847 );
1848 let components = parse(&doc).unwrap();
1849 let comp = &components[0];
1850 let result = comp.append_with_boundary(&doc, "### Re: Answer\n\nDone.", boundary_id);
1851
1852 assert!(result.contains("### Re: Answer"));
1853 assert!(result.contains("user prompt"));
1854 assert!(!result.contains(&format!("agent:boundary:{boundary_id}")));
1856 assert!(result.contains("agent:boundary:"));
1857 }
1858
1859 #[test]
1860 fn append_with_boundary_skips_already_present_content() {
1861 let boundary_id = "simple-uuid";
1862 let doc = format!(
1863 "<!-- agent:exchange patch=append -->\n\
1864 ### Re: Duplicate — gpt-5 (HEAD)\n\
1865 \n\
1866 Already applied.\n\
1867 <!-- agent:boundary:old-id -->\n\
1868 <!-- agent:boundary:{boundary_id} -->\n\
1869 <!-- /agent:exchange -->\n"
1870 );
1871 let components = parse(&doc).unwrap();
1872 let comp = &components[0];
1873 let result = comp.append_with_boundary(
1874 &doc,
1875 "### Re: Duplicate — gpt-5\n\nAlready applied.\n",
1876 boundary_id,
1877 );
1878
1879 assert_eq!(result, doc);
1880 }
1881
1882 #[test]
1883 fn append_with_caret_skips_already_present_content() {
1884 let doc = "<!-- agent:exchange patch=append -->\n\
1885 User prompt.\n\
1886 ### Re: Duplicate — gpt-5\n\
1887 \n\
1888 Already applied.\n\
1889 <!-- /agent:exchange -->\n";
1890 let components = parse(doc).unwrap();
1891 let comp = &components[0];
1892 let result =
1893 comp.append_with_caret(doc, "### Re: Duplicate — gpt-5\n\nAlready applied.\n", None);
1894
1895 assert_eq!(result, doc);
1896 }
1897
1898 #[test]
1901 fn strip_comments_removes_html_comment() {
1902 let result = strip_comments("before\n<!-- a comment -->\nafter\n");
1903 assert_eq!(result, "before\nafter\n");
1904 }
1905
1906 #[test]
1907 fn non_agent_html_comment_ranges_cover_multiline_body() {
1908 let doc = concat!(
1909 "before\n",
1910 "<!--\n",
1911 "do #hidden. spec-test-build-install-commit-push\n",
1912 "-->\n",
1913 "<!-- agent:exchange -->\n",
1914 "<!-- /agent:exchange -->\n"
1915 );
1916
1917 let ranges = find_non_agent_html_comment_ranges(doc);
1918 assert_eq!(ranges.len(), 1);
1919 let hidden = doc.find("do #hidden").unwrap();
1920 assert!(
1921 ranges
1922 .iter()
1923 .any(|&(start, end)| hidden >= start && hidden < end),
1924 "ordinary comment body should be inside a non-agent comment range"
1925 );
1926 assert!(
1927 ranges
1928 .iter()
1929 .all(|&(start, end)| !doc[start..end].contains("agent:exchange")),
1930 "agent component markers must not be treated as ordinary comments"
1931 );
1932 }
1933
1934 #[test]
1935 fn non_agent_html_comment_ranges_cover_unterminated_tail() {
1936 let doc = concat!(
1937 "before\n",
1938 "<!--\n",
1939 "do #hidden. spec-test-build-install-commit-push\n",
1940 "still typing\n"
1941 );
1942
1943 let ranges = find_non_agent_html_comment_ranges(doc);
1944 assert_eq!(ranges.len(), 1);
1945 let (start, end) = ranges[0];
1946 assert_eq!(
1947 &doc[start..end],
1948 "<!--\ndo #hidden. spec-test-build-install-commit-push\nstill typing\n"
1949 );
1950 assert_eq!(end, doc.len());
1951 }
1952
1953 #[test]
1954 fn strip_comments_preserves_agent_markers() {
1955 let input = "text\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1956 let result = strip_comments(input);
1957 assert!(result.contains("<!-- agent:status -->"));
1958 assert!(result.contains("<!-- /agent:status -->"));
1959 }
1960
1961 #[test]
1962 fn strip_comments_removes_link_ref() {
1963 let result = strip_comments("[//]: # (hidden note)\nvisible\n");
1964 assert_eq!(result, "visible\n");
1965 }
1966
1967 #[test]
1968 fn html_comment_in_content_does_not_eat_close_marker() {
1969 let doc = "\
1970<!-- agent:pending -->
1971- [ ] [#abc] Rule: first line (not starting with ### or ❯ or <!-- ) gets prefix
1972<!-- /agent:pending -->
1973";
1974 let comps = parse(doc).unwrap();
1975 assert_eq!(comps.len(), 1);
1976 assert_eq!(comps[0].name, "pending");
1977 assert!(comps[0].content(doc).contains("<!-- )"));
1978 }
1979
1980 #[test]
1981 fn nested_html_comment_like_text_in_exchange() {
1982 let doc = "\
1983<!-- agent:exchange -->
1984User typed <!-- some note --> in the text.
1985<!-- /agent:exchange -->
1986";
1987 let comps = parse(doc).unwrap();
1988 assert_eq!(comps.len(), 1);
1989 assert_eq!(comps[0].name, "exchange");
1990 }
1991
1992 #[test]
1993 fn pending_item_with_literal_html_comment_opener() {
1994 let doc = "\
1997<!-- agent:pending -->
1998- [ ] [#r7hw] Component parser: non-agent `<!-- ` in content ate close markers
1999- [ ] [#xyz] Another item
2000<!-- /agent:pending -->
2001";
2002 let comps = parse(doc).unwrap();
2003 assert_eq!(comps.len(), 1);
2004 assert_eq!(comps[0].name, "pending");
2005 let content = comps[0].content(doc);
2006 assert!(content.contains("#r7hw"));
2007 assert!(content.contains("#xyz"));
2008 }
2009
2010 #[test]
2011 fn multiple_non_agent_html_comments_in_pending() {
2012 let doc = "\
2014<!-- agent:pending -->
2015- [ ] [#a] Fix rule: skip lines starting with <!-- or -->
2016- [ ] [#b] Handle <!-- partial comment
2017- [ ] [#c] Normal item
2018<!-- /agent:pending -->
2019";
2020 let comps = parse(doc).unwrap();
2021 assert_eq!(comps.len(), 1);
2022 assert_eq!(comps[0].name, "pending");
2023 let content = comps[0].content(doc);
2024 assert!(content.contains("#a"));
2025 assert!(content.contains("#b"));
2026 assert!(content.contains("#c"));
2027 }
2028
2029 #[test]
2030 fn exchange_and_pending_both_with_html_comments_in_content() {
2031 let doc = "\
2034<!-- agent:exchange -->
2035The rule checks for <!-- prefix before deciding.
2036### Re: topic — opus
2037Fix applied to skip non-agent <!-- sequences.
2038<!-- /agent:exchange -->
2039
2040<!-- agent:pending -->
2041- [ ] [#cfdy] Verify <!-- in pending items
2042<!-- /agent:pending -->
2043";
2044 let comps = parse(doc).unwrap();
2045 assert_eq!(comps.len(), 2);
2046 assert_eq!(comps[0].name, "exchange");
2047 assert_eq!(comps[1].name, "pending");
2048 assert!(comps[0].content(doc).contains("<!-- prefix"));
2049 assert!(comps[1].content(doc).contains("#cfdy"));
2050 }
2051
2052 #[test]
2053 fn is_backlog_component_accepts_both_names() {
2054 assert!(is_backlog_component("backlog"));
2055 assert!(is_backlog_component("pending"));
2056 assert!(!is_backlog_component("exchange"));
2057 assert!(!is_backlog_component("status"));
2058 assert!(!is_backlog_component("pending-done"));
2059 assert!(!is_backlog_component("backlog-done"));
2060 assert!(!is_backlog_component("done"));
2061 }
2062
2063 #[test]
2064 fn is_backlog_done_component_accepts_only_canonical_done() {
2065 assert!(is_backlog_done_component("done"));
2066 assert!(!is_backlog_done_component("backlog-done"));
2067 assert!(!is_backlog_done_component("pending-done"));
2068 assert!(!is_backlog_done_component("backlog"));
2069 assert!(!is_backlog_done_component("pending"));
2070 assert!(!is_backlog_done_component("icebox"));
2071 }
2072
2073 #[test]
2074 fn is_review_component_accepts_name() {
2075 assert!(is_review_component("review"));
2076 assert!(!is_review_component("backlog"));
2077 assert!(!is_review_component("icebox"));
2078 }
2079
2080 #[test]
2081 fn is_icebox_component_accepts_name() {
2082 assert!(is_icebox_component("icebox"));
2083 assert!(!is_icebox_component("backlog"));
2084 assert!(!is_icebox_component("exchange"));
2085 assert!(!is_icebox_component("icebox-archive"));
2086 }
2087
2088 #[test]
2089 fn tracked_work_component_includes_review() {
2090 assert!(is_tracked_work_component("backlog"));
2091 assert!(is_tracked_work_component("pending"));
2092 assert!(is_tracked_work_component("review"));
2093 assert!(is_tracked_work_component("icebox"));
2094 assert!(!is_tracked_work_component("done"));
2095 }
2096
2097 #[test]
2098 fn icebox_component_parsed() {
2099 let doc = "\
2100<!-- agent:icebox -->
2101- Parked idea
2102<!-- /agent:icebox -->
2103";
2104 let comps = parse(doc).unwrap();
2105 assert_eq!(comps.len(), 1);
2106 assert_eq!(comps[0].name, "icebox");
2107 assert!(is_icebox_component(&comps[0].name));
2108 assert!(comps[0].content(doc).contains("Parked idea"));
2109 }
2110
2111 #[test]
2112 fn backlog_component_parsed_from_new_marker() {
2113 let doc = "\
2114<!-- agent:backlog -->
2115- [ ] [#abc] First item
2116<!-- /agent:backlog -->
2117";
2118 let comps = parse(doc).unwrap();
2119 assert_eq!(comps.len(), 1);
2120 assert_eq!(comps[0].name, "backlog");
2121 assert!(is_backlog_component(&comps[0].name));
2122 assert!(comps[0].content(doc).contains("#abc"));
2123 }
2124
2125 #[test]
2126 fn legacy_pending_marker_still_parsed() {
2127 let doc = "\
2128<!-- agent:pending -->
2129- [ ] [#xyz] Legacy item
2130<!-- /agent:pending -->
2131";
2132 let comps = parse(doc).unwrap();
2133 assert_eq!(comps.len(), 1);
2134 assert_eq!(comps[0].name, "pending");
2135 assert!(is_backlog_component(&comps[0].name));
2136 }
2137
2138 #[test]
2139 fn strip_backlog_patch_attr_removes_patch_replace() {
2140 let doc = "<!-- agent:backlog patch=replace -->\n- item\n<!-- /agent:backlog -->\n";
2141 let result = strip_backlog_patch_attr(doc);
2142 assert_eq!(
2143 result,
2144 "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2145 );
2146 }
2147
2148 #[test]
2149 fn strip_backlog_patch_attr_removes_pending_patch_replace() {
2150 let doc = "<!-- agent:pending patch=replace -->\n- item\n<!-- /agent:pending -->\n";
2151 let result = strip_backlog_patch_attr(doc);
2152 assert_eq!(
2153 result,
2154 "<!-- agent:pending -->\n- item\n<!-- /agent:pending -->\n"
2155 );
2156 }
2157
2158 #[test]
2159 fn strip_backlog_patch_attr_removes_mode_replace() {
2160 let doc = "<!-- agent:backlog mode=replace -->\n- item\n<!-- /agent:backlog -->\n";
2161 let result = strip_backlog_patch_attr(doc);
2162 assert_eq!(
2163 result,
2164 "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2165 );
2166 }
2167
2168 #[test]
2169 fn strip_backlog_patch_attr_preserves_other_attrs() {
2170 let doc =
2171 "<!-- agent:backlog patch=replace max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n";
2172 let result = strip_backlog_patch_attr(doc);
2173 assert_eq!(
2174 result,
2175 "<!-- agent:backlog max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n"
2176 );
2177 }
2178
2179 #[test]
2180 fn strip_backlog_patch_attr_noop_for_exchange() {
2181 let doc = "<!-- agent:exchange patch=append -->\ncontent\n<!-- /agent:exchange -->\n";
2182 let result = strip_backlog_patch_attr(doc);
2183 assert_eq!(result, doc);
2184 }
2185
2186 #[test]
2187 fn strip_backlog_patch_attr_noop_when_no_attr() {
2188 let doc = "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n";
2189 let result = strip_backlog_patch_attr(doc);
2190 assert_eq!(result, doc);
2191 }
2192
2193 #[test]
2194 fn converge_queue_auto_strips_auto() {
2195 let doc = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2196 let result = converge_queue_auto(doc, false).expect("tag changed");
2197 assert_eq!(
2198 result,
2199 "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n"
2200 );
2201 }
2202
2203 #[test]
2204 fn converge_queue_auto_adds_auto() {
2205 let doc = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2206 let result = converge_queue_auto(doc, true).expect("tag changed");
2207 assert_eq!(
2208 result,
2209 "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n"
2210 );
2211 }
2212
2213 #[test]
2214 fn converge_queue_auto_preserves_other_attrs() {
2215 let doc = "<!-- agent:queue auto patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n";
2216 let result = converge_queue_auto(doc, false).expect("tag changed");
2217 assert_eq!(
2218 result,
2219 "<!-- agent:queue patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n"
2220 );
2221 }
2222
2223 #[test]
2224 fn converge_queue_auto_normalizes_boolean_attrs_without_corrupting_preset() {
2225 let doc = concat!(
2226 "<!-- agent:queue auto=true priority=true preset=\"#spec-test-build-install-commit-push\"=true go=true -->\n",
2227 "- do [#x]\n",
2228 "<!-- /agent:queue -->\n",
2229 );
2230 let result = converge_queue_auto(doc, false).expect("tag normalized");
2231 assert_eq!(
2232 result,
2233 concat!(
2234 "<!-- agent:queue priority preset=\"#spec-test-build-install-commit-push\" go -->\n",
2235 "- do [#x]\n",
2236 "<!-- /agent:queue -->\n",
2237 )
2238 );
2239 }
2240
2241 #[test]
2242 fn converge_queue_auto_noop_when_already_matching() {
2243 let active = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2244 assert_eq!(converge_queue_auto(active, true), None);
2245 let inactive = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2246 assert_eq!(converge_queue_auto(inactive, false), None);
2247 }
2248
2249 #[test]
2250 fn converge_queue_auto_none_without_queue_component() {
2251 let doc = "<!-- agent:exchange -->\nhi\n<!-- /agent:exchange -->\n";
2252 assert_eq!(converge_queue_auto(doc, false), None);
2253 }
2254
2255 #[test]
2259 fn append_patch_already_present_ignores_user_prompt_prefix() {
2260 assert!(
2261 append_patch_already_present("expand the section", "❯ expand the section"),
2262 "bare buffer vs prefixed patch must dedup"
2263 );
2264 assert!(
2265 append_patch_already_present("❯ expand the section", "expand the section"),
2266 "prefixed buffer vs bare patch must dedup"
2267 );
2268 assert!(
2269 append_patch_already_present("❯ expand the section", "❯ expand the section"),
2270 "both prefixed must dedup"
2271 );
2272 }
2273
2274 #[test]
2275 fn append_patch_distinct_prompts_not_deduped() {
2276 assert!(
2277 !append_patch_already_present("❯ expand the section", "❯ summarize the section"),
2278 "genuinely distinct prompts must not be treated as duplicates"
2279 );
2280 }
2281
2282 #[test]
2289 fn append_patch_dedupes_midblock_typing_snapshot() {
2290 let live = concat!(
2291 "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2292 "- [#bndq]: confirmed\n",
2293 "- do [#s75b]\n",
2294 "- do [#vapk]\n",
2295 "- [#s93y]: Add to resume\n",
2296 "- [#ca8w]\n",
2297 "Add SampleOrders and sample portal to the resume if not already added.",
2298 );
2299 let snapshot = concat!(
2300 "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2301 "- [#bndq]: confirmed\n",
2302 "- do [#s75b]\n",
2303 "- do [#vapk]\n",
2304 "- [#s93y]: Add to\n",
2305 "- [#ca8w]\n",
2306 "Add SampleOrders and sample portal to the resume if not already added.",
2307 );
2308 assert!(
2309 append_patch_already_present(live, snapshot),
2310 "earlier keystroke snapshot must dedup against the live, further-typed region"
2311 );
2312 }
2313
2314 #[test]
2315 fn append_patch_typing_snapshot_does_not_collapse_distinct_lines() {
2316 let live = "- do task one\n- and the second thing\n- finally";
2319 let distinct = "- do task one\n- and a different thing\n- finally";
2320 assert!(
2321 !append_patch_already_present(live, distinct),
2322 "a mid-line replacement (not a prefix) is a distinct edit, not a snapshot"
2323 );
2324 }
2325
2326 #[test]
2327 fn append_with_boundary_does_not_duplicate_typing_snapshot() {
2328 let doc = concat!(
2332 "<!-- agent:exchange patch=append -->\n",
2333 "### Re: prior — opus-4-8\n\n",
2334 "Answer.\n",
2335 "<!-- agent:boundary:70bccf9b -->\n",
2336 "- [#yfg1]: ok\n",
2337 "- [#s93y]: Add to resume\n",
2338 "- [#ca8w]\n",
2339 "Add SampleOrders and sample portal to the resume if not already added.\n",
2340 "<!-- /agent:exchange -->\n",
2341 );
2342 let components = parse(doc).unwrap();
2343 let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2344 let snapshot = concat!(
2345 "- [#yfg1]: ok\n",
2346 "- [#s93y]: Add to\n",
2347 "- [#ca8w]\n",
2348 "Add SampleOrders and sample portal to the resume if not already added.",
2349 );
2350 let result = exchange.append_with_boundary(doc, snapshot, "70bccf9b");
2351 assert_eq!(
2352 result, doc,
2353 "stale typing-snapshot append must be a no-op:\n{result}"
2354 );
2355 assert_eq!(
2356 result.matches("<!-- /agent:exchange -->").count(),
2357 1,
2358 "exchange close marker must not duplicate:\n{result}"
2359 );
2360 }
2361
2362 #[test]
2363 fn append_with_caret_does_not_duplicate_prefixed_prompt() {
2364 let doc = concat!(
2368 "<!-- agent:exchange patch=append -->\n",
2369 "### Re: prior — opus-4-8\n\n",
2370 "Answer.\n",
2371 "<!-- agent:boundary:abc12345 -->\n",
2372 "expand the Confidential AI Startup section\n",
2373 "<!-- /agent:exchange -->\n",
2374 );
2375 let components = parse(doc).unwrap();
2376 let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2377 let result =
2378 exchange.append_with_caret(doc, "❯ expand the Confidential AI Startup section", None);
2379 assert_eq!(
2380 result
2381 .matches("expand the Confidential AI Startup section")
2382 .count(),
2383 1,
2384 "prefixed synthesized patch must not duplicate the already-typed prompt:\n{result}"
2385 );
2386 }
2387
2388 const CLEAN_DOC: &str = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\
2391<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2392<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- do [#x]\n<!-- /agent:queue -->\n\
2393<!-- agent:backlog -->\n- [ ] [#y] thing\n<!-- /agent:backlog -->\n";
2394
2395 #[test]
2396 fn structural_corruption_clean_doc_is_sound() {
2397 assert_eq!(structural_corruption_reason(CLEAN_DOC), None);
2398 }
2399
2400 #[test]
2401 fn structural_corruption_flags_duplicate_singleton_queue() {
2402 let doc = "<!-- agent:queue -->\n- a\n<!-- /agent:queue -->\n\
2405<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2406<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- b\n<!-- /agent:queue -->\n";
2407 let reason = structural_corruption_reason(doc).expect("two queue blocks must be flagged");
2408 assert!(
2409 reason.contains("duplicate_singleton_component") && reason.contains("queue=2"),
2410 "reason was: {reason}"
2411 );
2412 }
2413
2414 #[test]
2415 fn structural_corruption_flags_backlog_alias_duplicate() {
2416 let doc = "<!-- agent:backlog -->\n- [ ] [#a] x\n<!-- /agent:backlog -->\n\
2418<!-- agent:pending -->\n- [ ] [#b] y\n<!-- /agent:pending -->\n";
2419 let reason = structural_corruption_reason(doc).expect("backlog+pending must be flagged");
2420 assert!(reason.contains("backlog=2"), "reason was: {reason}");
2421 }
2422
2423 #[test]
2424 fn structural_corruption_flags_unterminated_quote_attr() {
2425 let doc = "<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2427<!-- agent:queue tasks=\" -->\n- a\n<!-- /agent:queue -->\n";
2428 let reason =
2429 structural_corruption_reason(doc).expect("unterminated quote attr must be flagged");
2430 assert!(
2431 reason.starts_with("malformed_attr:queue"),
2432 "reason was: {reason}"
2433 );
2434 }
2435
2436 #[test]
2437 fn structural_corruption_flags_truncated_agent_comment() {
2438 let doc = "<!-- agent:queue -->\n- a\n<!-- /agent:queue ->\n\
2439<!-- /agent:exchange --\n";
2440 let reason =
2441 structural_corruption_reason(doc).expect("truncated agent marker must be flagged");
2442 assert!(
2443 reason.starts_with("malformed_agent_comment:"),
2444 "reason was: {reason}"
2445 );
2446 }
2447
2448 #[test]
2449 fn structural_corruption_ignores_truncated_agent_comment_inside_code_fence() {
2450 let doc = "```md\n<!-- /agent:queue ->\n<!-- /agent:exchange --\n```\n";
2451 assert_eq!(structural_corruption_reason(doc), None);
2452 }
2453
2454 #[test]
2455 fn structural_corruption_ignores_truncated_agent_comment_inside_quotes() {
2456 let doc = "\"<!-- /agent:queue ->\"\n";
2457 assert_eq!(structural_corruption_reason(doc), None);
2458 }
2459
2460 #[test]
2461 fn structural_corruption_allows_balanced_quoted_attr() {
2462 let doc = "<!-- agent:queue preset=\"#a\" go -->\n- a\n<!-- /agent:queue -->\n";
2464 assert_eq!(structural_corruption_reason(doc), None);
2465 }
2466
2467 #[test]
2468 fn structural_corruption_allows_repeated_non_singleton() {
2469 let doc = "<!-- agent:log -->\na\n<!-- /agent:log -->\n\
2471<!-- agent:log -->\nb\n<!-- /agent:log -->\n";
2472 assert_eq!(structural_corruption_reason(doc), None);
2473 }
2474
2475 #[test]
2476 fn structural_corruption_flags_parse_failure() {
2477 let doc = "<!-- agent:queue -->\n- a\n";
2479 let reason = structural_corruption_reason(doc).expect("unclosed marker must be flagged");
2480 assert!(reason.starts_with("parse_error"), "reason was: {reason}");
2481 }
2482}