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 is_agent_boundary_marker(comment_text: &str) -> bool {
629 let trimmed = comment_text.trim();
630 if let Some(rest) = trimmed.strip_prefix("/agent:") {
631 rest.starts_with("boundary:")
632 } else if let Some(rest) = trimmed.strip_prefix("agent:") {
633 rest.split_whitespace()
634 .next()
635 .is_some_and(|name| name.starts_with("boundary:"))
636 } else {
637 false
638 }
639}
640
641fn bounded_preview(doc: &str, start: usize, max_bytes: usize) -> &str {
642 debug_assert!(doc.is_char_boundary(start));
643 let mut end = doc.len().min(start.saturating_add(max_bytes));
644 while end > start && !doc.is_char_boundary(end) {
645 end -= 1;
646 }
647 &doc[start..end]
648}
649
650pub fn parse_attrs(attr_text: &str) -> HashMap<String, String> {
655 let mut attrs = HashMap::new();
656 for token in attr_text.split_whitespace() {
657 if let Some((key, value)) = token.split_once('=') {
658 if !key.is_empty() && !value.is_empty() {
659 attrs.insert(key.to_string(), value.to_string());
660 }
661 } else if !token.is_empty() {
662 attrs.insert(token.to_string(), String::new());
663 }
664 }
665 attrs
666}
667
668pub fn find_code_ranges(doc: &str) -> Vec<(usize, usize)> {
674 let t = std::time::Instant::now();
675 let mut ranges = Vec::new();
676 let parser = Parser::new_ext(doc, Options::empty());
677 let mut iter = parser.into_offset_iter();
678 while let Some((event, range)) = iter.next() {
679 match event {
680 Event::Code(_) => {
682 ranges.push((range.start, range.end));
683 }
684 Event::Start(Tag::CodeBlock(_)) => {
686 let block_start = range.start;
687 let mut block_end = range.end;
688 for (inner_event, inner_range) in iter.by_ref() {
689 block_end = inner_range.end;
690 if matches!(inner_event, Event::End(TagEnd::CodeBlock)) {
691 break;
692 }
693 }
694 ranges.push((block_start, block_end));
695 }
696 _ => {}
697 }
698 }
699 let elapsed = t.elapsed().as_millis();
700 if elapsed > 0 {
701 eprintln!("[perf] find_code_ranges: {}ms", elapsed);
702 }
703 ranges
704}
705
706pub fn find_quoted_ranges(doc: &str) -> Vec<(usize, usize)> {
722 let mut ranges = Vec::new();
723 let mut offset = 0usize;
724 for raw_line in doc.split_inclusive('\n') {
725 let line_start = offset;
726 offset += raw_line.len();
727 let bytes = raw_line.as_bytes();
728 let line_content_len = raw_line.trim_end_matches(['\n', '\r']).len();
729 let mut i = 0usize;
730 while i < bytes.len() {
731 if bytes[i] != b'"' {
732 i += 1;
733 continue;
734 }
735 let span_start = line_start + i;
736 let mut j = i + 1;
737 let mut closed = false;
738 while j < bytes.len() {
739 match bytes[j] {
740 b'\\' => {
741 j += 2;
742 continue;
743 }
744 b'"' => {
745 closed = true;
746 break;
747 }
748 b'\n' | b'\r' => break,
749 _ => j += 1,
750 }
751 }
752 if closed {
753 ranges.push((span_start, line_start + j + 1));
754 i = j + 1;
755 } else {
756 ranges.push((span_start, line_start + line_content_len));
759 break;
760 }
761 }
762 }
763 ranges
764}
765
766pub fn find_non_agent_html_comment_ranges(doc: &str) -> Vec<(usize, usize)> {
775 let code_ranges = find_code_ranges(doc);
776 let quoted_ranges = find_quoted_ranges(doc);
777 let in_code = |pos: usize| {
778 code_ranges
779 .iter()
780 .chain(quoted_ranges.iter())
781 .any(|&(start, end)| pos >= start && pos < end)
782 };
783
784 let bytes = doc.as_bytes();
785 let len = bytes.len();
786 let mut ranges = Vec::new();
787 let mut pos = 0usize;
788 while pos + 4 <= len {
789 if &bytes[pos..pos + 4] != b"<!--" {
790 pos += 1;
791 continue;
792 }
793 if in_code(pos) {
794 pos += 4;
795 continue;
796 }
797 let Some(end) = find_comment_end(bytes, pos + 4) else {
798 let inner = &doc[pos + 4..];
799 if !is_agent_marker(inner) && !is_agent_boundary_marker(inner) {
800 ranges.push((pos, len));
801 break;
802 }
803 pos += 4;
804 continue;
805 };
806 let inner = &doc[pos + 4..end - 3];
807 if !is_agent_marker(inner) && !is_agent_boundary_marker(inner) {
808 ranges.push((pos, end));
809 }
810 pos = end;
811 }
812 ranges
813}
814
815pub fn parse(doc: &str) -> Result<Vec<Component>> {
821 let bytes = doc.as_bytes();
822 let len = bytes.len();
823 let code_ranges = find_code_ranges(doc);
824 let quoted_ranges = find_quoted_ranges(doc);
825 let mut templates: Vec<Component> = Vec::new();
826 let mut stack: Vec<(String, HashMap<String, String>, usize, usize)> = Vec::new();
828 let mut pos = 0;
829
830 while pos + 4 <= len {
831 if &bytes[pos..pos + 4] != b"<!--" {
833 pos += 1;
834 continue;
835 }
836
837 if code_ranges
840 .iter()
841 .chain(quoted_ranges.iter())
842 .any(|&(start, end)| pos >= start && pos < end)
843 {
844 pos += 4;
845 continue;
846 }
847
848 let peek_start = pos + 4;
853 let peek = bounded_preview(doc, peek_start, 20);
854 let peek_trimmed = peek.trim_start();
855 if !peek_trimmed.starts_with("agent:") && !peek_trimmed.starts_with("/agent:") {
856 pos += 1;
857 continue;
858 }
859
860 let marker_start = pos;
861
862 let close = match find_comment_end(bytes, pos + 4) {
864 Some(c) => c,
865 None => {
866 pos += 4;
867 continue;
868 }
869 };
870
871 let inner = &doc[marker_start + 4..close - 3]; let trimmed = inner.trim();
874
875 let mut marker_end = close;
877 if marker_end < len && bytes[marker_end] == b'\n' {
878 marker_end += 1;
879 }
880
881 if is_agent_boundary_marker(trimmed) {
882 pos = close;
883 continue;
884 }
885
886 if let Some(name) = trimmed.strip_prefix("/agent:") {
887 if !is_valid_name(name) {
889 bail!("invalid component name: '{}'", name);
890 }
891 match stack.pop() {
892 Some((open_name, open_attrs, open_start, open_end)) => {
893 if open_name != name {
894 bail!(
895 "mismatched component: opened '{}' but closed '{}'",
896 open_name,
897 name
898 );
899 }
900 templates.push(Component {
901 name: name.to_string(),
902 attrs: open_attrs,
903 open_start,
904 open_end,
905 close_start: marker_start,
906 close_end: marker_end,
907 });
908 }
909 None => bail!(
910 "closing marker <!-- /agent:{} --> without matching open",
911 name
912 ),
913 }
914 } else if let Some(rest) = trimmed.strip_prefix("agent:") {
915 if is_agent_boundary_marker(trimmed) {
917 pos = close;
918 continue;
919 }
920 let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
922 let name = parts.next().unwrap_or("");
923 let attr_text = parts.next().unwrap_or("");
924 if !is_valid_name(name) {
925 bail!("invalid component name: '{}'", name);
926 }
927 let attrs = parse_attrs(attr_text);
928 stack.push((name.to_string(), attrs, marker_start, marker_end));
929 }
930
931 pos = close;
932 }
933
934 if let Some((name, _, _, _)) = stack.last() {
935 bail!(
936 "unclosed component: <!-- agent:{} --> without matching close",
937 name
938 );
939 }
940
941 templates.sort_by_key(|t| t.open_start);
942 Ok(templates)
943}
944
945const SINGLETON_COMPONENT_NAMES: &[&str] = &[
949 EXCHANGE_COMPONENT,
950 "status",
951 "queue",
952 BACKLOG_COMPONENT,
953 REVIEW_COMPONENT,
954 ICEBOX_COMPONENT,
955 BACKLOG_DONE_COMPONENT,
956];
957
958fn canonical_singleton_name(name: &str) -> Option<&'static str> {
962 if is_backlog_component(name) {
963 return Some(BACKLOG_COMPONENT);
964 }
965 SINGLETON_COMPONENT_NAMES
966 .iter()
967 .copied()
968 .find(|&s| s == name)
969}
970
971fn marker_has_unterminated_quote(marker: &str) -> bool {
976 let mut open = false;
977 let mut escaped = false;
978 for ch in marker.chars() {
979 if escaped {
980 escaped = false;
981 continue;
982 }
983 match ch {
984 '\\' => escaped = true,
985 '"' => open = !open,
986 _ => {}
987 }
988 }
989 open
990}
991
992fn byte_in_ranges(offset: usize, ranges: &[(usize, usize)]) -> bool {
993 ranges
994 .iter()
995 .any(|&(start, end)| offset >= start && offset < end)
996}
997
998pub fn malformed_agent_comment_reason(doc: &str) -> Option<String> {
1003 let code_ranges = find_code_ranges(doc);
1004 let quoted_ranges = find_quoted_ranges(doc);
1005 let mut offset = 0usize;
1006 for (line_idx, raw_line) in doc.split_inclusive('\n').enumerate() {
1007 let line = raw_line.trim_end_matches(['\n', '\r']);
1008 let leading = line.len() - line.trim_start_matches([' ', '\t']).len();
1009 let marker_start = offset + leading;
1010 let trimmed = &line[leading..];
1011 offset += raw_line.len();
1012
1013 if !trimmed.starts_with("<!--") {
1014 continue;
1015 }
1016 if byte_in_ranges(marker_start, &code_ranges)
1017 || byte_in_ranges(marker_start, "ed_ranges)
1018 {
1019 continue;
1020 }
1021 let inner = trimmed["<!--".len()..].trim_start();
1022 if (inner.starts_with("agent:") || inner.starts_with("/agent:")) && !trimmed.contains("-->")
1023 {
1024 let preview = trimmed.chars().take(80).collect::<String>();
1025 return Some(format!(
1026 "malformed_agent_comment:line{}:{preview}",
1027 line_idx + 1
1028 ));
1029 }
1030 }
1031 None
1032}
1033
1034pub fn structural_corruption_reason(doc: &str) -> Option<String> {
1045 if let Some(reason) = malformed_agent_comment_reason(doc) {
1046 return Some(reason);
1047 }
1048
1049 let components = match parse(doc) {
1050 Ok(c) => c,
1051 Err(e) => return Some(format!("parse_error: {e}")),
1052 };
1053
1054 let mut counts: HashMap<&str, usize> = HashMap::new();
1056 for c in &components {
1057 if let Some(canonical) = canonical_singleton_name(&c.name) {
1058 *counts.entry(canonical).or_insert(0) += 1;
1059 }
1060 }
1061 let mut dupes: Vec<(&str, usize)> = counts
1062 .iter()
1063 .filter(|&(_, &n)| n > 1)
1064 .map(|(&k, &n)| (k, n))
1065 .collect();
1066 if !dupes.is_empty() {
1067 dupes.sort();
1068 let detail = dupes
1069 .iter()
1070 .map(|(k, n)| format!("{k}={n}"))
1071 .collect::<Vec<_>>()
1072 .join(",");
1073 return Some(format!("duplicate_singleton_component:{detail}"));
1074 }
1075
1076 for c in &components {
1078 let marker = &doc[c.open_start..c.open_end];
1079 if marker_has_unterminated_quote(marker) {
1080 return Some(format!("malformed_attr:{}", c.name));
1081 }
1082 }
1083
1084 None
1085}
1086
1087pub fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
1089 let len = bytes.len();
1090 let mut i = start;
1091 while i + 3 <= len {
1092 if &bytes[i..i + 3] == b"-->" {
1093 return Some(i + 3);
1094 }
1095 i += 1;
1096 }
1097 None
1098}
1099
1100pub fn strip_comments(content: &str) -> String {
1113 let code_ranges = find_code_ranges(content);
1114 let in_code = |pos: usize| {
1115 code_ranges
1116 .iter()
1117 .any(|&(start, end)| pos >= start && pos < end)
1118 };
1119
1120 let mut result = String::with_capacity(content.len());
1121 let bytes = content.as_bytes();
1122 let len = bytes.len();
1123 let mut pos = 0;
1124
1125 while pos < len {
1126 if bytes[pos] == b'['
1128 && !in_code(pos)
1129 && is_line_start_at(bytes, pos)
1130 && let Some(end) = match_link_ref_comment_at(bytes, pos)
1131 {
1132 pos = end;
1133 continue;
1134 }
1135
1136 if pos + 4 <= len
1138 && &bytes[pos..pos + 4] == b"<!--"
1139 && !in_code(pos)
1140 && let Some((end, inner)) = match_html_comment_at(content, pos)
1141 {
1142 if is_agent_marker(inner) {
1143 result.push_str(&content[pos..end]);
1145 pos = end;
1146 } else {
1147 let mut skip_to = end;
1149 if is_line_start_at(bytes, pos) && skip_to < len && bytes[skip_to] == b'\n' {
1150 skip_to += 1;
1151 }
1152 pos = skip_to;
1153 }
1154 continue;
1155 }
1156
1157 result.push(content[pos..].chars().next().unwrap());
1158 pos += content[pos..].chars().next().unwrap().len_utf8();
1159 }
1160
1161 result
1162}
1163
1164fn is_line_start_at(bytes: &[u8], pos: usize) -> bool {
1166 pos == 0 || bytes[pos - 1] == b'\n'
1167}
1168
1169fn match_link_ref_comment_at(bytes: &[u8], pos: usize) -> Option<usize> {
1171 let prefix = b"[//]: # (";
1172 let len = bytes.len();
1173 if pos + prefix.len() > len || &bytes[pos..pos + prefix.len()] != prefix {
1174 return None;
1175 }
1176 let mut i = pos + prefix.len();
1177 while i < len && bytes[i] != b')' && bytes[i] != b'\n' {
1178 i += 1;
1179 }
1180 if i < len && bytes[i] == b')' {
1181 i += 1;
1182 if i < len && bytes[i] == b'\n' {
1183 i += 1;
1184 }
1185 Some(i)
1186 } else {
1187 None
1188 }
1189}
1190
1191fn match_html_comment_at(content: &str, pos: usize) -> Option<(usize, &str)> {
1193 let bytes = content.as_bytes();
1194 let len = bytes.len();
1195 let mut i = pos + 4;
1196 while i + 3 <= len {
1197 if &bytes[i..i + 3] == b"-->" {
1198 let inner = &content[pos + 4..i];
1199 return Some((i + 3, inner));
1200 }
1201 i += 1;
1202 }
1203 None
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209
1210 #[test]
1211 fn single_range() {
1212 let doc = "before\n<!-- agent:status -->\nHello\n<!-- /agent:status -->\nafter\n";
1213 let ranges = parse(doc).unwrap();
1214 assert_eq!(ranges.len(), 1);
1215 assert_eq!(ranges[0].name, "status");
1216 assert_eq!(ranges[0].content(doc), "Hello\n");
1217 }
1218
1219 #[test]
1220 fn nested_ranges() {
1221 let doc = "\
1222<!-- agent:outer -->
1223<!-- agent:inner -->
1224content
1225<!-- /agent:inner -->
1226<!-- /agent:outer -->
1227";
1228 let ranges = parse(doc).unwrap();
1229 assert_eq!(ranges.len(), 2);
1230 assert_eq!(ranges[0].name, "outer");
1232 assert_eq!(ranges[1].name, "inner");
1233 assert_eq!(ranges[1].content(doc), "content\n");
1234 }
1235
1236 #[test]
1237 fn siblings() {
1238 let doc = "\
1239<!-- agent:a -->
1240alpha
1241<!-- /agent:a -->
1242<!-- agent:b -->
1243beta
1244<!-- /agent:b -->
1245";
1246 let ranges = parse(doc).unwrap();
1247 assert_eq!(ranges.len(), 2);
1248 assert_eq!(ranges[0].name, "a");
1249 assert_eq!(ranges[0].content(doc), "alpha\n");
1250 assert_eq!(ranges[1].name, "b");
1251 assert_eq!(ranges[1].content(doc), "beta\n");
1252 }
1253
1254 #[test]
1255 fn no_ranges() {
1256 let doc = "# Just a document\n\nWith no range templates.\n";
1257 let ranges = parse(doc).unwrap();
1258 assert!(ranges.is_empty());
1259 }
1260
1261 #[test]
1262 fn unmatched_open_error() {
1263 let doc = "<!-- agent:orphan -->\nContent\n";
1264 let err = parse(doc).unwrap_err();
1265 assert!(err.to_string().contains("unclosed component"));
1266 }
1267
1268 #[test]
1269 fn unmatched_close_error() {
1270 let doc = "Content\n<!-- /agent:orphan -->\n";
1271 let err = parse(doc).unwrap_err();
1272 assert!(err.to_string().contains("without matching open"));
1273 }
1274
1275 #[test]
1276 fn mismatched_names_error() {
1277 let doc = "<!-- agent:foo -->\n<!-- /agent:bar -->\n";
1278 let err = parse(doc).unwrap_err();
1279 assert!(err.to_string().contains("mismatched"));
1280 }
1281
1282 #[test]
1283 fn invalid_name() {
1284 let doc = "<!-- agent:-bad -->\n<!-- /agent:-bad -->\n";
1285 let err = parse(doc).unwrap_err();
1286 assert!(err.to_string().contains("invalid component name"));
1287 }
1288
1289 #[test]
1290 fn name_validation() {
1291 assert!(is_valid_name("status"));
1292 assert!(is_valid_name("my-section"));
1293 assert!(is_valid_name("a1"));
1294 assert!(is_valid_name("A"));
1295 assert!(!is_valid_name(""));
1296 assert!(!is_valid_name("-bad"));
1297 assert!(!is_valid_name("has space"));
1298 assert!(!is_valid_name("has_underscore"));
1299 }
1300
1301 #[test]
1302 fn content_extraction() {
1303 let doc = "<!-- agent:x -->\nfoo\nbar\n<!-- /agent:x -->\n";
1304 let ranges = parse(doc).unwrap();
1305 assert_eq!(ranges[0].content(doc), "foo\nbar\n");
1306 }
1307
1308 #[test]
1309 fn replace_roundtrip() {
1310 let doc = "before\n<!-- agent:s -->\nold\n<!-- /agent:s -->\nafter\n";
1311 let ranges = parse(doc).unwrap();
1312 let new_doc = ranges[0].replace_content(doc, "new\n");
1313 assert_eq!(
1314 new_doc,
1315 "before\n<!-- agent:s -->\nnew\n<!-- /agent:s -->\nafter\n"
1316 );
1317 let ranges2 = parse(&new_doc).unwrap();
1319 assert_eq!(ranges2.len(), 1);
1320 assert_eq!(ranges2[0].content(&new_doc), "new\n");
1321 }
1322
1323 #[test]
1324 fn is_agent_marker_yes() {
1325 assert!(is_agent_marker(" agent:status "));
1326 assert!(is_agent_marker("/agent:status"));
1327 assert!(is_agent_marker("agent:my-thing"));
1328 assert!(is_agent_marker(" /agent:A1 "));
1329 }
1330
1331 #[test]
1332 fn is_agent_marker_no() {
1333 assert!(!is_agent_marker("just a comment"));
1334 assert!(!is_agent_marker("agent:"));
1335 assert!(!is_agent_marker("/agent:"));
1336 assert!(!is_agent_marker("agent:-bad"));
1337 assert!(!is_agent_marker("some agent:fake stuff"));
1338 }
1339
1340 #[test]
1341 fn regular_comments_ignored() {
1342 let doc = "<!-- just a comment -->\n<!-- agent:x -->\ndata\n<!-- /agent:x -->\n";
1343 let ranges = parse(doc).unwrap();
1344 assert_eq!(ranges.len(), 1);
1345 assert_eq!(ranges[0].name, "x");
1346 }
1347
1348 #[test]
1349 fn regular_comments_with_multibyte_preview_boundary_ignored() {
1350 let doc = "\
1351<!-- 123456789012345678❯ ordinary comment -->
1352<!-- agent:x -->
1353data
1354<!-- /agent:x -->
1355";
1356 let ranges = parse(doc).unwrap();
1357 assert_eq!(ranges.len(), 1);
1358 assert_eq!(ranges[0].name, "x");
1359 }
1360
1361 #[test]
1362 fn multiline_comment_ignored() {
1363 let doc = "\
1364<!--
1365multi
1366line
1367comment
1368-->
1369<!-- agent:s -->
1370content
1371<!-- /agent:s -->
1372";
1373 let ranges = parse(doc).unwrap();
1374 assert_eq!(ranges.len(), 1);
1375 assert_eq!(ranges[0].name, "s");
1376 }
1377
1378 #[test]
1379 fn empty_content() {
1380 let doc = "<!-- agent:empty --><!-- /agent:empty -->\n";
1381 let ranges = parse(doc).unwrap();
1382 assert_eq!(ranges.len(), 1);
1383 assert_eq!(ranges[0].content(doc), "");
1384 }
1385
1386 #[test]
1387 fn markers_in_fenced_code_block_ignored() {
1388 let doc = "\
1389<!-- agent:real -->
1390content
1391<!-- /agent:real -->
1392```markdown
1393<!-- agent:fake -->
1394this is just an example
1395<!-- /agent:fake -->
1396```
1397";
1398 let ranges = parse(doc).unwrap();
1399 assert_eq!(ranges.len(), 1);
1400 assert_eq!(ranges[0].name, "real");
1401 }
1402
1403 #[test]
1404 fn markers_in_inline_code_ignored() {
1405 let doc = "\
1406Use `<!-- agent:example -->` markers for components.
1407<!-- agent:real -->
1408content
1409<!-- /agent:real -->
1410";
1411 let ranges = parse(doc).unwrap();
1412 assert_eq!(ranges.len(), 1);
1413 assert_eq!(ranges[0].name, "real");
1414 }
1415
1416 #[test]
1417 fn markers_in_tilde_fence_ignored() {
1418 let doc = "\
1419<!-- agent:x -->
1420data
1421<!-- /agent:x -->
1422~~~
1423<!-- agent:y -->
1424example
1425<!-- /agent:y -->
1426~~~
1427";
1428 let ranges = parse(doc).unwrap();
1429 assert_eq!(ranges.len(), 1);
1430 assert_eq!(ranges[0].name, "x");
1431 }
1432
1433 #[test]
1434 fn markers_in_indented_fenced_code_block_ignored() {
1435 let doc = "\
1437<!-- agent:exchange -->
1438Content here.
1439<!-- /agent:exchange -->
1440
1441 ```markdown
1442 <!-- agent:fake -->
1443 demo without closing tag
1444 ```
1445";
1446 let ranges = parse(doc).unwrap();
1447 assert_eq!(ranges.len(), 1);
1448 assert_eq!(ranges[0].name, "exchange");
1449 }
1450
1451 #[test]
1452 fn indented_fence_inside_component_ignored() {
1453 let doc = "\
1455<!-- agent:exchange -->
1456Here's how to set up:
1457
1458 ```markdown
1459 <!-- agent:status -->
1460 Your status here
1461 ```
1462
1463Done explaining.
1464<!-- /agent:exchange -->
1465";
1466 let ranges = parse(doc).unwrap();
1467 assert_eq!(ranges.len(), 1);
1468 assert_eq!(ranges[0].name, "exchange");
1469 }
1470
1471 #[test]
1472 fn deeply_indented_fence_ignored() {
1473 let doc = "\
1475<!-- agent:x -->
1476ok
1477<!-- /agent:x -->
1478 ```
1479 <!-- agent:y -->
1480 inside fence
1481 ```
1482";
1483 let ranges = parse(doc).unwrap();
1484 assert_eq!(ranges.len(), 1);
1485 assert_eq!(ranges[0].name, "x");
1486 }
1487
1488 #[test]
1489 fn indented_fence_code_ranges_detected() {
1490 let doc = "before\n ```\n code\n ```\nafter\n";
1491 let ranges = find_code_ranges(doc);
1492 assert_eq!(ranges.len(), 1);
1493 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1494 }
1495
1496 #[test]
1497 fn code_ranges_detected() {
1498 let doc = "before\n```\ncode\n```\nafter `inline` end\n";
1499 let ranges = find_code_ranges(doc);
1500 assert_eq!(ranges.len(), 2);
1501 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1503 assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
1505 }
1506
1507 #[test]
1508 fn code_ranges_double_backtick() {
1509 let doc = "text `` `<!--` `` more\n";
1511 let ranges = find_code_ranges(doc);
1512 assert_eq!(ranges.len(), 1);
1513 let span = &doc[ranges[0].0..ranges[0].1];
1514 assert!(
1515 span.contains("<!--"),
1516 "double-backtick span should contain <!--: {:?}",
1517 span
1518 );
1519 }
1520
1521 #[test]
1522 fn code_ranges_double_backtick_does_not_match_single() {
1523 let doc = "text `` foo ` bar `` end\n";
1525 let ranges = find_code_ranges(doc);
1526 assert_eq!(ranges.len(), 1);
1527 let span = &doc[ranges[0].0..ranges[0].1];
1528 assert_eq!(span, "`` foo ` bar ``");
1529 }
1530
1531 #[test]
1532 fn double_backtick_comment_before_agent_marker() {
1533 let doc = "\
1535<!-- agent:exchange -->\n\
1536text `` `<!--` `` description\n\
1537new content here\n\
1538<!-- /agent:exchange -->\n";
1539 let components = parse(doc).unwrap();
1540 assert_eq!(components.len(), 1);
1541 assert_eq!(components[0].name, "exchange");
1542 assert!(components[0].content(doc).contains("new content here"));
1543 }
1544
1545 #[test]
1546 fn quoted_agent_marker_in_prose_is_not_parsed() {
1547 let doc = "\
1551<!-- agent:exchange -->\n\
1552Operator note: \"<!-- agent:queue -->\" attributes were dropped on compaction.\n\
1553new content here\n\
1554<!-- /agent:exchange -->\n";
1555 let components = parse(doc).unwrap();
1556 assert_eq!(components.len(), 1);
1557 assert_eq!(components[0].name, "exchange");
1558 assert!(components[0].content(doc).contains("new content here"));
1559 assert!(
1560 components[0]
1561 .content(doc)
1562 .contains("attributes were dropped")
1563 );
1564 }
1565
1566 #[test]
1567 fn quoted_id_token_in_prose_is_content() {
1568 let doc = "\
1570<!-- agent:exchange -->\n\
1571The operator asked to clear \"#advance-review\" but no such line exists.\n\
1572<!-- /agent:exchange -->\n";
1573 let components = parse(doc).unwrap();
1574 assert_eq!(components.len(), 1);
1575 assert!(components[0].content(doc).contains("#advance-review"));
1576 }
1577
1578 #[test]
1579 fn fenced_agent_marker_in_prose_is_not_parsed() {
1580 let doc = "\
1583<!-- agent:exchange -->\n\
1584```\n\
1585<!-- agent:queue -->\n\
1586```\n\
1587new content here\n\
1588<!-- /agent:exchange -->\n";
1589 let components = parse(doc).unwrap();
1590 assert_eq!(components.len(), 1);
1591 assert_eq!(components[0].name, "exchange");
1592 assert!(components[0].content(doc).contains("new content here"));
1593 }
1594
1595 #[test]
1596 fn real_marker_with_quoted_preset_attr_still_parses() {
1597 let doc = "\
1601<!-- agent:queue preset=\"#spec-test-build-install-commit-push\" priority go -->\n\
1602- :pushpin: do [#thing]\n\
1603<!-- /agent:queue -->\n";
1604 let components = parse(doc).unwrap();
1605 assert_eq!(components.len(), 1);
1606 assert_eq!(components[0].name, "queue");
1607 assert!(
1610 components[0]
1611 .attrs
1612 .get("preset")
1613 .is_some_and(|v| v.contains("#spec-test-build-install-commit-push")),
1614 "preset attr should survive quote-masking, got {:?}",
1615 components[0].attrs.get("preset")
1616 );
1617 }
1618
1619 #[test]
1620 fn find_quoted_ranges_is_same_line_and_fail_safe() {
1621 let doc = "a \"bc\" d\n\"ef\n";
1623 let ranges = find_quoted_ranges(doc);
1624 assert_eq!(ranges.len(), 2);
1627 assert_eq!(&doc[ranges[0].0..ranges[0].1], "\"bc\"");
1628 assert_eq!(&doc[ranges[1].0..ranges[1].1], "\"ef");
1629 }
1630
1631 #[test]
1634 fn parse_component_with_mode_attr() {
1635 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1636 let components = parse(doc).unwrap();
1637 assert_eq!(components.len(), 1);
1638 assert_eq!(components[0].name, "exchange");
1639 assert_eq!(
1640 components[0].attrs.get("mode").map(|s| s.as_str()),
1641 Some("append")
1642 );
1643 assert_eq!(components[0].content(doc), "Content\n");
1644 }
1645
1646 #[test]
1647 fn parse_component_with_multiple_attrs() {
1648 let doc = "<!-- agent:log mode=prepend timestamp=true -->\nData\n<!-- /agent:log -->\n";
1649 let components = parse(doc).unwrap();
1650 assert_eq!(components.len(), 1);
1651 assert_eq!(components[0].name, "log");
1652 assert_eq!(
1653 components[0].attrs.get("mode").map(|s| s.as_str()),
1654 Some("prepend")
1655 );
1656 assert_eq!(
1657 components[0].attrs.get("timestamp").map(|s| s.as_str()),
1658 Some("true")
1659 );
1660 }
1661
1662 #[test]
1663 fn parse_component_no_attrs_backward_compat() {
1664 let doc = "<!-- agent:status -->\nOK\n<!-- /agent:status -->\n";
1665 let components = parse(doc).unwrap();
1666 assert_eq!(components.len(), 1);
1667 assert_eq!(components[0].name, "status");
1668 assert!(components[0].attrs.is_empty());
1669 }
1670
1671 #[test]
1672 fn is_agent_marker_with_attrs() {
1673 assert!(is_agent_marker(" agent:exchange mode=append "));
1674 assert!(is_agent_marker("agent:status mode=replace"));
1675 assert!(is_agent_marker("agent:log mode=prepend timestamp=true"));
1676 }
1677
1678 #[test]
1679 fn bounded_preview_clamps_to_char_boundary_inside_multibyte() {
1680 let doc = format!("<!--{}❯ trailing", "x".repeat(18));
1688 let preview = bounded_preview(&doc, 4, 20);
1691 assert!(doc.is_char_boundary(4 + preview.len()));
1692 assert_eq!(preview, "x".repeat(18)); }
1694
1695 #[test]
1696 fn parse_does_not_panic_on_prompt_glyph_adjacent_to_agent_marker() {
1697 let doc = "<!-- agent:exchange -->\n❯ tighten this up — simpler — lower rates\n<!-- agent:boundary:0d861cfa -->\n❯ His platform is in Rust & Vue.\n<!-- /agent:exchange -->\n";
1701 let components = parse(doc).expect("prompt glyphs near markers must not panic");
1702 assert_eq!(components.len(), 1);
1703 assert_eq!(components[0].name, "exchange");
1704 }
1705
1706 #[test]
1707 fn parse_skips_boundary_close_artifact_as_non_component() {
1708 let doc = "<!-- agent:exchange -->\nanswer\n<!-- /agent:boundary:a37c9696 -->\n<!-- /agent:exchange -->\n";
1709 let components = parse(doc).expect("boundary close artifact must not be a component");
1710 assert_eq!(components.len(), 1);
1711 assert_eq!(components[0].name, "exchange");
1712 assert!(
1713 components[0]
1714 .content(doc)
1715 .contains("<!-- /agent:boundary:a37c9696 -->")
1716 );
1717 }
1718
1719 #[test]
1720 fn boundary_close_artifact_is_not_an_ordinary_comment_range() {
1721 let doc = concat!(
1722 "<!-- agent:exchange -->\n",
1723 "answer\n",
1724 "<!-- /agent:boundary:a37c9696 -->\n",
1725 "<!-- /agent:exchange -->\n"
1726 );
1727 assert!(
1728 find_non_agent_html_comment_ranges(doc).is_empty(),
1729 "boundary close artifacts are transient agent comments, not ordinary comments"
1730 );
1731 }
1732
1733 #[test]
1734 fn closing_tag_unchanged_with_attrs() {
1735 let doc = "<!-- agent:status mode=replace -->\n- [x] Done\n<!-- /agent:status -->\n";
1737 let components = parse(doc).unwrap();
1738 assert_eq!(components.len(), 1);
1739 let new_doc = components[0].replace_content(doc, "- [ ] Todo\n");
1740 assert!(new_doc.contains("<!-- agent:status mode=replace -->"));
1741 assert!(new_doc.contains("<!-- /agent:status -->"));
1742 assert!(new_doc.contains("- [ ] Todo"));
1743 }
1744
1745 #[test]
1746 fn parse_component_with_patch_attr() {
1747 let doc = "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n";
1748 let components = parse(doc).unwrap();
1749 assert_eq!(components.len(), 1);
1750 assert_eq!(components[0].name, "exchange");
1751 assert_eq!(components[0].patch_mode(), Some("append"));
1752 assert_eq!(components[0].content(doc), "Content\n");
1753 }
1754
1755 #[test]
1756 fn patch_attr_takes_precedence_over_mode() {
1757 let doc = "<!-- agent:exchange patch=replace mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1758 let components = parse(doc).unwrap();
1759 assert_eq!(components[0].patch_mode(), Some("replace"));
1760 }
1761
1762 #[test]
1763 fn mode_attr_backward_compat() {
1764 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1765 let components = parse(doc).unwrap();
1766 assert_eq!(components[0].patch_mode(), Some("append"));
1767 }
1768
1769 #[test]
1770 fn no_patch_or_mode_attr() {
1771 let doc = "<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
1772 let components = parse(doc).unwrap();
1773 assert_eq!(components[0].patch_mode(), None);
1774 }
1775
1776 #[test]
1779 fn single_backtick_component_tag_ignored() {
1780 let doc = "\
1782Use `<!-- agent:pending -->` to mark pending sections.
1783<!-- agent:real -->
1784content
1785<!-- /agent:real -->
1786";
1787 let components = parse(doc).unwrap();
1788 assert_eq!(components.len(), 1);
1789 assert_eq!(components[0].name, "real");
1790 }
1791
1792 #[test]
1793 fn double_backtick_component_tag_ignored() {
1794 let doc = "\
1796Use ``<!-- agent:pending -->`` to mark pending sections.
1797<!-- agent:real -->
1798content
1799<!-- /agent:real -->
1800";
1801 let components = parse(doc).unwrap();
1802 assert_eq!(components.len(), 1);
1803 assert_eq!(components[0].name, "real");
1804 }
1805
1806 #[test]
1807 fn component_tags_not_in_backticks_still_work() {
1808 let doc = "\
1810<!-- agent:a -->
1811alpha
1812<!-- /agent:a -->
1813<!-- agent:b patch=append -->
1814beta
1815<!-- /agent:b -->
1816";
1817 let components = parse(doc).unwrap();
1818 assert_eq!(components.len(), 2);
1819 assert_eq!(components[0].name, "a");
1820 assert_eq!(components[1].name, "b");
1821 assert_eq!(components[1].patch_mode(), Some("append"));
1822 }
1823
1824 #[test]
1825 fn mixed_backtick_and_real_tags() {
1826 let doc = "\
1828Here is an example: `<!-- agent:fake -->` and ``<!-- /agent:fake -->``.
1829<!-- agent:real -->
1830real content
1831<!-- /agent:real -->
1832Another example: `<!-- agent:also-fake patch=replace -->` is just documentation.
1833";
1834 let components = parse(doc).unwrap();
1835 assert_eq!(components.len(), 1);
1836 assert_eq!(components[0].name, "real");
1837 assert_eq!(components[0].content(doc), "real content\n");
1838 }
1839
1840 #[test]
1841 fn inline_code_mid_line_with_surrounding_text_ignored() {
1842 let doc = "\
1845Wrap markers like `<!-- agent:status -->` in backticks to show them literally.
1846<!-- agent:real -->
1847actual content
1848<!-- /agent:real -->
1849";
1850 let components = parse(doc).unwrap();
1851 assert_eq!(components.len(), 1);
1852 assert_eq!(components[0].name, "real");
1853 assert_eq!(components[0].content(doc), "actual content\n");
1854 }
1855
1856 #[test]
1857 fn parse_attrs_unit() {
1858 let attrs = parse_attrs("mode=append");
1859 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1860
1861 let attrs = parse_attrs("mode=replace timestamp=true");
1862 assert_eq!(attrs.len(), 2);
1863
1864 let attrs = parse_attrs("");
1865 assert!(attrs.is_empty());
1866
1867 let attrs = parse_attrs("mode=append broken novalue=");
1869 assert_eq!(attrs.len(), 2);
1870 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1871 assert_eq!(attrs.get("broken").map(|s| s.as_str()), Some(""));
1872
1873 let attrs = parse_attrs("auto");
1875 assert_eq!(attrs.len(), 1);
1876 assert!(attrs.contains_key("auto"));
1877 }
1878
1879 #[test]
1880 fn append_with_boundary_skips_code_block() {
1881 let boundary_id = "real-uuid";
1884 let doc = format!(
1885 "<!-- agent:exchange patch=append -->\n\
1886 user prompt\n\
1887 ```\n\
1888 <!-- agent:boundary:{boundary_id} -->\n\
1889 ```\n\
1890 more user text\n\
1891 <!-- agent:boundary:{boundary_id} -->\n\
1892 <!-- /agent:exchange -->\n"
1893 );
1894 let components = parse(&doc).unwrap();
1895 let comp = &components[0];
1896 let result =
1897 comp.append_with_boundary(&doc, "### Re: Response\n\nContent here.", boundary_id);
1898
1899 assert!(result.contains("### Re: Response"));
1902 assert!(result.contains("more user text"));
1903 assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
1905 assert!(!result.contains(&format!(
1907 "more user text\n<!-- agent:boundary:{boundary_id} -->\n<!-- /agent:exchange -->"
1908 )));
1909 }
1910
1911 #[test]
1912 fn append_with_boundary_no_code_block() {
1913 let boundary_id = "simple-uuid";
1915 let doc = format!(
1916 "<!-- agent:exchange patch=append -->\n\
1917 user prompt\n\
1918 <!-- agent:boundary:{boundary_id} -->\n\
1919 <!-- /agent:exchange -->\n"
1920 );
1921 let components = parse(&doc).unwrap();
1922 let comp = &components[0];
1923 let result = comp.append_with_boundary(&doc, "### Re: Answer\n\nDone.", boundary_id);
1924
1925 assert!(result.contains("### Re: Answer"));
1926 assert!(result.contains("user prompt"));
1927 assert!(!result.contains(&format!("agent:boundary:{boundary_id}")));
1929 assert!(result.contains("agent:boundary:"));
1930 }
1931
1932 #[test]
1933 fn append_with_boundary_skips_already_present_content() {
1934 let boundary_id = "simple-uuid";
1935 let doc = format!(
1936 "<!-- agent:exchange patch=append -->\n\
1937 ### Re: Duplicate — gpt-5 (HEAD)\n\
1938 \n\
1939 Already applied.\n\
1940 <!-- agent:boundary:old-id -->\n\
1941 <!-- agent:boundary:{boundary_id} -->\n\
1942 <!-- /agent:exchange -->\n"
1943 );
1944 let components = parse(&doc).unwrap();
1945 let comp = &components[0];
1946 let result = comp.append_with_boundary(
1947 &doc,
1948 "### Re: Duplicate — gpt-5\n\nAlready applied.\n",
1949 boundary_id,
1950 );
1951
1952 assert_eq!(result, doc);
1953 }
1954
1955 #[test]
1956 fn append_with_caret_skips_already_present_content() {
1957 let doc = "<!-- agent:exchange patch=append -->\n\
1958 User prompt.\n\
1959 ### Re: Duplicate — gpt-5\n\
1960 \n\
1961 Already applied.\n\
1962 <!-- /agent:exchange -->\n";
1963 let components = parse(doc).unwrap();
1964 let comp = &components[0];
1965 let result =
1966 comp.append_with_caret(doc, "### Re: Duplicate — gpt-5\n\nAlready applied.\n", None);
1967
1968 assert_eq!(result, doc);
1969 }
1970
1971 #[test]
1974 fn strip_comments_removes_html_comment() {
1975 let result = strip_comments("before\n<!-- a comment -->\nafter\n");
1976 assert_eq!(result, "before\nafter\n");
1977 }
1978
1979 #[test]
1980 fn non_agent_html_comment_ranges_cover_multiline_body() {
1981 let doc = concat!(
1982 "before\n",
1983 "<!--\n",
1984 "do #hidden. spec-test-build-install-commit-push\n",
1985 "-->\n",
1986 "<!-- agent:exchange -->\n",
1987 "<!-- /agent:exchange -->\n"
1988 );
1989
1990 let ranges = find_non_agent_html_comment_ranges(doc);
1991 assert_eq!(ranges.len(), 1);
1992 let hidden = doc.find("do #hidden").unwrap();
1993 assert!(
1994 ranges
1995 .iter()
1996 .any(|&(start, end)| hidden >= start && hidden < end),
1997 "ordinary comment body should be inside a non-agent comment range"
1998 );
1999 assert!(
2000 ranges
2001 .iter()
2002 .all(|&(start, end)| !doc[start..end].contains("agent:exchange")),
2003 "agent component markers must not be treated as ordinary comments"
2004 );
2005 }
2006
2007 #[test]
2008 fn non_agent_html_comment_ranges_cover_unterminated_tail() {
2009 let doc = concat!(
2010 "before\n",
2011 "<!--\n",
2012 "do #hidden. spec-test-build-install-commit-push\n",
2013 "still typing\n"
2014 );
2015
2016 let ranges = find_non_agent_html_comment_ranges(doc);
2017 assert_eq!(ranges.len(), 1);
2018 let (start, end) = ranges[0];
2019 assert_eq!(
2020 &doc[start..end],
2021 "<!--\ndo #hidden. spec-test-build-install-commit-push\nstill typing\n"
2022 );
2023 assert_eq!(end, doc.len());
2024 }
2025
2026 #[test]
2027 fn strip_comments_preserves_agent_markers() {
2028 let input = "text\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
2029 let result = strip_comments(input);
2030 assert!(result.contains("<!-- agent:status -->"));
2031 assert!(result.contains("<!-- /agent:status -->"));
2032 }
2033
2034 #[test]
2035 fn strip_comments_removes_link_ref() {
2036 let result = strip_comments("[//]: # (hidden note)\nvisible\n");
2037 assert_eq!(result, "visible\n");
2038 }
2039
2040 #[test]
2041 fn html_comment_in_content_does_not_eat_close_marker() {
2042 let doc = "\
2043<!-- agent:pending -->
2044- [ ] [#abc] Rule: first line (not starting with ### or ❯ or <!-- ) gets prefix
2045<!-- /agent:pending -->
2046";
2047 let comps = parse(doc).unwrap();
2048 assert_eq!(comps.len(), 1);
2049 assert_eq!(comps[0].name, "pending");
2050 assert!(comps[0].content(doc).contains("<!-- )"));
2051 }
2052
2053 #[test]
2054 fn nested_html_comment_like_text_in_exchange() {
2055 let doc = "\
2056<!-- agent:exchange -->
2057User typed <!-- some note --> in the text.
2058<!-- /agent:exchange -->
2059";
2060 let comps = parse(doc).unwrap();
2061 assert_eq!(comps.len(), 1);
2062 assert_eq!(comps[0].name, "exchange");
2063 }
2064
2065 #[test]
2066 fn pending_item_with_literal_html_comment_opener() {
2067 let doc = "\
2070<!-- agent:pending -->
2071- [ ] [#r7hw] Component parser: non-agent `<!-- ` in content ate close markers
2072- [ ] [#xyz] Another item
2073<!-- /agent:pending -->
2074";
2075 let comps = parse(doc).unwrap();
2076 assert_eq!(comps.len(), 1);
2077 assert_eq!(comps[0].name, "pending");
2078 let content = comps[0].content(doc);
2079 assert!(content.contains("#r7hw"));
2080 assert!(content.contains("#xyz"));
2081 }
2082
2083 #[test]
2084 fn multiple_non_agent_html_comments_in_pending() {
2085 let doc = "\
2087<!-- agent:pending -->
2088- [ ] [#a] Fix rule: skip lines starting with <!-- or -->
2089- [ ] [#b] Handle <!-- partial comment
2090- [ ] [#c] Normal item
2091<!-- /agent:pending -->
2092";
2093 let comps = parse(doc).unwrap();
2094 assert_eq!(comps.len(), 1);
2095 assert_eq!(comps[0].name, "pending");
2096 let content = comps[0].content(doc);
2097 assert!(content.contains("#a"));
2098 assert!(content.contains("#b"));
2099 assert!(content.contains("#c"));
2100 }
2101
2102 #[test]
2103 fn exchange_and_pending_both_with_html_comments_in_content() {
2104 let doc = "\
2107<!-- agent:exchange -->
2108The rule checks for <!-- prefix before deciding.
2109### Re: topic — opus
2110Fix applied to skip non-agent <!-- sequences.
2111<!-- /agent:exchange -->
2112
2113<!-- agent:pending -->
2114- [ ] [#cfdy] Verify <!-- in pending items
2115<!-- /agent:pending -->
2116";
2117 let comps = parse(doc).unwrap();
2118 assert_eq!(comps.len(), 2);
2119 assert_eq!(comps[0].name, "exchange");
2120 assert_eq!(comps[1].name, "pending");
2121 assert!(comps[0].content(doc).contains("<!-- prefix"));
2122 assert!(comps[1].content(doc).contains("#cfdy"));
2123 }
2124
2125 #[test]
2126 fn is_backlog_component_accepts_both_names() {
2127 assert!(is_backlog_component("backlog"));
2128 assert!(is_backlog_component("pending"));
2129 assert!(!is_backlog_component("exchange"));
2130 assert!(!is_backlog_component("status"));
2131 assert!(!is_backlog_component("pending-done"));
2132 assert!(!is_backlog_component("backlog-done"));
2133 assert!(!is_backlog_component("done"));
2134 }
2135
2136 #[test]
2137 fn is_backlog_done_component_accepts_only_canonical_done() {
2138 assert!(is_backlog_done_component("done"));
2139 assert!(!is_backlog_done_component("backlog-done"));
2140 assert!(!is_backlog_done_component("pending-done"));
2141 assert!(!is_backlog_done_component("backlog"));
2142 assert!(!is_backlog_done_component("pending"));
2143 assert!(!is_backlog_done_component("icebox"));
2144 }
2145
2146 #[test]
2147 fn is_review_component_accepts_name() {
2148 assert!(is_review_component("review"));
2149 assert!(!is_review_component("backlog"));
2150 assert!(!is_review_component("icebox"));
2151 }
2152
2153 #[test]
2154 fn is_icebox_component_accepts_name() {
2155 assert!(is_icebox_component("icebox"));
2156 assert!(!is_icebox_component("backlog"));
2157 assert!(!is_icebox_component("exchange"));
2158 assert!(!is_icebox_component("icebox-archive"));
2159 }
2160
2161 #[test]
2162 fn tracked_work_component_includes_review() {
2163 assert!(is_tracked_work_component("backlog"));
2164 assert!(is_tracked_work_component("pending"));
2165 assert!(is_tracked_work_component("review"));
2166 assert!(is_tracked_work_component("icebox"));
2167 assert!(!is_tracked_work_component("done"));
2168 }
2169
2170 #[test]
2171 fn icebox_component_parsed() {
2172 let doc = "\
2173<!-- agent:icebox -->
2174- Parked idea
2175<!-- /agent:icebox -->
2176";
2177 let comps = parse(doc).unwrap();
2178 assert_eq!(comps.len(), 1);
2179 assert_eq!(comps[0].name, "icebox");
2180 assert!(is_icebox_component(&comps[0].name));
2181 assert!(comps[0].content(doc).contains("Parked idea"));
2182 }
2183
2184 #[test]
2185 fn backlog_component_parsed_from_new_marker() {
2186 let doc = "\
2187<!-- agent:backlog -->
2188- [ ] [#abc] First item
2189<!-- /agent:backlog -->
2190";
2191 let comps = parse(doc).unwrap();
2192 assert_eq!(comps.len(), 1);
2193 assert_eq!(comps[0].name, "backlog");
2194 assert!(is_backlog_component(&comps[0].name));
2195 assert!(comps[0].content(doc).contains("#abc"));
2196 }
2197
2198 #[test]
2199 fn legacy_pending_marker_still_parsed() {
2200 let doc = "\
2201<!-- agent:pending -->
2202- [ ] [#xyz] Legacy item
2203<!-- /agent:pending -->
2204";
2205 let comps = parse(doc).unwrap();
2206 assert_eq!(comps.len(), 1);
2207 assert_eq!(comps[0].name, "pending");
2208 assert!(is_backlog_component(&comps[0].name));
2209 }
2210
2211 #[test]
2212 fn strip_backlog_patch_attr_removes_patch_replace() {
2213 let doc = "<!-- agent:backlog patch=replace -->\n- item\n<!-- /agent:backlog -->\n";
2214 let result = strip_backlog_patch_attr(doc);
2215 assert_eq!(
2216 result,
2217 "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2218 );
2219 }
2220
2221 #[test]
2222 fn strip_backlog_patch_attr_removes_pending_patch_replace() {
2223 let doc = "<!-- agent:pending patch=replace -->\n- item\n<!-- /agent:pending -->\n";
2224 let result = strip_backlog_patch_attr(doc);
2225 assert_eq!(
2226 result,
2227 "<!-- agent:pending -->\n- item\n<!-- /agent:pending -->\n"
2228 );
2229 }
2230
2231 #[test]
2232 fn strip_backlog_patch_attr_removes_mode_replace() {
2233 let doc = "<!-- agent:backlog mode=replace -->\n- item\n<!-- /agent:backlog -->\n";
2234 let result = strip_backlog_patch_attr(doc);
2235 assert_eq!(
2236 result,
2237 "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2238 );
2239 }
2240
2241 #[test]
2242 fn strip_backlog_patch_attr_preserves_other_attrs() {
2243 let doc =
2244 "<!-- agent:backlog patch=replace max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n";
2245 let result = strip_backlog_patch_attr(doc);
2246 assert_eq!(
2247 result,
2248 "<!-- agent:backlog max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n"
2249 );
2250 }
2251
2252 #[test]
2253 fn strip_backlog_patch_attr_noop_for_exchange() {
2254 let doc = "<!-- agent:exchange patch=append -->\ncontent\n<!-- /agent:exchange -->\n";
2255 let result = strip_backlog_patch_attr(doc);
2256 assert_eq!(result, doc);
2257 }
2258
2259 #[test]
2260 fn strip_backlog_patch_attr_noop_when_no_attr() {
2261 let doc = "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n";
2262 let result = strip_backlog_patch_attr(doc);
2263 assert_eq!(result, doc);
2264 }
2265
2266 #[test]
2267 fn converge_queue_auto_strips_auto() {
2268 let doc = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2269 let result = converge_queue_auto(doc, false).expect("tag changed");
2270 assert_eq!(
2271 result,
2272 "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n"
2273 );
2274 }
2275
2276 #[test]
2277 fn converge_queue_auto_adds_auto() {
2278 let doc = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2279 let result = converge_queue_auto(doc, true).expect("tag changed");
2280 assert_eq!(
2281 result,
2282 "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n"
2283 );
2284 }
2285
2286 #[test]
2287 fn converge_queue_auto_preserves_other_attrs() {
2288 let doc = "<!-- agent:queue auto patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n";
2289 let result = converge_queue_auto(doc, false).expect("tag changed");
2290 assert_eq!(
2291 result,
2292 "<!-- agent:queue patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n"
2293 );
2294 }
2295
2296 #[test]
2297 fn converge_queue_auto_normalizes_boolean_attrs_without_corrupting_preset() {
2298 let doc = concat!(
2299 "<!-- agent:queue auto=true priority=true preset=\"#spec-test-build-install-commit-push\"=true go=true -->\n",
2300 "- do [#x]\n",
2301 "<!-- /agent:queue -->\n",
2302 );
2303 let result = converge_queue_auto(doc, false).expect("tag normalized");
2304 assert_eq!(
2305 result,
2306 concat!(
2307 "<!-- agent:queue priority preset=\"#spec-test-build-install-commit-push\" go -->\n",
2308 "- do [#x]\n",
2309 "<!-- /agent:queue -->\n",
2310 )
2311 );
2312 }
2313
2314 #[test]
2315 fn converge_queue_auto_noop_when_already_matching() {
2316 let active = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2317 assert_eq!(converge_queue_auto(active, true), None);
2318 let inactive = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2319 assert_eq!(converge_queue_auto(inactive, false), None);
2320 }
2321
2322 #[test]
2323 fn converge_queue_auto_none_without_queue_component() {
2324 let doc = "<!-- agent:exchange -->\nhi\n<!-- /agent:exchange -->\n";
2325 assert_eq!(converge_queue_auto(doc, false), None);
2326 }
2327
2328 #[test]
2332 fn append_patch_already_present_ignores_user_prompt_prefix() {
2333 assert!(
2334 append_patch_already_present("expand the section", "❯ expand the section"),
2335 "bare buffer vs prefixed patch must dedup"
2336 );
2337 assert!(
2338 append_patch_already_present("❯ expand the section", "expand the section"),
2339 "prefixed buffer vs bare patch must dedup"
2340 );
2341 assert!(
2342 append_patch_already_present("❯ expand the section", "❯ expand the section"),
2343 "both prefixed must dedup"
2344 );
2345 }
2346
2347 #[test]
2348 fn append_patch_distinct_prompts_not_deduped() {
2349 assert!(
2350 !append_patch_already_present("❯ expand the section", "❯ summarize the section"),
2351 "genuinely distinct prompts must not be treated as duplicates"
2352 );
2353 }
2354
2355 #[test]
2362 fn append_patch_dedupes_midblock_typing_snapshot() {
2363 let live = concat!(
2364 "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2365 "- [#bndq]: confirmed\n",
2366 "- do [#s75b]\n",
2367 "- do [#vapk]\n",
2368 "- [#s93y]: Add to resume\n",
2369 "- [#ca8w]\n",
2370 "Add SampleOrders and sample portal to the resume if not already added.",
2371 );
2372 let snapshot = concat!(
2373 "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2374 "- [#bndq]: confirmed\n",
2375 "- do [#s75b]\n",
2376 "- do [#vapk]\n",
2377 "- [#s93y]: Add to\n",
2378 "- [#ca8w]\n",
2379 "Add SampleOrders and sample portal to the resume if not already added.",
2380 );
2381 assert!(
2382 append_patch_already_present(live, snapshot),
2383 "earlier keystroke snapshot must dedup against the live, further-typed region"
2384 );
2385 }
2386
2387 #[test]
2388 fn append_patch_typing_snapshot_does_not_collapse_distinct_lines() {
2389 let live = "- do task one\n- and the second thing\n- finally";
2392 let distinct = "- do task one\n- and a different thing\n- finally";
2393 assert!(
2394 !append_patch_already_present(live, distinct),
2395 "a mid-line replacement (not a prefix) is a distinct edit, not a snapshot"
2396 );
2397 }
2398
2399 #[test]
2400 fn append_with_boundary_does_not_duplicate_typing_snapshot() {
2401 let doc = concat!(
2405 "<!-- agent:exchange patch=append -->\n",
2406 "### Re: prior — opus-4-8\n\n",
2407 "Answer.\n",
2408 "<!-- agent:boundary:70bccf9b -->\n",
2409 "- [#yfg1]: ok\n",
2410 "- [#s93y]: Add to resume\n",
2411 "- [#ca8w]\n",
2412 "Add SampleOrders and sample portal to the resume if not already added.\n",
2413 "<!-- /agent:exchange -->\n",
2414 );
2415 let components = parse(doc).unwrap();
2416 let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2417 let snapshot = concat!(
2418 "- [#yfg1]: ok\n",
2419 "- [#s93y]: Add to\n",
2420 "- [#ca8w]\n",
2421 "Add SampleOrders and sample portal to the resume if not already added.",
2422 );
2423 let result = exchange.append_with_boundary(doc, snapshot, "70bccf9b");
2424 assert_eq!(
2425 result, doc,
2426 "stale typing-snapshot append must be a no-op:\n{result}"
2427 );
2428 assert_eq!(
2429 result.matches("<!-- /agent:exchange -->").count(),
2430 1,
2431 "exchange close marker must not duplicate:\n{result}"
2432 );
2433 }
2434
2435 #[test]
2436 fn append_with_caret_does_not_duplicate_prefixed_prompt() {
2437 let doc = concat!(
2441 "<!-- agent:exchange patch=append -->\n",
2442 "### Re: prior — opus-4-8\n\n",
2443 "Answer.\n",
2444 "<!-- agent:boundary:abc12345 -->\n",
2445 "expand the Confidential AI Startup section\n",
2446 "<!-- /agent:exchange -->\n",
2447 );
2448 let components = parse(doc).unwrap();
2449 let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2450 let result =
2451 exchange.append_with_caret(doc, "❯ expand the Confidential AI Startup section", None);
2452 assert_eq!(
2453 result
2454 .matches("expand the Confidential AI Startup section")
2455 .count(),
2456 1,
2457 "prefixed synthesized patch must not duplicate the already-typed prompt:\n{result}"
2458 );
2459 }
2460
2461 const CLEAN_DOC: &str = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\
2464<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2465<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- do [#x]\n<!-- /agent:queue -->\n\
2466<!-- agent:backlog -->\n- [ ] [#y] thing\n<!-- /agent:backlog -->\n";
2467
2468 #[test]
2469 fn structural_corruption_clean_doc_is_sound() {
2470 assert_eq!(structural_corruption_reason(CLEAN_DOC), None);
2471 }
2472
2473 #[test]
2474 fn structural_corruption_flags_duplicate_singleton_queue() {
2475 let doc = "<!-- agent:queue -->\n- a\n<!-- /agent:queue -->\n\
2478<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2479<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- b\n<!-- /agent:queue -->\n";
2480 let reason = structural_corruption_reason(doc).expect("two queue blocks must be flagged");
2481 assert!(
2482 reason.contains("duplicate_singleton_component") && reason.contains("queue=2"),
2483 "reason was: {reason}"
2484 );
2485 }
2486
2487 #[test]
2488 fn structural_corruption_flags_backlog_alias_duplicate() {
2489 let doc = "<!-- agent:backlog -->\n- [ ] [#a] x\n<!-- /agent:backlog -->\n\
2491<!-- agent:pending -->\n- [ ] [#b] y\n<!-- /agent:pending -->\n";
2492 let reason = structural_corruption_reason(doc).expect("backlog+pending must be flagged");
2493 assert!(reason.contains("backlog=2"), "reason was: {reason}");
2494 }
2495
2496 #[test]
2497 fn structural_corruption_flags_unterminated_quote_attr() {
2498 let doc = "<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2500<!-- agent:queue tasks=\" -->\n- a\n<!-- /agent:queue -->\n";
2501 let reason =
2502 structural_corruption_reason(doc).expect("unterminated quote attr must be flagged");
2503 assert!(
2504 reason.starts_with("malformed_attr:queue"),
2505 "reason was: {reason}"
2506 );
2507 }
2508
2509 #[test]
2510 fn structural_corruption_flags_truncated_agent_comment() {
2511 let doc = "<!-- agent:queue -->\n- a\n<!-- /agent:queue ->\n\
2512<!-- /agent:exchange --\n";
2513 let reason =
2514 structural_corruption_reason(doc).expect("truncated agent marker must be flagged");
2515 assert!(
2516 reason.starts_with("malformed_agent_comment:"),
2517 "reason was: {reason}"
2518 );
2519 }
2520
2521 #[test]
2522 fn structural_corruption_ignores_truncated_agent_comment_inside_code_fence() {
2523 let doc = "```md\n<!-- /agent:queue ->\n<!-- /agent:exchange --\n```\n";
2524 assert_eq!(structural_corruption_reason(doc), None);
2525 }
2526
2527 #[test]
2528 fn structural_corruption_ignores_truncated_agent_comment_inside_quotes() {
2529 let doc = "\"<!-- /agent:queue ->\"\n";
2530 assert_eq!(structural_corruption_reason(doc), None);
2531 }
2532
2533 #[test]
2534 fn structural_corruption_allows_balanced_quoted_attr() {
2535 let doc = "<!-- agent:queue preset=\"#a\" go -->\n- a\n<!-- /agent:queue -->\n";
2537 assert_eq!(structural_corruption_reason(doc), None);
2538 }
2539
2540 #[test]
2541 fn structural_corruption_allows_repeated_non_singleton() {
2542 let doc = "<!-- agent:log -->\na\n<!-- /agent:log -->\n\
2544<!-- agent:log -->\nb\n<!-- /agent:log -->\n";
2545 assert_eq!(structural_corruption_reason(doc), None);
2546 }
2547
2548 #[test]
2549 fn structural_corruption_flags_parse_failure() {
2550 let doc = "<!-- agent:queue -->\n- a\n";
2552 let reason = structural_corruption_reason(doc).expect("unclosed marker must be flagged");
2553 assert!(reason.starts_with("parse_error"), "reason was: {reason}");
2554 }
2555}