difflore_cli/hooks/session_banner/render.rs
1//! Banner text formatter.
2//!
3//! Produces a multi-line, ≤6-line / ≤400-char string the adapters
4//! append to whatever `additional_context` they already produce. The
5//! shape mirrors the spec in the original feature request:
6//!
7//! ```text
8//! DiffLore: 2 new rules learned for this repo since 2026-05-20T14:30:00Z
9//! · Return 413 for body size limit errors ← from review by alice (PR #88)
10//! · Wrap context cancellation in errgroup ← from PR merge signature
11//! Run `difflore status` to see the value loop.
12//! ```
13//!
14//! Each bullet is at most ~80 chars (60-char title cap + provenance
15//! suffix). The closing call-to-action is fixed so the agent can learn
16//! to discover the status surface from any session.
17
18use super::query::NewRule;
19
20/// Max chars in a rendered rule title before we truncate with `…`.
21/// 60 was chosen by walking real rule names in the corpus: anything
22/// longer than this is usually a wrapped sentence and reads poorly as
23/// a bullet anyway.
24const TITLE_TRUNCATE_AT: usize = 60;
25
26/// Cap on the entire banner string. Spec calls for ≤ 400 chars; this
27/// constant is a hard ceiling we enforce *after* assembly so a future
28/// rule that smuggles a 300-char title can't overflow the budget.
29const BANNER_MAX_CHARS: usize = 400;
30
31/// Build the banner from rule rows + the previous-session label.
32/// `prev_label` is either an RFC-3339 timestamp ("2026-05-20T14:30:00Z")
33/// or the synthetic phrase "the start of this repo" used on first
34/// session — formatter doesn't care which, it just inlines whatever
35/// the caller computed.
36///
37/// Returns a single string with embedded `\n`s. Trailing newline is
38/// intentionally absent: the adapters append directly to other context
39/// blocks and the caller can add separators.
40pub fn format_banner(rules: &[NewRule], prev_label: &str) -> String {
41 // Header line: pluralise "rule" / "rules" so the banner reads
42 // naturally on a single new rule too.
43 let count = rules.len();
44 let rule_word = if count == 1 { "rule" } else { "rules" };
45 let mut out =
46 format!("DiffLore: {count} new {rule_word} learned for this repo since {prev_label}");
47
48 for rule in rules {
49 let title = truncate_title(&rule.title);
50 let provenance = provenance_phrase(&rule.origin);
51 // The middle dot `·` matches the bullet style elsewhere in
52 // DiffLore's CLI output (e.g. `difflore status`'s value-loop
53 // table) so the banner doesn't visually clash on copy/paste.
54 out.push('\n');
55 out.push_str(" · ");
56 out.push_str(&title);
57 out.push_str(" ← ");
58 out.push_str(provenance);
59 }
60
61 out.push_str("\nRun `difflore status` to see the value loop.");
62
63 // Hard ceiling. We'd rather emit a truncated banner with an
64 // ellipsis than blow past the agent's prompt-window budget. In
65 // practice this rarely triggers: 5 rules × 80 chars + header +
66 // CTA is well under 400. We compare bytes (`len()`) here because
67 // the budget is measured against agent token-window cost, which
68 // is byte-driven for UTF-8 corpora.
69 //
70 // Truncate via `char_indices` so we never split mid-codepoint
71 // (which would panic in `String::truncate`). Reserve 3 bytes for
72 // the U+2026 ellipsis we append.
73 const ELLIPSIS_BYTES: usize = '…'.len_utf8();
74 if out.len() > BANNER_MAX_CHARS {
75 let cap = BANNER_MAX_CHARS.saturating_sub(ELLIPSIS_BYTES);
76 let cut = out
77 .char_indices()
78 .take_while(|(idx, _)| *idx <= cap)
79 .last()
80 .map_or(0, |(idx, _)| idx);
81 out.truncate(cut);
82 out.push('…');
83 }
84 out
85}
86
87/// Truncate `s` to `TITLE_TRUNCATE_AT` chars (counted by chars, not
88/// bytes — multibyte titles like Japanese rule names wouldn't survive
89/// a byte-truncate). Appends `…` when truncation actually fired.
90fn truncate_title(s: &str) -> String {
91 let trimmed = s.trim();
92 if trimmed.chars().count() <= TITLE_TRUNCATE_AT {
93 return trimmed.to_owned();
94 }
95 let mut out: String = trimmed
96 .chars()
97 .take(TITLE_TRUNCATE_AT.saturating_sub(1))
98 .collect();
99 out.push('…');
100 out
101}
102
103/// Map a `skills.origin` enum value to the bullet's "← from …" phrase.
104/// Unknown origins fall through to a generic phrasing so a new origin
105/// value introduced cloud-side doesn't break the banner.
106fn provenance_phrase(origin: &str) -> &'static str {
107 match origin {
108 "pr_review" => "from a PR review",
109 "conversation" => "from agent chat (`remember_rule`)",
110 "extracted" => "from cross-repo pattern mining",
111 "manual" => "added manually",
112 _ => "newly learned",
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::hooks::session_banner::query::NewRule;
120
121 fn rule(title: &str, origin: &str) -> NewRule {
122 NewRule {
123 title: title.to_owned(),
124 origin: origin.to_owned(),
125 source_repo: Some("acme/billing".to_owned()),
126 }
127 }
128
129 #[test]
130 fn header_pluralises_correctly_and_includes_watermark() {
131 let one = format_banner(&[rule("Return 413", "pr_review")], "2026-05-20T14:30:00Z");
132 assert!(one.contains("1 new rule learned"), "got: {one}");
133 assert!(one.contains("2026-05-20T14:30:00Z"));
134
135 let many = format_banner(
136 &[
137 rule("Return 413", "pr_review"),
138 rule("Wrap errgroup", "extracted"),
139 ],
140 "the start of this repo",
141 );
142 assert!(many.contains("2 new rules learned"), "got: {many}");
143 assert!(many.contains("the start of this repo"));
144 }
145
146 #[test]
147 fn provenance_phrases_match_origin() {
148 let rules = [
149 rule("a", "pr_review"),
150 rule("b", "conversation"),
151 rule("c", "extracted"),
152 rule("d", "manual"),
153 rule("e", "future-origin-we-dont-know"),
154 ];
155 let out = format_banner(&rules, "1970-01-01T00:00:00Z");
156 assert!(out.contains("from a PR review"));
157 assert!(out.contains("from agent chat"));
158 assert!(out.contains("from cross-repo pattern mining"));
159 assert!(out.contains("added manually"));
160 assert!(out.contains("newly learned"));
161 }
162
163 #[test]
164 fn long_title_is_truncated_with_ellipsis() {
165 // 200-char title — would push the bullet past 60 chars by a wide margin.
166 let long_title = "x".repeat(200);
167 let r = rule(&long_title, "pr_review");
168 let out = format_banner(&[r], "1970-01-01T00:00:00Z");
169 // Truncated form ends with the U+2026 horizontal ellipsis.
170 assert!(out.contains('…'), "expected ellipsis, got: {out}");
171 // Title itself must not exceed the cap (allow the ellipsis).
172 let bullet_line = out.lines().find(|l| l.starts_with(" · ")).expect("bullet");
173 let title_part = bullet_line
174 .trim_start_matches(" · ")
175 .split(" ← ")
176 .next()
177 .unwrap_or("");
178 assert!(
179 title_part.chars().count() <= TITLE_TRUNCATE_AT,
180 "title overran cap: {title_part:?}"
181 );
182 }
183
184 #[test]
185 fn banner_includes_call_to_action_and_obeys_size_cap() {
186 let many = (0..5)
187 .map(|i| rule(&format!("Rule {i}"), "pr_review"))
188 .collect::<Vec<_>>();
189 let out = format_banner(&many, "2026-05-20T14:30:00Z");
190 assert!(out.contains("Run `difflore status`"), "missing CTA: {out}");
191 assert!(
192 out.len() <= BANNER_MAX_CHARS,
193 "banner overran {BANNER_MAX_CHARS} chars: {} bytes",
194 out.len()
195 );
196 // 5 bullets + header + CTA = 7 lines.
197 assert_eq!(out.lines().count(), 7);
198 }
199
200 #[test]
201 fn hard_cap_truncates_pathological_input() {
202 // Construct an input that, even after per-title truncation,
203 // would push past the 400-char ceiling. 5 bullets × 60 chars
204 // ≈ 300 base + envelope is below the cap, so use longer
205 // titles than the cap (and disable truncation by going via
206 // the formatter directly).
207 let huge_title = "y".repeat(80); // each bullet ~85 chars after envelope
208 let many = (0..5)
209 .map(|_| rule(&huge_title, "pr_review"))
210 .collect::<Vec<_>>();
211 let out = format_banner(&many, "2026-05-20T14:30:00Z");
212 // Hard ceiling holds.
213 assert!(
214 out.len() <= BANNER_MAX_CHARS,
215 "expected ≤ {BANNER_MAX_CHARS}, got {}",
216 out.len()
217 );
218 }
219}