use anyhow::{Context, Result};
use crate::card::Card;
#[derive(Debug, Clone)]
pub struct BranchSeen {
pub task_id: i64,
pub name: String,
pub remote: Option<String>,
pub tip: String,
pub tip_at: String,
pub subject: String,
}
#[derive(Debug, Clone)]
pub struct CommitSeen {
pub task_id: i64,
pub hash: String,
pub at: String,
pub subject: String,
}
#[derive(Debug, Default)]
pub struct Seen {
pub branches: Vec<BranchSeen>,
pub commits: Vec<CommitSeen>,
}
const READ_DEPTH: usize = 500;
pub fn needles(card: &Card) -> Vec<String> {
let mut out = vec![card.key.to_ascii_uppercase()];
for alias in &card.aliases {
let alias = alias.trim();
let is_an_id = !alias.chars().any(char::is_whitespace)
&& alias.chars().any(|c| c.is_ascii_alphabetic())
&& alias.chars().any(|c| c.is_ascii_digit())
&& alias.contains(['-', '_']);
let upper = alias.to_ascii_uppercase();
if is_an_id && !out.contains(&upper) {
out.push(upper);
}
}
out
}
pub fn names(text: &str, name: &str) -> bool {
if name.is_empty() {
return false;
}
let hay = text.to_ascii_uppercase();
let needle = name.to_ascii_uppercase();
let bytes = hay.as_bytes();
let mut from = 0;
while let Some(at) = hay[from..].find(&needle) {
let start = from + at;
let end = start + needle.len();
let before = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
let after = end == bytes.len() || !bytes[end].is_ascii_alphanumeric();
if before && after {
return true;
}
from = start + 1;
while !hay.is_char_boundary(from) {
from += 1;
}
}
false
}
fn named(text: &str, cards: &[(i64, Vec<String>)]) -> Vec<i64> {
cards
.iter()
.filter(|(_, names_of)| names_of.iter().any(|n| names(text, n)))
.map(|(id, _)| *id)
.collect()
}
pub fn read(repo: &gix::Repository, cards: &[Card]) -> Result<Seen> {
let mut seen = Seen::default();
let wanted: Vec<(i64, Vec<String>)> = cards.iter().map(|c| (c.id, needles(c))).collect();
if wanted.is_empty() {
return Ok(seen);
}
let refs = repo.references().context("cannot read the repository's references")?;
let mut tips = Vec::new();
let mut branches: Vec<(String, Option<String>, gix::ObjectId)> = Vec::new();
for reference in refs.local_branches().context("cannot list branches")? {
let Ok(mut reference) = reference else { continue };
let name = reference.name().shorten().to_string();
let Ok(id) = reference.peel_to_id() else { continue };
branches.push((name, None, id.detach()));
}
for reference in refs.remote_branches().context("cannot list remote branches")? {
let Ok(mut reference) = reference else { continue };
let short = reference.name().shorten().to_string();
let Some((remote, name)) = short.split_once('/') else { continue };
if name == "HEAD" || branches.iter().any(|(n, _, _)| n == name) {
continue;
}
let Ok(id) = reference.peel_to_id() else { continue };
branches.push((name.to_string(), Some(remote.to_string()), id.detach()));
}
for (name, remote, tip) in &branches {
if !tips.contains(tip) {
tips.push(*tip);
}
let ids = named(name, &wanted);
if ids.is_empty() {
continue;
}
let Some((at, subject)) = commit_facts(repo, *tip) else { continue };
for task_id in ids {
seen.branches.push(BranchSeen {
task_id,
name: name.clone(),
remote: remote.clone(),
tip: tip.to_hex().to_string(),
tip_at: at.clone(),
subject: subject.clone(),
});
}
}
if tips.is_empty() {
return Ok(seen);
}
let walk = repo
.rev_walk(tips)
.sorting(gix::revision::walk::Sorting::ByCommitTime(Default::default()))
.all()
.context("cannot walk the history")?;
for step in walk.take(READ_DEPTH) {
let Ok(info) = step else { break };
let Ok(object) = repo.find_object(info.id) else { continue };
let Ok(commit) = object.try_into_commit() else { continue };
let Ok(message) = commit.message_raw() else { continue };
let message = message.to_string();
let ids = named(&message, &wanted);
if ids.is_empty() {
continue;
}
let at = commit.time().ok().map(|t| moment(t.seconds)).unwrap_or_default();
let subject = message.lines().next().unwrap_or_default().trim().to_string();
for task_id in ids {
seen.commits.push(CommitSeen {
task_id,
hash: info.id.to_hex().to_string(),
at: at.clone(),
subject: subject.clone(),
});
}
}
Ok(seen)
}
fn commit_facts(repo: &gix::Repository, id: gix::ObjectId) -> Option<(String, String)> {
let commit = repo.find_object(id).ok()?.try_into_commit().ok()?;
let at = moment(commit.time().ok()?.seconds);
let message = commit.message_raw().ok()?.to_string();
Some((at, message.lines().next().unwrap_or_default().trim().to_string()))
}
fn moment(seconds: i64) -> String {
jiff::Timestamp::from_second(seconds).map(|t| t.to_string()).unwrap_or_default()
}
pub fn days_since(at: &str) -> Option<i64> {
let then: jiff::Timestamp = at.parse().ok()?;
let seconds = jiff::Timestamp::now().as_second() - then.as_second();
Some((seconds / 86_400).max(0))
}
#[cfg(test)]
mod tests {
use super::*;
fn card(key: &str, aliases: &[&str]) -> Card {
Card {
id: 1,
key: key.to_string(),
title: String::new(),
status: "new".to_string(),
aliases: aliases.iter().map(|a| a.to_string()).collect(),
summary: None,
created_at: String::new(),
updated_at: None,
}
}
#[test]
fn a_name_is_found_as_a_whole_word_in_any_case() {
assert!(names("fix/WA-4130-rtf", "WA-4130"));
assert!(names("fix(wa-4130): read rtf", "WA-4130"));
assert!(names("WA-4130", "WA-4130"));
assert!(names("see WA-4130.", "WA-4130"));
assert!(!names("fix/WA-41300-rtf", "WA-4130"));
assert!(!names("fix/XWA-4130", "WA-4130"));
assert!(!names("fix/WA-41", "WA-4130"));
assert!(names("WA-41300 then WA-4130", "WA-4130"));
assert!(names("local-20260908-1-filter", "LOCAL-20260908-1"));
assert!(!names("local-20260908-12", "LOCAL-20260908-1"));
assert!(names("ёж WA-4130 ёж", "WA-4130"));
assert!(!names("anything", ""));
}
#[test]
fn an_alias_is_a_needle_only_when_it_is_an_id() {
let c = card("WA-4130", &["api-241", "rtf export", "rtf", "LOCAL-20260908-1"]);
assert_eq!(needles(&c), vec!["WA-4130", "API-241", "LOCAL-20260908-1"]);
}
#[test]
fn days_are_whole_and_never_negative() {
assert_eq!(days_since(&jiff::Timestamp::now().to_string()), Some(0));
let three = jiff::Timestamp::now().as_second() - 3 * 86_400 - 60;
assert_eq!(days_since(&moment(three)), Some(3));
let ahead = jiff::Timestamp::now().as_second() + 5 * 86_400;
assert_eq!(days_since(&moment(ahead)), Some(0));
assert_eq!(days_since("not a moment"), None);
}
}