1use std::collections::BTreeMap;
11use std::fmt;
12
13use serde::de::{self, Deserializer, Visitor};
14use serde::{Deserialize, Serialize, Serializer};
15
16fn norm_token(text: &str) -> String {
17 text.trim()
18 .to_lowercase()
19 .chars()
20 .filter(|c| c.is_ascii_alphanumeric())
21 .collect()
22}
23
24macro_rules! string_enum {
25 (
26 $(#[$meta:meta])*
27 pub enum $name:ident {
28 $( $(#[$vmeta:meta])* $variant:ident = $canonical:literal $( | $alias:literal )* ),+ $(,)?
29 }
30 ) => {
31 $(#[$meta])*
32 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33 pub enum $name { $( $(#[$vmeta])* $variant ),+ }
34
35 impl $name {
36 pub fn as_str(self) -> &'static str {
37 match self { $( $name::$variant => $canonical ),+ }
38 }
39
40 pub fn parse_lenient(text: &str) -> Option<Self> {
43 let got = norm_token(text);
44 $(
45 if got == norm_token($canonical) $( || got == norm_token($alias) )* {
46 return Some($name::$variant);
47 }
48 )+
49 None
50 }
51
52 pub fn valid_values() -> String {
53 [$( $canonical ),+].join(", ")
54 }
55 }
56
57 impl fmt::Display for $name {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(self.as_str())
60 }
61 }
62
63 impl Serialize for $name {
64 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
65 s.serialize_str(self.as_str())
66 }
67 }
68
69 impl<'de> Deserialize<'de> for $name {
70 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
71 let raw = String::deserialize(d)?;
72 $name::parse_lenient(&raw).ok_or_else(|| {
73 de::Error::custom(format!(
74 "{} is not one of: {}",
75 raw,
76 $name::valid_values()
77 ))
78 })
79 }
80 }
81 };
82}
83
84string_enum! {
85 pub enum Complexity {
87 S = "s" | "small" | "sm" | "xs" | "trivial",
88 M = "m" | "medium" | "med" | "moderate",
89 L = "l" | "large" | "lg" | "xl" | "big" | "huge",
90 }
91}
92
93string_enum! {
94 pub enum Risk {
96 Low = "low" | "l" | "minimal" | "none",
97 Med = "med" | "medium" | "m" | "moderate",
98 High = "high" | "h" | "severe" | "critical",
99 }
100}
101
102string_enum! {
103 pub enum Severity {
108 Blocking = "blocking" | "block" | "major" | "critical",
109 NonBlocking = "non-blocking" | "nonblocking" | "non_blocking" | "minor" | "suggestion",
110 Nit = "nit" | "nitpick" | "style" | "trivial",
111 }
112}
113
114string_enum! {
115 pub enum Verdict {
116 Approve = "approve" | "approved" | "lgtm",
117 ChangesRequested = "changes_requested" | "changes-requested" | "request_changes" | "reject",
118 }
119}
120
121string_enum! {
122 pub enum NextAction {
123 Merge = "merge" | "approve" | "ship",
124 FixMyself = "fix_myself" | "fix-myself" | "fix" | "self_fix",
125 HandBack = "hand_back" | "hand-back" | "handback" | "return",
126 }
127}
128
129string_enum! {
130 pub enum Action {
134 Fixed = "fixed" | "fix" | "accepted" | "done",
135 Refuted = "refuted" | "refute" | "rejected" | "disagree" | "wontfix",
136 FiledIssue = "filed_issue" | "filed-issue" | "filed" | "deferred" | "out_of_scope",
137 }
138}
139
140string_enum! {
141 pub enum Status {
143 Pending = "pending",
144 Abandoned = "abandoned",
145 Approved = "approved",
146 Merged = "merged",
147 Escalated = "escalated",
148 Error = "error",
149 Reviewed = "reviewed",
151 Clean = "clean",
153 }
154}
155
156impl Complexity {
157 pub fn rank(self) -> u8 {
158 match self {
159 Complexity::S => 0,
160 Complexity::M => 1,
161 Complexity::L => 2,
162 }
163 }
164}
165
166impl Severity {
167 pub fn rank(self) -> u8 {
171 match self {
172 Severity::Nit => 0,
173 Severity::NonBlocking => 1,
174 Severity::Blocking => 2,
175 }
176 }
177
178 pub fn graver(self, other: Self) -> Self {
185 if self.rank() >= other.rank() {
186 self
187 } else {
188 other
189 }
190 }
191}
192
193impl Risk {
194 pub fn rank(self) -> u8 {
195 match self {
196 Risk::Low => 0,
197 Risk::Med => 1,
198 Risk::High => 2,
199 }
200 }
201}
202
203pub fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
210 struct V;
211 impl<'de> Visitor<'de> for V {
212 type Value = i64;
213 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214 f.write_str("an issue number")
215 }
216 fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
217 Ok(v)
218 }
219 fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
220 Ok(v as i64)
221 }
222 fn visit_f64<E: de::Error>(self, v: f64) -> Result<i64, E> {
223 Ok(v as i64)
224 }
225 fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
226 v.trim()
227 .trim_start_matches('#')
228 .parse()
229 .map_err(|_| E::custom(format!("{v} is not a number")))
230 }
231 }
232 d.deserialize_any(V)
233}
234
235fn de_i64_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<i64>, D::Error> {
236 #[derive(Deserialize)]
237 struct One(#[serde(deserialize_with = "de_i64")] i64);
238 let raw = Option::<Vec<One>>::deserialize(d)?;
239 Ok(raw
240 .unwrap_or_default()
241 .into_iter()
242 .map(|One(n)| n)
243 .collect())
244}
245
246pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
248 struct V;
249 impl<'de> Visitor<'de> for V {
250 type Value = bool;
251 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252 f.write_str("a boolean")
253 }
254 fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
255 Ok(v)
256 }
257 fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
258 Ok(v != 0)
259 }
260 fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
261 Ok(v != 0)
262 }
263 fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
264 match norm_token(v).as_str() {
265 "true" | "yes" | "y" | "1" => Ok(true),
266 "false" | "no" | "n" | "0" => Ok(false),
267 other => Err(E::custom(format!("{other} is not a boolean"))),
268 }
269 }
270 }
271 d.deserialize_any(V)
272}
273
274fn de_bool_default_true<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
275 #[derive(Deserialize)]
276 struct Wrap(#[serde(deserialize_with = "de_bool")] bool);
277 Ok(Option::<Wrap>::deserialize(d)?
278 .map(|Wrap(b)| b)
279 .unwrap_or(true))
280}
281
282fn de_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
283 Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct TriageVerdict {
292 #[serde(deserialize_with = "de_i64")]
293 pub issue: i64,
294 #[serde(deserialize_with = "de_bool")]
295 pub worth_doing: bool,
296 #[serde(default, deserialize_with = "de_bool")]
299 pub tracker: bool,
300 #[serde(default, deserialize_with = "de_string")]
301 pub reason: String,
302 pub complexity: Complexity,
303 #[serde(default, deserialize_with = "de_i64_vec")]
304 pub depends_on: Vec<i64>,
305 pub risk: Risk,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct TriageResponse {
310 #[serde(default)]
311 pub issues: Vec<TriageVerdict>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct Finding {
316 pub severity: Severity,
317 #[serde(default, deserialize_with = "de_string")]
318 pub title: String,
319 #[serde(default, deserialize_with = "de_string")]
320 pub detail: String,
321 #[serde(default, deserialize_with = "de_string")]
322 pub file: String,
323 #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
326 pub in_scope: bool,
327
328 #[serde(default)]
336 pub problem: Option<String>,
337 #[serde(default)]
339 pub reproduction: Option<String>,
340 #[serde(default)]
342 pub impact: Option<String>,
343 #[serde(default)]
345 pub expected: Option<String>,
346}
347
348impl Default for Finding {
349 fn default() -> Self {
356 Self {
357 severity: Severity::Nit,
358 title: String::new(),
359 detail: String::new(),
360 file: String::new(),
361 in_scope: true,
362 problem: None,
363 reproduction: None,
364 impact: None,
365 expected: None,
366 }
367 }
368}
369
370impl Finding {
371 pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
374 [
375 ("Problem", self.problem.as_deref()),
376 ("Reproduction", self.reproduction.as_deref()),
377 ("Impact", self.impact.as_deref()),
378 ("Expected behavior", self.expected.as_deref()),
379 ]
380 .into_iter()
381 .filter_map(|(heading, text)| {
382 text.map(str::trim)
383 .filter(|t| !t.is_empty())
384 .map(|t| (heading, t))
385 })
386 .collect()
387 }
388}
389
390fn yes() -> bool {
391 true
392}
393
394impl Finding {
395 pub fn blocks(&self) -> bool {
396 self.severity == Severity::Blocking && self.in_scope
397 }
398
399 pub fn where_at(&self) -> &str {
400 if self.file.trim().is_empty() {
401 "general"
402 } else {
403 self.file.trim()
404 }
405 }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct Review {
410 pub verdict: Verdict,
411 pub next_action: NextAction,
412 #[serde(default, deserialize_with = "de_string")]
413 pub summary: String,
414 #[serde(default)]
415 pub findings: Vec<Finding>,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct Disposition {
420 #[serde(default, deserialize_with = "de_string")]
421 pub title: String,
422 #[serde(default, deserialize_with = "de_string")]
426 pub file: String,
427 pub action: Action,
428 #[serde(default, deserialize_with = "de_string")]
429 pub reasoning: String,
430 #[serde(default)]
431 pub new_issue_title: Option<String>,
432 #[serde(default)]
433 pub new_issue_body: Option<String>,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct Adjudication {
444 #[serde(default, deserialize_with = "de_string")]
445 pub title: String,
446 #[serde(default, deserialize_with = "de_string")]
447 pub file: String,
448 #[serde(deserialize_with = "de_bool")]
451 pub agrees: bool,
452 pub severity: Severity,
454 #[serde(default, deserialize_with = "de_string")]
455 pub reasoning: String,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct AdjudicationDoc {
460 #[serde(default)]
461 pub verdicts: Vec<Adjudication>,
462}
463
464#[derive(Debug, Clone)]
466pub struct Judged {
467 pub finding: Finding,
468 pub raised_by: String,
470 pub standing: Standing,
472 pub counterpoint: Option<String>,
474 pub defence: Option<String>,
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum Standing {
483 Corroborated,
485 Confirmed,
487 Disputed,
490 Withdrawn,
492 Unverified,
494}
495
496#[derive(Debug, Clone, Default, Serialize, Deserialize)]
505pub struct Implementation {
506 #[serde(default, deserialize_with = "de_bool")]
508 pub not_worth_doing: bool,
509 #[serde(default, deserialize_with = "de_string")]
512 pub reason: String,
513 #[serde(default, deserialize_with = "de_string")]
515 pub summary: String,
516 #[serde(default, deserialize_with = "de_string")]
519 pub problem: String,
520 #[serde(default)]
522 pub changes: Vec<String>,
523 #[serde(default)]
525 pub testing: Vec<String>,
526 #[serde(default)]
529 pub notes: Option<String>,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct ResponseDoc {
534 #[serde(default, deserialize_with = "de_string")]
535 pub summary: String,
536 #[serde(default)]
537 pub dispositions: Vec<Disposition>,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize)]
545pub struct PlanItem {
546 pub issue: i64,
547 pub title: String,
548 pub complexity: Complexity,
549 pub risk: Risk,
550 pub depends_on: Vec<i64>,
551 pub reason: String,
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct SkippedItem {
556 pub issue: i64,
557 pub title: String,
558 pub reasons: BTreeMap<String, String>,
560 #[serde(default)]
568 pub tracker: bool,
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize)]
572pub struct ContestedItem {
573 pub issue: i64,
574 pub title: String,
575 pub positions: BTreeMap<String, String>,
577 pub reasons: BTreeMap<String, String>,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub note: Option<String>,
580}
581
582#[derive(Debug, Clone, Default, Serialize, Deserialize)]
583pub struct Plan {
584 #[serde(default)]
585 pub order: Vec<PlanItem>,
586 #[serde(default)]
587 pub skipped: Vec<SkippedItem>,
588 #[serde(default)]
589 pub contested: Vec<ContestedItem>,
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct LedgerEntry {
594 pub title: String,
595 pub file: String,
596 pub reasoning: String,
597 pub round: u32,
598 #[serde(default)]
599 pub reraised: u32,
600}
601
602pub type Ledger = BTreeMap<String, LedgerEntry>;
606
607#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct Dispute {
609 pub title: String,
610 pub reasoning: String,
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct IssueRun {
616 pub issue: i64,
617 pub title: String,
618 pub status: Status,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub pr: Option<String>,
621 #[serde(default)]
622 pub rounds: u32,
623 #[serde(default)]
624 pub disputes: Vec<Dispute>,
625 #[serde(default)]
626 pub filed: Vec<String>,
627 #[serde(default)]
628 pub notes: Vec<String>,
629}
630
631impl IssueRun {
632 pub fn new(issue: i64, title: impl Into<String>) -> Self {
633 Self {
634 issue,
635 title: title.into(),
636 status: Status::Pending,
637 pr: None,
638 rounds: 0,
639 disputes: Vec::new(),
640 filed: Vec::new(),
641 notes: Vec::new(),
642 }
643 }
644
645 pub fn succeeded(&self) -> bool {
650 matches!(
651 self.status,
652 Status::Merged
653 | Status::Approved
654 | Status::Abandoned
655 | Status::Reviewed
656 | Status::Clean
657 )
658 }
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize)]
663pub struct PersistedState {
664 pub version: u32,
665 pub round: u32,
666 pub next_actor: String,
667 pub status: Status,
668 #[serde(default)]
669 pub ledger: Ledger,
670 #[serde(default)]
671 pub filed: Vec<String>,
672}
673
674pub const STATE_VERSION: u32 = 1;
675
676#[derive(Debug, Clone, Deserialize)]
681pub struct Label {
682 #[serde(default)]
683 pub name: String,
684}
685
686#[derive(Debug, Clone, Deserialize)]
687pub struct Issue {
688 pub number: i64,
689 #[serde(default)]
690 pub title: String,
691 #[serde(default)]
692 pub body: Option<String>,
693 #[serde(default)]
694 pub state: String,
695 #[serde(default)]
696 pub url: String,
697 #[serde(default)]
698 pub labels: Vec<Label>,
699}
700
701impl Issue {
702 pub fn body_text(&self) -> &str {
703 self.body.as_deref().unwrap_or("")
704 }
705
706 pub fn body_for_prompt(&self, max: usize) -> (String, bool) {
718 let body = self.body_text().trim();
719 if body.chars().count() <= max {
720 return (body.to_string(), false);
721 }
722 let clipped: String = body.chars().take(max).collect();
723 let mut kept = match clipped.rfind('\n') {
724 Some(at) => clipped[..at].to_string(),
725 None => clipped,
726 };
727 if kept.matches("```").count() % 2 == 1 {
728 kept.push_str("\n```");
729 }
730 kept.push_str("\n\n[Shortened to fit. The rest of this issue was not included.]");
731 (kept, true)
732 }
733
734 pub fn is_closed(&self) -> bool {
735 self.state.eq_ignore_ascii_case("closed")
736 }
737}
738
739#[derive(Debug, Clone, Deserialize)]
740pub struct PrRef {
741 pub number: i64,
742 #[serde(default)]
743 pub url: String,
744 #[serde(default)]
745 pub title: String,
746}
747
748#[derive(Debug, Clone, Deserialize)]
749pub struct IssueRef {
750 pub number: i64,
751}
752
753#[derive(Debug, Clone, Deserialize)]
754#[serde(rename_all = "camelCase")]
755pub struct PrView {
756 pub number: i64,
757 #[serde(default)]
758 pub url: String,
759 #[serde(default)]
760 pub title: String,
761 #[serde(default)]
762 pub head_ref_name: String,
763 #[serde(default)]
764 pub base_ref_name: String,
765 #[serde(default)]
766 pub state: String,
767 #[serde(default)]
768 pub closing_issues_references: Vec<IssueRef>,
769 #[serde(default)]
772 pub is_cross_repository: bool,
773}
774
775#[derive(Debug, Clone, Copy, PartialEq, Eq)]
778pub enum ItemKind {
779 Issue,
780 Pr,
781}
782
783impl std::fmt::Display for ItemKind {
784 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
785 f.write_str(match self {
786 ItemKind::Issue => "issue",
787 ItemKind::Pr => "pull request",
788 })
789 }
790}
791
792impl PrView {
793 pub fn is_open(&self) -> bool {
794 self.state.eq_ignore_ascii_case("open")
795 }
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801
802 #[test]
803 fn severity_accepts_the_canonical_spelling() {
804 assert_eq!(
805 Some(Severity::NonBlocking),
806 Severity::parse_lenient("non-blocking")
807 );
808 }
809
810 #[test]
811 fn severity_accepts_near_misses() {
812 for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
813 assert_eq!(
814 Some(Severity::NonBlocking),
815 Severity::parse_lenient(text),
816 "{text}"
817 );
818 }
819 }
820
821 #[test]
822 fn severity_rejects_nonsense() {
823 assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
824 }
825
826 #[test]
827 fn complexity_ordering_is_cheapest_first() {
828 assert!(Complexity::S.rank() < Complexity::M.rank());
829 assert!(Complexity::M.rank() < Complexity::L.rank());
830 }
831
832 #[test]
833 fn finding_defaults_to_in_scope() {
834 let f: Finding = serde_json::from_value(serde_json::json!({
835 "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
836 }))
837 .unwrap();
838 assert!(f.in_scope);
839 assert!(f.blocks());
840 }
841
842 #[test]
843 fn out_of_scope_blocking_does_not_block() {
844 let f: Finding = serde_json::from_value(serde_json::json!({
845 "severity": "blocking", "title": "t", "detail": "d",
846 "file": "a.rs", "in_scope": false
847 }))
848 .unwrap();
849 assert!(!f.blocks());
850 }
851
852 #[test]
853 fn triage_tolerates_a_quoted_issue_number() {
854 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
855 "issue": "#42", "worth_doing": "yes", "reason": "r",
856 "complexity": "medium", "depends_on": ["39"], "risk": "low"
857 }))
858 .unwrap();
859 assert_eq!(42, v.issue);
860 assert!(v.worth_doing);
861 assert_eq!(Complexity::M, v.complexity);
862 assert_eq!(vec![39], v.depends_on);
863 }
864
865 #[test]
866 fn triage_tolerates_a_missing_depends_on() {
867 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
868 "issue": 1, "worth_doing": false, "reason": "r",
869 "complexity": "s", "risk": "high"
870 }))
871 .unwrap();
872 assert!(v.depends_on.is_empty());
873 }
874
875 #[test]
876 fn review_tolerates_a_missing_findings_array() {
877 let r: Review = serde_json::from_value(serde_json::json!({
878 "verdict": "approve", "next_action": "merge", "summary": "fine"
879 }))
880 .unwrap();
881 assert!(r.findings.is_empty());
882 }
883
884 #[test]
885 fn a_null_reason_is_an_empty_string_not_a_failure() {
886 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
887 "issue": 1, "worth_doing": true, "reason": null,
888 "complexity": "s", "depends_on": [], "risk": "low"
889 }))
890 .unwrap();
891 assert_eq!("", v.reason);
892 }
893
894 #[test]
895 fn unknown_severity_is_an_error_not_a_silent_downgrade() {
896 let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
897 "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
898 }));
899 assert!(out.is_err());
900 }
901
902 #[test]
903 fn status_round_trips_through_json() {
904 let run = IssueRun::new(4, "t");
905 let text = serde_json::to_string(&run).unwrap();
906 let back: IssueRun = serde_json::from_str(&text).unwrap();
907 assert_eq!(Status::Pending, back.status);
908 }
909}
910
911#[cfg(test)]
912mod body_for_prompt_tests {
913 use super::*;
914
915 fn issue(body: &str) -> Issue {
916 let mut i: Issue = serde_json::from_value(serde_json::json!({
917 "number": 1, "title": "t", "state": "open", "url": "u"
918 }))
919 .expect("an issue");
920 i.body = Some(body.to_string());
921 i
922 }
923
924 #[test]
927 fn an_issue_that_fits_is_handed_over_whole() {
928 let (body, cut) = issue("The guard is inverted.").body_for_prompt(60_000);
929 assert_eq!("The guard is inverted.", body);
930 assert!(!cut);
931 }
932
933 #[test]
937 fn a_shortened_body_says_so_in_the_text() {
938 let long = "line of text\n".repeat(500);
939 let (body, cut) = issue(&long).body_for_prompt(200);
940 assert!(cut);
941 assert!(body.contains("Shortened to fit"), "{body}");
942 assert!(body.len() < long.len());
943 }
944
945 #[test]
948 fn a_cut_never_leaves_a_code_fence_open() {
949 let body = format!("intro\n\n```rust\n{}\n```\n", "let x = 1;\n".repeat(200));
950 let (out, cut) = issue(&body).body_for_prompt(120);
951 assert!(cut);
952 assert_eq!(0, out.matches("```").count() % 2, "{out}");
953 }
954
955 #[test]
957 fn a_cut_lands_on_a_line_boundary() {
958 let body = "aaaa bbbb cccc\n".repeat(100);
959 let (out, _) = issue(&body).body_for_prompt(100);
960 let kept = out.split("\n\n[Shortened").next().expect("the kept part");
961 assert!(kept.ends_with("cccc"), "{kept:?}");
962 }
963}