use crate::cli::{OutputFormat, StatsArgs, resolve_output_format_basic_with_outer_mode};
use crate::config;
use crate::error::Result;
use crate::format::{
Breakdown, BreakdownEntry, RecentActivity, Statistics, StatsSummary, truncate_title,
};
use crate::model::{Issue, IssueType, Status};
use crate::output::{OutputContext, OutputMode};
use crate::storage::{SqliteStorage, StatsIssueRow};
use chrono::Utc;
use rich_rust::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::{debug, info};
pub fn execute(
args: &StatsArgs,
_json: bool,
cli: &config::CliOverrides,
outer_ctx: &OutputContext,
) -> Result<()> {
let beads_dir = config::discover_beads_dir_with_cli(cli)?;
execute_inner(args, cli, outer_ctx, &beads_dir, None, None)
}
pub fn execute_with_storage(
args: &StatsArgs,
cli: &config::CliOverrides,
outer_ctx: &OutputContext,
beads_dir: &Path,
storage: &SqliteStorage,
) -> Result<()> {
execute_inner(args, cli, outer_ctx, beads_dir, Some(storage), None)
}
pub fn execute_with_storage_ctx(
args: &StatsArgs,
cli: &config::CliOverrides,
outer_ctx: &OutputContext,
beads_dir: &Path,
storage_ctx: &config::OpenStorageResult,
) -> Result<()> {
execute_inner(args, cli, outer_ctx, beads_dir, None, Some(storage_ctx))
}
fn execute_inner(
args: &StatsArgs,
cli: &config::CliOverrides,
outer_ctx: &OutputContext,
beads_dir: &Path,
preloaded_storage: Option<&SqliteStorage>,
preloaded_storage_ctx: Option<&config::OpenStorageResult>,
) -> Result<()> {
let owned_storage_ctx = if preloaded_storage.is_some() || preloaded_storage_ctx.is_some() {
None
} else {
Some(config::open_storage_with_cli(beads_dir, cli)?)
};
let storage = preloaded_storage
.or_else(|| preloaded_storage_ctx.map(|ctx| &ctx.storage))
.or_else(|| owned_storage_ctx.as_ref().map(|ctx| &ctx.storage))
.expect("stats should have an open storage handle");
let jsonl_path = preloaded_storage_ctx
.or(owned_storage_ctx.as_ref())
.map_or_else(
|| beads_dir.join("issues.jsonl"),
|ctx| ctx.paths.jsonl_path.clone(),
);
let config_layer =
if let Some(storage_ctx) = preloaded_storage_ctx.or(owned_storage_ctx.as_ref()) {
storage_ctx.load_config(cli)?
} else {
config::load_config(beads_dir, Some(storage), cli)?
};
let use_color = config::should_use_color(&config_layer);
let output_format = resolve_output_format_basic_with_outer_mode(
args.format,
outer_ctx.inherited_output_mode(),
args.robot,
);
let quiet = cli.quiet.unwrap_or(false);
let ctx = OutputContext::from_output_format(output_format, quiet, !use_color);
info!("Computing project statistics");
let all_issues = storage.list_stats_issues()?;
debug!(total = all_issues.len(), "Loaded all issues for stats");
let external_db_paths = config::external_project_db_paths(&config_layer, beads_dir);
let external_blockers = if storage.has_external_dependencies(true)? {
let external_statuses =
storage.resolve_external_dependency_statuses(&external_db_paths, true)?;
Some(storage.external_blockers(&external_statuses)?)
} else {
None
};
let summary = compute_summary(storage, &all_issues, external_blockers.as_ref())?;
let mut breakdowns = Vec::new();
if args.by_type {
breakdowns.push(compute_type_breakdown(&all_issues));
}
if args.by_priority {
breakdowns.push(compute_priority_breakdown(&all_issues));
}
if args.by_assignee {
breakdowns.push(compute_assignee_breakdown(&all_issues));
}
if args.by_label {
breakdowns.push(compute_label_breakdown(storage, &all_issues)?);
}
let recent_activity = if should_include_activity(args) {
compute_recent_activity(Some(storage), &jsonl_path, args.activity_hours)
} else {
None
};
let output = Statistics {
summary,
breakdowns,
recent_activity,
};
if matches!(ctx.mode(), OutputMode::Quiet) {
return Ok(());
}
match output_format {
OutputFormat::Json => {
ctx.json_pretty(&output);
}
OutputFormat::Toon => {
ctx.toon_with_stats(&output, args.stats);
}
OutputFormat::Text | OutputFormat::Csv => {
if matches!(ctx.mode(), OutputMode::Rich) {
render_stats_rich(&output, &ctx);
} else {
print_text_output(&output);
}
}
}
Ok(())
}
const fn should_include_activity(args: &StatsArgs) -> bool {
!args.no_activity
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct ActivityCounts {
created: usize,
closed: usize,
updated: usize,
reopened: usize,
}
impl ActivityCounts {
const fn total_changes(self) -> usize {
self.created + self.closed + self.updated + self.reopened
}
fn merge(&mut self, other: Self) {
self.created += other.created;
self.closed += other.closed;
self.updated += other.updated;
self.reopened += other.reopened;
}
fn record_transition(&mut self, previous: Option<&Issue>, current: Option<&Issue>) {
match (previous, current) {
(None, Some(issue)) => {
if issue.status != Status::Tombstone {
self.created += 1;
}
}
(Some(before), Some(after)) => {
if !matches!(before.status, Status::Closed | Status::Tombstone)
&& after.status == Status::Closed
{
self.closed += 1;
return;
}
if before.status == Status::Closed
&& !matches!(after.status, Status::Closed | Status::Tombstone)
{
self.reopened += 1;
return;
}
if !before.sync_equals(after) {
self.updated += 1;
}
}
(_, None) => {}
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct GitActivitySummary {
commit_count: usize,
counts: ActivityCounts,
earliest_commit_ts: Option<i64>,
}
const GIT_ACTIVITY_COMMIT_MARKER: &str = "__BR_ACTIVITY_COMMIT__:";
const RECENT_ACTIVITY_CACHE_KEY_PREFIX: &str = "stats_recent_activity";
#[derive(Debug, Clone)]
struct GitRepoContext {
repo_root: PathBuf,
head: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RecentActivityCacheEntry {
repo_root: String,
repo_head: String,
pathspec: String,
hours: u32,
valid_until_epoch: Option<i64>,
activity: RecentActivity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RecentActivityCachePolicy {
Cache { valid_until_epoch: Option<i64> },
Skip,
}
#[allow(clippy::cast_precision_loss)]
fn compute_summary(
storage: &SqliteStorage,
issues: &[StatsIssueRow],
external_blockers: Option<&std::collections::HashMap<String, Vec<String>>>,
) -> Result<StatsSummary> {
let mut open = 0;
let mut in_progress = 0;
let mut closed = 0;
let mut blocked_by_status = 0;
let mut deferred = 0;
let mut draft = 0;
let mut tombstone = 0;
let mut pinned = 0;
let mut epics = Vec::new();
let mut lead_times = Vec::new();
let blocked_by_blocks = {
let status_map: HashSet<&str> = issues
.iter()
.filter(|i| !matches!(i.status, Status::Closed | Status::Tombstone))
.map(|i| i.id.as_str())
.collect();
let edges = storage.get_blocks_dep_edges()?;
let mut blocked = HashSet::new();
for (issue_id, depends_on_id) in &edges {
if status_map.contains(depends_on_id.as_str()) && status_map.contains(issue_id.as_str())
{
blocked.insert(issue_id.clone());
}
}
blocked
};
let all_blocked_ids = storage.get_blocked_ids()?;
for issue in issues {
match issue.status {
Status::Open => open += 1,
Status::InProgress => in_progress += 1,
Status::Closed => {
closed += 1;
if let Some(closed_at) = issue.closed_at {
let lead_time = closed_at.signed_duration_since(issue.created_at);
lead_times.push(lead_time.num_hours() as f64);
}
}
Status::Blocked => blocked_by_status += 1,
Status::Deferred => deferred += 1,
Status::Draft => draft += 1,
Status::Tombstone => tombstone += 1,
Status::Pinned | Status::Custom(_) => {}
}
if issue.pinned || issue.status == Status::Pinned {
pinned += 1;
}
if issue.issue_type == IssueType::Epic
&& !matches!(issue.status, Status::Closed | Status::Tombstone)
&& !issue.is_template
{
epics.push(issue.id.clone());
}
}
let now = Utc::now();
let ready = issues
.iter()
.filter(|i| {
i.status == Status::Open
&& !all_blocked_ids.contains(&i.id)
&& external_blockers.is_none_or(|eb| !eb.contains_key(&i.id))
&& !i.ephemeral
&& !is_wisp_issue_id(&i.id)
&& !i.pinned
&& !i.is_template
&& i.defer_until.is_none_or(|d| d <= now)
})
.count();
let blocked = blocked_by_blocks.len();
let epics_eligible = count_epics_eligible_for_closure(storage, &epics)?;
let avg_lead_time = if lead_times.is_empty() {
None
} else {
let sum: f64 = lead_times.iter().sum();
Some(sum / lead_times.len() as f64)
};
let total = issues
.iter()
.filter(|i| i.status != Status::Tombstone)
.count();
let _ = blocked_by_status;
Ok(StatsSummary {
total_issues: total,
open_issues: open,
in_progress_issues: in_progress,
closed_issues: closed,
blocked_issues: blocked,
deferred_issues: deferred,
draft_issues: draft,
ready_issues: ready,
tombstone_issues: tombstone,
pinned_issues: pinned,
epics_eligible_for_closure: epics_eligible,
average_lead_time_hours: avg_lead_time,
})
}
fn is_wisp_issue_id(id: &str) -> bool {
id.contains("-wisp-")
}
fn count_epics_eligible_for_closure(storage: &SqliteStorage, epic_ids: &[String]) -> Result<usize> {
if epic_ids.is_empty() {
return Ok(0);
}
let mut eligible = 0;
let counts = storage.get_epic_counts()?;
for epic_id in epic_ids {
if let Some(&(total, closed)) = counts.get(epic_id)
&& total > 0
&& total == closed
{
eligible += 1;
}
}
Ok(eligible)
}
fn compute_type_breakdown(issues: &[StatsIssueRow]) -> Breakdown {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for issue in issues {
if issue.status == Status::Tombstone {
continue;
}
let key = issue.issue_type.as_str().to_string();
*counts.entry(key).or_insert(0) += 1;
}
Breakdown {
dimension: "type".to_string(),
counts: counts
.into_iter()
.map(|(key, count)| BreakdownEntry { key, count })
.collect(),
}
}
fn compute_priority_breakdown(issues: &[StatsIssueRow]) -> Breakdown {
let mut counts: BTreeMap<i32, usize> = BTreeMap::new();
for issue in issues {
if issue.status == Status::Tombstone {
continue;
}
*counts.entry(issue.priority.0).or_insert(0) += 1;
}
Breakdown {
dimension: "priority".to_string(),
counts: counts
.into_iter()
.map(|(p, count)| BreakdownEntry {
key: format!("P{p}"),
count,
})
.collect(),
}
}
fn compute_assignee_breakdown(issues: &[StatsIssueRow]) -> Breakdown {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for issue in issues {
if issue.status == Status::Tombstone {
continue;
}
let key = issue
.assignee
.as_deref()
.unwrap_or("(unassigned)")
.to_string();
*counts.entry(key).or_insert(0) += 1;
}
Breakdown {
dimension: "assignee".to_string(),
counts: counts
.into_iter()
.map(|(key, count)| BreakdownEntry { key, count })
.collect(),
}
}
fn compute_label_breakdown(storage: &SqliteStorage, issues: &[StatsIssueRow]) -> Result<Breakdown> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
let issue_ids: Vec<String> = issues
.iter()
.filter(|issue| issue.status != Status::Tombstone)
.map(|issue| issue.id.clone())
.collect();
let mut labels_map = storage.get_labels_for_issues(&issue_ids)?;
for issue in issues {
if issue.status == Status::Tombstone {
continue;
}
if let Some(labels) = labels_map.remove(&issue.id) {
if labels.is_empty() {
*counts.entry("(no labels)".to_string()).or_insert(0) += 1;
} else {
for label in labels {
*counts.entry(label).or_insert(0) += 1;
}
}
} else {
*counts.entry("(no labels)".to_string()).or_insert(0) += 1;
}
}
Ok(Breakdown {
dimension: "label".to_string(),
counts: counts
.into_iter()
.map(|(key, count)| BreakdownEntry { key, count })
.collect(),
})
}
fn compute_recent_activity(
storage: Option<&SqliteStorage>,
jsonl_path: &Path,
hours: u32,
) -> Option<RecentActivity> {
if !jsonl_path.exists() {
debug!("No issues.jsonl found for activity tracking");
return None;
}
let repo_ctx = git_repo_context(jsonl_path.parent()?)?;
let pathspec = repo_relative_git_path(jsonl_path, &repo_ctx.repo_root)?;
let pathspec_str = git_pathspec_string(&pathspec);
let cache_key = recent_activity_cache_key(&pathspec_str, hours);
let now_epoch = Utc::now().timestamp();
if let Some(storage) = storage
&& let Some(activity) = load_recent_activity_cache(
storage,
&cache_key,
&repo_ctx,
&pathspec_str,
hours,
now_epoch,
)
{
debug!(hours, pathspec = %pathspec_str, "Using cached stats recent activity");
return Some(activity);
}
let since = format!("{hours} hours ago");
let GitActivitySummary {
commit_count,
counts,
earliest_commit_ts,
} = git_recent_activity(&repo_ctx.repo_root, &pathspec_str, &since)?;
let activity = RecentActivity {
hours_tracked: hours,
commit_count,
issues_created: counts.created,
issues_closed: counts.closed,
issues_updated: counts.updated,
issues_reopened: counts.reopened,
total_changes: counts.total_changes(),
};
if let Some(storage) = storage
&& let RecentActivityCachePolicy::Cache { valid_until_epoch } =
recent_activity_cache_policy(commit_count, earliest_commit_ts, hours)
{
let entry = RecentActivityCacheEntry {
repo_root: repo_ctx.repo_root.to_string_lossy().into_owned(),
repo_head: repo_ctx.head,
pathspec: pathspec_str,
hours,
valid_until_epoch,
activity: activity.clone(),
};
store_recent_activity_cache(storage, &cache_key, &entry);
}
Some(activity)
}
fn git_recent_activity(
repo_root: &Path,
pathspec: &str,
since: &str,
) -> Option<GitActivitySummary> {
use std::io::{BufReader, Read};
use std::process::Stdio;
let mut child = Command::new("git")
.args([
"log",
"--format=__BR_ACTIVITY_COMMIT__:%H\t%ct",
"--patch",
"--unified=0",
"--no-color",
"--no-ext-diff",
"--since",
since,
"--",
pathspec,
])
.current_dir(repo_root)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok()?;
let stderr_handle = child.stderr.take().map(|mut stderr| {
std::thread::spawn(move || {
let mut err_msg = String::new();
let _ = stderr.read_to_string(&mut err_msg);
err_msg
})
});
let stdout = child.stdout.take()?;
let reader = BufReader::new(stdout);
let summary = parse_issue_activity_stream(reader);
let status = child.wait().ok()?;
let err_msg = stderr_handle
.and_then(|handle| handle.join().ok())
.unwrap_or_default();
if !status.success() {
debug!(stderr = %err_msg.trim(), "Git log failed for activity diff");
return None;
}
Some(summary)
}
fn parse_issue_activity_stream<R: std::io::BufRead>(reader: R) -> GitActivitySummary {
let mut summary = GitActivitySummary::default();
let mut removed = BTreeMap::new();
let mut added = BTreeMap::new();
let mut in_commit = false;
for line_result in reader.lines() {
let Ok(line) = line_result else { continue };
if line.starts_with(GIT_ACTIVITY_COMMIT_MARKER) {
if in_commit {
summary
.counts
.merge(count_issue_activity_transitions(&removed, &added));
removed.clear();
added.clear();
}
in_commit = true;
summary.commit_count += 1;
if let Some(commit_ts) = parse_activity_commit_marker(&line) {
summary.earliest_commit_ts = Some(
summary
.earliest_commit_ts
.map_or(commit_ts, |earliest| earliest.min(commit_ts)),
);
}
continue;
}
record_issue_activity_patch_line(&line, &mut removed, &mut added);
}
if in_commit {
summary
.counts
.merge(count_issue_activity_transitions(&removed, &added));
}
summary
}
#[cfg(test)]
fn parse_issue_activity_patch(patch: &str) -> ActivityCounts {
let mut removed = BTreeMap::new();
let mut added = BTreeMap::new();
for line in patch.lines() {
record_issue_activity_patch_line(line, &mut removed, &mut added);
}
count_issue_activity_transitions(&removed, &added)
}
fn record_issue_activity_patch_line(
line: &str,
removed: &mut BTreeMap<String, Issue>,
added: &mut BTreeMap<String, Issue>,
) {
let Some((marker, payload)) = parse_issue_patch_line(line) else {
return;
};
match serde_json::from_str::<Issue>(payload) {
Ok(issue) => match marker {
'+' => {
added.insert(issue.id.clone(), issue);
}
'-' => {
removed.insert(issue.id.clone(), issue);
}
_ => unreachable!("parse_issue_patch_line only returns +/- markers"),
},
Err(err) => {
debug!(%err, "Skipping unparsable issue line from git diff");
}
}
}
fn count_issue_activity_transitions(
removed: &BTreeMap<String, Issue>,
added: &BTreeMap<String, Issue>,
) -> ActivityCounts {
let mut counts = ActivityCounts::default();
let mut issue_ids: HashSet<&str> = removed.keys().map(String::as_str).collect();
issue_ids.extend(added.keys().map(String::as_str));
for issue_id in issue_ids {
counts.record_transition(removed.get(issue_id), added.get(issue_id));
}
counts
}
fn parse_issue_patch_line(line: &str) -> Option<(char, &str)> {
if line.starts_with("+++ ")
|| line.starts_with("--- ")
|| line.starts_with("@@")
|| line.starts_with("diff --git")
|| line.starts_with("index ")
|| line.starts_with("new file mode ")
|| line.starts_with("deleted file mode ")
|| line.starts_with("\\ No newline at end of file")
{
return None;
}
let marker = *line.as_bytes().first()? as char;
if !matches!(marker, '+' | '-') {
return None;
}
let payload = &line[1..];
if !payload.starts_with('{') {
return None;
}
Some((marker, payload))
}
fn parse_activity_commit_marker(line: &str) -> Option<i64> {
let payload = line.strip_prefix(GIT_ACTIVITY_COMMIT_MARKER)?;
let (_, ts) = payload.rsplit_once('\t')?;
ts.parse::<i64>().ok()
}
fn git_repo_context(start: &Path) -> Option<GitRepoContext> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel", "HEAD"])
.current_dir(start)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut lines = stdout.lines();
let repo_root = lines.next()?.trim();
let head = lines.next()?.trim();
if repo_root.is_empty() || head.is_empty() {
return None;
}
Some(GitRepoContext {
repo_root: PathBuf::from(repo_root),
head: head.to_string(),
})
}
fn repo_relative_git_path(path: &Path, repo_root: &Path) -> Option<PathBuf> {
let canonical_repo_root = dunce::canonicalize(repo_root).ok()?;
let canonical_path = dunce::canonicalize(path).ok()?;
canonical_path
.strip_prefix(&canonical_repo_root)
.ok()
.map(Path::to_path_buf)
}
fn git_pathspec_string(path: &Path) -> String {
let pathspec = path.to_string_lossy().into_owned();
if cfg!(windows) {
pathspec.replace('\\', "/")
} else {
pathspec
}
}
fn recent_activity_cache_key(pathspec: &str, hours: u32) -> String {
format!("{RECENT_ACTIVITY_CACHE_KEY_PREFIX}:{hours}:{pathspec}")
}
fn load_recent_activity_cache(
storage: &SqliteStorage,
cache_key: &str,
repo_ctx: &GitRepoContext,
pathspec: &str,
hours: u32,
now_epoch: i64,
) -> Option<RecentActivity> {
let raw = storage.get_metadata(cache_key).ok()??;
parse_recent_activity_cache(&raw, repo_ctx, pathspec, hours, now_epoch)
}
fn parse_recent_activity_cache(
raw: &str,
repo_ctx: &GitRepoContext,
pathspec: &str,
hours: u32,
now_epoch: i64,
) -> Option<RecentActivity> {
let entry = serde_json::from_str::<RecentActivityCacheEntry>(raw)
.map_err(|error| {
debug!(%error, "Ignoring invalid recent activity cache entry");
error
})
.ok()?;
if entry.repo_root != repo_ctx.repo_root.to_string_lossy()
|| entry.repo_head != repo_ctx.head
|| entry.pathspec != pathspec
|| entry.hours != hours
|| entry
.valid_until_epoch
.is_some_and(|valid_until| now_epoch > valid_until)
{
return None;
}
Some(entry.activity)
}
fn recent_activity_cache_policy(
commit_count: usize,
earliest_commit_ts: Option<i64>,
hours: u32,
) -> RecentActivityCachePolicy {
if commit_count == 0 {
return RecentActivityCachePolicy::Cache {
valid_until_epoch: None,
};
}
earliest_commit_ts.map_or(RecentActivityCachePolicy::Skip, |ts| {
RecentActivityCachePolicy::Cache {
valid_until_epoch: Some(ts.saturating_add(i64::from(hours) * 3600)),
}
})
}
fn store_recent_activity_cache(
storage: &SqliteStorage,
cache_key: &str,
entry: &RecentActivityCacheEntry,
) {
let Ok(serialized) = serde_json::to_string(entry).map_err(|error| {
debug!(%error, cache_key, "Failed to serialize recent activity cache entry");
error
}) else {
return;
};
if let Err(error) = storage.set_metadata_shared(cache_key, &serialized) {
debug!(%error, cache_key, "Failed to persist recent activity cache entry");
}
}
fn print_text_output(output: &Statistics) {
println!("📊 Issue Database Status\n");
let s = &output.summary;
println!("Summary:");
println!(" Total Issues: {}", s.total_issues);
println!(" Open: {}", s.open_issues);
println!(" In Progress: {}", s.in_progress_issues);
println!(" Blocked: {}", s.blocked_issues);
println!(" Closed: {}", s.closed_issues);
println!(" Ready to Work: {}", s.ready_issues);
if s.deferred_issues > 0 {
println!(" Deferred: {}", s.deferred_issues);
}
if s.tombstone_issues > 0 {
println!(" Tombstones: {}", s.tombstone_issues);
}
if s.pinned_issues > 0 {
println!(" Pinned: {}", s.pinned_issues);
}
if s.epics_eligible_for_closure > 0 {
println!(" Epics ready to close: {}", s.epics_eligible_for_closure);
}
if s.average_lead_time_hours.is_some() || s.tombstone_issues > 0 {
println!("\nExtended:");
if let Some(avg_hours) = s.average_lead_time_hours {
let formatted = if avg_hours >= 24.0 {
let avg_days = avg_hours / 24.0;
format!("{avg_days:.1} days")
} else {
format!("{avg_hours:.1} hours")
};
println!(" Avg Lead Time: {formatted}");
}
if s.tombstone_issues > 0 {
println!(
" Deleted: {} (tombstones)",
s.tombstone_issues
);
}
}
for breakdown in &output.breakdowns {
println!("\nBy {}:", breakdown.dimension);
for entry in &breakdown.counts {
println!(" {}: {}", entry.key, entry.count);
}
}
if let Some(activity) = &output.recent_activity {
println!("\nRecent Activity (last {} hours):", activity.hours_tracked);
println!(" Commits: {}", activity.commit_count);
println!(" Total Changes: {}", activity.total_changes);
println!(" Issues Created: {}", activity.issues_created);
println!(" Issues Closed: {}", activity.issues_closed);
println!(" Issues Reopened: {}", activity.issues_reopened);
println!(" Issues Updated: {}", activity.issues_updated);
}
println!("\nFor more details, use 'br list' to see individual issues.");
}
#[allow(clippy::cast_precision_loss)]
fn render_stats_rich(output: &Statistics, ctx: &OutputContext) {
let console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut content = Text::new("");
content.append_styled("\u{1f4ca} Overview\n", theme.section.clone());
let s = &output.summary;
content.append_styled(" Total: ", theme.dimmed.clone());
content.append_styled(&s.total_issues.to_string(), theme.emphasis.clone());
content.append_styled(" Ready: ", theme.dimmed.clone());
content.append_styled(&s.ready_issues.to_string(), theme.success.clone());
content.append_styled(" \u{2713}", theme.success.clone());
content.append_styled(" Blocked: ", theme.dimmed.clone());
content.append_styled(&s.blocked_issues.to_string(), theme.warning.clone());
if s.blocked_issues > 0 {
content.append_styled(" \u{26a0}", theme.warning.clone());
}
content.append("\n\n");
content.append_styled("\u{1f4c8} By Status\n", theme.section.clone());
render_status_bars(&mut content, s, theme);
content.append("\n");
for breakdown in &output.breakdowns {
content.append_styled(
&format!("\u{1f4c8} By {}\n", capitalize(&breakdown.dimension)),
theme.section.clone(),
);
render_breakdown_bars(&mut content, breakdown, s.total_issues, theme);
content.append("\n");
}
if let Some(activity) = &output.recent_activity {
content.append_styled(
&format!(
"\u{1f4c5} Activity (last {} hours)\n",
activity.hours_tracked
),
theme.section.clone(),
);
content.append_styled(" Commits: ", theme.dimmed.clone());
content.append(&activity.commit_count.to_string());
if activity.total_changes > 0 {
content.append_styled(" Changes: ", theme.dimmed.clone());
content.append(&activity.total_changes.to_string());
}
content.append("\n\n");
}
let mut warnings = Vec::new();
if s.blocked_issues > 5 {
warnings.push(format!("{} issues blocked", s.blocked_issues));
}
if s.epics_eligible_for_closure > 0 {
warnings.push(format!(
"{} epic{} ready to close",
s.epics_eligible_for_closure,
if s.epics_eligible_for_closure == 1 {
""
} else {
"s"
}
));
}
if s.deferred_issues > 10 {
warnings.push(format!("{} issues deferred", s.deferred_issues));
}
if !warnings.is_empty() {
content.append_styled("\u{26a0} Health Warnings\n", theme.warning.clone());
for warning in &warnings {
content.append_styled(" \u{2022} ", theme.warning.clone());
content.append(warning);
content.append("\n");
}
}
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled("Project Health", theme.panel_title.clone()))
.box_style(theme.box_style);
console.print_renderable(&panel);
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn render_status_bars(content: &mut Text, summary: &StatsSummary, theme: &crate::output::Theme) {
let total = summary.total_issues.max(1);
let bar_width: usize = 24;
let statuses = [
("Open", summary.open_issues, &theme.status_open),
(
"In Progress",
summary.in_progress_issues,
&theme.status_in_progress,
),
("Blocked", summary.blocked_issues, &theme.status_blocked),
("Closed", summary.closed_issues, &theme.status_closed),
];
for (label, count, style) in statuses {
if count == 0 {
continue;
}
let pct = (count as f64 / total as f64) * 100.0;
let filled = ((count as f64 / total as f64) * bar_width as f64).round() as usize;
let empty = bar_width.saturating_sub(filled);
content.append_styled(&format!(" {:<12}", label), style.clone());
content.append_styled(&"\u{2588}".repeat(filled), style.clone());
content.append_styled(&"\u{2591}".repeat(empty), theme.dimmed.clone());
content.append_styled(
&format!(" {:>3} ({:.0}%)", count, pct),
theme.dimmed.clone(),
);
content.append("\n");
}
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn render_breakdown_bars(
content: &mut Text,
breakdown: &Breakdown,
total: usize,
theme: &crate::output::Theme,
) {
let total = total.max(1);
let bar_width: usize = 24;
for entry in &breakdown.counts {
let pct = (entry.count as f64 / total as f64) * 100.0;
let filled = ((entry.count as f64 / total as f64) * bar_width as f64).round() as usize;
let empty = bar_width.saturating_sub(filled);
let style = match breakdown.dimension.as_str() {
"priority" => match entry.key.as_str() {
"P0" => theme.priority_critical.clone(),
"P1" => theme.priority_high.clone(),
"P2" => theme.priority_medium.clone(),
"P3" => theme.priority_low.clone(),
_ => theme.priority_backlog.clone(),
},
"type" => match entry.key.as_str() {
"task" => theme.type_task.clone(),
"bug" => theme.type_bug.clone(),
"feature" => theme.type_feature.clone(),
"epic" => theme.type_epic.clone(),
"chore" => theme.type_chore.clone(),
"docs" => theme.type_docs.clone(),
"question" => theme.type_question.clone(),
_ => theme.dimmed.clone(),
},
_ => theme.accent.clone(),
};
content.append_styled(
&format!(" {:<12}", truncate_title(&entry.key, 12)),
style.clone(),
);
content.append_styled(&"\u{2588}".repeat(filled), style.clone());
content.append_styled(&"\u{2591}".repeat(empty), theme.dimmed.clone());
content.append_styled(
&format!(" {:>3} ({:.0}%)", entry.count, pct),
theme.dimmed.clone(),
);
content.append("\n");
}
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().chain(chars).collect()
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Issue, IssueType, Priority, Status};
use crate::storage::SqliteStorage;
use chrono::{TimeZone, Utc};
use std::fs;
use tempfile::TempDir;
fn make_issue(id: &str, status: Status, issue_type: IssueType) -> Issue {
Issue {
id: id.to_string(),
title: format!("Issue {id}"),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status,
priority: Priority::MEDIUM,
issue_type,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: Utc::now(),
created_by: None,
updated_at: Utc::now(),
closed_at: None,
close_reason: None,
closed_by_session: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
content_hash: None,
}
}
fn stats_row(issue: &Issue) -> StatsIssueRow {
StatsIssueRow {
id: issue.id.clone(),
status: issue.status.clone(),
priority: issue.priority,
issue_type: issue.issue_type.clone(),
assignee: issue.assignee.clone(),
created_at: issue.created_at,
closed_at: issue.closed_at,
defer_until: issue.defer_until,
ephemeral: issue.ephemeral,
pinned: issue.pinned,
is_template: issue.is_template,
}
}
#[test]
fn test_compute_type_breakdown() {
let test_issues = [
make_issue("t-1", Status::Open, IssueType::Task),
make_issue("t-2", Status::Open, IssueType::Task),
make_issue("t-3", Status::Open, IssueType::Bug),
make_issue("t-4", Status::Tombstone, IssueType::Feature), ]
.iter()
.map(stats_row)
.collect::<Vec<_>>();
let breakdown = compute_type_breakdown(&test_issues);
assert_eq!(breakdown.dimension, "type");
let mut map: BTreeMap<String, usize> = BTreeMap::new();
for entry in &breakdown.counts {
map.insert(entry.key.clone(), entry.count);
}
assert_eq!(map.get("task"), Some(&2));
assert_eq!(map.get("bug"), Some(&1));
assert_eq!(map.get("feature"), None); }
#[test]
fn test_compute_priority_breakdown() {
let mut test_issues = [
make_issue("t-1", Status::Open, IssueType::Task),
make_issue("t-2", Status::Open, IssueType::Task),
make_issue("t-3", Status::Open, IssueType::Bug),
];
test_issues[0].priority = Priority::CRITICAL;
test_issues[1].priority = Priority::CRITICAL;
test_issues[2].priority = Priority::LOW;
let test_issues = test_issues.iter().map(stats_row).collect::<Vec<_>>();
let breakdown = compute_priority_breakdown(&test_issues);
assert_eq!(breakdown.dimension, "priority");
let mut map: BTreeMap<String, usize> = BTreeMap::new();
for entry in &breakdown.counts {
map.insert(entry.key.clone(), entry.count);
}
assert_eq!(map.get("P0"), Some(&2));
assert_eq!(map.get("P3"), Some(&1));
}
#[test]
fn test_compute_assignee_breakdown() {
let mut test_issues = [
make_issue("t-1", Status::Open, IssueType::Task),
make_issue("t-2", Status::Open, IssueType::Task),
make_issue("t-3", Status::Open, IssueType::Bug),
];
test_issues[0].assignee = Some("alice".to_string());
test_issues[1].assignee = Some("alice".to_string());
let test_issues = test_issues.iter().map(stats_row).collect::<Vec<_>>();
let breakdown = compute_assignee_breakdown(&test_issues);
assert_eq!(breakdown.dimension, "assignee");
let mut map: BTreeMap<String, usize> = BTreeMap::new();
for entry in &breakdown.counts {
map.insert(entry.key.clone(), entry.count);
}
assert_eq!(map.get("alice"), Some(&2));
assert_eq!(map.get("(unassigned)"), Some(&1));
}
#[test]
fn test_compute_summary_basic() {
let mut storage = SqliteStorage::open_memory().unwrap();
let first_issue = make_issue("t-1", Status::Open, IssueType::Task);
let second_issue = make_issue("t-2", Status::InProgress, IssueType::Task);
let mut third_issue = make_issue("t-3", Status::Closed, IssueType::Bug);
third_issue.closed_at = Some(Utc::now());
storage.create_issue(&first_issue, "tester").unwrap();
storage.create_issue(&second_issue, "tester").unwrap();
storage.create_issue(&third_issue, "tester").unwrap();
let all_issues = [&first_issue, &second_issue, &third_issue]
.into_iter()
.map(stats_row)
.collect::<Vec<_>>();
let summary = compute_summary(&storage, &all_issues, None).unwrap();
assert_eq!(summary.total_issues, 3);
assert_eq!(summary.open_issues, 1);
assert_eq!(summary.in_progress_issues, 1);
assert_eq!(summary.closed_issues, 1);
}
#[test]
fn test_blocked_by_blocks_deps() {
let mut storage = SqliteStorage::open_memory().unwrap();
let blocking_issue = make_issue("t-1", Status::Open, IssueType::Task);
let dependent_issue = make_issue("t-2", Status::Open, IssueType::Task);
storage.create_issue(&blocking_issue, "tester").unwrap();
storage.create_issue(&dependent_issue, "tester").unwrap();
storage
.add_dependency("t-2", "t-1", "blocks", "tester")
.unwrap();
let blocked_ids = storage.get_blocked_by_blocks_deps_only().unwrap();
assert!(blocked_ids.contains("t-2"));
assert!(!blocked_ids.contains("t-1"));
}
#[test]
fn test_compute_summary_ready_excludes_templates() {
let mut storage = SqliteStorage::open_memory().unwrap();
let regular_issue = make_issue("t-1", Status::Open, IssueType::Task);
let mut template_issue = make_issue("t-2", Status::Open, IssueType::Task);
template_issue.is_template = true;
storage.create_issue(®ular_issue, "tester").unwrap();
storage.create_issue(&template_issue, "tester").unwrap();
let all_issues = [®ular_issue, &template_issue]
.into_iter()
.map(stats_row)
.collect::<Vec<_>>();
let summary = compute_summary(&storage, &all_issues, None).unwrap();
assert_eq!(summary.ready_issues, 1);
}
#[test]
fn test_compute_summary_ready_excludes_wisp_ids() {
let mut storage = SqliteStorage::open_memory().unwrap();
let regular_issue = make_issue("t-1", Status::Open, IssueType::Task);
let wisp_issue = make_issue("t-wisp-2", Status::Open, IssueType::Task);
storage.create_issue(®ular_issue, "tester").unwrap();
storage.create_issue(&wisp_issue, "tester").unwrap();
let all_issues = [®ular_issue, &wisp_issue]
.into_iter()
.map(stats_row)
.collect::<Vec<_>>();
let summary = compute_summary(&storage, &all_issues, None).unwrap();
assert_eq!(summary.ready_issues, 1);
}
#[test]
fn test_compute_summary_excludes_template_epics_from_close_eligible_count() {
let mut storage = SqliteStorage::open_memory().unwrap();
let mut epic = make_issue("bd-epic-template", Status::Open, IssueType::Epic);
epic.is_template = true;
let mut child = make_issue("bd-task-closed", Status::Closed, IssueType::Task);
child.closed_at = Some(Utc::now());
storage.create_issue(&epic, "tester").unwrap();
storage.create_issue(&child, "tester").unwrap();
storage
.add_dependency(&child.id, &epic.id, "parent-child", "tester")
.unwrap();
let all_issues = [&epic, &child]
.into_iter()
.map(stats_row)
.collect::<Vec<_>>();
let summary = compute_summary(&storage, &all_issues, None).unwrap();
assert_eq!(summary.epics_eligible_for_closure, 0);
}
#[test]
fn test_blocked_cleared_when_blocker_closed() {
let mut storage = SqliteStorage::open_memory().unwrap();
let mut blocking_issue = make_issue("t-1", Status::Closed, IssueType::Task);
blocking_issue.closed_at = Some(Utc::now());
let dependent_issue = make_issue("t-2", Status::Open, IssueType::Task);
storage.create_issue(&blocking_issue, "tester").unwrap();
storage.create_issue(&dependent_issue, "tester").unwrap();
storage
.add_dependency("t-2", "t-1", "blocks", "tester")
.unwrap();
let blocked_ids = storage.get_blocked_by_blocks_deps_only().unwrap();
assert!(!blocked_ids.contains("t-2"));
}
#[test]
fn test_label_breakdown() {
let mut storage = SqliteStorage::open_memory().unwrap();
let first_issue = make_issue("t-1", Status::Open, IssueType::Task);
let second_issue = make_issue("t-2", Status::Open, IssueType::Task);
let third_issue = make_issue("t-3", Status::Open, IssueType::Task);
storage.create_issue(&first_issue, "tester").unwrap();
storage.create_issue(&second_issue, "tester").unwrap();
storage.create_issue(&third_issue, "tester").unwrap();
storage.add_label("t-1", "backend", "tester").unwrap();
storage.add_label("t-1", "urgent", "tester").unwrap();
storage.add_label("t-2", "backend", "tester").unwrap();
let test_issues = [&first_issue, &second_issue, &third_issue]
.into_iter()
.map(stats_row)
.collect::<Vec<_>>();
let breakdown = compute_label_breakdown(&storage, &test_issues).unwrap();
let mut map: BTreeMap<String, usize> = BTreeMap::new();
for entry in &breakdown.counts {
map.insert(entry.key.clone(), entry.count);
}
assert_eq!(map.get("backend"), Some(&2));
assert_eq!(map.get("urgent"), Some(&1));
assert_eq!(map.get("(no labels)"), Some(&1));
}
#[test]
fn test_truncate_title_ascii() {
assert_eq!(truncate_title("short", 12), "short");
assert_eq!(truncate_title("exactly_twelve", 14), "exactly_twelve");
assert_eq!(
truncate_title("this_is_too_long_for_column", 12),
"this_is_t..."
);
}
#[test]
fn test_truncate_title_multibyte() {
let emoji = "😊".repeat(10);
let result = truncate_title(&emoji, 5);
assert!(result.ends_with("..."));
assert_eq!(result, "😊...");
let mixed = "abc😊def";
assert_eq!(truncate_title(mixed, 8), "abc😊def");
assert_eq!(truncate_title(mixed, 6), "abc...");
}
#[test]
fn test_capitalize() {
assert_eq!(capitalize("type"), "Type");
assert_eq!(capitalize("priority"), "Priority");
assert_eq!(capitalize(""), "");
assert_eq!(capitalize("ALREADY"), "ALREADY");
}
fn git(cwd: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.expect("run git");
assert!(status.success(), "git {:?} failed", args);
}
fn git_stdout(cwd: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.expect("run git");
assert!(output.status.success(), "git {:?} failed", args);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn write_issue_jsonl(path: &Path, issue: &Issue) {
let line = serde_json::to_string(issue).expect("serialize issue");
fs::write(path, format!("{line}\n")).expect("write issue jsonl");
}
#[test]
fn test_compute_recent_activity_uses_resolved_jsonl_path() {
let temp = TempDir::new().expect("tempdir");
git(temp.path(), &["init", "-q"]);
git(temp.path(), &["config", "user.email", "tester@example.com"]);
git(temp.path(), &["config", "user.name", "Tester"]);
let jsonl_dir = temp.path().join("tracking").join("custom");
fs::create_dir_all(&jsonl_dir).expect("create jsonl dir");
let jsonl_path = jsonl_dir.join("issues.snapshot.jsonl");
fs::write(&jsonl_path, "{\"id\":\"bd-abc\",\"title\":\"Example\"}\n").expect("write jsonl");
git(
temp.path(),
&["add", "tracking/custom/issues.snapshot.jsonl"],
);
git(
temp.path(),
&["commit", "-q", "-m", "Track bd-abc in custom issues file"],
);
let activity = compute_recent_activity(None, &jsonl_path, 24)
.expect("activity for committed custom jsonl");
assert_eq!(activity.commit_count, 1);
assert_eq!(activity.hours_tracked, 24);
}
#[test]
fn test_compute_recent_activity_counts_issue_transitions_from_git_history() {
let temp = TempDir::new().expect("tempdir");
git(temp.path(), &["init", "-q"]);
git(temp.path(), &["config", "user.email", "tester@example.com"]);
git(temp.path(), &["config", "user.name", "Tester"]);
let jsonl_dir = temp.path().join(".beads");
fs::create_dir_all(&jsonl_dir).expect("create beads dir");
let jsonl_path = jsonl_dir.join("issues.jsonl");
let base_time = Utc.with_ymd_and_hms(2026, 3, 11, 0, 0, 0).unwrap();
let mut issue = make_issue("bd-activity", Status::Open, IssueType::Task);
issue.title = "Track recent activity".to_string();
issue.created_at = base_time;
issue.updated_at = base_time;
write_issue_jsonl(&jsonl_path, &issue);
git(temp.path(), &["add", ".beads/issues.jsonl"]);
git(temp.path(), &["commit", "-q", "-m", "Create bd-activity"]);
issue.title = "Track recent activity better".to_string();
issue.updated_at = base_time + chrono::Duration::hours(1);
write_issue_jsonl(&jsonl_path, &issue);
git(temp.path(), &["add", ".beads/issues.jsonl"]);
git(temp.path(), &["commit", "-q", "-m", "Update bd-activity"]);
issue.status = Status::Closed;
issue.updated_at = base_time + chrono::Duration::hours(2);
issue.closed_at = Some(issue.updated_at);
issue.close_reason = Some("done".to_string());
write_issue_jsonl(&jsonl_path, &issue);
git(temp.path(), &["add", ".beads/issues.jsonl"]);
git(temp.path(), &["commit", "-q", "-m", "Close bd-activity"]);
issue.status = Status::Open;
issue.updated_at = base_time + chrono::Duration::hours(3);
issue.closed_at = None;
issue.close_reason = None;
write_issue_jsonl(&jsonl_path, &issue);
git(temp.path(), &["add", ".beads/issues.jsonl"]);
git(temp.path(), &["commit", "-q", "-m", "Reopen bd-activity"]);
let activity = compute_recent_activity(None, &jsonl_path, 24).expect("recent activity");
assert_eq!(activity.commit_count, 4);
assert_eq!(activity.issues_created, 1);
assert_eq!(activity.issues_updated, 1);
assert_eq!(activity.issues_closed, 1);
assert_eq!(activity.issues_reopened, 1);
assert_eq!(activity.total_changes, 4);
}
#[test]
fn test_parse_recent_activity_cache_returns_matching_entry() {
let repo_ctx = GitRepoContext {
repo_root: PathBuf::from("/tmp/repo"),
head: "head-1".to_string(),
};
let cached_activity = RecentActivity {
hours_tracked: 24,
commit_count: 99,
issues_created: 88,
issues_closed: 77,
issues_updated: 66,
issues_reopened: 55,
total_changes: 286,
};
let cache_entry = RecentActivityCacheEntry {
repo_root: repo_ctx.repo_root.to_string_lossy().into_owned(),
repo_head: repo_ctx.head.clone(),
pathspec: ".beads/issues.jsonl".to_string(),
hours: 24,
valid_until_epoch: Some(Utc::now().timestamp() + 3600),
activity: cached_activity.clone(),
};
let raw = serde_json::to_string(&cache_entry).expect("serialize cache entry");
let activity = parse_recent_activity_cache(
&raw,
&repo_ctx,
".beads/issues.jsonl",
24,
Utc::now().timestamp(),
)
.expect("cached recent activity");
assert_eq!(activity.commit_count, cached_activity.commit_count);
assert_eq!(activity.total_changes, cached_activity.total_changes);
assert_eq!(activity.issues_created, cached_activity.issues_created);
assert_eq!(activity.issues_closed, cached_activity.issues_closed);
}
#[test]
fn test_activity_counts_tombstone_transition_as_update_not_reopen() {
let mut before = make_issue("bd-closed", Status::Closed, IssueType::Task);
before.closed_at = Some(Utc::now());
let mut after = before.clone();
after.status = Status::Tombstone;
after.deleted_at = Some(Utc::now());
after.delete_reason = Some("purged".to_string());
let mut counts = ActivityCounts::default();
counts.record_transition(Some(&before), Some(&after));
assert_eq!(counts.reopened, 0);
assert_eq!(counts.updated, 1);
}
#[test]
fn test_parse_issue_activity_patch_counts_created_issue() {
let issue = make_issue("bd-created", Status::Open, IssueType::Task);
let patch = format!(
"+{}\n",
serde_json::to_string(&issue).expect("serialize created issue"),
);
let counts = parse_issue_activity_patch(&patch);
assert_eq!(counts.created, 1);
assert_eq!(counts.closed, 0);
assert_eq!(counts.updated, 0);
assert_eq!(counts.reopened, 0);
assert_eq!(counts.total_changes(), 1);
}
#[test]
fn test_parse_issue_activity_stream_keeps_commit_boundaries_separate() {
let base_time = Utc.with_ymd_and_hms(2026, 3, 11, 0, 0, 0).unwrap();
let mut original = make_issue("bd-activity", Status::Open, IssueType::Task);
original.title = "Track recent activity".to_string();
original.created_at = base_time;
original.updated_at = base_time;
let mut updated = original.clone();
updated.title = "Track recent activity better".to_string();
updated.updated_at = base_time + chrono::Duration::hours(1);
let mut closed = updated.clone();
closed.status = Status::Closed;
closed.updated_at = base_time + chrono::Duration::hours(2);
closed.closed_at = Some(closed.updated_at);
closed.close_reason = Some("done".to_string());
let log = format!(
"{marker}commit-1\t{first_ts}\n-{original}\n+{updated}\n{marker}commit-2\t{second_ts}\n-{updated}\n+{closed}\n",
marker = GIT_ACTIVITY_COMMIT_MARKER,
first_ts = base_time.timestamp(),
second_ts = (base_time + chrono::Duration::hours(2)).timestamp(),
original = serde_json::to_string(&original).expect("serialize original issue"),
updated = serde_json::to_string(&updated).expect("serialize updated issue"),
closed = serde_json::to_string(&closed).expect("serialize closed issue"),
);
let activity = parse_issue_activity_stream(std::io::BufReader::new(log.as_bytes()));
assert_eq!(activity.commit_count, 2);
assert_eq!(activity.counts.created, 0);
assert_eq!(activity.counts.updated, 1);
assert_eq!(activity.counts.closed, 1);
assert_eq!(activity.counts.reopened, 0);
assert_eq!(activity.earliest_commit_ts, Some(base_time.timestamp()));
assert_eq!(activity.counts.total_changes(), 2);
}
#[test]
fn test_recent_activity_cache_valid_until_expires_at_boundary() {
let earliest_commit_ts = 1_000_i64;
let RecentActivityCachePolicy::Cache { valid_until_epoch } =
recent_activity_cache_policy(1, Some(earliest_commit_ts), 24)
else {
panic!("expected cache entry");
};
let valid_until = valid_until_epoch.expect("expiry timestamp");
assert_eq!(valid_until, earliest_commit_ts + 24 * 3600);
let repo_ctx = GitRepoContext {
repo_root: PathBuf::from("/tmp/repo"),
head: "head-1".to_string(),
};
let activity = RecentActivity {
hours_tracked: 24,
commit_count: 1,
issues_created: 1,
issues_closed: 0,
issues_updated: 0,
issues_reopened: 0,
total_changes: 1,
};
let cache_entry = RecentActivityCacheEntry {
repo_root: repo_ctx.repo_root.to_string_lossy().into_owned(),
repo_head: repo_ctx.head.clone(),
pathspec: ".beads/issues.jsonl".to_string(),
hours: 24,
valid_until_epoch: Some(valid_until),
activity,
};
let raw = serde_json::to_string(&cache_entry).expect("serialize cache entry");
assert!(
parse_recent_activity_cache(
&raw,
&repo_ctx,
".beads/issues.jsonl",
24,
valid_until - 1,
)
.is_some()
);
assert!(
parse_recent_activity_cache(&raw, &repo_ctx, ".beads/issues.jsonl", 24, valid_until,)
.is_some()
);
assert!(
parse_recent_activity_cache(
&raw,
&repo_ctx,
".beads/issues.jsonl",
24,
valid_until + 1,
)
.is_none()
);
}
#[test]
fn test_should_include_activity_defaults_on() {
assert!(should_include_activity(&StatsArgs::default()));
assert!(should_include_activity(&StatsArgs {
activity: true,
..StatsArgs::default()
}));
assert!(!should_include_activity(&StatsArgs {
activity: true,
no_activity: true,
..StatsArgs::default()
}));
}
#[test]
fn test_repo_relative_git_path_rejects_path_outside_repo() {
let temp = TempDir::new().expect("tempdir");
let repo_root = temp.path().join("repo");
let outside_root = temp.path().join("outside");
fs::create_dir_all(&repo_root).expect("create repo root");
fs::create_dir_all(&outside_root).expect("create outside root");
let outside_path = outside_root.join("issues.jsonl");
fs::write(&outside_path, "").expect("write outside jsonl");
assert!(repo_relative_git_path(&outside_path, &repo_root).is_none());
}
#[test]
fn test_git_pathspec_string_normalizes_separators() {
assert_eq!(
git_pathspec_string(&PathBuf::from(".beads").join("issues.jsonl")),
".beads/issues.jsonl"
);
assert_eq!(
git_pathspec_string(&PathBuf::from(".beads/issues.jsonl")),
".beads/issues.jsonl"
);
}
#[test]
fn test_git_repo_context_reads_root_and_head() {
let temp = TempDir::new().expect("tempdir");
git(temp.path(), &["init", "-q"]);
git(temp.path(), &["config", "user.email", "tester@example.com"]);
git(temp.path(), &["config", "user.name", "Tester"]);
fs::write(temp.path().join("README.md"), "stats\n").expect("write readme");
git(temp.path(), &["add", "README.md"]);
git(temp.path(), &["commit", "-q", "-m", "init"]);
let repo_ctx = git_repo_context(temp.path()).expect("repo context");
assert_eq!(repo_ctx.repo_root, temp.path());
assert_eq!(
repo_ctx.head,
git_stdout(temp.path(), &["rev-parse", "HEAD"])
);
}
}