mod adopt;
mod calendar;
mod commit;
mod context;
mod db;
mod export;
mod hub;
mod import;
mod mcp;
mod open;
mod owner;
mod paths;
mod repo;
mod retro;
mod search;
mod session;
mod skill;
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,
},
Adopt {
root: PathBuf,
#[arg(long)]
hubs: Option<PathBuf>,
#[arg(long)]
check: bool,
#[arg(long)]
json: bool,
},
Skill {
#[arg(required_unless_present = "print_template")]
project: Option<String>,
#[arg(long)]
install: bool,
#[arg(long, value_name = "DIR")]
dir: Option<PathBuf>,
#[arg(long)]
replace: bool,
#[arg(long, value_name = "FILE")]
template: Option<PathBuf>,
#[arg(long)]
print_template: 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,
},
Retro {
#[arg(long)]
cycle: bool,
#[arg(long, value_name = "N", conflicts_with = "cycle")]
weeks: Option<u32>,
#[arg(long, value_name = "WEEK")]
to: Option<String>,
#[arg(long)]
record: bool,
#[arg(long)]
json: bool,
},
Session {
#[command(subcommand)]
command: SessionCommand,
},
Export {
project: String,
#[arg(long)]
hub: PathBuf,
#[arg(long)]
check: bool,
#[arg(long)]
adopt: bool,
#[arg(long)]
json: bool,
},
Mcp,
Resolve {
project: String,
id: i64,
answer: Option<String>,
},
Wish {
project: String,
text: String,
},
Backup,
Doctor {
#[arg(long)]
hubs: bool,
#[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>,
},
Service {
name: 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 SessionCommand {
Start {
project: Option<String>,
#[arg(long)]
json: bool,
},
End {
project: Option<String>,
#[arg(long, value_name = "TEXT")]
heading: Option<String>,
#[arg(long, value_name = "FILE")]
diary: Option<PathBuf>,
#[arg(long)]
remind: bool,
#[arg(long)]
json: bool,
},
}
#[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 {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err) => return usage_error(err),
};
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err:#}");
ExitCode::FAILURE
}
}
}
fn usage_error(err: clap::Error) -> ExitCode {
let _ = err.print();
match err.use_stderr() {
true => ExitCode::FAILURE,
false => ExitCode::SUCCESS,
}
}
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::Service { name } => project_service(&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::Adopt { root, hubs, check, json } => adopt_root(&root, hubs.as_deref(), check, json),
Command::Skill {
project,
install,
dir,
replace,
template,
print_template,
} => write_skill(
project.as_deref(),
install || dir.is_some(),
dir.as_deref(),
replace,
template.as_deref(),
print_template,
),
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::Retro {
cycle,
weeks,
to,
record,
json,
} => show_retro(cycle, weeks, to.as_deref(), record, json),
Command::Session { command } => match command {
SessionCommand::Start { project, json } => session_start(project.as_deref(), json),
SessionCommand::End {
project,
heading,
diary,
remind,
json,
} => session_end(project.as_deref(), heading.as_deref(), diary.as_deref(), remind, json),
},
Command::Export {
project,
hub,
check,
adopt,
json,
} => export_hub(&project, &hub, check, adopt, 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 { hubs, json } => doctor(hubs, 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 hub_dir = &dunce::canonicalize(hub_dir).unwrap_or_else(|_| hub_dir.to_path_buf());
db.set_hub_path(project.id, 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 adopt_root(root: &Path, hubs: Option<&Path>, check: bool, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let adopted = adopt::adopt(&db, root, hubs, check)?;
if json {
println!("{}", serde_json::to_string_pretty(&adopted)?);
return Ok(());
}
let width = adopted.iter().map(|a| a.name.len()).max().unwrap_or(0);
let mut recorded = 0;
let mut known = 0;
let mut without = 0;
let mut skipped = 0;
let mut hubs_read = 0;
for a in &adopted {
let status = match (&a.status, check) {
(adopt::Status::Recorded, true) => "would record",
(adopt::Status::Recorded, false) => "recorded",
(adopt::Status::Known, _) => "known",
(adopt::Status::NoHub, _) => "no hub",
(adopt::Status::Skipped(_), _) => "skipped",
};
match &a.status {
adopt::Status::Recorded => recorded += 1,
adopt::Status::Known => known += 1,
adopt::Status::NoHub => without += 1,
adopt::Status::Skipped(_) => skipped += 1,
}
let hub = match (&a.hub, &a.status, check) {
(_, adopt::Status::NoHub, _) => String::new(),
(None, _, _) => "hub: none".to_string(),
(Some(_), adopt::Status::Skipped(_), _) | (Some(_), _, true) => "hub: found".to_string(),
(Some(_), _, false) => {
hubs_read += 1;
a.hub_summary()
}
};
let git = match (&a.status, check) {
(adopt::Status::Skipped(_) | adopt::Status::NoHub, _) | (_, true) => String::new(),
_ if a.shipped + a.changes_read == 0 => " git: nothing new".to_string(),
_ => format!(
" git: {} shipped, {} read",
plural(a.shipped as usize, "version", "versions"),
plural(a.changes_read as usize, "change", "changes")
),
};
println!("{:width$} {status:<12} {hub}{git}", a.name);
if let adopt::Status::Skipped(reason) = &a.status {
println!("{:width$} {reason}", "");
}
for warning in &a.warnings {
println!("{:width$} note: {warning}", "");
}
}
let total = adopted.len();
let verb = if check { "would be recorded" } else { "recorded" };
let without = match without {
0 => String::new(),
n => format!(", {n} without a hub"),
};
println!(
"\n{}: {recorded} {verb}, {known} known{without}, {skipped} skipped; {} read.",
plural(total, "repository", "repositories"),
plural(hubs_read, "hub", "hubs")
);
if check {
println!("Nothing was written. Run again without --check to record them.");
}
Ok(())
}
fn write_skill(project: Option<&str>, install: bool, dir: Option<&Path>, replace: bool, template: Option<&Path>, print_template: bool) -> Result<()> {
if print_template {
print!("{}", skill::DEFAULT_TEMPLATE);
return Ok(());
}
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project.unwrap_or_default())?;
let (template, source) = skill::load_template(template)?;
let about = match project.kind {
db::Kind::Repo => repo::detect_about(Path::new(&project.path)),
db::Kind::Service => None,
};
let fields = skill::Fields {
name: &project.name,
path: &project.path,
remote: project.remote.as_deref(),
hub: project.hub_path.as_deref().map(Path::new),
about: about.as_deref(),
};
let rendered = skill::render(&template, &fields)?;
for note in &rendered.notes {
eprintln!("note: {note}");
}
if !install {
print!("{}", rendered.text);
return Ok(());
}
let dir = match dir {
Some(dir) => dir.to_path_buf(),
None => skill::skills_dir()?,
}
.join(&project.name);
let path = dir.join("SKILL.md");
let before = std::fs::read_to_string(&path).unwrap_or_default();
if !before.is_empty() && !skill::is_generated(&before) && !replace {
bail!(
"{} was written by hand and rigger has not written it before.
Move what it says that only this project can say into the hub, then run again with `--replace`.",
path.display()
);
}
if before == rendered.text {
println!("{} is already what the template says.", path.display());
return Ok(());
}
std::fs::create_dir_all(&dir).with_context(|| format!("cannot create {}", dir.display()))?;
std::fs::write(&path, &rendered.text).with_context(|| format!("cannot write {}", path.display()))?;
let what = if before.is_empty() { "Wrote" } else { "Rewrote" };
println!("{what} {} from {source}.", path.display());
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 project_here(db: &Db, name: Option<&str>) -> Result<db::Project> {
if let Some(name) = name {
return open_project(db, name);
}
let here = std::env::current_dir().context("cannot read the working directory")?;
let here = dunce::canonicalize(&here).unwrap_or(here);
for dir in here.ancestors() {
if let Some(project) = db.project_by_path(&dir.to_string_lossy())? {
return Ok(project);
}
}
bail!(
"no project recorded at {} or above it; name one, or add this directory with `rigger project add`",
here.display()
)
}
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 {
if !project.kind.reads_git() {
if projects.len() == 1 {
println!("{} is a place the record keeps for itself; there is no repository to read", project.name);
}
continue;
}
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(), db::Kind::Repo)?;
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_service(name: &str) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let path = format!("service:{name}");
let project = db.add_project(name, &path, None, db::Kind::Service)?;
println!("Recorded '{}' as a place the record keeps for itself", project.name);
println!(" no repository: sync will not ask git about it");
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, where_it_lives(p));
}
Ok(())
}
fn where_it_lives(project: &db::Project) -> String {
match project.kind {
db::Kind::Repo => project.path.clone(),
db::Kind::Service => "(no repository - a place the record keeps for itself)".to_string(),
}
}
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);
match project.kind {
db::Kind::Repo => {
println!(" path: {}", project.path);
println!(" remote: {}", project.remote.as_deref().unwrap_or("none"));
}
db::Kind::Service => println!(" kind: a place the record keeps for itself; no repository"),
}
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 show_retro(cycle: bool, weeks: Option<u32>, to: Option<&str>, record: bool, json: bool) -> Result<()> {
let span = match (cycle, weeks) {
(true, _) => retro::CYCLE_WEEKS,
(_, Some(0)) => bail!("a retro of 0 weeks looks back at nothing; ask for at least one"),
(_, Some(n)) => n,
(false, None) => 4,
};
let db = Db::open(&paths::db_path()?)?;
let to = week_or_now(to)?;
let from = to.plus(-i64::from(span - 1));
let mut versions = Vec::new();
let mut projects = Vec::new();
for project in db.projects()? {
versions.extend(db.calendar_versions(project.id, &project.name)?);
let tier = project.tier.as_deref().and_then(|t| calendar::Tier::parse(t).ok());
projects.push((project.name.clone(), tier, project.rhythm_weeks));
}
let looked = retro::look_back(from, to, &versions, &projects);
let summary = retro::summary(&looked);
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"from": looked.from,
"to": looked.to,
"weeks": looked.weeks(),
"shipped": looked.shipped,
"missed": looked.missed,
"standings": looked.standings,
"on_time": looked.on_time(),
"slipped": looked.slipped(),
"unplanned": looked.unplanned(),
"planned_share": looked.planned_share(),
"summary": summary,
}))?
);
return Ok(());
}
println!("{} to {} — {}", looked.from, looked.to, plural(looked.weeks().max(0) as usize, "week", "weeks"));
println!();
if looked.shipped.is_empty() && looked.missed.is_empty() {
println!("Nothing shipped and nothing was aimed at these weeks.");
if record {
println!();
println!("Nothing to keep.");
}
return Ok(());
}
println!(
"{} shipped — {} on time, {} slipped, {} unplanned",
looked.shipped.len(),
looked.on_time(),
looked.slipped(),
looked.unplanned()
);
if let Some(share) = looked.planned_share() {
println!("{share}% of what shipped had been planned");
}
if !looked.missed.is_empty() {
println!();
println!("Planned and not shipped:");
for item in &looked.missed {
println!(
" {} {} — was due {} ({} by the end of the window)",
item.project,
item.version,
item.planned,
weeks_late(item.weeks)
);
}
}
let mut slipped: Vec<&retro::Shipped> = looked.shipped.iter().filter(|s| s.slip.is_some_and(|n| n != 0)).collect();
slipped.sort_by_key(|s| std::cmp::Reverse(s.slip));
if !slipped.is_empty() {
println!();
println!("Shipped, but not when it was aimed:");
for item in slipped.iter().take(10) {
let aimed = item.planned.map(|w| w.to_string()).unwrap_or_default();
println!(
" {} {} — aimed at {aimed}, out in {} ({})",
item.project,
item.version,
item.week,
weeks_late(item.slip.unwrap_or(0))
);
}
if slipped.len() > 10 {
println!(" ... and {} more", slipped.len() - 10);
}
}
if !looked.standings.is_empty() {
println!();
println!("Per project:");
let width = looked.standings.iter().map(|s| s.project.chars().count()).max().unwrap_or(0);
for item in &looked.standings {
let tier = item.tier.map(|t| format!("[{t}]")).unwrap_or_else(|| " ".to_string());
let asked = match item.expected {
Some(n) => format!("{n} asked"),
None => "none asked".to_string(),
};
let missed = if item.missed > 0 {
format!(", {} missed", item.missed)
} else {
String::new()
};
println!(
" {:width$} {tier} {} shipped ({} planned), {asked}{missed}",
item.project, item.shipped, item.planned_and_shipped
);
}
}
let stalled = looked.misfits(retro::Misfit::Stalled);
let outgrown = looked.misfits(retro::Misfit::Outgrown);
if !stalled.is_empty() {
println!();
println!("Nothing shipped, and their tier asked for something:");
for item in &stalled {
let tier = item.tier.map(|t| t.to_string()).unwrap_or_default();
println!(" {} [{tier}] 0 against {} asked for", item.project, item.expected.unwrap_or(0));
}
}
if !outgrown.is_empty() {
println!();
println!("Shipping past their tier — it may be describing the wrong thing now:");
for item in outgrown.iter().take(5) {
let tier = item.tier.map(|t| t.to_string()).unwrap_or_default();
let over = item.times_over().unwrap_or(0);
println!(
" {} [{tier}] {} shipped against {} asked for ({over}x)",
item.project,
item.shipped,
item.expected.unwrap_or(0)
);
}
if outgrown.len() > 5 {
println!(" ... and {} more", outgrown.len() - 5);
}
}
if !stalled.is_empty() || !outgrown.is_empty() {
println!(" move one with: rigger project tier <project> <A|B|C|out>");
}
println!();
if record {
record_retro(&db, &looked, &summary)?;
} else {
println!("Keep this in the record with: rigger retro --record");
}
Ok(())
}
fn record_retro(db: &Db, looked: &retro::Retro, summary: &str) -> Result<()> {
let Some(project) = db.service_project()? else {
bail!(
"no place to keep it: a retro is about every project, so its summary belongs to none of them.
Make one with: rigger project service line"
);
};
let at = format!("{}T00:00:00Z", looked.to.friday());
let change = db.record_event(project.id, "change", summary, &at, "assistant")?;
match change {
db::Change::Unchanged => println!("That retro is already in the record, under '{}'.", project.name),
_ => println!("Kept in the record under '{}'.", project.name),
}
Ok(())
}
fn session_start(project: Option<&str>, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = project_here(&db, project)?;
let (session, change) = db.start_session(project.id, &db::now())?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({ "session": session, "already_open": change == db::Change::Unchanged }))?
);
return Ok(());
}
match change {
db::Change::Unchanged => println!("A session on {} is already open, since {}.", project.name, session.started_at),
_ => println!("Session open on {}. Everything recorded now belongs to it.", project.name),
}
Ok(())
}
fn session_end(project: Option<&str>, heading: Option<&str>, diary: Option<&Path>, remind: bool, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = match (project_here(&db, project), remind) {
(Ok(project), _) => project,
(Err(_), true) => return Ok(()),
(Err(e), false) => return Err(e),
};
let Some(open) = db.open_session(project.id)? else {
if remind {
return Ok(());
}
if json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "session": serde_json::Value::Null }))?);
return Ok(());
}
println!("No session is open on {}.", project.name);
println!("Open one with: rigger session start {}", project.name);
return Ok(());
};
let at = db::now();
let events = db.session_events(open.id)?;
let shipped = db.shipped_between(project.id, &open.started_at, &at)?;
let closed = db.tasks_closed_between(project.id, &open.started_at, &at)?;
let next_step = db.latest_event_body(project.id, "next")?;
let ended = db::Session {
ended_at: Some(at.clone()),
..open.clone()
};
let summary = session::summarise(&project.name, &ended, &events, shipped, closed, next_step);
db.end_session(open.id, &at)?;
let written = match diary {
Some(path) => Some(write_diary(path, &summary, heading)?),
None => None,
};
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"session": summary,
"missing": summary.missing(),
"diary": written,
}))?
);
return Ok(());
}
if remind {
let missing = summary.missing();
if missing.is_empty() {
return Ok(());
}
println!("Session on {} closed - {}:", project.name, plural(summary.recorded(), "event", "events"));
for item in &missing {
println!(" {item}");
}
return Ok(());
}
println!("Session on {} closed, open since {}.", project.name, open.started_at);
println!();
if summary.empty() {
println!("Nothing was recorded in it.");
} else {
if !summary.shipped.is_empty() {
println!("shipped {}", summary.shipped.join(", "));
}
let counted = [
("decision", "decisions", summary.decisions.len()),
("finding", "findings", summary.findings.len()),
("pitfall", "pitfalls", summary.pitfalls.len()),
("change", "changes", summary.changes.len()),
("question", "questions", summary.questions.len()),
];
let recorded: Vec<String> = counted.iter().filter(|(_, _, n)| *n > 0).map(|(one, many, n)| plural(*n, one, many)).collect();
if !recorded.is_empty() {
println!("recorded {}", recorded.join(", "));
}
if !summary.tasks_closed.is_empty() {
println!("closed {}", plural(summary.tasks_closed.len(), "task", "tasks"));
}
}
if let Some(next) = &summary.next_step {
println!("next: {}", first_line(next));
}
let missing = summary.missing();
if !missing.is_empty() {
println!();
println!("The ritual asks for:");
for item in &missing {
println!(" {item}");
}
}
match written {
Some(path) => println!(
"
Diary entry appended to {path}"
),
None => println!(
"
Write it into a diary with: rigger session end {} --diary <file>",
project.name
),
}
Ok(())
}
fn write_diary(path: &Path, summary: &session::Summary, heading: Option<&str>) -> Result<String> {
let day = summary.ended_at.split('T').next().unwrap_or_default().to_string();
let entry = session::diary_entry(summary, &day, heading);
let existing = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).with_context(|| format!("cannot read {}", path.display())),
};
let (preamble, entries) = match existing.find(
"
## ",
) {
Some(at) => existing.split_at(at + 1),
None => (existing.as_str(), ""),
};
let mut out = String::new();
if !preamble.trim().is_empty() {
out.push_str(preamble.trim_end());
out.push_str(
"
",
);
}
out.push_str(entry.trim_end());
out.push_str(
"
",
);
if !entries.trim().is_empty() {
out.push_str(entries.trim_start());
if !out.ends_with('\n') {
out.push('\n');
}
}
std::fs::write(path, out).with_context(|| format!("cannot write {}", path.display()))?;
Ok(path.display().to_string())
}
fn first_line(text: &str) -> &str {
text.lines().find(|l| !l.trim().is_empty()).unwrap_or(text).trim()
}
fn export_hub(project: &str, hub_dir: &Path, check: bool, adopt: bool, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
if !hub_dir.is_dir() {
bail!("{} is not a directory", hub_dir.display());
}
let mut files = Vec::new();
for name in export::GENERATED {
files.push((name, generate(&db, &project, name)?));
}
if !check {
db.set_hub_path(project.id, hub_dir)?;
}
let mut written = Vec::new();
for (name, text) in &files {
let path = hub_dir.join(name);
let before = std::fs::read_to_string(&path).unwrap_or_default();
let text = &export::with_line_ending(text, export::line_ending(&before));
let unchanged = before == *text;
if !unchanged && !before.is_empty() && !export::is_generated(&before) && !adopt && !check {
bail!(
"{} was written by hand and the record does not own it yet.
Check what would change with `--check`, then hand it over with `--adopt`.",
path.display()
);
}
if !check && !unchanged {
std::fs::write(&path, text).with_context(|| format!("cannot write {}", path.display()))?;
}
written.push(export::Written {
file: name.to_string(),
bytes: text.len(),
unchanged,
});
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({ "project": project.name, "files": written }))?
);
return Ok(());
}
let changed = written.iter().filter(|w| !w.unchanged).count();
for file in &written {
let state = match (file.unchanged, check) {
(true, _) => "unchanged",
(false, true) => "would change",
(false, false) => "written",
};
println!(" {:<14} {state:<12} {} bytes", file.file, file.bytes);
}
match (changed, check) {
(0, _) => println!("\n{} is already what the record says.", hub_dir.display()),
(n, true) => println!("\n{} of {} files differ from the record.", n, written.len()),
(n, false) => println!("\n{} wrote {} of {} files.", project.name, n, written.len()),
}
Ok(())
}
fn generate(db: &Db, project: &db::Project, name: &str) -> Result<String> {
let prose = db.hub_prose(project.id, name)?;
Ok(match name {
n if n == export::GENERATED[0] => {
let questions: Vec<String> = db.open_events(project.id, "question")?.into_iter().map(|(_, text)| text).collect();
export::plan(&prose, &db.stages(project.id, false)?, &questions)
}
n if n == export::GENERATED[1] => export::changes(&prose, &db.stages(project.id, true)?),
n if n == export::GENERATED[3] => export::readme(&prose, &db.state_lines(project.id)?),
_ => export::diary(&prose, &db.diary_entries(project.id)?),
})
}
fn hub_drift(db: &Db) -> Result<Vec<(String, String, &'static str)>> {
let mut out = Vec::new();
for project in db.projects()? {
if !project.kind.reads_git() {
continue;
}
let Some(dir) = project.hub_path.as_deref().map(std::path::PathBuf::from) else {
out.push((project.name.clone(), String::from("-"), "no hub recorded; import or export one"));
continue;
};
if !dir.is_dir() {
out.push((project.name.clone(), String::from("-"), "the hub is not where the record says"));
continue;
}
for name in export::GENERATED {
let path = dir.join(name);
let Ok(text) = std::fs::read_to_string(&path) else { continue };
if !export::is_generated(&text) {
continue;
}
let want = generate(db, &project, name)?;
let want = export::with_line_ending(&want, export::line_ending(&text));
if want != text {
out.push((project.name.clone(), name.to_string(), "edited since it was generated"));
}
}
}
Ok(out)
}
fn doctor(hubs: bool, 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 !project.kind.reads_git() {
continue;
}
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,
"hubs": if hubs {
serde_json::to_value(
hub_drift(&db)?
.iter()
.map(|(project, file, why)| serde_json::json!({ "project": project, "file": file, "why": why }))
.collect::<Vec<_>>(),
)?
} else {
serde_json::Value::Null
},
"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");
}
if hubs {
let drift = hub_drift(&db)?;
println!();
if drift.is_empty() {
println!("hubs: every generated file matches the record");
} else {
println!("hubs the record cannot vouch for ({}):", drift.len());
for (project, file, why) in &drift {
println!(" {project:<12} {file:<14} {why}");
}
if drift.iter().any(|(_, file, _)| file != "-") {
println!(" edited: `rigger import` takes the edit into the record; `rigger export` discards it");
}
}
}
Ok(())
}