mod calendar;
mod commit;
mod context;
mod db;
mod hub;
mod import;
mod mcp;
mod open;
mod owner;
mod paths;
mod repo;
mod search;
mod sync;
mod week;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use crate::db::Db;
#[derive(Parser)]
#[command(name = "rigger", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Init,
Project {
#[command(subcommand)]
command: ProjectCommand,
},
Import {
project: String,
#[arg(long)]
hub: PathBuf,
#[arg(long)]
json: bool,
},
Context {
project: String,
#[arg(long)]
json: bool,
#[arg(long)]
explain: bool,
#[arg(long, default_value_t = context::DEFAULT_BUDGET)]
budget: usize,
},
Note {
project: String,
text: String,
#[arg(long, value_name = "KIND", default_value = "finding")]
kind: NoteKind,
},
Open {
project: String,
#[arg(long)]
print: bool,
#[arg(long, default_value_t = context::DEFAULT_BUDGET)]
budget: usize,
},
Sync {
project: Option<String>,
#[arg(long)]
json: bool,
},
Inbox {
#[arg(long)]
project: Option<String>,
#[arg(long)]
json: bool,
},
Digest {
project: Option<String>,
#[arg(long, default_value = "7d")]
since: String,
#[arg(long)]
json: bool,
},
Find {
query: String,
#[arg(long)]
project: Option<String>,
#[arg(long, value_name = "KIND")]
kind: Option<String>,
#[arg(long, default_value_t = 20)]
limit: u32,
#[arg(long)]
json: bool,
},
Why {
project: String,
version: String,
#[arg(long)]
json: bool,
},
Version {
#[command(subcommand)]
command: VersionCommand,
},
Calendar {
#[arg(long, default_value_t = 6)]
weeks: u32,
#[arg(long, value_name = "WEEK")]
from: Option<String>,
#[arg(long)]
json: bool,
},
Next {
#[arg(long, value_name = "WEEK")]
week: Option<String>,
#[arg(long)]
json: bool,
},
Week {
#[arg(long, value_name = "WEEK")]
week: Option<String>,
#[arg(long)]
json: bool,
},
ReleaseDay {
#[arg(long, value_name = "WEEK")]
week: Option<String>,
#[arg(long)]
json: bool,
},
Mcp,
Resolve {
project: String,
id: i64,
answer: Option<String>,
},
Wish {
project: String,
text: String,
},
Backup,
Doctor {
#[arg(long)]
json: bool,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum NoteKind {
Decision,
Finding,
Pitfall,
Change,
Next,
}
impl NoteKind {
fn as_str(self) -> &'static str {
match self {
NoteKind::Decision => "decision",
NoteKind::Finding => "finding",
NoteKind::Pitfall => "pitfall",
NoteKind::Change => "change",
NoteKind::Next => "next",
}
}
}
#[derive(Subcommand)]
enum ProjectCommand {
Add {
path: PathBuf,
#[arg(long)]
name: Option<String>,
},
List {
#[arg(long)]
json: bool,
},
Show {
name: String,
#[arg(long)]
json: bool,
},
Tier {
name: String,
tier: String,
#[arg(long, value_name = "WEEKS")]
rhythm: Option<u32>,
},
}
#[derive(Subcommand)]
enum VersionCommand {
Plan {
project: String,
version: String,
#[arg(long, value_name = "WEEK")]
week: Option<String>,
#[arg(long, conflicts_with = "week")]
clear: bool,
},
}
fn main() -> ExitCode {
match run(Cli::parse()) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err:#}");
ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> Result<()> {
match cli.command {
Command::Init => init(),
Command::Project { command } => match command {
ProjectCommand::Add { path, name } => project_add(path, name),
ProjectCommand::List { json } => project_list(json),
ProjectCommand::Show { name, json } => project_show(&name, json),
ProjectCommand::Tier { name, tier, rhythm } => project_tier(&name, &tier, rhythm),
},
Command::Import { project, hub, json } => import_hub(&project, &hub, json),
Command::Context {
project,
json,
explain,
budget,
} => show_context(&project, json, explain, budget),
Command::Open { project, print, budget } => open_session(&project, print, budget),
Command::Note { project, text, kind } => note(&project, kind.as_str(), &text),
Command::Sync { project, json } => sync_projects(project.as_deref(), json),
Command::Inbox { project, json } => inbox(project.as_deref(), json),
Command::Digest { project, since, json } => digest(project.as_deref(), &since, json),
Command::Find {
query,
project,
kind,
limit,
json,
} => find(&query, project.as_deref(), kind.as_deref(), limit, json),
Command::Why { project, version, json } => why(&project, &version, json),
Command::Version { command } => match command {
VersionCommand::Plan { project, version, week, clear } => version_plan(&project, &version, week.as_deref(), clear),
},
Command::Calendar { weeks, from, json } => show_calendar(weeks, from.as_deref(), json),
Command::Next { week, json } => show_next(week.as_deref(), json),
Command::Week { week, json } => show_week(week.as_deref(), json),
Command::ReleaseDay { week, json } => show_release_day(week.as_deref(), json),
Command::Mcp => mcp::serve(),
Command::Resolve { project, id, answer } => resolve(&project, id, answer.as_deref()),
Command::Wish { project, text } => note(&project, "wish", &text),
Command::Backup => backup(),
Command::Doctor { json } => doctor(json),
}
}
fn import_hub(project: &str, hub_dir: &Path, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let Some(project) = db.project_by_name(project)? else {
bail!("no project named '{project}'; see `rigger project list`");
};
let hub = hub::read(hub_dir)?;
let report = import::import(&db, project.id, &hub)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
for warning in &report.warnings {
println!("note: {warning}");
}
if !report.changed() {
println!("{}: nothing changed", project.name);
return Ok(());
}
println!("{}:", project.name);
let line = |label: &str, added: u32, updated: u32| {
if added + updated > 0 {
println!(" {label:<10} {added} added, {updated} updated");
}
};
line("versions", report.versions_added, report.versions_updated);
line("tasks", report.tasks_added, report.tasks_updated);
if report.decisions_added > 0 {
println!(" {:<10} {} added", "decisions", report.decisions_added);
}
if report.questions_added > 0 {
println!(" {:<10} {} added", "questions", report.questions_added);
}
Ok(())
}
fn open_project(db: &Db, name: &str) -> Result<db::Project> {
match db.project_by_name(name)? {
Some(project) => Ok(project),
None => bail!("no project named '{name}'; see `rigger project list`"),
}
}
fn show_context(project: &str, json: bool, explain: bool, budget: usize) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
let packet = context::build(&db, &project, budget)?;
if json {
println!("{}", serde_json::to_string_pretty(&packet)?);
return Ok(());
}
let text = context::render(&packet);
print!("{text}");
if explain {
println!("\n## Cost");
for cost in context::costs(&packet) {
println!("{:<14} {:>5} tokens", cost.section, cost.tokens);
}
println!("{:<14} {:>5} tokens of {budget}", "total", context::estimate_tokens(&text));
}
Ok(())
}
fn open_session(project: &str, print: bool, budget: usize) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
let packet = context::build(&db, &project, budget)?;
let message = open::first_message(&context::render(&packet));
if print {
print!("{message}");
return Ok(());
}
let dir = Path::new(&project.path);
open::check_dir(dir)?;
let (program, _) = open::assistant();
eprintln!("Starting {program} in {} with the packet for {}", project.path, project.name);
let code = open::run(dir, &message)?;
if code != 0 {
std::process::exit(code);
}
Ok(())
}
fn sync_projects(project: Option<&str>, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let projects = match project {
Some(name) => vec![open_project(&db, name)?],
None => db.projects()?,
};
let mut reports = Vec::new();
for project in &projects {
reports.push(sync::sync(&db, project)?);
}
if json {
println!("{}", serde_json::to_string_pretty(&reports)?);
return Ok(());
}
for report in &reports {
print_sync(report, projects.len() > 1);
}
Ok(())
}
fn print_sync(report: &sync::Report, many: bool) {
let quiet = !report.changed() && report.untagged.is_empty() && report.warnings.is_empty();
if many && quiet {
return;
}
println!("{}:", report.project);
for warning in &report.warnings {
println!(" note: {warning}");
}
let newly: Vec<&sync::Shipped> = report.shipped.iter().filter(|s| s.newly).collect();
for shipped in &newly {
let unplanned = report.unplanned.contains(&shipped.version);
let note = if unplanned { " (not in the plan)" } else { "" };
println!(" shipped {} on {}{note}", shipped.version, shipped.date);
}
if report.changes_recorded > 0 {
let n = report.changes_recorded;
let plural = if n == 1 { "change" } else { "changes" };
println!(" read {n} {plural} from commit messages");
}
for version in &report.untagged {
println!(" no tag {version} is closed in the plan");
}
if report.commits_since_tag > 0 && !quiet {
let since = match report.shipped.iter().max_by_key(|s| db::version_order(&s.version)) {
Some(newest) => format!(" since {}", newest.version),
None => String::new(),
};
let when = report.last_commit_at.as_deref().unwrap_or("unknown");
let commits = report.commits_since_tag;
let plural = if commits == 1 { "commit" } else { "commits" };
println!(" activity {commits} {plural}{since}, last on {when}");
}
if quiet {
println!(" nothing changed");
}
}
fn note(project: &str, kind: &str, text: &str) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
db.record_event(project.id, kind, text, &db::now(), "assistant")?;
println!("Recorded a {kind} for {}", project.name);
Ok(())
}
fn resolve(project: &str, id: i64, answer: Option<&str>) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
let (kind, body) = db.resolve_event(project.id, id, answer)?;
let first_line = body.lines().next().unwrap_or(&body);
match kind.as_str() {
"question" => println!("Answered [{id}]: {first_line}"),
_ => println!("Sorted [{id}]: {first_line}"),
}
if answer.is_some() {
println!(" the answer is recorded as a decision");
}
Ok(())
}
fn find(query: &str, project: Option<&str>, kind: Option<&str>, limit: u32, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
if let Some(name) = project {
open_project(&db, name)?;
}
let found = db
.find_events(&search::as_fts_query(query), project, kind, limit)
.with_context(|| format!("{query:?} is not a search FTS5 understands"))?;
if json {
println!("{}", serde_json::to_string_pretty(&found)?);
return Ok(());
}
if found.is_empty() {
println!("{}", search::nothing_found(query, project, kind));
return Ok(());
}
let show_project = project.is_none();
for event in &found {
print!("{}", search::render_event(event, show_project));
}
if found.len() as u32 == limit {
println!("({limit} shown; --limit for more)");
}
Ok(())
}
fn why(project: &str, version: &str, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
let why = search::why(&db, &project, version)?;
if json {
println!("{}", serde_json::to_string_pretty(&why)?);
return Ok(());
}
let mut heading = why.version.name.clone();
if let Some(title) = &why.version.title {
heading.push_str(&format!(" · {title}"));
}
match &why.version.shipped_at {
Some(on) => println!("{heading} — shipped {on}"),
None => println!("{heading} — being built"),
}
match &why.after {
Some(before) => println!("the work after {} ({})", before.name, before.shipped_at.as_deref().unwrap_or("undated")),
None => println!("the work from the start of the record"),
}
println!();
if why.events.is_empty() {
println!("Nothing was recorded in that window.");
if let Some(before) = &why.after
&& before.shipped_ts.is_some()
&& before.shipped_ts == why.version.shipped_ts
{
println!(
"{} and {} were tagged in the same second, so no work falls between them.",
before.name, why.version.name
);
}
return Ok(());
}
for event in &why.events {
print!("{}", search::render_event(event, false));
}
Ok(())
}
fn inbox(project: Option<&str>, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
if let Some(name) = project {
open_project(&db, name)?;
}
let mut waiting = db.open_questions()?;
if let Some(name) = project {
waiting.retain(|q| q.project == name);
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"waiting": waiting,
"shared": owner::shared_subjects(&waiting),
}))?
);
return Ok(());
}
if waiting.is_empty() {
match project {
Some(name) => println!("{name} is waiting on nothing."),
None => println!("Nothing is waiting on you."),
}
return Ok(());
}
let projects: std::collections::BTreeSet<&str> = waiting.iter().map(|q| q.project.as_str()).collect();
match project {
Some(_) => println!(
"{}
",
plural(waiting.len(), "question", "questions")
),
None => println!(
"{} in {}
",
plural(waiting.len(), "question", "questions"),
plural(projects.len(), "project", "projects")
),
}
let mut last: Option<&str> = None;
for question in &waiting {
let name = if last == Some(question.project.as_str()) {
String::new()
} else {
question.project.clone()
};
last = Some(&question.project);
println!("{name:<12} [{:>3}] {} {}", question.id, question.date, owner::subject(&question.body));
}
let shared = owner::shared_subjects(&waiting);
if !shared.is_empty() {
println!(
"
Asked by several projects - one answer settles each group:"
);
for group in &shared {
println!(" {} — {}", group.subject, group.projects.join(", "));
}
}
println!(
"
Answer one with: rigger resolve <project> <id> \"<answer>\""
);
Ok(())
}
fn digest(project: Option<&str>, since: &str, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let days = parse_days(since)?;
let from = day_before(days);
let projects = match project {
Some(name) => vec![open_project(&db, name)?],
None => db.projects()?,
};
let signals = week_facts(&db, calendar::Week::current())?.signals;
let mut reports = Vec::new();
for project in &projects {
let facts = db.digest(project.id, &from)?;
let stage = db.current_stage(project.id)?;
let next = stage.map(|s| match s.title {
Some(title) => format!("{} · {title}", s.version),
None => s.version,
});
let quiet = db.last_event_at(project.id)?.as_deref().and_then(days_since_utc);
let signal = signals.iter().find(|s| s.project == project.name).map(signal_line);
let lines = owner::digest_lines(&facts, next.as_deref(), quiet, signal.as_deref());
reports.push((project.name.clone(), facts, next, lines, signal));
}
if json {
let payload: Vec<_> = reports
.iter()
.map(|(name, facts, next, lines, signal)| serde_json::json!({ "project": name, "facts": facts, "next": next, "lines": lines, "signal": signal }))
.collect();
println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "since": from, "projects": payload }))?);
return Ok(());
}
println!(
"Since {from}
"
);
let (moved, still): (Vec<_>, Vec<_>) = reports
.iter()
.partition(|(_, facts, _, _, signal)| signal.is_some() || !facts.shipped.is_empty() || facts.decisions + facts.findings + facts.changes > 0);
let listed = if project.is_some() {
reports.iter().collect::<Vec<_>>()
} else {
moved.clone()
};
for (name, _, _, lines, _) in &listed {
println!("{name}");
for line in lines.iter() {
println!(" {line}");
}
}
if listed.is_empty() {
println!("Nothing moved.");
}
if project.is_none() && !still.is_empty() {
let names: Vec<&str> = still.iter().map(|(name, _, _, _, _)| name.as_str()).collect();
println!(
"
Quiet: {}",
names.join(", ")
);
}
Ok(())
}
fn parse_days(since: &str) -> Result<i64> {
let digits = since.trim().trim_end_matches(['d', 'D']);
digits
.parse::<i64>()
.ok()
.filter(|d| *d >= 0)
.with_context(|| format!("{since:?} is not a number of days; write it as `7d` or `30`"))
}
fn day_before(days: i64) -> String {
let seconds = jiff::Timestamp::now().as_second() - days * 86_400;
jiff::Timestamp::from_second(seconds)
.map(|t| t.to_string().split('T').next().unwrap_or_default().to_string())
.unwrap_or_default()
}
fn days_since_utc(timestamp: &str) -> Option<i64> {
let then: jiff::Timestamp = timestamp.parse().ok()?;
Some(((jiff::Timestamp::now().as_second() - then.as_second()) / 86_400).max(0))
}
fn plural(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
fn backup() -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let target = db.backup()?;
println!("Copied to {}", target.display());
Ok(())
}
fn init() -> Result<()> {
let path = paths::db_path()?;
if path.exists() {
Db::open(&path)?;
println!("Already initialised: {}", path.display());
return Ok(());
}
let db = Db::create(&path)?;
println!("Created {} (schema version {})", db.path().display(), db.schema_version()?);
println!("Next: rigger project add <path>");
Ok(())
}
fn project_add(path: PathBuf, name: Option<String>) -> Result<()> {
let root = dunce::canonicalize(&path).with_context(|| format!("{} is not a directory rigger can read", path.display()))?;
if !root.is_dir() {
bail!("{} is not a directory", root.display());
}
let db = Db::open(&paths::db_path()?)?;
let name = name.unwrap_or_else(|| repo::detect_name(&root));
let remote = repo::detect_remote(&root);
let project = db.add_project(&name, &root.to_string_lossy(), remote.as_deref())?;
println!("Recorded '{}' at {}", project.name, project.path);
match &project.remote {
Some(url) => println!(" remote: {url}"),
None => println!(" remote: none (no origin in .git/config)"),
}
Ok(())
}
fn project_list(json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let projects = db.projects()?;
if json {
println!("{}", serde_json::to_string_pretty(&projects)?);
return Ok(());
}
if projects.is_empty() {
println!("No projects yet. Add one with: rigger project add <path>");
return Ok(());
}
let width = projects.iter().map(|p| p.name.len()).max().unwrap_or(0);
for p in &projects {
println!("{:width$} {}", p.name, p.path);
}
Ok(())
}
fn project_show(name: &str, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let Some(project) = db.project_by_name(name)? else {
bail!("no project named '{name}'; see `rigger project list`");
};
if json {
println!("{}", serde_json::to_string_pretty(&project)?);
return Ok(());
}
println!("{}", project.name);
println!(" path: {}", project.path);
println!(" remote: {}", project.remote.as_deref().unwrap_or("none"));
println!(" since: {}", project.created_at);
Ok(())
}
fn project_tier(name: &str, tier: &str, rhythm: Option<u32>) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, name)?;
let tier = calendar::Tier::parse(tier)?;
let rhythm = match rhythm {
Some(0) => bail!("a rhythm of 0 weeks is not a rhythm; leave it out to use the tier's"),
Some(weeks) => Some(weeks),
None => tier.default_rhythm(),
};
db.set_tier(project.id, tier.as_str(), rhythm)?;
println!("{} is tier {tier} - {}", project.name, tier.describe());
match rhythm {
Some(weeks) => println!(" a release every {}", plural(weeks as usize, "week", "weeks")),
None => println!(" no rhythm to keep"),
}
Ok(())
}
fn version_plan(project: &str, version: &str, week: Option<&str>, clear: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
if week.is_none() && !clear {
bail!("say which week with --week 2026-W37, or --clear to take it off the calendar");
}
let week = week.map(calendar::Week::parse).transpose()?;
let stored = week.map(|w| w.to_string());
let change = db.set_planned_week(project.id, version, stored.as_deref())?;
match (week, change) {
(_, db::Change::Unchanged) => println!("{version} was already there; nothing changed"),
(Some(week), _) => println!("{version} is aimed at {week} - the week of {}", week.friday()),
(None, _) => println!("{version} is off the calendar"),
}
Ok(())
}
fn show_calendar(weeks: u32, from: Option<&str>, json: bool) -> Result<()> {
if weeks == 0 {
bail!("a calendar of 0 weeks shows nothing; ask for at least one");
}
let db = Db::open(&paths::db_path()?)?;
let now = calendar::Week::current();
let from = match from {
Some(text) => calendar::Week::parse(text)?,
None => now,
};
let mut rows = Vec::new();
let mut all = Vec::new();
for project in db.projects()? {
let versions = db.calendar_versions(project.id, &project.name)?;
let tier = project.tier.as_deref().and_then(|t| calendar::Tier::parse(t).ok());
let row = calendar::row(&project.name, tier, project.rhythm_weeks, &versions, from, weeks, now);
if !row.cells.is_empty() {
rows.push(row);
}
all.push((project.name.clone(), versions));
}
let span: Vec<calendar::Week> = (0..weeks).map(|n| from.plus(i64::from(n))).collect();
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"now": now,
"weeks": span,
"projects": rows,
}))?
);
return Ok(());
}
if rows.is_empty() {
println!("Nothing is on the calendar for these {}.", plural(weeks as usize, "week", "weeks"));
println!("Aim a version at a week with: rigger version plan <project> <version> --week 2026-W37");
return Ok(());
}
let name_width = rows.iter().map(|r| r.project.chars().count()).max().unwrap_or(0).max(7);
let widths: Vec<usize> = span
.iter()
.map(|week| {
rows.iter()
.map(|row| cell_text(row, *week).chars().count())
.max()
.unwrap_or(0)
.max(week.to_string().chars().count() + usize::from(*week == now))
})
.collect();
print!("{:name_width$}", "");
for (week, width) in span.iter().zip(&widths) {
let heading = if *week == now { format!("{week}*") } else { week.to_string() };
print!(" {heading:width$}");
}
println!();
for row in &rows {
print!("{:name_width$}", row.project);
for (week, width) in span.iter().zip(&widths) {
print!(" {:width$}", cell_text(row, *week));
}
if let Some(tier) = row.tier {
print!(" {tier}");
}
println!();
}
println!();
println!(
"{} shipped as planned {} slipped {} overdue {} unplanned {} planned",
calendar::Standing::Shipped.mark(),
calendar::Standing::Slipped.mark(),
calendar::Standing::Overdue.mark(),
calendar::Standing::Unplanned.mark(),
calendar::Standing::Planned.mark(),
);
let mut late: Vec<String> = Vec::new();
for row in &rows {
let Some((_, versions)) = all.iter().find(|(name, _)| *name == row.project) else {
continue;
};
for cell in &row.cells {
if !matches!(cell.standing, calendar::Standing::Slipped | calendar::Standing::Overdue) {
continue;
}
let Some(version) = versions.iter().find(|v| v.version == cell.version) else {
continue;
};
let Some(weeks) = version.slip().or_else(|| version.overdue(now)) else {
continue;
};
let aimed = version.planned.map(|w| w.to_string()).unwrap_or_default();
late.push(format!(
"{:name_width$} {} — aimed at {aimed}, {}",
row.project,
cell.version,
weeks_late(weeks)
));
}
}
if !late.is_empty() {
println!();
for line in &late {
println!("{line}");
}
}
Ok(())
}
fn cell_text(row: &calendar::Row, week: calendar::Week) -> String {
let cells: Vec<&calendar::Cell> = row.cells.iter().filter(|cell| cell.week == week).collect();
let named = |cell: &calendar::Cell| format!("{}{}", cell.standing.mark(), cell.version);
match cells.len() {
0 => String::new(),
1..=2 => cells.iter().map(|c| named(c)).collect::<Vec<_>>().join(" "),
n => {
let worst = cells
.iter()
.map(|c| c.standing)
.max_by_key(|s| severity(*s))
.unwrap_or(calendar::Standing::Shipped);
format!(
"{}{}..{} ({n})",
worst.mark(),
cells.first().map(|c| c.version.as_str()).unwrap_or(""),
cells.last().map(|c| c.version.as_str()).unwrap_or("")
)
}
}
}
fn severity(standing: calendar::Standing) -> u8 {
match standing {
calendar::Standing::Overdue => 4,
calendar::Standing::Slipped => 3,
calendar::Standing::Planned => 2,
calendar::Standing::Unplanned => 1,
calendar::Standing::Shipped => 0,
}
}
fn weeks_late(weeks: i64) -> String {
match weeks {
1 => "a week late".to_string(),
n if n < 0 => format!("{} early", plural(n.unsigned_abs() as usize, "week", "weeks")),
n => format!("{} late", plural(n as usize, "week", "weeks")),
}
}
struct WeekFacts {
focus: Vec<calendar::Focus>,
overdue: Vec<calendar::Focus>,
lapsed: Vec<calendar::Overdue>,
signals: Vec<week::Raised>,
release_day: week::ReleaseDay,
}
fn week_facts(db: &Db, now: calendar::Week) -> Result<WeekFacts> {
let mut focus = Vec::new();
let mut overdue = Vec::new();
let mut rhythms = Vec::new();
let mut standings = Vec::new();
let mut all_versions = Vec::new();
for project in db.projects()? {
let versions = db.calendar_versions(project.id, &project.name)?;
let tier = project.tier.as_deref().and_then(|t| calendar::Tier::parse(t).ok());
for version in &versions {
if version.planned == Some(now) && version.shipped.is_none() {
focus.push(calendar::Focus {
project: project.name.clone(),
tier,
version: version.version.clone(),
title: version.title.clone(),
planned: now,
overdue_weeks: None,
});
} else if let Some(weeks) = version.overdue(now) {
overdue.push(calendar::Focus {
project: project.name.clone(),
tier,
version: version.version.clone(),
title: version.title.clone(),
planned: version.planned.unwrap_or(now),
overdue_weeks: Some(weeks),
});
}
}
let last_shipped = versions
.iter()
.filter_map(|v| v.shipped.map(|week| (db::version_order(&v.version), week)))
.max()
.map(|(_, week)| week);
if let (Some(tier), Some(rhythm)) = (tier, project.rhythm_weeks)
&& tier != calendar::Tier::Out
{
rhythms.push((project.name.clone(), tier, rhythm, last_shipped));
}
if let Some(tier) = tier {
let touched = [db.last_event_at(project.id)?, db.activity(project.id)?.and_then(|a| a.last_commit_at)]
.into_iter()
.flatten()
.filter_map(|stamp| calendar::Week::of_recorded(&stamp))
.max();
standings.push(week::Standing {
project: project.name.clone(),
tier,
rhythm_weeks: project.rhythm_weeks,
last_shipped,
last_touched: touched,
has_first_release: last_shipped.is_some(),
});
}
all_versions.extend(versions);
}
focus.sort_by(|a, b| a.tier.cmp(&b.tier).then_with(|| a.project.cmp(&b.project)));
overdue.sort_by(|a, b| b.overdue_weeks.cmp(&a.overdue_weeks).then_with(|| a.project.cmp(&b.project)));
Ok(WeekFacts {
focus,
overdue,
lapsed: calendar::lapsed(&rhythms, now),
signals: week::signals(&standings, now),
release_day: week::release_day(now, &all_versions),
})
}
fn week_or_now(week: Option<&str>) -> Result<calendar::Week> {
match week {
Some(text) => calendar::Week::parse(text),
None => Ok(calendar::Week::current()),
}
}
fn show_next(week_arg: Option<&str>, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let now = week_or_now(week_arg)?;
let WeekFacts {
focus,
overdue,
lapsed,
signals,
..
} = week_facts(&db, now)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"week": now,
"friday": now.friday().to_string(),
"focus": focus,
"overdue": overdue,
"lapsed": lapsed,
"signals": signals,
}))?
);
return Ok(());
}
println!("{now} — releases on {}", now.friday());
println!();
if focus.is_empty() {
println!("Nothing is aimed at this week.");
} else {
for item in &focus {
let tier = item.tier.map(|t| format!(" [{t}]")).unwrap_or_default();
let title = item.title.as_deref().map(|t| format!(" · {t}")).unwrap_or_default();
println!("{}{tier} {}{title}", item.project, item.version);
}
}
if !overdue.is_empty() {
println!();
println!("Past their week:");
for item in &overdue {
let weeks = item.overdue_weeks.unwrap_or_default();
let ago = if weeks == 1 {
"a week ago".to_string()
} else {
format!("{} ago", plural(weeks.max(0) as usize, "week", "weeks"))
};
println!("{} {} — was due {} ({ago})", item.project, item.version, item.planned);
}
}
if !lapsed.is_empty() {
println!();
println!("Behind their rhythm:");
for item in &lapsed {
let since = match item.since {
Some(week) => format!("last shipped {week}"),
None => "never shipped".to_string(),
};
println!(
"{} [{}] {since}, {} without a release, rhythm is {}",
item.project,
item.tier,
plural(item.weeks.max(0) as usize, "week", "weeks"),
plural(item.rhythm_weeks as usize, "week", "weeks")
);
}
}
print_signals(&signals);
Ok(())
}
fn signal_line(item: &week::Raised) -> String {
let weeks = item.weeks.map(|w| plural(w.max(0) as usize, "week", "weeks")).unwrap_or_default();
match item.signal {
week::Signal::MissedCycle => format!("tier {} asks for more: more than one cycle missed - {weeks} without a release", item.tier),
week::Signal::WithoutFocus => format!("tier {} asks for more: no turn in the focus for {weeks}", item.tier),
week::Signal::SecondStart => match item.alongside.as_deref() {
Some(first) => format!("tier {} asks for more: started before {first} shipped anything", item.tier),
None => format!("tier {} asks for more: started out of turn", item.tier),
},
}
}
fn print_signals(signals: &[week::Raised]) {
if signals.is_empty() {
return;
}
println!();
println!("Their tier asks for more:");
for item in signals {
let said = signal_line(item).replacen(&format!("tier {} asks for more: ", item.tier), "", 1);
println!("{} [{}] {said}", item.project, item.tier);
}
}
fn show_week(week_arg: Option<&str>, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let now = week_or_now(week_arg)?;
let facts = week_facts(&db, now)?;
let waiting = db.open_questions()?;
let shared = owner::shared_subjects(&waiting);
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"week": now,
"monday": now.monday().to_string(),
"friday": now.friday().to_string(),
"focus": facts.focus,
"overdue": facts.overdue,
"shipping": facts.release_day.queued,
"shipped": facts.release_day.shipped,
"waiting": waiting,
"shared": shared,
"lapsed": facts.lapsed,
"signals": facts.signals,
}))?
);
return Ok(());
}
println!("{now} — {} to {}", now.monday(), now.friday());
println!();
println!("Focus");
if facts.focus.is_empty() {
println!(" nothing is aimed at this week");
} else {
for item in &facts.focus {
let tier = item.tier.map(|t| format!(" [{t}]")).unwrap_or_default();
let title = item.title.as_deref().map(|t| format!(" · {t}")).unwrap_or_default();
println!(" {}{tier} {}{title}", item.project, item.version);
}
}
println!();
println!("Ships on {}", now.friday());
if facts.release_day.queued.is_empty() && facts.release_day.shipped.is_empty() {
println!(" nothing is queued");
} else {
for item in &facts.release_day.queued {
let title = item.title.as_deref().map(|t| format!(" · {t}")).unwrap_or_default();
println!(" {} {}{title}", item.project, item.version);
}
let out = facts.release_day.shipped.len();
if out > 0 {
let over = facts.release_day.over_the_slot();
let spent = if over > 0 {
format!(" {} already out — {} past this week's one slot", plural(out, "release", "releases"), over)
} else {
format!(" {} already out — this week's slot is spent", plural(out, "release", "releases"))
};
println!("{spent}");
println!(" see the queue with: rigger release-day");
}
}
println!();
println!("Waiting on you");
if waiting.is_empty() {
println!(" nothing");
} else {
let projects: std::collections::BTreeSet<&str> = waiting.iter().map(|q| q.project.as_str()).collect();
println!(
" {} in {}",
plural(waiting.len(), "question", "questions"),
plural(projects.len(), "project", "projects")
);
for group in shared.iter().take(3) {
println!(" {} — {}", group.subject, group.projects.join(", "));
}
println!(" see them with: rigger inbox");
}
if !facts.overdue.is_empty() {
println!();
println!("Past their week:");
for item in &facts.overdue {
println!(" {} {} — was due {}", item.project, item.version, item.planned);
}
}
print_signals(&facts.signals);
Ok(())
}
fn show_release_day(week_arg: Option<&str>, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let now = week_or_now(week_arg)?;
let day = week_facts(&db, now)?.release_day;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"week": day.week,
"friday": day.friday,
"shipped": day.shipped,
"queued": day.queued,
"early": day.early(),
"over_the_slot": day.over_the_slot(),
}))?
);
return Ok(());
}
println!("{now} — releases on {}", day.friday);
println!();
if day.queued.is_empty() {
println!("Nothing is waiting for Friday.");
} else {
println!("Waiting for Friday:");
for item in &day.queued {
let title = item.title.as_deref().map(|t| format!(" · {t}")).unwrap_or_default();
println!(" {} {}{title}", item.project, item.version);
}
}
let days = day.days();
if !days.is_empty() {
println!();
println!("Already out this week:");
for entry in &days {
let mark = if entry.on_release_day { "Friday" } else { "early" };
let named: Vec<String> = entry.projects.iter().map(|p| format!("{} {}", p.project, p.summary())).collect();
println!(" {} {:<6} {:>2} {}", entry.day, mark, entry.releases, named.join(", "));
}
}
let early = day.early();
let over = day.over_the_slot();
if early > 0 || over > 0 {
println!();
if over > 0 {
println!("{} past the one release this week has room for", plural(over, "release", "releases"));
}
if early > 0 {
println!("{} went out before Friday", plural(early, "release", "releases"));
}
}
Ok(())
}
fn doctor(json: bool) -> Result<()> {
let path = paths::db_path()?;
if !path.exists() {
if json {
println!("{}", serde_json::json!({ "database": path, "initialised": false }));
} else {
println!("database: {} (missing - run `rigger init`)", path.display());
}
return Ok(());
}
let db = Db::open(&path)?;
let schema = db.schema_version()?;
let counts = db.counts()?;
let mut mismatches = Vec::new();
let mut unsynced = Vec::new();
for project in db.projects()? {
if db.activity(project.id)?.is_none() {
unsynced.push(project.name.clone());
continue;
}
for version in db.shipped_without_a_tag(project.id)? {
mismatches.push((project.name.clone(), version));
}
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"database": path,
"initialised": true,
"schema_version": schema,
"counts": counts,
"closed_without_a_tag": mismatches
.iter()
.map(|(project, version)| serde_json::json!({ "project": project, "version": version }))
.collect::<Vec<_>>(),
"never_synced": unsynced,
}))?
);
return Ok(());
}
println!("database: {}", path.display());
println!("schema: version {schema}");
println!("projects: {}", counts.projects);
println!("versions: {}", counts.versions);
println!("tasks: {}", counts.tasks);
println!("sessions: {}", counts.sessions);
println!("events: {}", counts.events);
if !unsynced.is_empty() {
println!(
"
never synced ({}): {}",
unsynced.len(),
unsynced.join(", ")
);
println!(" run `rigger sync` to read what git says about them");
}
if !mismatches.is_empty() {
println!(
"
closed in the plan, no tag in git ({}):",
mismatches.len()
);
for (project, version) in &mismatches {
println!(" {project:<12} {version}");
}
println!(" a tag would settle it; rigger does not change what you wrote");
}
Ok(())
}