use anyhow::{Result, bail};
use serde::Serialize;
use crate::db::{Db, Found, VersionFacts};
#[derive(Debug, Serialize)]
pub struct Why {
pub project: String,
pub version: VersionFacts,
pub after: Option<VersionFacts>,
pub events: Vec<Found>,
}
pub fn why(db: &Db, project: &crate::db::Project, version: &str) -> Result<Why> {
let Some((version, after)) = db.version_and_predecessor(project.id, version)? else {
bail!("no version '{version}' recorded for {}; `rigger context` lists what there is", project.name);
};
let from = after.as_ref().and_then(bound_after);
let until = bound_until(&version);
let events = db.events_between(project.id, from.as_deref(), until.as_deref())?;
Ok(Why {
project: project.name.clone(),
version,
after,
events,
})
}
pub fn as_fts_query(query: &str) -> String {
let query = query.trim();
let has_syntax = query.contains(['"', '*', '(', ')', ':', '^', '-']) || query.split_whitespace().any(|w| matches!(w, "AND" | "OR" | "NOT" | "NEAR"));
if has_syntax || query.is_empty() {
return query.to_string();
}
query.split_whitespace().map(|word| format!("{word}*")).collect::<Vec<_>>().join(" ")
}
pub fn render_event(event: &Found, show_project: bool) -> String {
let mut out = String::new();
if show_project {
out.push_str(&format!("{:<12} ", truncate(&event.project, 12)));
}
out.push_str(&format!("{} {:<9} ", event.date, event.kind));
out.push_str(&one_line(&event.body));
out.push('\n');
out
}
fn one_line(body: &str) -> String {
let first = body.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim();
truncate(first, 96)
}
fn truncate(text: &str, limit: usize) -> String {
if text.chars().count() <= limit {
return text.to_string();
}
let cut: String = text.chars().take(limit.saturating_sub(1)).collect();
format!("{}…", cut.trim_end())
}
pub fn nothing_found(query: &str, project: Option<&str>, kind: Option<&str>) -> String {
let mut out = format!("Nothing matches {query:?}");
match (project, kind) {
(Some(p), Some(k)) => out.push_str(&format!(" in {p}, among {k} events")),
(Some(p), None) => out.push_str(&format!(" in {p}")),
(None, Some(k)) => out.push_str(&format!(" among {k} events")),
(None, None) => {}
}
out.push_str(".\nA bare word is searched as a prefix: try a shorter one, or FTS5 syntax - \"exact phrase\", `one OR two`.");
out
}
fn bound_after(v: &VersionFacts) -> Option<String> {
match (&v.shipped_ts, &v.shipped_at) {
(Some(ts), _) => Some(ts.clone()),
(None, Some(day)) => Some(format!("{day}T23:59:59Z")),
(None, None) => None,
}
}
fn bound_until(v: &VersionFacts) -> Option<String> {
match (&v.shipped_ts, &v.shipped_at) {
(Some(ts), _) => Some(ts.clone()),
(None, Some(day)) => Some(format!("{day}T23:59:59Z")),
(None, None) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(kind: &str, body: &str) -> Found {
Found {
project: "sample".into(),
kind: kind.into(),
date: "2026-09-04".into(),
body: body.into(),
from_git: false,
commit_hash: None,
}
}
#[test]
fn an_event_is_one_line_however_long_the_body() {
let long = event("decision", &"слово ".repeat(400));
let line = render_event(&long, true);
assert_eq!(line.lines().count(), 1, "{line}");
assert!(line.chars().count() < 140, "{} chars: {line}", line.chars().count());
assert!(line.contains('…'), "a cut line must say it was cut: {line}");
}
#[test]
fn the_first_line_is_what_identifies_an_event() {
let e = event("decision", "**The record is the database.**\n\nBecause prose cannot be filtered.");
let line = render_event(&e, false);
assert!(line.contains("The record is the database."), "{line}");
assert!(!line.contains("Because prose"), "{line}");
}
#[test]
fn a_leading_blank_line_does_not_produce_an_empty_result() {
let e = event("finding", "\n\nThe thing that was learnt.");
assert!(render_event(&e, false).contains("The thing that was learnt."));
}
#[test]
fn truncation_counts_characters_not_bytes() {
let text = "я".repeat(200);
let cut = truncate(&text, 96);
assert_eq!(cut.chars().count(), 96);
}
#[test]
fn a_bare_word_becomes_a_prefix_search() {
assert_eq!(as_fts_query("миграция"), "миграция*");
assert_eq!(as_fts_query("budget"), "budget*");
assert_eq!(as_fts_query("two words"), "two* words*");
assert_eq!(as_fts_query(" spaced "), "spaced*");
}
#[test]
fn a_query_that_uses_the_syntax_is_left_alone() {
for query in ["\"exact phrase\"", "budget AND packet", "миграц*", "packet NOT commit", "body:decision"] {
assert_eq!(as_fts_query(query), query, "{query} must pass through untouched");
}
}
#[test]
fn nothing_found_says_where_it_looked() {
let msg = nothing_found("budget", Some("kasl"), Some("decision"));
assert!(msg.contains("kasl") && msg.contains("decision"), "{msg}");
assert!(msg.contains("prefix"), "{msg}");
assert!(msg.contains("syntax"), "{msg}");
}
}