Skip to main content

mxblocks_asks/
lib.rs

1//! Reaction asks in a bot's plain-text reply: a paragraph that asks the
2//! reader to react gets those reactions seeded (and mxblocks buttons), so on
3//! a phone they tap one instead of hunting for the emoji, and the tap can be
4//! handled like a button press.
5//!
6//! - **The marker.** `[ask: ✅ deploy | ❌ skip]` (2 to 4 options, an emoji
7//!   and a short label each) anywhere in a paragraph: that paragraph goes
8//!   as its own message, the marker replaced by `✅ deploy · ❌ skip`, with a
9//!   buttons block and the keys seeded. A label over [`MAX_LABEL`] is
10//!   shortened on its button, kept whole in the text. A marker that still
11//!   doesn't parse is never posted raw: it becomes its options joined
12//!   with ` · `.
13//! - **The safety net.** Without a marker, only the end of the reply's last
14//!   paragraph, only strict shapes: `✅ to deploy, ❌ to skip` (two or three,
15//!   buttons too), `react ✅`, `👍/👎?` and `👍 or 👎` (seeds only). A last
16//!   paragraph that is only the marker's rendered form (`✅ deploy · ❌
17//!   skip`, written instead of the marker) stays text: [`rendered_only`]
18//!   lets the sender log it. A press note can quote the ask in marker form
19//!   ([`Ask::marker`], [`Ask::quoted`]), so the model never sees the
20//!   rendered form as its own.
21//!
22//! Pure: [`plan`] says what each message carries; the sender seeds the
23//! reactions and registers the buttons.
24
25#![doc = include_str!("../README.md")]
26#![warn(missing_docs)]
27
28use std::sync::LazyLock;
29
30use mxblocks::{Button, MAX_LABEL, Rich, Style, norm_key};
31use regex::Regex;
32
33/// One emoji (with its variation selector, skin tone, ZWJ sequence), or a
34/// keycap.
35const EMO: &str = r"(?:\p{Extended_Pictographic}|[#*0-9]\x{FE0F}?\x{20E3})(?:\x{FE0F}|\p{Emoji_Modifier})*(?:\x{200D}\p{Extended_Pictographic}(?:\x{FE0F}|\p{Emoji_Modifier})*)*";
36
37fn re(p: &str) -> Regex {
38    Regex::new(&p.replace("EMO", EMO)).unwrap_or_else(|e| panic!("reaction_asks regex: {e}"))
39}
40
41static EMO_RE: LazyLock<Regex> = LazyLock::new(|| re("EMO"));
42/// A code fence: up to 3 spaces, then 3+ backticks or tildes.
43static FENCE_RE: LazyLock<Regex> = LazyLock::new(|| re(r"^\s{0,3}(`{3,}|~{3,})(.*)$"));
44static MARKER_RE: LazyLock<Regex> = LazyLock::new(|| re(r"\[ask:\s*([^\]\n]+)\]"));
45static OPTION_RE: LazyLock<Regex> = LazyLock::new(|| re(r"^(EMO)\s*(.*)$"));
46/// `✅ to deploy, ❌ to skip` (or `/`, `;`, `·`, `|`, `or`), two or three,
47/// at the end of the line.
48static TO_RE: LazyLock<Regex> = LazyLock::new(|| {
49    let item = r"EMO\s+to\s+[^,;·|/\n\p{Extended_Pictographic}]{1,60}?";
50    let sep = r"(?:\s*[,;·|/]\s*(?:or\s+)?|\s+or\s+)";
51    re(&format!(r"{item}{sep}{item}(?:{sep}{item})?[\s.!?)]*$"))
52});
53/// `✅ merge #5366 · ⏸ hold`: the marker's rendered form, a whole line
54/// ([`rendered_only`]).
55static RENDERED_RE: LazyLock<Regex> = LazyLock::new(|| {
56    let item = r"EMO\s*[^·|\n\p{Extended_Pictographic}]{1,80}?";
57    re(&format!(r"^\s*{item}(?:\s+·\s+{item}){{1,3}}\s*$"))
58});
59/// `react ✅`, `react with 👍 or 👎`.
60static REACT_RE: LazyLock<Regex> =
61    LazyLock::new(|| re(r"(?i)\breact(?:\s+with)?\s+(EMO(?:\s*(?:/|,|or)\s*EMO){0,3})[\s.!?)]*$"));
62/// `👍/👎?`, `👍 or 👎`.
63static PAIR_RE: LazyLock<Regex> = LazyLock::new(|| {
64    re(r"(?:^|[\s(:—–-])(EMO\s*(?:/|or)\s*EMO(?:\s*(?:/|or)\s*EMO)?)\s*\??[\s.!)]*$")
65});
66
67/// One option: the reaction key and its label (empty: the key alone).
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Opt {
70    /// The reaction key (an emoji).
71    pub key: String,
72    /// Its label; empty: the key alone.
73    pub label: String,
74}
75
76/// A message's ask: its options, and whether Element shows them as
77/// buttons (the seeds go either way).
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Ask {
80    /// The options, in order.
81    pub opts: Vec<Opt>,
82    /// Whether the message shows them as buttons.
83    pub buttons: bool,
84}
85
86impl Ask {
87    /// The reactions to seed, in order.
88    pub fn seeds(&self) -> Vec<String> {
89        self.opts.iter().map(|o| o.key.clone()).collect()
90    }
91
92    /// The buttons block (each label, shortened to [`MAX_LABEL`], or its
93    /// key without one): what is registered for presses, and shown with
94    /// [`Self::buttons`].
95    pub fn rich(&self) -> Rich {
96        Rich::buttons(
97            self.opts
98                .iter()
99                .map(|o| {
100                    let label = if o.label.is_empty() {
101                        o.key.clone()
102                    } else {
103                        short(&o.label)
104                    };
105                    Button::react(&label, Style::Default, &o.key)
106                })
107                .collect(),
108        )
109    }
110
111    /// The block to put on the message, if it shows buttons.
112    pub fn shown(&self) -> Option<Rich> {
113        self.buttons.then(|| self.rich())
114    }
115
116    /// The marker itself: `[ask: ✅ deploy | ❌ skip]`.
117    pub fn marker(&self) -> String {
118        let opts: Vec<String> = self
119            .opts
120            .iter()
121            .map(|o| format!("{} {}", o.key, o.label).trim().to_owned())
122            .collect();
123        format!("[ask: {}]", opts.join(" | "))
124    }
125
126    /// `text` (the message this ask went out in) with its fallback back in
127    /// marker form: what a press note quotes.
128    pub fn quoted(&self, text: &str) -> String {
129        text.replacen(&self.fallback(), &self.marker(), 1)
130    }
131
132    /// The plain-text stand-in for the marker: `✅ deploy · ❌ skip`.
133    pub fn fallback(&self) -> String {
134        self.opts
135            .iter()
136            .map(|o| format!("{} {}", o.key, o.label).trim().to_owned())
137            .collect::<Vec<_>>()
138            .join(" · ")
139    }
140}
141
142/// `label` cut to [`MAX_LABEL`] characters for a button: at a word
143/// boundary when there is one, ending in `…`.
144fn short(label: &str) -> String {
145    if label.chars().count() <= MAX_LABEL {
146        return label.to_owned();
147    }
148    let head: String = label.chars().take(MAX_LABEL - 1).collect();
149    let cut = match head.rfind(char::is_whitespace) {
150        Some(i) if head[..i].trim_end().chars().count() >= MAX_LABEL / 2 => &head[..i],
151        _ => &head,
152    };
153    let cut = cut.trim_end_matches(|c: char| c.is_whitespace() || ",;:·-–—|/".contains(c));
154    format!("{cut}…")
155}
156
157/// 1 to 4 options with distinct keys, else `None`.
158fn ask_of(opts: Vec<Opt>, min: usize, buttons: bool) -> Option<Ask> {
159    if opts.len() < min || opts.len() > 4 {
160        return None;
161    }
162    let mut keys: Vec<String> = opts.iter().map(|o| norm_key(&o.key)).collect();
163    keys.sort();
164    keys.dedup();
165    (keys.len() == opts.len()).then_some(Ask { opts, buttons })
166}
167
168/// A marker's inside (`✅ deploy | ❌ skip`).
169fn parse_marker(inner: &str) -> Option<Ask> {
170    let opts = inner
171        .split('|')
172        .map(|o| {
173            let c = OPTION_RE.captures(o.trim())?;
174            let label = c[2].trim();
175            (!label.is_empty()).then(|| Opt {
176                key: c[1].to_owned(),
177                label: label.to_owned(),
178            })
179        })
180        .collect::<Option<Vec<_>>>()?;
181    ask_of(opts, 2, true)
182}
183
184/// Lines of `text` outside fenced code blocks, as byte ranges.
185fn prose_lines(text: &str) -> Vec<(usize, &str)> {
186    let mut out = Vec::new();
187    let mut fence = false;
188    let mut at = 0;
189    for line in text.split('\n') {
190        let start = at;
191        at += line.len() + 1;
192        if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
193            fence = !fence;
194            continue;
195        }
196        if !fence {
197            out.push((start, line));
198        }
199    }
200    out
201}
202
203/// The first valid marker in `para` outside code: the paragraph with it
204/// replaced by its fallback, and the ask.
205pub fn take_marker(para: &str) -> Option<(String, Ask)> {
206    for (start, line) in prose_lines(para) {
207        for m in MARKER_RE.captures_iter(line) {
208            let all = m.get(0)?;
209            // Inside inline code: left alone.
210            if line[..all.start()].matches('`').count() % 2 == 1 {
211                continue;
212            }
213            let Some(ask) = parse_marker(&m[1]) else {
214                continue;
215            };
216            let (a, b) = (start + all.start(), start + all.end());
217            let text = format!("{}{}{}", &para[..a], ask.fallback(), &para[b..]);
218            return Some((text.trim().to_owned(), ask));
219        }
220    }
221    None
222}
223
224/// `text` with every marker outside code that [`take_marker`] left (one
225/// that doesn't parse, or a second one in a paragraph) made readable: its
226/// options joined with ` · `, without the brackets and `ask:`.
227pub fn plain_markers(text: &str) -> String {
228    let mut out = String::with_capacity(text.len());
229    let mut at = 0;
230    for (start, line) in prose_lines(text) {
231        for m in MARKER_RE.captures_iter(line) {
232            let Some(all) = m.get(0) else { continue };
233            if line[..all.start()].matches('`').count() % 2 == 1 {
234                continue;
235            }
236            let plain = m[1]
237                .split('|')
238                .map(str::trim)
239                .filter(|o| !o.is_empty())
240                .collect::<Vec<_>>()
241                .join(" · ");
242            out.push_str(&text[at..start + all.start()]);
243            out.push_str(&plain);
244            at = start + all.end();
245        }
246    }
247    out.push_str(&text[at..]);
248    out
249}
250
251/// `s` without trailing separators, `or` and closing punctuation.
252fn label_end(s: &str) -> String {
253    let trim = |s: &str| {
254        s.trim_end_matches(|c: char| c.is_whitespace() || ",;·|/.!?)".contains(c))
255            .to_owned()
256    };
257    let mut s = trim(s);
258    if let Some(r) = s.strip_suffix(" or") {
259        s = trim(r);
260    }
261    s
262}
263
264/// The safety net: an ask in one of the strict shapes at the very end of
265/// `text` (a reply's last paragraph), outside code.
266pub fn detect(text: &str) -> Option<Ask> {
267    let text = text.trim_end();
268    let (_, line) = *prose_lines(text).last()?;
269    // The paragraph's last line, not one inside or closing a code block.
270    if !text.ends_with(line) || line.contains('`') {
271        return None;
272    }
273    if let Some(m) = TO_RE.find(line) {
274        let span = m.as_str();
275        let emos: Vec<_> = EMO_RE.find_iter(span).collect();
276        let opts = emos
277            .iter()
278            .enumerate()
279            .map(|(i, e)| {
280                let end = emos.get(i + 1).map_or(span.len(), regex::Match::start);
281                let rest = span[e.end()..end].trim_start();
282                let label = label_end(rest.strip_prefix("to")?.trim_start());
283                (!label.is_empty()).then(|| Opt {
284                    key: e.as_str().to_owned(),
285                    label,
286                })
287            })
288            .collect::<Option<Vec<_>>>()?;
289        return ask_of(opts, 2, true);
290    }
291    let keys = |s: &str| -> Vec<Opt> {
292        EMO_RE
293            .find_iter(s)
294            .map(|e| Opt {
295                key: e.as_str().to_owned(),
296                label: String::new(),
297            })
298            .collect()
299    };
300    if let Some(c) = REACT_RE.captures(line) {
301        return ask_of(keys(&c[1]), 1, false);
302    }
303    if let Some(c) = PAIR_RE.captures(line) {
304        let g = c.get(1)?;
305        // A question, or the pair alone on its line.
306        if line.contains('?') || line[..g.start()].trim().is_empty() {
307            return ask_of(keys(g.as_str()), 2, false);
308        }
309    }
310    None
311}
312
313/// Whether `text`'s last paragraph is only the marker's rendered form
314/// (`✅ deploy · ❌ skip`, two to four options, distinct keys): an ask
315/// written without its marker, which goes out as text.
316pub fn rendered_only(text: &str) -> bool {
317    let text = text.trim_end();
318    let Some(&(_, line)) = prose_lines(text).last() else {
319        return false;
320    };
321    let before = text[..text.len() - line.len()].trim_end_matches([' ', '\t']);
322    (before.is_empty() || before.ends_with("\n\n"))
323        && text.ends_with(line)
324        && RENDERED_RE.is_match(line)
325        && parse_marker(&line.replace('·', "|")).is_some()
326}
327
328/// `text`'s paragraphs (blank-line separated) of Markdown; a fenced code
329/// block stays in one piece even with blank lines inside.
330pub fn paragraphs(text: &str) -> Vec<String> {
331    let mut blocks: Vec<Vec<&str>> = Vec::new();
332    let mut cur: Vec<&str> = Vec::new();
333    let mut fence: Option<String> = None;
334    for line in text.split('\n') {
335        if fence.is_none() && line.trim().is_empty() {
336            if !cur.is_empty() {
337                blocks.push(core::mem::take(&mut cur));
338            }
339            continue;
340        }
341        cur.push(line);
342        if let Some(m) = FENCE_RE.captures(line) {
343            match &fence {
344                None => fence = Some(m[1].to_owned()),
345                Some(f) if m[1].starts_with(f.as_str()) && m[2].trim().is_empty() => fence = None,
346                _ => {}
347            }
348        }
349    }
350    if !cur.is_empty() {
351        blocks.push(cur);
352    }
353    blocks.into_iter().map(|b| b.join("\n")).collect()
354}
355
356/// What each message of a reply carries: the parts (each with the
357/// caller's per-message data `M`: a reply target, a thread, ...) with every
358/// paragraph ([`paragraphs`]) that has a marker split off as its own
359/// message (the marker replaced by its fallback), each with its ask;
360/// without any marker, the last one's ask by [`detect`]. A split part's
361/// `M` goes on its first message, the rest get `M::default()`.
362pub fn plan<M: Default>(parts: Vec<(M, String)>) -> Vec<(M, String, Option<Ask>)> {
363    plan_with(parts, paragraphs)
364}
365
366/// [`plan`] with the caller's paragraph splitter.
367pub fn plan_with<M: Default>(
368    parts: Vec<(M, String)>,
369    split: impl Fn(&str) -> Vec<String>,
370) -> Vec<(M, String, Option<Ask>)> {
371    let mut out: Vec<(M, String, Option<Ask>)> = Vec::new();
372    for (marks, part) in parts {
373        let blocks = split(&part);
374        if blocks.len() < 2 || !blocks.iter().any(|b| take_marker(b).is_some()) {
375            let (text, ask) = take_marker(&part).map_or((part, None), |(t, a)| (t, Some(a)));
376            out.push((marks, text, ask));
377            continue;
378        }
379        let mut marks = Some(marks);
380        let mut run: Vec<String> = Vec::new();
381        let flush = |run: &mut Vec<String>, out: &mut Vec<_>, marks: &mut Option<M>| {
382            if !run.is_empty() {
383                out.push((marks.take().unwrap_or_default(), run.join("\n\n"), None));
384                run.clear();
385            }
386        };
387        for b in blocks {
388            match take_marker(&b) {
389                Some((text, ask)) => {
390                    flush(&mut run, &mut out, &mut marks);
391                    out.push((marks.take().unwrap_or_default(), text, Some(ask)));
392                }
393                None => run.push(b),
394            }
395        }
396        flush(&mut run, &mut out, &mut marks);
397    }
398    for (_, text, _) in &mut out {
399        if text.contains("[ask:") {
400            *text = plain_markers(text);
401        }
402    }
403    if out.iter().all(|(_, _, a)| a.is_none())
404        && let Some(last) = out.last_mut()
405    {
406        last.2 = detect(&last.1);
407    }
408    out
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    /// A caller's per-message data.
416    #[derive(Debug, Clone, Default, PartialEq, Eq)]
417    struct Marks {
418        reply: Option<String>,
419    }
420
421    fn keys(a: &Ask) -> Vec<(&str, &str)> {
422        a.opts
423            .iter()
424            .map(|o| (o.key.as_str(), o.label.as_str()))
425            .collect()
426    }
427
428    #[test]
429    fn markers() {
430        let (t, a) = take_marker("v2 is staged. Deploy? [ask: ✅ deploy | ❌ skip]").unwrap();
431        assert_eq!(t, "v2 is staged. Deploy? ✅ deploy · ❌ skip");
432        assert_eq!(keys(&a), [("✅", "deploy"), ("❌", "skip")]);
433        assert!(a.buttons);
434        assert_eq!(a.seeds(), ["✅", "❌"]);
435        let r = serde_json::to_value(a.rich()).unwrap();
436        assert_eq!(r["blocks"][0]["buttons"][1]["label"], "skip");
437        assert_eq!(r["blocks"][0]["buttons"][1]["action"]["key"], "❌");
438        // Four, with presentation selectors and a ZWJ sequence.
439        let (_, a) =
440            take_marker("[ask:👍🏽 yes|❤\u{fe0f} love it | 🧑‍💻 later | 1\u{fe0f}\u{20e3} one]")
441                .unwrap();
442        assert_eq!(a.opts.len(), 4);
443        assert_eq!(a.opts[2].key, "🧑‍💻");
444        // Too few, too many, no label, duplicate keys, not an emoji: left as is.
445        for bad in [
446            "[ask: ✅ deploy]",
447            "[ask: ✅ a | ❌ b | 👍 c | 👎 d | 🕐 e]",
448            "[ask: ✅ | ❌ skip]",
449            "[ask: ✅ a | ✅\u{fe0f} b]",
450            "[ask: yes deploy | no skip]",
451            "`[ask: ✅ deploy | ❌ skip]` is the marker",
452            "```\n[ask: ✅ deploy | ❌ skip]\n```",
453        ] {
454            assert!(take_marker(bad).is_none(), "{bad}");
455        }
456    }
457
458    #[test]
459    fn long_labels() {
460        let labels = |a: &Ask| -> Vec<String> {
461            let r = serde_json::to_value(a.rich()).unwrap();
462            r["blocks"][0]["buttons"]
463                .as_array()
464                .unwrap()
465                .iter()
466                .map(|b| b["label"].as_str().unwrap().to_owned())
467                .collect()
468        };
469        // A 47-character label: a button as is.
470        let msg = "[ask: ✅ keep the old schema, migrate the rows next week | 🔀 do the full schema migration right now | ❌ hold]";
471        let (t, a) = take_marker(msg).unwrap();
472        assert_eq!(
473            t,
474            "✅ keep the old schema, migrate the rows next week · 🔀 do the full schema migration right now · ❌ hold"
475        );
476        assert_eq!(a.seeds(), ["✅", "🔀", "❌"]);
477        assert!(a.buttons);
478        assert_eq!(
479            labels(&a),
480            [
481                "keep the old schema, migrate the rows next week",
482                "do the full schema migration right now",
483                "hold"
484            ]
485        );
486        a.rich().validate().unwrap();
487        // Over MAX_LABEL: shortened on its button at a word, whole in the text.
488        let long = "ship the whole thing now, ".repeat(5) + "then tell the team";
489        assert!(long.chars().count() > MAX_LABEL);
490        let (t, a) = take_marker(&format!("Go? [ask: ✅ {long} | ❌ hold]")).unwrap();
491        assert_eq!(t, format!("Go? ✅ {long} · ❌ hold"));
492        let b = &labels(&a)[0];
493        assert!(b.ends_with(" whole…"), "{b}");
494        assert!(b.chars().count() <= MAX_LABEL);
495        assert!(long.starts_with(b.trim_end_matches('…')));
496        a.rich().validate().unwrap();
497        // No space to cut at: cut mid-word.
498        assert_eq!(
499            short(&"x".repeat(MAX_LABEL + 10)),
500            format!("{}…", "x".repeat(MAX_LABEL - 1))
501        );
502        assert_eq!(short("fits"), "fits");
503    }
504
505    #[test]
506    fn unparsed_markers_are_plain() {
507        let p = |t: &str| plan(vec![(Marks::default(), t.to_owned())]);
508        let five = p("Pick: [ask: ✅ a | ❌ b | 👍 c | 👎 d | 🕐 e]");
509        assert_eq!(five[0].1, "Pick: ✅ a · ❌ b · 👍 c · 👎 d · 🕐 e");
510        assert!(five[0].2.is_none());
511        let bare = p("Ship? [ask: yes deploy | no skip]");
512        assert_eq!(bare[0].1, "Ship? yes deploy · no skip");
513        assert_eq!(p("[ask: ✅ deploy]")[0].1, "✅ deploy");
514        // A second marker in a paragraph that already has one.
515        let two = p("Now? [ask: ✅ go | ❌ wait] Later? [ask: 👍 yes | 👎 no]");
516        assert_eq!(two[0].1, "Now? ✅ go · ❌ wait Later? 👍 yes · 👎 no");
517        assert!(two[0].2.is_some());
518        // In code: untouched.
519        for code in [
520            "`[ask: ✅ deploy | ❌ skip]` is the marker",
521            "```\n[ask: yes | no]\n```",
522        ] {
523            assert_eq!(p(code)[0].1, code);
524        }
525    }
526
527    #[test]
528    fn safety_net() {
529        let d = |s: &str| {
530            detect(s).map(|a| {
531                (
532                    keys(&a)
533                        .iter()
534                        .map(|(k, l)| format!("{k}={l}"))
535                        .collect::<Vec<_>>()
536                        .join(" "),
537                    a.buttons,
538                )
539            })
540        };
541        assert_eq!(
542            d("Staged v2.\n\n✅ to deploy, ❌ to skip"),
543            Some(("✅=deploy ❌=skip".into(), true))
544        );
545        assert_eq!(
546            d("Shall I send it? (✅ to send it now or ❌ to drop it.)"),
547            Some(("✅=send it now ❌=drop it".into(), true))
548        );
549        assert_eq!(
550            d("👍 to merge / 👎 to hold / 🕐 to wait a day"),
551            Some(("👍=merge 👎=hold 🕐=wait a day".into(), true))
552        );
553        assert_eq!(d("Want it? React ✅."), Some(("✅=".into(), false)));
554        assert_eq!(d("react with 👍 or 👎"), Some(("👍= 👎=".into(), false)));
555        assert_eq!(d("Ship it? 👍/👎"), Some(("👍= 👎=".into(), false)));
556        assert_eq!(d("Good to go 👍/👎?"), Some(("👍= 👎=".into(), false)));
557        assert_eq!(
558            d("Notes are up.\n👍 or 👎"),
559            Some(("👍= 👎=".into(), false))
560        );
561        // Negatives.
562        for s in [
563            "Approve the ✅ ones and I'll do the rest.",
564            "✅ to deploy, ❌ to skip.\n\nAnyway, the build is green.",
565            "I marked ✅ to deploy and ❌ to skip in the doc, then left",
566            "```\n✅ to deploy, ❌ to skip\n```",
567            "Use `👍/👎?` in the template",
568            "I had 🍕 or 🍔 for lunch",
569            "✅ to deploy, ✅ to skip",
570            "Done ✅",
571            // The marker's rendered form, or a status line: text.
572            "#5366 is ready.\n\n✅ merge #5366 · ⏸ hold",
573            "✅ CI green · ❌ e2e red",
574            "",
575        ] {
576            assert!(detect(s).is_none(), "{s}");
577        }
578    }
579
580    #[test]
581    fn rendered_forms() {
582        for s in [
583            "#5366 is ready. CI is green.\n\n✅ merge #5366 · ⏸ hold",
584            "✅ merge both · 👀 merge #5297 only · ⏸ hold",
585        ] {
586            assert!(rendered_only(s), "{s}");
587        }
588        for s in [
589            "CI:\n✅ merge #5366 · ⏸ hold",
590            "Shipped ✅ merge #5366 · ⏸ hold",
591            "✅ lint · ✅ tests",
592            "✅ a · ❌ b · 👍 c · 👎 d · 🕐 e",
593            "Deploy? [ask: ✅ deploy | ❌ skip]",
594            "Done ✅",
595            "",
596        ] {
597            assert!(!rendered_only(s), "{s}");
598        }
599        // A press quotes the ask in marker form.
600        let (t, a) = take_marker("Close #5293?\n[ask: ✅ close #5293 | ⏸ leave it]").unwrap();
601        assert_eq!(t, "Close #5293?\n✅ close #5293 · ⏸ leave it");
602        assert_eq!(a.marker(), "[ask: ✅ close #5293 | ⏸ leave it]");
603        assert_eq!(
604            a.quoted(&t),
605            "Close #5293?\n[ask: ✅ close #5293 | ⏸ leave it]"
606        );
607        // A safety-net ask has no fallback in its text: as is.
608        let a = detect("Ship it? 👍/👎").unwrap();
609        assert_eq!(a.quoted("Ship it? 👍/👎"), "Ship it? 👍/👎");
610    }
611
612    #[test]
613    fn plans() {
614        let m = |t: &str| (Marks::default(), t.to_owned());
615        // A marker paragraph inside a merged part goes on its own.
616        let first = Marks {
617            reply: Some("$r".into()),
618        };
619        let p = plan(vec![
620            (
621                first.clone(),
622                "Built.\n\nDeploy? [ask: ✅ deploy | ❌ skip]\n\nMore after.".into(),
623            ),
624            m("Last one ✅ to a, ❌ to b"),
625        ]);
626        let texts: Vec<_> = p
627            .iter()
628            .map(|(_, t, a)| (t.as_str(), a.is_some()))
629            .collect();
630        assert_eq!(
631            texts,
632            [
633                ("Built.", false),
634                ("Deploy? ✅ deploy · ❌ skip", true),
635                ("More after.", false),
636                // A marker anywhere: no auto-detect.
637                ("Last one ✅ to a, ❌ to b", false),
638            ]
639        );
640        assert_eq!(p[0].0, first);
641        assert_eq!(p[1].0, Marks::default());
642        // No marker: the last part only.
643        let p = plan(vec![m("✅ to a, ❌ to b"), m("Ship it? 👍/👎")]);
644        assert!(p[0].2.is_none());
645        assert_eq!(p[1].2.as_ref().unwrap().seeds(), ["👍", "👎"]);
646        assert!(plan(vec![m("Plain.")])[0].2.is_none());
647        // A blank line inside a fence doesn't split it.
648        let p = plan(vec![m(
649            "```\n[ask: ✅ a | ❌ b]\n\nx\n```\n\nGo? [ask: ✅ go | ❌ no]",
650        )]);
651        assert_eq!(p.len(), 2);
652        assert!(p[0].2.is_none() && p[1].2.is_some());
653        assert_eq!(p[0].1, "```\n[ask: ✅ a | ❌ b]\n\nx\n```");
654    }
655
656    #[test]
657    fn splits_paragraphs() {
658        assert_eq!(paragraphs("a\nb\n\n\nc"), ["a\nb", "c"]);
659        assert_eq!(
660            paragraphs("x\n~~~~\n\n```\n~~~\n\n~~~~\ny"),
661            ["x\n~~~~\n\n```\n~~~\n\n~~~~\ny"]
662        );
663        assert!(paragraphs("\n\n").is_empty());
664    }
665}