Skip to main content

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}