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
974pub fn structural_corruption_reason(doc: &str) -> Option<String> {
985 let components = match parse(doc) {
986 Ok(c) => c,
987 Err(e) => return Some(format!("parse_error: {e}")),
988 };
989
990 let mut counts: HashMap<&str, usize> = HashMap::new();
992 for c in &components {
993 if let Some(canonical) = canonical_singleton_name(&c.name) {
994 *counts.entry(canonical).or_insert(0) += 1;
995 }
996 }
997 let mut dupes: Vec<(&str, usize)> = counts
998 .iter()
999 .filter(|&(_, &n)| n > 1)
1000 .map(|(&k, &n)| (k, n))
1001 .collect();
1002 if !dupes.is_empty() {
1003 dupes.sort();
1004 let detail = dupes
1005 .iter()
1006 .map(|(k, n)| format!("{k}={n}"))
1007 .collect::<Vec<_>>()
1008 .join(",");
1009 return Some(format!("duplicate_singleton_component:{detail}"));
1010 }
1011
1012 for c in &components {
1014 let marker = &doc[c.open_start..c.open_end];
1015 if marker_has_unterminated_quote(marker) {
1016 return Some(format!("malformed_attr:{}", c.name));
1017 }
1018 }
1019
1020 None
1021}
1022
1023pub fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
1025 let len = bytes.len();
1026 let mut i = start;
1027 while i + 3 <= len {
1028 if &bytes[i..i + 3] == b"-->" {
1029 return Some(i + 3);
1030 }
1031 i += 1;
1032 }
1033 None
1034}
1035
1036pub fn strip_comments(content: &str) -> String {
1049 let code_ranges = find_code_ranges(content);
1050 let in_code = |pos: usize| {
1051 code_ranges
1052 .iter()
1053 .any(|&(start, end)| pos >= start && pos < end)
1054 };
1055
1056 let mut result = String::with_capacity(content.len());
1057 let bytes = content.as_bytes();
1058 let len = bytes.len();
1059 let mut pos = 0;
1060
1061 while pos < len {
1062 if bytes[pos] == b'['
1064 && !in_code(pos)
1065 && is_line_start_at(bytes, pos)
1066 && let Some(end) = match_link_ref_comment_at(bytes, pos)
1067 {
1068 pos = end;
1069 continue;
1070 }
1071
1072 if pos + 4 <= len
1074 && &bytes[pos..pos + 4] == b"<!--"
1075 && !in_code(pos)
1076 && let Some((end, inner)) = match_html_comment_at(content, pos)
1077 {
1078 if is_agent_marker(inner) {
1079 result.push_str(&content[pos..end]);
1081 pos = end;
1082 } else {
1083 let mut skip_to = end;
1085 if is_line_start_at(bytes, pos) && skip_to < len && bytes[skip_to] == b'\n' {
1086 skip_to += 1;
1087 }
1088 pos = skip_to;
1089 }
1090 continue;
1091 }
1092
1093 result.push(content[pos..].chars().next().unwrap());
1094 pos += content[pos..].chars().next().unwrap().len_utf8();
1095 }
1096
1097 result
1098}
1099
1100fn is_line_start_at(bytes: &[u8], pos: usize) -> bool {
1102 pos == 0 || bytes[pos - 1] == b'\n'
1103}
1104
1105fn match_link_ref_comment_at(bytes: &[u8], pos: usize) -> Option<usize> {
1107 let prefix = b"[//]: # (";
1108 let len = bytes.len();
1109 if pos + prefix.len() > len || &bytes[pos..pos + prefix.len()] != prefix {
1110 return None;
1111 }
1112 let mut i = pos + prefix.len();
1113 while i < len && bytes[i] != b')' && bytes[i] != b'\n' {
1114 i += 1;
1115 }
1116 if i < len && bytes[i] == b')' {
1117 i += 1;
1118 if i < len && bytes[i] == b'\n' {
1119 i += 1;
1120 }
1121 Some(i)
1122 } else {
1123 None
1124 }
1125}
1126
1127fn match_html_comment_at(content: &str, pos: usize) -> Option<(usize, &str)> {
1129 let bytes = content.as_bytes();
1130 let len = bytes.len();
1131 let mut i = pos + 4;
1132 while i + 3 <= len {
1133 if &bytes[i..i + 3] == b"-->" {
1134 let inner = &content[pos + 4..i];
1135 return Some((i + 3, inner));
1136 }
1137 i += 1;
1138 }
1139 None
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144 use super::*;
1145
1146 #[test]
1147 fn single_range() {
1148 let doc = "before\n<!-- agent:status -->\nHello\n<!-- /agent:status -->\nafter\n";
1149 let ranges = parse(doc).unwrap();
1150 assert_eq!(ranges.len(), 1);
1151 assert_eq!(ranges[0].name, "status");
1152 assert_eq!(ranges[0].content(doc), "Hello\n");
1153 }
1154
1155 #[test]
1156 fn nested_ranges() {
1157 let doc = "\
1158<!-- agent:outer -->
1159<!-- agent:inner -->
1160content
1161<!-- /agent:inner -->
1162<!-- /agent:outer -->
1163";
1164 let ranges = parse(doc).unwrap();
1165 assert_eq!(ranges.len(), 2);
1166 assert_eq!(ranges[0].name, "outer");
1168 assert_eq!(ranges[1].name, "inner");
1169 assert_eq!(ranges[1].content(doc), "content\n");
1170 }
1171
1172 #[test]
1173 fn siblings() {
1174 let doc = "\
1175<!-- agent:a -->
1176alpha
1177<!-- /agent:a -->
1178<!-- agent:b -->
1179beta
1180<!-- /agent:b -->
1181";
1182 let ranges = parse(doc).unwrap();
1183 assert_eq!(ranges.len(), 2);
1184 assert_eq!(ranges[0].name, "a");
1185 assert_eq!(ranges[0].content(doc), "alpha\n");
1186 assert_eq!(ranges[1].name, "b");
1187 assert_eq!(ranges[1].content(doc), "beta\n");
1188 }
1189
1190 #[test]
1191 fn no_ranges() {
1192 let doc = "# Just a document\n\nWith no range templates.\n";
1193 let ranges = parse(doc).unwrap();
1194 assert!(ranges.is_empty());
1195 }
1196
1197 #[test]
1198 fn unmatched_open_error() {
1199 let doc = "<!-- agent:orphan -->\nContent\n";
1200 let err = parse(doc).unwrap_err();
1201 assert!(err.to_string().contains("unclosed component"));
1202 }
1203
1204 #[test]
1205 fn unmatched_close_error() {
1206 let doc = "Content\n<!-- /agent:orphan -->\n";
1207 let err = parse(doc).unwrap_err();
1208 assert!(err.to_string().contains("without matching open"));
1209 }
1210
1211 #[test]
1212 fn mismatched_names_error() {
1213 let doc = "<!-- agent:foo -->\n<!-- /agent:bar -->\n";
1214 let err = parse(doc).unwrap_err();
1215 assert!(err.to_string().contains("mismatched"));
1216 }
1217
1218 #[test]
1219 fn invalid_name() {
1220 let doc = "<!-- agent:-bad -->\n<!-- /agent:-bad -->\n";
1221 let err = parse(doc).unwrap_err();
1222 assert!(err.to_string().contains("invalid component name"));
1223 }
1224
1225 #[test]
1226 fn name_validation() {
1227 assert!(is_valid_name("status"));
1228 assert!(is_valid_name("my-section"));
1229 assert!(is_valid_name("a1"));
1230 assert!(is_valid_name("A"));
1231 assert!(!is_valid_name(""));
1232 assert!(!is_valid_name("-bad"));
1233 assert!(!is_valid_name("has space"));
1234 assert!(!is_valid_name("has_underscore"));
1235 }
1236
1237 #[test]
1238 fn content_extraction() {
1239 let doc = "<!-- agent:x -->\nfoo\nbar\n<!-- /agent:x -->\n";
1240 let ranges = parse(doc).unwrap();
1241 assert_eq!(ranges[0].content(doc), "foo\nbar\n");
1242 }
1243
1244 #[test]
1245 fn replace_roundtrip() {
1246 let doc = "before\n<!-- agent:s -->\nold\n<!-- /agent:s -->\nafter\n";
1247 let ranges = parse(doc).unwrap();
1248 let new_doc = ranges[0].replace_content(doc, "new\n");
1249 assert_eq!(
1250 new_doc,
1251 "before\n<!-- agent:s -->\nnew\n<!-- /agent:s -->\nafter\n"
1252 );
1253 let ranges2 = parse(&new_doc).unwrap();
1255 assert_eq!(ranges2.len(), 1);
1256 assert_eq!(ranges2[0].content(&new_doc), "new\n");
1257 }
1258
1259 #[test]
1260 fn is_agent_marker_yes() {
1261 assert!(is_agent_marker(" agent:status "));
1262 assert!(is_agent_marker("/agent:status"));
1263 assert!(is_agent_marker("agent:my-thing"));
1264 assert!(is_agent_marker(" /agent:A1 "));
1265 }
1266
1267 #[test]
1268 fn is_agent_marker_no() {
1269 assert!(!is_agent_marker("just a comment"));
1270 assert!(!is_agent_marker("agent:"));
1271 assert!(!is_agent_marker("/agent:"));
1272 assert!(!is_agent_marker("agent:-bad"));
1273 assert!(!is_agent_marker("some agent:fake stuff"));
1274 }
1275
1276 #[test]
1277 fn regular_comments_ignored() {
1278 let doc = "<!-- just a comment -->\n<!-- agent:x -->\ndata\n<!-- /agent:x -->\n";
1279 let ranges = parse(doc).unwrap();
1280 assert_eq!(ranges.len(), 1);
1281 assert_eq!(ranges[0].name, "x");
1282 }
1283
1284 #[test]
1285 fn regular_comments_with_multibyte_preview_boundary_ignored() {
1286 let doc = "\
1287<!-- 123456789012345678❯ ordinary comment -->
1288<!-- agent:x -->
1289data
1290<!-- /agent:x -->
1291";
1292 let ranges = parse(doc).unwrap();
1293 assert_eq!(ranges.len(), 1);
1294 assert_eq!(ranges[0].name, "x");
1295 }
1296
1297 #[test]
1298 fn multiline_comment_ignored() {
1299 let doc = "\
1300<!--
1301multi
1302line
1303comment
1304-->
1305<!-- agent:s -->
1306content
1307<!-- /agent:s -->
1308";
1309 let ranges = parse(doc).unwrap();
1310 assert_eq!(ranges.len(), 1);
1311 assert_eq!(ranges[0].name, "s");
1312 }
1313
1314 #[test]
1315 fn empty_content() {
1316 let doc = "<!-- agent:empty --><!-- /agent:empty -->\n";
1317 let ranges = parse(doc).unwrap();
1318 assert_eq!(ranges.len(), 1);
1319 assert_eq!(ranges[0].content(doc), "");
1320 }
1321
1322 #[test]
1323 fn markers_in_fenced_code_block_ignored() {
1324 let doc = "\
1325<!-- agent:real -->
1326content
1327<!-- /agent:real -->
1328```markdown
1329<!-- agent:fake -->
1330this is just an example
1331<!-- /agent:fake -->
1332```
1333";
1334 let ranges = parse(doc).unwrap();
1335 assert_eq!(ranges.len(), 1);
1336 assert_eq!(ranges[0].name, "real");
1337 }
1338
1339 #[test]
1340 fn markers_in_inline_code_ignored() {
1341 let doc = "\
1342Use `<!-- agent:example -->` markers for components.
1343<!-- agent:real -->
1344content
1345<!-- /agent:real -->
1346";
1347 let ranges = parse(doc).unwrap();
1348 assert_eq!(ranges.len(), 1);
1349 assert_eq!(ranges[0].name, "real");
1350 }
1351
1352 #[test]
1353 fn markers_in_tilde_fence_ignored() {
1354 let doc = "\
1355<!-- agent:x -->
1356data
1357<!-- /agent:x -->
1358~~~
1359<!-- agent:y -->
1360example
1361<!-- /agent:y -->
1362~~~
1363";
1364 let ranges = parse(doc).unwrap();
1365 assert_eq!(ranges.len(), 1);
1366 assert_eq!(ranges[0].name, "x");
1367 }
1368
1369 #[test]
1370 fn markers_in_indented_fenced_code_block_ignored() {
1371 let doc = "\
1373<!-- agent:exchange -->
1374Content here.
1375<!-- /agent:exchange -->
1376
1377 ```markdown
1378 <!-- agent:fake -->
1379 demo without closing tag
1380 ```
1381";
1382 let ranges = parse(doc).unwrap();
1383 assert_eq!(ranges.len(), 1);
1384 assert_eq!(ranges[0].name, "exchange");
1385 }
1386
1387 #[test]
1388 fn indented_fence_inside_component_ignored() {
1389 let doc = "\
1391<!-- agent:exchange -->
1392Here's how to set up:
1393
1394 ```markdown
1395 <!-- agent:status -->
1396 Your status here
1397 ```
1398
1399Done explaining.
1400<!-- /agent:exchange -->
1401";
1402 let ranges = parse(doc).unwrap();
1403 assert_eq!(ranges.len(), 1);
1404 assert_eq!(ranges[0].name, "exchange");
1405 }
1406
1407 #[test]
1408 fn deeply_indented_fence_ignored() {
1409 let doc = "\
1411<!-- agent:x -->
1412ok
1413<!-- /agent:x -->
1414 ```
1415 <!-- agent:y -->
1416 inside fence
1417 ```
1418";
1419 let ranges = parse(doc).unwrap();
1420 assert_eq!(ranges.len(), 1);
1421 assert_eq!(ranges[0].name, "x");
1422 }
1423
1424 #[test]
1425 fn indented_fence_code_ranges_detected() {
1426 let doc = "before\n ```\n code\n ```\nafter\n";
1427 let ranges = find_code_ranges(doc);
1428 assert_eq!(ranges.len(), 1);
1429 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1430 }
1431
1432 #[test]
1433 fn code_ranges_detected() {
1434 let doc = "before\n```\ncode\n```\nafter `inline` end\n";
1435 let ranges = find_code_ranges(doc);
1436 assert_eq!(ranges.len(), 2);
1437 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1439 assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
1441 }
1442
1443 #[test]
1444 fn code_ranges_double_backtick() {
1445 let doc = "text `` `<!--` `` more\n";
1447 let ranges = find_code_ranges(doc);
1448 assert_eq!(ranges.len(), 1);
1449 let span = &doc[ranges[0].0..ranges[0].1];
1450 assert!(
1451 span.contains("<!--"),
1452 "double-backtick span should contain <!--: {:?}",
1453 span
1454 );
1455 }
1456
1457 #[test]
1458 fn code_ranges_double_backtick_does_not_match_single() {
1459 let doc = "text `` foo ` bar `` end\n";
1461 let ranges = find_code_ranges(doc);
1462 assert_eq!(ranges.len(), 1);
1463 let span = &doc[ranges[0].0..ranges[0].1];
1464 assert_eq!(span, "`` foo ` bar ``");
1465 }
1466
1467 #[test]
1468 fn double_backtick_comment_before_agent_marker() {
1469 let doc = "\
1471<!-- agent:exchange -->\n\
1472text `` `<!--` `` description\n\
1473new content here\n\
1474<!-- /agent:exchange -->\n";
1475 let components = parse(doc).unwrap();
1476 assert_eq!(components.len(), 1);
1477 assert_eq!(components[0].name, "exchange");
1478 assert!(components[0].content(doc).contains("new content here"));
1479 }
1480
1481 #[test]
1482 fn quoted_agent_marker_in_prose_is_not_parsed() {
1483 let doc = "\
1487<!-- agent:exchange -->\n\
1488Operator note: \"<!-- agent:queue -->\" attributes were dropped on compaction.\n\
1489new content here\n\
1490<!-- /agent:exchange -->\n";
1491 let components = parse(doc).unwrap();
1492 assert_eq!(components.len(), 1);
1493 assert_eq!(components[0].name, "exchange");
1494 assert!(components[0].content(doc).contains("new content here"));
1495 assert!(
1496 components[0]
1497 .content(doc)
1498 .contains("attributes were dropped")
1499 );
1500 }
1501
1502 #[test]
1503 fn quoted_id_token_in_prose_is_content() {
1504 let doc = "\
1506<!-- agent:exchange -->\n\
1507The operator asked to clear \"#advance-review\" but no such line exists.\n\
1508<!-- /agent:exchange -->\n";
1509 let components = parse(doc).unwrap();
1510 assert_eq!(components.len(), 1);
1511 assert!(components[0].content(doc).contains("#advance-review"));
1512 }
1513
1514 #[test]
1515 fn fenced_agent_marker_in_prose_is_not_parsed() {
1516 let doc = "\
1519<!-- agent:exchange -->\n\
1520```\n\
1521<!-- agent:queue -->\n\
1522```\n\
1523new content here\n\
1524<!-- /agent:exchange -->\n";
1525 let components = parse(doc).unwrap();
1526 assert_eq!(components.len(), 1);
1527 assert_eq!(components[0].name, "exchange");
1528 assert!(components[0].content(doc).contains("new content here"));
1529 }
1530
1531 #[test]
1532 fn real_marker_with_quoted_preset_attr_still_parses() {
1533 let doc = "\
1537<!-- agent:queue preset=\"#spec-test-build-install-commit-push\" priority go -->\n\
1538- :pushpin: do [#thing]\n\
1539<!-- /agent:queue -->\n";
1540 let components = parse(doc).unwrap();
1541 assert_eq!(components.len(), 1);
1542 assert_eq!(components[0].name, "queue");
1543 assert!(
1546 components[0]
1547 .attrs
1548 .get("preset")
1549 .is_some_and(|v| v.contains("#spec-test-build-install-commit-push")),
1550 "preset attr should survive quote-masking, got {:?}",
1551 components[0].attrs.get("preset")
1552 );
1553 }
1554
1555 #[test]
1556 fn find_quoted_ranges_is_same_line_and_fail_safe() {
1557 let doc = "a \"bc\" d\n\"ef\n";
1559 let ranges = find_quoted_ranges(doc);
1560 assert_eq!(ranges.len(), 2);
1563 assert_eq!(&doc[ranges[0].0..ranges[0].1], "\"bc\"");
1564 assert_eq!(&doc[ranges[1].0..ranges[1].1], "\"ef");
1565 }
1566
1567 #[test]
1570 fn parse_component_with_mode_attr() {
1571 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1572 let components = parse(doc).unwrap();
1573 assert_eq!(components.len(), 1);
1574 assert_eq!(components[0].name, "exchange");
1575 assert_eq!(
1576 components[0].attrs.get("mode").map(|s| s.as_str()),
1577 Some("append")
1578 );
1579 assert_eq!(components[0].content(doc), "Content\n");
1580 }
1581
1582 #[test]
1583 fn parse_component_with_multiple_attrs() {
1584 let doc = "<!-- agent:log mode=prepend timestamp=true -->\nData\n<!-- /agent:log -->\n";
1585 let components = parse(doc).unwrap();
1586 assert_eq!(components.len(), 1);
1587 assert_eq!(components[0].name, "log");
1588 assert_eq!(
1589 components[0].attrs.get("mode").map(|s| s.as_str()),
1590 Some("prepend")
1591 );
1592 assert_eq!(
1593 components[0].attrs.get("timestamp").map(|s| s.as_str()),
1594 Some("true")
1595 );
1596 }
1597
1598 #[test]
1599 fn parse_component_no_attrs_backward_compat() {
1600 let doc = "<!-- agent:status -->\nOK\n<!-- /agent:status -->\n";
1601 let components = parse(doc).unwrap();
1602 assert_eq!(components.len(), 1);
1603 assert_eq!(components[0].name, "status");
1604 assert!(components[0].attrs.is_empty());
1605 }
1606
1607 #[test]
1608 fn is_agent_marker_with_attrs() {
1609 assert!(is_agent_marker(" agent:exchange mode=append "));
1610 assert!(is_agent_marker("agent:status mode=replace"));
1611 assert!(is_agent_marker("agent:log mode=prepend timestamp=true"));
1612 }
1613
1614 #[test]
1615 fn closing_tag_unchanged_with_attrs() {
1616 let doc = "<!-- agent:status mode=replace -->\n- [x] Done\n<!-- /agent:status -->\n";
1618 let components = parse(doc).unwrap();
1619 assert_eq!(components.len(), 1);
1620 let new_doc = components[0].replace_content(doc, "- [ ] Todo\n");
1621 assert!(new_doc.contains("<!-- agent:status mode=replace -->"));
1622 assert!(new_doc.contains("<!-- /agent:status -->"));
1623 assert!(new_doc.contains("- [ ] Todo"));
1624 }
1625
1626 #[test]
1627 fn parse_component_with_patch_attr() {
1628 let doc = "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n";
1629 let components = parse(doc).unwrap();
1630 assert_eq!(components.len(), 1);
1631 assert_eq!(components[0].name, "exchange");
1632 assert_eq!(components[0].patch_mode(), Some("append"));
1633 assert_eq!(components[0].content(doc), "Content\n");
1634 }
1635
1636 #[test]
1637 fn patch_attr_takes_precedence_over_mode() {
1638 let doc = "<!-- agent:exchange patch=replace mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1639 let components = parse(doc).unwrap();
1640 assert_eq!(components[0].patch_mode(), Some("replace"));
1641 }
1642
1643 #[test]
1644 fn mode_attr_backward_compat() {
1645 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1646 let components = parse(doc).unwrap();
1647 assert_eq!(components[0].patch_mode(), Some("append"));
1648 }
1649
1650 #[test]
1651 fn no_patch_or_mode_attr() {
1652 let doc = "<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
1653 let components = parse(doc).unwrap();
1654 assert_eq!(components[0].patch_mode(), None);
1655 }
1656
1657 #[test]
1660 fn single_backtick_component_tag_ignored() {
1661 let doc = "\
1663Use `<!-- agent:pending -->` to mark pending sections.
1664<!-- agent:real -->
1665content
1666<!-- /agent:real -->
1667";
1668 let components = parse(doc).unwrap();
1669 assert_eq!(components.len(), 1);
1670 assert_eq!(components[0].name, "real");
1671 }
1672
1673 #[test]
1674 fn double_backtick_component_tag_ignored() {
1675 let doc = "\
1677Use ``<!-- agent:pending -->`` to mark pending sections.
1678<!-- agent:real -->
1679content
1680<!-- /agent:real -->
1681";
1682 let components = parse(doc).unwrap();
1683 assert_eq!(components.len(), 1);
1684 assert_eq!(components[0].name, "real");
1685 }
1686
1687 #[test]
1688 fn component_tags_not_in_backticks_still_work() {
1689 let doc = "\
1691<!-- agent:a -->
1692alpha
1693<!-- /agent:a -->
1694<!-- agent:b patch=append -->
1695beta
1696<!-- /agent:b -->
1697";
1698 let components = parse(doc).unwrap();
1699 assert_eq!(components.len(), 2);
1700 assert_eq!(components[0].name, "a");
1701 assert_eq!(components[1].name, "b");
1702 assert_eq!(components[1].patch_mode(), Some("append"));
1703 }
1704
1705 #[test]
1706 fn mixed_backtick_and_real_tags() {
1707 let doc = "\
1709Here is an example: `<!-- agent:fake -->` and ``<!-- /agent:fake -->``.
1710<!-- agent:real -->
1711real content
1712<!-- /agent:real -->
1713Another example: `<!-- agent:also-fake patch=replace -->` is just documentation.
1714";
1715 let components = parse(doc).unwrap();
1716 assert_eq!(components.len(), 1);
1717 assert_eq!(components[0].name, "real");
1718 assert_eq!(components[0].content(doc), "real content\n");
1719 }
1720
1721 #[test]
1722 fn inline_code_mid_line_with_surrounding_text_ignored() {
1723 let doc = "\
1726Wrap markers like `<!-- agent:status -->` in backticks to show them literally.
1727<!-- agent:real -->
1728actual content
1729<!-- /agent:real -->
1730";
1731 let components = parse(doc).unwrap();
1732 assert_eq!(components.len(), 1);
1733 assert_eq!(components[0].name, "real");
1734 assert_eq!(components[0].content(doc), "actual content\n");
1735 }
1736
1737 #[test]
1738 fn parse_attrs_unit() {
1739 let attrs = parse_attrs("mode=append");
1740 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1741
1742 let attrs = parse_attrs("mode=replace timestamp=true");
1743 assert_eq!(attrs.len(), 2);
1744
1745 let attrs = parse_attrs("");
1746 assert!(attrs.is_empty());
1747
1748 let attrs = parse_attrs("mode=append broken novalue=");
1750 assert_eq!(attrs.len(), 2);
1751 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1752 assert_eq!(attrs.get("broken").map(|s| s.as_str()), Some(""));
1753
1754 let attrs = parse_attrs("auto");
1756 assert_eq!(attrs.len(), 1);
1757 assert!(attrs.contains_key("auto"));
1758 }
1759
1760 #[test]
1761 fn append_with_boundary_skips_code_block() {
1762 let boundary_id = "real-uuid";
1765 let doc = format!(
1766 "<!-- agent:exchange patch=append -->\n\
1767 user prompt\n\
1768 ```\n\
1769 <!-- agent:boundary:{boundary_id} -->\n\
1770 ```\n\
1771 more user text\n\
1772 <!-- agent:boundary:{boundary_id} -->\n\
1773 <!-- /agent:exchange -->\n"
1774 );
1775 let components = parse(&doc).unwrap();
1776 let comp = &components[0];
1777 let result =
1778 comp.append_with_boundary(&doc, "### Re: Response\n\nContent here.", boundary_id);
1779
1780 assert!(result.contains("### Re: Response"));
1783 assert!(result.contains("more user text"));
1784 assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
1786 assert!(!result.contains(&format!(
1788 "more user text\n<!-- agent:boundary:{boundary_id} -->\n<!-- /agent:exchange -->"
1789 )));
1790 }
1791
1792 #[test]
1793 fn append_with_boundary_no_code_block() {
1794 let boundary_id = "simple-uuid";
1796 let doc = format!(
1797 "<!-- agent:exchange patch=append -->\n\
1798 user prompt\n\
1799 <!-- agent:boundary:{boundary_id} -->\n\
1800 <!-- /agent:exchange -->\n"
1801 );
1802 let components = parse(&doc).unwrap();
1803 let comp = &components[0];
1804 let result = comp.append_with_boundary(&doc, "### Re: Answer\n\nDone.", boundary_id);
1805
1806 assert!(result.contains("### Re: Answer"));
1807 assert!(result.contains("user prompt"));
1808 assert!(!result.contains(&format!("agent:boundary:{boundary_id}")));
1810 assert!(result.contains("agent:boundary:"));
1811 }
1812
1813 #[test]
1814 fn append_with_boundary_skips_already_present_content() {
1815 let boundary_id = "simple-uuid";
1816 let doc = format!(
1817 "<!-- agent:exchange patch=append -->\n\
1818 ### Re: Duplicate — gpt-5 (HEAD)\n\
1819 \n\
1820 Already applied.\n\
1821 <!-- agent:boundary:old-id -->\n\
1822 <!-- agent:boundary:{boundary_id} -->\n\
1823 <!-- /agent:exchange -->\n"
1824 );
1825 let components = parse(&doc).unwrap();
1826 let comp = &components[0];
1827 let result = comp.append_with_boundary(
1828 &doc,
1829 "### Re: Duplicate — gpt-5\n\nAlready applied.\n",
1830 boundary_id,
1831 );
1832
1833 assert_eq!(result, doc);
1834 }
1835
1836 #[test]
1837 fn append_with_caret_skips_already_present_content() {
1838 let doc = "<!-- agent:exchange patch=append -->\n\
1839 User prompt.\n\
1840 ### Re: Duplicate — gpt-5\n\
1841 \n\
1842 Already applied.\n\
1843 <!-- /agent:exchange -->\n";
1844 let components = parse(doc).unwrap();
1845 let comp = &components[0];
1846 let result =
1847 comp.append_with_caret(doc, "### Re: Duplicate — gpt-5\n\nAlready applied.\n", None);
1848
1849 assert_eq!(result, doc);
1850 }
1851
1852 #[test]
1855 fn strip_comments_removes_html_comment() {
1856 let result = strip_comments("before\n<!-- a comment -->\nafter\n");
1857 assert_eq!(result, "before\nafter\n");
1858 }
1859
1860 #[test]
1861 fn non_agent_html_comment_ranges_cover_multiline_body() {
1862 let doc = concat!(
1863 "before\n",
1864 "<!--\n",
1865 "do #hidden. spec-test-build-install-commit-push\n",
1866 "-->\n",
1867 "<!-- agent:exchange -->\n",
1868 "<!-- /agent:exchange -->\n"
1869 );
1870
1871 let ranges = find_non_agent_html_comment_ranges(doc);
1872 assert_eq!(ranges.len(), 1);
1873 let hidden = doc.find("do #hidden").unwrap();
1874 assert!(
1875 ranges
1876 .iter()
1877 .any(|&(start, end)| hidden >= start && hidden < end),
1878 "ordinary comment body should be inside a non-agent comment range"
1879 );
1880 assert!(
1881 ranges
1882 .iter()
1883 .all(|&(start, end)| !doc[start..end].contains("agent:exchange")),
1884 "agent component markers must not be treated as ordinary comments"
1885 );
1886 }
1887
1888 #[test]
1889 fn non_agent_html_comment_ranges_cover_unterminated_tail() {
1890 let doc = concat!(
1891 "before\n",
1892 "<!--\n",
1893 "do #hidden. spec-test-build-install-commit-push\n",
1894 "still typing\n"
1895 );
1896
1897 let ranges = find_non_agent_html_comment_ranges(doc);
1898 assert_eq!(ranges.len(), 1);
1899 let (start, end) = ranges[0];
1900 assert_eq!(
1901 &doc[start..end],
1902 "<!--\ndo #hidden. spec-test-build-install-commit-push\nstill typing\n"
1903 );
1904 assert_eq!(end, doc.len());
1905 }
1906
1907 #[test]
1908 fn strip_comments_preserves_agent_markers() {
1909 let input = "text\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1910 let result = strip_comments(input);
1911 assert!(result.contains("<!-- agent:status -->"));
1912 assert!(result.contains("<!-- /agent:status -->"));
1913 }
1914
1915 #[test]
1916 fn strip_comments_removes_link_ref() {
1917 let result = strip_comments("[//]: # (hidden note)\nvisible\n");
1918 assert_eq!(result, "visible\n");
1919 }
1920
1921 #[test]
1922 fn html_comment_in_content_does_not_eat_close_marker() {
1923 let doc = "\
1924<!-- agent:pending -->
1925- [ ] [#abc] Rule: first line (not starting with ### or ❯ or <!-- ) gets prefix
1926<!-- /agent:pending -->
1927";
1928 let comps = parse(doc).unwrap();
1929 assert_eq!(comps.len(), 1);
1930 assert_eq!(comps[0].name, "pending");
1931 assert!(comps[0].content(doc).contains("<!-- )"));
1932 }
1933
1934 #[test]
1935 fn nested_html_comment_like_text_in_exchange() {
1936 let doc = "\
1937<!-- agent:exchange -->
1938User typed <!-- some note --> in the text.
1939<!-- /agent:exchange -->
1940";
1941 let comps = parse(doc).unwrap();
1942 assert_eq!(comps.len(), 1);
1943 assert_eq!(comps[0].name, "exchange");
1944 }
1945
1946 #[test]
1947 fn pending_item_with_literal_html_comment_opener() {
1948 let doc = "\
1951<!-- agent:pending -->
1952- [ ] [#r7hw] Component parser: non-agent `<!-- ` in content ate close markers
1953- [ ] [#xyz] Another item
1954<!-- /agent:pending -->
1955";
1956 let comps = parse(doc).unwrap();
1957 assert_eq!(comps.len(), 1);
1958 assert_eq!(comps[0].name, "pending");
1959 let content = comps[0].content(doc);
1960 assert!(content.contains("#r7hw"));
1961 assert!(content.contains("#xyz"));
1962 }
1963
1964 #[test]
1965 fn multiple_non_agent_html_comments_in_pending() {
1966 let doc = "\
1968<!-- agent:pending -->
1969- [ ] [#a] Fix rule: skip lines starting with <!-- or -->
1970- [ ] [#b] Handle <!-- partial comment
1971- [ ] [#c] Normal item
1972<!-- /agent:pending -->
1973";
1974 let comps = parse(doc).unwrap();
1975 assert_eq!(comps.len(), 1);
1976 assert_eq!(comps[0].name, "pending");
1977 let content = comps[0].content(doc);
1978 assert!(content.contains("#a"));
1979 assert!(content.contains("#b"));
1980 assert!(content.contains("#c"));
1981 }
1982
1983 #[test]
1984 fn exchange_and_pending_both_with_html_comments_in_content() {
1985 let doc = "\
1988<!-- agent:exchange -->
1989The rule checks for <!-- prefix before deciding.
1990### Re: topic — opus
1991Fix applied to skip non-agent <!-- sequences.
1992<!-- /agent:exchange -->
1993
1994<!-- agent:pending -->
1995- [ ] [#cfdy] Verify <!-- in pending items
1996<!-- /agent:pending -->
1997";
1998 let comps = parse(doc).unwrap();
1999 assert_eq!(comps.len(), 2);
2000 assert_eq!(comps[0].name, "exchange");
2001 assert_eq!(comps[1].name, "pending");
2002 assert!(comps[0].content(doc).contains("<!-- prefix"));
2003 assert!(comps[1].content(doc).contains("#cfdy"));
2004 }
2005
2006 #[test]
2007 fn is_backlog_component_accepts_both_names() {
2008 assert!(is_backlog_component("backlog"));
2009 assert!(is_backlog_component("pending"));
2010 assert!(!is_backlog_component("exchange"));
2011 assert!(!is_backlog_component("status"));
2012 assert!(!is_backlog_component("pending-done"));
2013 assert!(!is_backlog_component("backlog-done"));
2014 assert!(!is_backlog_component("done"));
2015 }
2016
2017 #[test]
2018 fn is_backlog_done_component_accepts_only_canonical_done() {
2019 assert!(is_backlog_done_component("done"));
2020 assert!(!is_backlog_done_component("backlog-done"));
2021 assert!(!is_backlog_done_component("pending-done"));
2022 assert!(!is_backlog_done_component("backlog"));
2023 assert!(!is_backlog_done_component("pending"));
2024 assert!(!is_backlog_done_component("icebox"));
2025 }
2026
2027 #[test]
2028 fn is_review_component_accepts_name() {
2029 assert!(is_review_component("review"));
2030 assert!(!is_review_component("backlog"));
2031 assert!(!is_review_component("icebox"));
2032 }
2033
2034 #[test]
2035 fn is_icebox_component_accepts_name() {
2036 assert!(is_icebox_component("icebox"));
2037 assert!(!is_icebox_component("backlog"));
2038 assert!(!is_icebox_component("exchange"));
2039 assert!(!is_icebox_component("icebox-archive"));
2040 }
2041
2042 #[test]
2043 fn tracked_work_component_includes_review() {
2044 assert!(is_tracked_work_component("backlog"));
2045 assert!(is_tracked_work_component("pending"));
2046 assert!(is_tracked_work_component("review"));
2047 assert!(is_tracked_work_component("icebox"));
2048 assert!(!is_tracked_work_component("done"));
2049 }
2050
2051 #[test]
2052 fn icebox_component_parsed() {
2053 let doc = "\
2054<!-- agent:icebox -->
2055- Parked idea
2056<!-- /agent:icebox -->
2057";
2058 let comps = parse(doc).unwrap();
2059 assert_eq!(comps.len(), 1);
2060 assert_eq!(comps[0].name, "icebox");
2061 assert!(is_icebox_component(&comps[0].name));
2062 assert!(comps[0].content(doc).contains("Parked idea"));
2063 }
2064
2065 #[test]
2066 fn backlog_component_parsed_from_new_marker() {
2067 let doc = "\
2068<!-- agent:backlog -->
2069- [ ] [#abc] First item
2070<!-- /agent:backlog -->
2071";
2072 let comps = parse(doc).unwrap();
2073 assert_eq!(comps.len(), 1);
2074 assert_eq!(comps[0].name, "backlog");
2075 assert!(is_backlog_component(&comps[0].name));
2076 assert!(comps[0].content(doc).contains("#abc"));
2077 }
2078
2079 #[test]
2080 fn legacy_pending_marker_still_parsed() {
2081 let doc = "\
2082<!-- agent:pending -->
2083- [ ] [#xyz] Legacy item
2084<!-- /agent:pending -->
2085";
2086 let comps = parse(doc).unwrap();
2087 assert_eq!(comps.len(), 1);
2088 assert_eq!(comps[0].name, "pending");
2089 assert!(is_backlog_component(&comps[0].name));
2090 }
2091
2092 #[test]
2093 fn strip_backlog_patch_attr_removes_patch_replace() {
2094 let doc = "<!-- agent:backlog patch=replace -->\n- item\n<!-- /agent:backlog -->\n";
2095 let result = strip_backlog_patch_attr(doc);
2096 assert_eq!(
2097 result,
2098 "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2099 );
2100 }
2101
2102 #[test]
2103 fn strip_backlog_patch_attr_removes_pending_patch_replace() {
2104 let doc = "<!-- agent:pending patch=replace -->\n- item\n<!-- /agent:pending -->\n";
2105 let result = strip_backlog_patch_attr(doc);
2106 assert_eq!(
2107 result,
2108 "<!-- agent:pending -->\n- item\n<!-- /agent:pending -->\n"
2109 );
2110 }
2111
2112 #[test]
2113 fn strip_backlog_patch_attr_removes_mode_replace() {
2114 let doc = "<!-- agent:backlog mode=replace -->\n- item\n<!-- /agent:backlog -->\n";
2115 let result = strip_backlog_patch_attr(doc);
2116 assert_eq!(
2117 result,
2118 "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2119 );
2120 }
2121
2122 #[test]
2123 fn strip_backlog_patch_attr_preserves_other_attrs() {
2124 let doc =
2125 "<!-- agent:backlog patch=replace max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n";
2126 let result = strip_backlog_patch_attr(doc);
2127 assert_eq!(
2128 result,
2129 "<!-- agent:backlog max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n"
2130 );
2131 }
2132
2133 #[test]
2134 fn strip_backlog_patch_attr_noop_for_exchange() {
2135 let doc = "<!-- agent:exchange patch=append -->\ncontent\n<!-- /agent:exchange -->\n";
2136 let result = strip_backlog_patch_attr(doc);
2137 assert_eq!(result, doc);
2138 }
2139
2140 #[test]
2141 fn strip_backlog_patch_attr_noop_when_no_attr() {
2142 let doc = "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n";
2143 let result = strip_backlog_patch_attr(doc);
2144 assert_eq!(result, doc);
2145 }
2146
2147 #[test]
2148 fn converge_queue_auto_strips_auto() {
2149 let doc = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2150 let result = converge_queue_auto(doc, false).expect("tag changed");
2151 assert_eq!(
2152 result,
2153 "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n"
2154 );
2155 }
2156
2157 #[test]
2158 fn converge_queue_auto_adds_auto() {
2159 let doc = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2160 let result = converge_queue_auto(doc, true).expect("tag changed");
2161 assert_eq!(
2162 result,
2163 "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n"
2164 );
2165 }
2166
2167 #[test]
2168 fn converge_queue_auto_preserves_other_attrs() {
2169 let doc = "<!-- agent:queue auto patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n";
2170 let result = converge_queue_auto(doc, false).expect("tag changed");
2171 assert_eq!(
2172 result,
2173 "<!-- agent:queue patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n"
2174 );
2175 }
2176
2177 #[test]
2178 fn converge_queue_auto_normalizes_boolean_attrs_without_corrupting_preset() {
2179 let doc = concat!(
2180 "<!-- agent:queue auto=true priority=true preset=\"#spec-test-build-install-commit-push\"=true go=true -->\n",
2181 "- do [#x]\n",
2182 "<!-- /agent:queue -->\n",
2183 );
2184 let result = converge_queue_auto(doc, false).expect("tag normalized");
2185 assert_eq!(
2186 result,
2187 concat!(
2188 "<!-- agent:queue priority preset=\"#spec-test-build-install-commit-push\" go -->\n",
2189 "- do [#x]\n",
2190 "<!-- /agent:queue -->\n",
2191 )
2192 );
2193 }
2194
2195 #[test]
2196 fn converge_queue_auto_noop_when_already_matching() {
2197 let active = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2198 assert_eq!(converge_queue_auto(active, true), None);
2199 let inactive = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2200 assert_eq!(converge_queue_auto(inactive, false), None);
2201 }
2202
2203 #[test]
2204 fn converge_queue_auto_none_without_queue_component() {
2205 let doc = "<!-- agent:exchange -->\nhi\n<!-- /agent:exchange -->\n";
2206 assert_eq!(converge_queue_auto(doc, false), None);
2207 }
2208
2209 #[test]
2213 fn append_patch_already_present_ignores_user_prompt_prefix() {
2214 assert!(
2215 append_patch_already_present("expand the section", "❯ expand the section"),
2216 "bare buffer vs prefixed patch must dedup"
2217 );
2218 assert!(
2219 append_patch_already_present("❯ expand the section", "expand the section"),
2220 "prefixed buffer vs bare patch must dedup"
2221 );
2222 assert!(
2223 append_patch_already_present("❯ expand the section", "❯ expand the section"),
2224 "both prefixed must dedup"
2225 );
2226 }
2227
2228 #[test]
2229 fn append_patch_distinct_prompts_not_deduped() {
2230 assert!(
2231 !append_patch_already_present("❯ expand the section", "❯ summarize the section"),
2232 "genuinely distinct prompts must not be treated as duplicates"
2233 );
2234 }
2235
2236 #[test]
2243 fn append_patch_dedupes_midblock_typing_snapshot() {
2244 let live = concat!(
2245 "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2246 "- [#bndq]: confirmed\n",
2247 "- do [#s75b]\n",
2248 "- do [#vapk]\n",
2249 "- [#s93y]: Add to resume\n",
2250 "- [#ca8w]\n",
2251 "Add MonsterRodHolders and EFS to the resume if not already added.",
2252 );
2253 let snapshot = concat!(
2254 "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2255 "- [#bndq]: confirmed\n",
2256 "- do [#s75b]\n",
2257 "- do [#vapk]\n",
2258 "- [#s93y]: Add to\n",
2259 "- [#ca8w]\n",
2260 "Add MonsterRodHolders and EFS to the resume if not already added.",
2261 );
2262 assert!(
2263 append_patch_already_present(live, snapshot),
2264 "earlier keystroke snapshot must dedup against the live, further-typed region"
2265 );
2266 }
2267
2268 #[test]
2269 fn append_patch_typing_snapshot_does_not_collapse_distinct_lines() {
2270 let live = "- do task one\n- and the second thing\n- finally";
2273 let distinct = "- do task one\n- and a different thing\n- finally";
2274 assert!(
2275 !append_patch_already_present(live, distinct),
2276 "a mid-line replacement (not a prefix) is a distinct edit, not a snapshot"
2277 );
2278 }
2279
2280 #[test]
2281 fn append_with_boundary_does_not_duplicate_typing_snapshot() {
2282 let doc = concat!(
2286 "<!-- agent:exchange patch=append -->\n",
2287 "### Re: prior — opus-4-8\n\n",
2288 "Answer.\n",
2289 "<!-- agent:boundary:70bccf9b -->\n",
2290 "- [#yfg1]: ok\n",
2291 "- [#s93y]: Add to resume\n",
2292 "- [#ca8w]\n",
2293 "Add MonsterRodHolders and EFS to the resume if not already added.\n",
2294 "<!-- /agent:exchange -->\n",
2295 );
2296 let components = parse(doc).unwrap();
2297 let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2298 let snapshot = concat!(
2299 "- [#yfg1]: ok\n",
2300 "- [#s93y]: Add to\n",
2301 "- [#ca8w]\n",
2302 "Add MonsterRodHolders and EFS to the resume if not already added.",
2303 );
2304 let result = exchange.append_with_boundary(doc, snapshot, "70bccf9b");
2305 assert_eq!(
2306 result, doc,
2307 "stale typing-snapshot append must be a no-op:\n{result}"
2308 );
2309 assert_eq!(
2310 result.matches("<!-- /agent:exchange -->").count(),
2311 1,
2312 "exchange close marker must not duplicate:\n{result}"
2313 );
2314 }
2315
2316 #[test]
2317 fn append_with_caret_does_not_duplicate_prefixed_prompt() {
2318 let doc = concat!(
2322 "<!-- agent:exchange patch=append -->\n",
2323 "### Re: prior — opus-4-8\n\n",
2324 "Answer.\n",
2325 "<!-- agent:boundary:abc12345 -->\n",
2326 "expand the Confidential AI Startup section\n",
2327 "<!-- /agent:exchange -->\n",
2328 );
2329 let components = parse(doc).unwrap();
2330 let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2331 let result =
2332 exchange.append_with_caret(doc, "❯ expand the Confidential AI Startup section", None);
2333 assert_eq!(
2334 result
2335 .matches("expand the Confidential AI Startup section")
2336 .count(),
2337 1,
2338 "prefixed synthesized patch must not duplicate the already-typed prompt:\n{result}"
2339 );
2340 }
2341
2342 const CLEAN_DOC: &str = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\
2345<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2346<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- do [#x]\n<!-- /agent:queue -->\n\
2347<!-- agent:backlog -->\n- [ ] [#y] thing\n<!-- /agent:backlog -->\n";
2348
2349 #[test]
2350 fn structural_corruption_clean_doc_is_sound() {
2351 assert_eq!(structural_corruption_reason(CLEAN_DOC), None);
2352 }
2353
2354 #[test]
2355 fn structural_corruption_flags_duplicate_singleton_queue() {
2356 let doc = "<!-- agent:queue -->\n- a\n<!-- /agent:queue -->\n\
2359<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2360<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- b\n<!-- /agent:queue -->\n";
2361 let reason = structural_corruption_reason(doc).expect("two queue blocks must be flagged");
2362 assert!(
2363 reason.contains("duplicate_singleton_component") && reason.contains("queue=2"),
2364 "reason was: {reason}"
2365 );
2366 }
2367
2368 #[test]
2369 fn structural_corruption_flags_backlog_alias_duplicate() {
2370 let doc = "<!-- agent:backlog -->\n- [ ] [#a] x\n<!-- /agent:backlog -->\n\
2372<!-- agent:pending -->\n- [ ] [#b] y\n<!-- /agent:pending -->\n";
2373 let reason = structural_corruption_reason(doc).expect("backlog+pending must be flagged");
2374 assert!(reason.contains("backlog=2"), "reason was: {reason}");
2375 }
2376
2377 #[test]
2378 fn structural_corruption_flags_unterminated_quote_attr() {
2379 let doc = "<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2381<!-- agent:queue tasks=\" -->\n- a\n<!-- /agent:queue -->\n";
2382 let reason =
2383 structural_corruption_reason(doc).expect("unterminated quote attr must be flagged");
2384 assert!(
2385 reason.starts_with("malformed_attr:queue"),
2386 "reason was: {reason}"
2387 );
2388 }
2389
2390 #[test]
2391 fn structural_corruption_allows_balanced_quoted_attr() {
2392 let doc = "<!-- agent:queue preset=\"#a\" go -->\n- a\n<!-- /agent:queue -->\n";
2394 assert_eq!(structural_corruption_reason(doc), None);
2395 }
2396
2397 #[test]
2398 fn structural_corruption_allows_repeated_non_singleton() {
2399 let doc = "<!-- agent:log -->\na\n<!-- /agent:log -->\n\
2401<!-- agent:log -->\nb\n<!-- /agent:log -->\n";
2402 assert_eq!(structural_corruption_reason(doc), None);
2403 }
2404
2405 #[test]
2406 fn structural_corruption_flags_parse_failure() {
2407 let doc = "<!-- agent:queue -->\n- a\n";
2409 let reason = structural_corruption_reason(doc).expect("unclosed marker must be flagged");
2410 assert!(reason.starts_with("parse_error"), "reason was: {reason}");
2411 }
2412}