mod adopt;
mod answers;
mod calendar;
mod card;
mod commit;
mod context;
mod db;
#[allow(dead_code)]
mod doc;
mod export;
mod gate;
mod hub;
mod import;
mod line;
mod link;
mod mcp;
mod open;
mod owner;
mod paths;
mod profile;
mod repo;
mod retro;
mod search;
mod session;
mod show;
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, required_unless_present = "answers", conflicts_with = "answers")]
hub: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
answers: Option<PathBuf>,
#[arg(long)]
check: bool,
#[arg(long)]
json: bool,
},
Profile {
#[command(subcommand)]
command: ProfileCommand,
},
Task {
#[command(subcommand)]
command: TaskCommand,
},
Adopt {
root: Option<PathBuf>,
#[arg(long)]
hubs: Option<PathBuf>,
#[arg(long)]
check: bool,
#[arg(long)]
json: bool,
},
Skill {
#[arg(required_unless_present_any = ["print_template", "line"])]
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,
#[arg(long, conflicts_with = "project")]
line: bool,
},
Show {
project: String,
#[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,
#[arg(long, value_name = "NAME")]
principle: Option<String>,
},
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: Option<String>,
version: Option<String>,
#[arg(long, value_name = "NAME")]
principle: Option<String>,
#[arg(long)]
principles: bool,
#[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 {
#[arg(required_unless_present = "line")]
project: Option<String>,
#[arg(long, required_unless_present = "line", conflicts_with = "line")]
hub: Option<PathBuf>,
#[arg(long)]
line: bool,
#[arg(long, value_name = "FILE", requires = "line")]
to: Option<PathBuf>,
#[arg(long)]
check: bool,
#[arg(long)]
adopt: bool,
#[arg(long)]
docs: bool,
#[arg(long)]
json: bool,
},
Mcp,
Resolve {
project: String,
id: i64,
answer: Option<String>,
},
Wish {
project: String,
text: String,
#[arg(long = "from", value_name = "PROJECT")]
from_project: Option<String>,
},
Link {
#[command(subcommand)]
command: LinkCommand,
},
Doc {
#[command(subcommand)]
command: DocCommand,
},
Gate {
project: Option<String>,
#[arg(long)]
check: bool,
#[arg(long)]
json: bool,
},
Rules {
project: Option<String>,
#[arg(long)]
json: bool,
},
Backup {
#[arg(long, default_value_t = KEEP_BACKUPS, value_name = "N")]
keep: usize,
#[arg(long)]
list: bool,
},
Doctor {
#[arg(long)]
hubs: bool,
#[arg(long)]
json: bool,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum NoteKind {
Decision,
Finding,
Pitfall,
Change,
Next,
State,
Plan,
}
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",
NoteKind::State => "state",
NoteKind::Plan => "plan",
}
}
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum LinkKind {
Pair,
Consumer,
Donor,
}
impl LinkKind {
fn to_domain(self) -> link::Kind {
match self {
LinkKind::Pair => link::Kind::Pair,
LinkKind::Consumer => link::Kind::Consumer,
LinkKind::Donor => link::Kind::Donor,
}
}
}
#[derive(Subcommand)]
enum LinkCommand {
Add {
from: String,
to: String,
#[arg(long, value_name = "KIND", default_value = "pair")]
kind: LinkKind,
#[arg(long)]
note: Option<String>,
},
List {
project: Option<String>,
#[arg(long)]
json: bool,
},
Remove {
id: i64,
},
Drift {
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum DocCommand {
List {
project: String,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
json: bool,
},
Show {
project: String,
slug: String,
#[arg(long)]
json: bool,
},
Add {
project: String,
title: String,
#[arg(long, default_value = "other")]
kind: String,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
body: Option<String>,
},
Edit {
project: String,
slug: String,
#[arg(long)]
title: Option<String>,
#[arg(long)]
body: Option<String>,
},
Remove {
project: String,
slug: String,
},
Template {
kind: String,
#[arg(long)]
write: bool,
},
}
#[derive(Subcommand)]
enum ProfileCommand {
List {
#[arg(long)]
json: bool,
},
Show {
name: Option<String>,
#[arg(long)]
json: bool,
},
Use {
name: String,
},
Add {
name: String,
#[arg(long, value_enum, default_value_t = profile::Kind::Line)]
kind: profile::Kind,
#[arg(long = "root", value_name = "DIR")]
roots: Vec<PathBuf>,
#[arg(long, value_name = "DIR")]
hubs: Option<PathBuf>,
#[arg(long, value_name = "REGEX")]
id_pattern: Option<String>,
#[arg(long, value_name = "DIR")]
inbox: Option<PathBuf>,
#[arg(long)]
r#use: bool,
},
Set {
name: Option<String>,
#[arg(long = "root", value_name = "DIR")]
roots: Vec<PathBuf>,
#[arg(long, value_name = "DIR")]
hubs: Option<PathBuf>,
#[arg(long, value_name = "REGEX")]
id_pattern: Option<String>,
#[arg(long, value_name = "DIR")]
inbox: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum TaskCommand {
New {
title: String,
#[arg(long = "id", value_name = "KEY")]
key: Option<String>,
#[arg(long = "alias", value_name = "TEXT")]
aliases: Vec<String>,
#[arg(long = "project", value_name = "NAME")]
projects: Vec<String>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
summary: Option<String>,
},
Find {
query: String,
#[arg(long)]
json: bool,
},
Open {
task: String,
},
Active {
#[arg(long)]
json: bool,
},
Close {
task: Option<String>,
#[arg(long, default_value = "done")]
status: String,
},
Show {
task: String,
#[arg(long)]
json: bool,
},
Context {
task: String,
#[arg(long, default_value_t = context::DEFAULT_BUDGET)]
budget: usize,
},
Link {
task: String,
project: String,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
role: Option<String>,
},
List {
#[arg(long, default_value = "open")]
status: String,
#[arg(long)]
json: bool,
},
Rename {
task: String,
key: String,
},
Alias {
task: String,
alias: String,
},
Summary {
task: String,
text: String,
},
Note {
task: String,
text: String,
#[arg(long, value_name = "KIND", default_value = "finding")]
kind: NoteKind,
},
Status {
task: String,
status: String,
},
}
#[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,
},
Set {
name: String,
#[arg(long, value_name = "COMMAND")]
gate: Option<String>,
#[arg(long, conflicts_with = "gate")]
no_gate: bool,
#[arg(long, value_name = "COMMAND")]
on_session_end: Option<String>,
#[arg(long, conflicts_with = "on_session_end")]
no_on_session_end: bool,
},
Mark {
name: String,
#[arg(long)]
code: Option<String>,
#[arg(long)]
accent: Option<String>,
#[arg(long)]
accent2: Option<String>,
#[arg(long)]
form: Option<String>,
#[arg(long, value_name = "URL")]
docs: Option<String>,
#[arg(long, conflicts_with_all = ["code", "accent", "accent2", "form", "docs"])]
clear: 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,
},
}
const STACK: usize = 16 * 1024 * 1024;
fn main() -> ExitCode {
match std::thread::Builder::new().stack_size(STACK).spawn(work).map(std::thread::JoinHandle::join) {
Ok(Ok(code)) => code,
Ok(Err(_)) => ExitCode::from(101),
Err(e) => {
eprintln!("error: cannot start the working thread: {e}");
ExitCode::FAILURE
}
}
}
fn work() -> 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::Set {
name,
gate,
no_gate,
on_session_end,
no_on_session_end,
} => project_set(&name, gate.as_deref(), no_gate, on_session_end.as_deref(), no_on_session_end),
ProjectCommand::Mark {
name,
code,
accent,
accent2,
form,
docs,
clear,
} => project_mark(
&name,
code.as_deref(),
accent.as_deref(),
accent2.as_deref(),
form.as_deref(),
docs.as_deref(),
clear,
),
ProjectCommand::Tier { name, tier, rhythm } => project_tier(&name, &tier, rhythm),
},
Command::Import {
project,
hub,
answers,
check,
json,
} => match answers {
Some(answers) => import_answers(&project, &answers, check, json),
None => import_hub(&project, &hub.expect("clap requires --hub without --answers"), json),
},
Command::Profile { command } => match command {
ProfileCommand::List { json } => profile_list(json),
ProfileCommand::Show { name, json } => profile_show(name.as_deref(), json),
ProfileCommand::Use { name } => profile_use(&name),
ProfileCommand::Add {
name,
kind,
roots,
hubs,
id_pattern,
inbox,
r#use,
} => profile_add(
&name,
profile::Profile {
kind,
roots,
hubs,
id_pattern,
inbox,
},
r#use,
),
ProfileCommand::Set {
name,
roots,
hubs,
id_pattern,
inbox,
} => profile_set(name.as_deref(), roots, hubs, id_pattern, inbox),
},
Command::Task { command } => match command {
TaskCommand::New {
title,
key,
aliases,
projects,
branch,
summary,
} => task_new(&title, key.as_deref(), &aliases, &projects, branch.as_deref(), summary.as_deref()),
TaskCommand::Find { query, json } => task_find(&query, json),
TaskCommand::Open { task } => task_open(&task),
TaskCommand::Active { json } => task_active(json),
TaskCommand::Close { task, status } => task_close(task.as_deref(), &status),
TaskCommand::Show { task, json } => task_show(&task, json),
TaskCommand::Context { task, budget } => task_context(&task, budget),
TaskCommand::Link { task, project, branch, role } => task_link(&task, &project, branch.as_deref(), role.as_deref()),
TaskCommand::List { status, json } => task_list(&status, json),
TaskCommand::Rename { task, key } => task_rename(&task, &key),
TaskCommand::Alias { task, alias } => task_alias(&task, &alias),
TaskCommand::Summary { task, text } => task_summary(&task, &text),
TaskCommand::Note { task, text, kind } => note_on_card(&task, kind.as_str(), &text),
TaskCommand::Status { task, status } => task_status(&task, &status),
},
Command::Adopt { root, hubs, check, json } => adopt_root(root.as_deref(), hubs.as_deref(), check, json),
Command::Skill {
project,
install,
dir,
replace,
template,
print_template,
line,
} => {
let install = install || dir.is_some();
if line {
write_line_skill(install, dir.as_deref(), replace, template.as_deref(), print_template)
} else {
write_skill(project.as_deref(), install, dir.as_deref(), replace, template.as_deref(), print_template)
}
}
Command::Show { project, json } => show_project(&project, 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,
principle,
} => note(&project, kind.as_str(), &text, principle.as_deref(), None),
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,
principle,
principles,
json,
} => why(project.as_deref(), version.as_deref(), principle.as_deref(), principles, 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,
line,
to,
check,
adopt,
docs,
json,
} => match line {
true => export_line(to.as_deref(), check),
false => export_hub(
&project.expect("clap requires a project without --line"),
&hub.expect("clap requires --hub without --line"),
check,
adopt,
docs,
json,
),
},
Command::Mcp => mcp::serve(),
Command::Resolve { project, id, answer } => resolve(&project, id, answer.as_deref()),
Command::Wish { project, text, from_project } => note(&project, "wish", &text, None, from_project.as_deref()),
Command::Link { command } => match command {
LinkCommand::Add { from, to, kind, note } => link_add(&from, &to, kind, note.as_deref()),
LinkCommand::List { project, json } => link_list(project.as_deref(), json),
LinkCommand::Remove { id } => link_remove(id),
LinkCommand::Drift { json } => link_drift(json),
},
Command::Doc { command } => match command {
DocCommand::List { project, kind, json } => doc_list(&project, kind.as_deref(), json),
DocCommand::Show { project, slug, json } => doc_show(&project, &slug, json),
DocCommand::Add {
project,
title,
kind,
slug,
body,
} => doc_add(&project, &title, &kind, slug.as_deref(), body.as_deref()),
DocCommand::Edit { project, slug, title, body } => doc_edit(&project, &slug, title.as_deref(), body.as_deref()),
DocCommand::Remove { project, slug } => doc_remove(&project, &slug),
DocCommand::Template { kind, write } => doc_template(&kind, write),
},
Command::Gate { project, check, json } => gate(project.as_deref(), check, json),
Command::Rules { project, json } => rules(project.as_deref(), json),
Command::Backup { keep, list } => backup(keep, list),
Command::Doctor { hubs, json } => doctor(hubs, json),
}
}
fn import_answers(project: &str, path: &Path, check: bool, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
let answers = answers::read(path)?;
let entries = answers.entries();
let at = answers.at().unwrap_or_else(db::now);
if check {
println!(
"{} would take {} from {}:",
project.name,
plural(entries.len(), "event", "events"),
path.display()
);
for entry in &entries {
println!(" {:<9} {}", entry.kind, first_line(&entry.body));
}
return Ok(());
}
let mut added = 0usize;
let mut already = 0usize;
for entry in &entries {
match db.record_event(project.id, entry.kind, &entry.body, &at, "owner")? {
db::Change::Unchanged => already += 1,
_ => added += 1,
}
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"project": project.name,
"added": added,
"already_recorded": already,
}))?
);
return Ok(());
}
println!("{}:", project.name);
println!(" {:<10} {added} added", "answers");
if already > 0 {
println!(" {:<10} {already} already recorded", "");
}
Ok(())
}
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.versions_dropped + report.tasks_dropped > 0 {
println!(
" {:<10} {} and {} struck from the hub",
"dropped",
plural(report.versions_dropped as usize, "version", "versions"),
plural(report.tasks_dropped as usize, "task", "tasks")
);
}
if report.questions_withdrawn > 0 {
println!(
" {:<10} {} struck from the hub",
"withdrawn",
plural(report.questions_withdrawn as usize, "question", "questions")
);
}
if report.decisions_added > 0 {
println!(" {:<10} {} added", "decisions", report.decisions_added);
}
if report.questions_added > 0 {
println!(" {:<10} {} added", "questions", report.questions_added);
}
if report.wishes_added > 0 {
println!(" {:<10} {} taken out of {}", "wishes", report.wishes_added, hub::WISHES_FILE);
}
line("documents", report.documents_added, report.documents_updated);
Ok(())
}
fn adopt_root(root: Option<&Path>, hubs: Option<&Path>, check: bool, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let config = profile::Config::load()?;
let (name, current) = config.current()?;
let roots: Vec<PathBuf> = match root {
Some(root) => vec![root.to_path_buf()],
None if current.roots.is_empty() => {
bail!("profile '{name}' names no roots; give a directory, or set one with `rigger profile set {name} --root <dir>`")
}
None => current.roots.clone(),
};
let hubs = hubs.or(current.hubs.as_deref());
let mut adopted = Vec::new();
for root in &roots {
adopted.extend(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 write_line_skill(install: bool, dir: Option<&Path>, replace: bool, template: Option<&Path>, print_template: bool) -> Result<()> {
if print_template {
print!("{}", skill::DEFAULT_LINE_TEMPLATE);
return Ok(());
}
let db = Db::open(&paths::db_path()?)?;
let line = profile::Config::load()?.current_name().to_string();
let (template, source) = skill::load_line_template(template)?;
let listed = listed_projects(&db)?;
let description = skill::line_description(&line, &listed);
let rendered = skill::render_line(&template, &line, &description, &listed)?;
if !install {
print!("{}", rendered.text);
return Ok(());
}
let dir = match dir {
Some(dir) => dir.to_path_buf(),
None => skill::skills_dir()?,
}
.join(&line);
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 into the record, 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}: {} projects, description {} of {} characters.",
path.display(),
listed.len(),
description.chars().count(),
skill::DESCRIPTION_LIMIT
);
Ok(())
}
fn listed_projects(db: &Db) -> Result<Vec<skill::Listed>> {
let mut listed: Vec<skill::Listed> = db
.projects()?
.into_iter()
.filter(|p| p.kind == db::Kind::Repo)
.map(|p| skill::Listed {
about: repo::detect_about(Path::new(&p.path)),
name: p.name,
path: p.path,
})
.collect();
listed.sort_by(|a, b| a.name.cmp(&b.name));
Ok(listed)
}
fn open_project(db: &Db, name: &str) -> Result<db::Project> {
if let Some(project) = db.project_by_name(name)? {
return Ok(project);
}
if name == profile::Config::load()?.current_name() {
return db.profile_project();
}
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_project(project: &str, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
let about = match project.kind {
db::Kind::Repo => repo::detect_about(Path::new(&project.path)),
db::Kind::Service => None,
};
let screen = show::build(&db, &project, about)?;
if json {
println!("{}", serde_json::to_string_pretty(&screen)?);
return Ok(());
}
print!("{}", show::render(&screen));
Ok(())
}
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));
print!("{}", context::render_dropped(&packet));
}
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_on_card(task: &str, kind: &str, text: &str) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
if kind == "state" {
bail!("a state line belongs to a project's README, not to a card");
}
let card = find_card(&db, task)?;
let desk = db.desk_project()?;
db.record_task_event(desk.id, card.id, kind, text, &db::now(), "assistant")?;
println!("Recorded a {kind} on {}", card.key);
Ok(())
}
fn note(project: &str, kind: &str, text: &str, principle: Option<&str>, asked_by: Option<&str>) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
if kind == "state" {
db.add_state_line(project.id, &db::today(), text)?;
println!("Added a state line for {}; `rigger export` writes it into the README", project.name);
return Ok(());
}
if principle.is_some() && kind != "decision" {
bail!("a principle belongs to a decision; this is a {kind}");
}
let asker = match asked_by {
Some(name) => Some(open_project(&db, name)?),
None => None,
};
if let Some(asker) = &asker
&& asker.id == project.id
{
bail!("{} cannot be the neighbour asking {} for something", asker.name, project.name);
}
let change = db.record_event(project.id, kind, text, &db::now(), "assistant")?;
if let Some(id) = db.latest_event_id(project.id, kind)? {
if let Some(principle) = principle {
db.name_principle(id, principle)?;
}
if let Some(asker) = &asker {
db.name_asker(id, asker.id)?;
}
}
match (kind, &asker) {
("wish", Some(asker)) => println!("Recorded a wish for {} from {}", project.name, asker.name),
_ => println!("Recorded a {kind} for {}", project.name),
}
if let Some(principle) = principle {
println!(" on the principle {principle:?}");
}
if change == db::Change::Unchanged {
println!(" the record already held it");
}
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)?;
}
use search::Provider as _;
let provider = search::Fts(&db);
let hits = search::look(&provider, query, project, kind, limit).with_context(|| format!("{query:?} is not a search {} understands", provider.name()))?;
if json {
println!("{}", serde_json::to_string_pretty(&hits)?);
return Ok(());
}
if hits.is_empty() {
println!("{}", search::nothing_found(query, project, kind));
return Ok(());
}
let show_project = project.is_none();
for event in &hits.events {
print!("{}", search::render_event(event, show_project));
}
if !hits.documents.is_empty() {
if !hits.events.is_empty() {
println!();
}
println!("Documents");
for doc in &hits.documents {
print!("{}", search::render_document(doc, show_project));
println!(" {}", search::open_command(doc));
}
}
if hits.events.len() as u32 == limit {
println!("({limit} shown; --limit for more)");
}
Ok(())
}
fn why(project: Option<&str>, version: Option<&str>, principle: Option<&str>, principles: bool, json: bool) -> Result<()> {
if principles {
return list_principles(json);
}
if let Some(principle) = principle {
return why_principle(principle, project, json);
}
let (Some(project), Some(version)) = (project, version) else {
bail!("`why` wants a project and a version, or `--principle <name>`; `rigger why --principles` lists the names")
};
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 list_principles(json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let principles = db.principles()?;
if json {
println!("{}", serde_json::to_string_pretty(&principles)?);
return Ok(());
}
if principles.is_empty() {
println!("No decision names a principle yet.");
println!("`rigger note <project> <text> --kind decision --principle <name>` starts one.");
return Ok(());
}
for (name, count) in &principles {
println!("{name} — {}", plural(*count as usize, "decision", "decisions"));
}
Ok(())
}
fn why_principle(principle: &str, project: Option<&str>, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
if let Some(name) = project {
open_project(&db, name)?;
}
let mut events = db.on_principle(principle)?;
if let Some(name) = project {
events.retain(|e| e.project == name);
}
if json {
println!("{}", serde_json::to_string_pretty(&events)?);
return Ok(());
}
if events.is_empty() {
println!("Nothing stands on {principle:?} in the record.");
let known = db.principles()?;
if !known.is_empty() {
println!();
println!("The record knows these:");
for (name, count) in known.iter().take(12) {
println!(" {name} ({count})");
}
}
return Ok(());
}
let projects: std::collections::BTreeSet<&str> = events.iter().map(|e| e.project.as_str()).collect();
println!("{principle}");
println!(
"{} across {}",
plural(events.len(), "decision", "decisions"),
plural(projects.len(), "project", "projects")
);
println!();
for event in &events {
let day = event.at.split('T').next().unwrap_or(&event.at);
let version = event.version.as_deref().map(|v| format!(" · {v}")).unwrap_or_default();
println!("{day} · {}{version}", event.project);
for line in event.body.lines() {
println!(" {line}");
}
println!();
}
Ok(())
}
fn link_side(text: &str) -> (&str, Option<&str>) {
match text.split_once('@') {
Some((project, version)) if !version.is_empty() => (project, Some(version)),
_ => (text, None),
}
}
fn link_add(from: &str, to: &str, kind: LinkKind, note: Option<&str>) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let (from_name, from_version) = link_side(from);
let (to_name, to_version) = link_side(to);
let from_project = open_project(&db, from_name)?;
let to_project = open_project(&db, to_name)?;
if from_project.id == to_project.id {
bail!("a link joins two projects; {} is one", from_project.name);
}
let kind = kind.to_domain();
let (id, change, anchored) = db.add_link(&db::NewLink {
kind,
from_project: from_project.id,
from_version,
to_project: to_project.id,
to_version,
note,
})?;
let side = |name: &str, version: Option<&String>| match version {
Some(v) => format!("{name} {v}"),
None => name.to_string(),
};
let arrow = if kind.symmetric() { "<->" } else { "->" };
let both = format!(
"{} {arrow} {} ({kind})",
side(&from_project.name, anchored.from.as_ref()),
side(&to_project.name, anchored.to.as_ref())
);
match change {
db::Change::Added => println!("[{id}] {both}"),
_ => println!("[{id}] already recorded: {both}"),
}
Ok(())
}
fn link_list(project: Option<&str>, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let links = match project {
Some(name) => {
let project = open_project(&db, name)?;
db.links_of(project.id)?
}
None => {
let mut all: Vec<link::Link> = Vec::new();
for project in db.projects()? {
for found in db.links_of(project.id)? {
if !all.iter().any(|seen| seen.id == found.id) {
all.push(found);
}
}
}
all.sort_by(|a, b| a.near.project.cmp(&b.near.project).then_with(|| a.id.cmp(&b.id)));
all
}
};
if json {
println!("{}", serde_json::to_string_pretty(&links)?);
return Ok(());
}
if links.is_empty() {
match project {
Some(name) => println!("{name} is tied to nothing in the record."),
None => println!("The record holds no links yet."),
}
println!("`rigger link add <project>[@version] <project>[@version]` records one.");
return Ok(());
}
for found in &links {
println!("{}", render_link(found));
}
Ok(())
}
fn render_link(found: &link::Link) -> String {
let side = |end: &link::End| match &end.version {
Some(version) => format!("{} {version}", end.project),
None => end.project.clone(),
};
let arrow = if found.kind.symmetric() { "<->" } else { "->" };
let note = found.note.as_deref().map(|n| format!(" {n}")).unwrap_or_default();
format!("[{}] {:<9} {} {arrow} {}{note}", found.id, found.kind, side(&found.near), side(&found.far))
}
fn link_remove(id: i64) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
match db.remove_link(id)? {
true => println!("Forgot link [{id}]"),
false => bail!("the record has no link [{id}]; `rigger link list` prints the ids"),
}
Ok(())
}
fn link_drift(json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let parted = link::parted(&db.pairs()?);
if json {
println!("{}", serde_json::to_string_pretty(&parted)?);
return Ok(());
}
if parted.is_empty() {
println!("Every pair in the record has both halves in step.");
return Ok(());
}
for item in &parted {
println!("{}", drift_line(item));
}
Ok(())
}
fn drift_line(item: &link::Parted) -> String {
fn version(end: &link::End) -> &str {
end.version.as_deref().unwrap_or("its half")
}
let note = item.note.as_deref().map(|n| format!(" — {n}")).unwrap_or_default();
match item.drift {
link::Drift::ShippedAlone => format!(
"{} {} shipped without {} {}{note}",
item.ahead.project,
version(&item.ahead),
item.behind.project,
version(&item.behind),
),
link::Drift::RunAhead => format!(
"{} is {} ahead of {}, which is still at {}{note}",
item.ahead.project,
plural(item.versions.unwrap_or(0) as usize, "version", "versions"),
item.behind.project,
version(&item.behind),
),
}
}
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);
}
let mut asked = db.all_asked_wishes()?;
if let Some(name) = project {
asked.retain(|a| a.project == name);
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"waiting": waiting,
"shared": owner::shared_subjects(&waiting),
"asked_by_neighbours": asked,
}))?
);
return Ok(());
}
if waiting.is_empty() && asked.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();
if waiting.is_empty() {
print_asked(&asked, project.is_some());
return Ok(());
}
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(", "));
}
}
print_asked(&asked, project.is_some());
println!(
"
Answer one with: rigger resolve <project> <id> \"<answer>\""
);
Ok(())
}
fn print_asked(asked: &[db::AskedOf], one_project: bool) {
if asked.is_empty() {
return;
}
println!();
println!("Neighbours are asking for:");
let mut last: Option<&str> = None;
for wish in asked {
let name = match (one_project, last == Some(wish.project.as_str())) {
(true, _) | (_, true) => String::new(),
_ => wish.project.clone(),
};
last = Some(&wish.project);
let first = wish.body.lines().next().unwrap_or("").trim();
println!("{name:<12} [{:>3}] {} {} — {}", wish.id, wish.date, owner::subject(first), wish.asked_by);
}
println!();
println!("Sort one with: rigger resolve <project> <id>");
}
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 body_argument(body: &str) -> Result<String> {
if body != "-" {
return Ok(body.to_string());
}
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut text).context("cannot read the body from standard input")?;
Ok(text)
}
fn body_from_editor(project: &str, slug: &str, seed: &str) -> Result<String> {
let path = doc::scratch_path(project, slug);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("cannot make {}", dir.display()))?;
}
std::fs::write(&path, seed).with_context(|| format!("cannot write {}", path.display()))?;
let edited = doc::edit_file(&path).and_then(|()| std::fs::read_to_string(&path).with_context(|| format!("cannot read back {}", path.display())));
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(doc::scratch_dir());
edited
}
fn body_for(project: &str, slug: &str, body: Option<&str>, seed: &str) -> Result<String> {
match body {
Some(body) => body_argument(body),
None => body_from_editor(project, slug, seed),
}
}
fn doc_list(project: &str, kind: Option<&str>, json: bool) -> Result<()> {
if let Some(kind) = kind {
doc::check_kind(kind)?;
}
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
let docs = db.documents(project.id, kind)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(
&docs
.iter()
.map(|d| serde_json::json!({
"slug": d.slug,
"kind": d.kind,
"title": d.title,
"updated_at": d.updated_at,
"bytes": d.body.len(),
}))
.collect::<Vec<_>>()
)?
);
return Ok(());
}
if docs.is_empty() {
println!("{} has no documents yet.", project.name);
println!("Write one with: rigger doc add {} \"Vision\" --kind vision", project.name);
return Ok(());
}
println!("{}:", plural(docs.len(), "document", "documents"));
let width = docs.iter().map(|d| d.slug.chars().count()).max().unwrap_or(0).max(12);
for d in &docs {
let when = days_since_utc(&d.updated_at)
.map(|days| match days {
0 => "today".to_string(),
1 => "yesterday".to_string(),
d => format!("{d} days ago"),
})
.unwrap_or_else(|| "unknown".to_string());
println!(" {:<width$} {:<10} {:<12} {}", d.slug, d.kind, when, first_line(&d.title));
}
Ok(())
}
fn doc_show(project: &str, slug: &str, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
let doc = open_document(&db, &project, slug)?;
if json {
println!("{}", serde_json::to_string_pretty(&doc)?);
return Ok(());
}
println!("{}", doc.body.trim_end());
Ok(())
}
fn doc_add(project: &str, title: &str, kind: &str, slug: Option<&str>, body: Option<&str>) -> Result<()> {
doc::check_kind(kind)?;
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
if doc::is_singular(kind)
&& let Some(existing) = db.documents(project.id, Some(kind))?.first()
{
bail!(
"{} already has a {kind}: '{}'; edit it with `rigger doc edit {} {}`",
project.name,
existing.slug,
project.name,
existing.slug
);
}
let slug = match slug {
Some(slug) => slug.to_string(),
None => match db::slugify(title) {
slug if !slug.is_empty() => slug,
_ => next_slug(&db, project.id, kind)?,
},
};
if db.document(project.id, &slug)?.is_some() {
bail!(
"{} already has a document at '{slug}'; give another with --slug, or edit that one",
project.name
);
}
let text = body_for(&project.name, &slug, body, &doc::template(kind, title))?;
if text.trim().is_empty() {
println!("Nothing was written; no document was made.");
return Ok(());
}
let written = db.write_document(project.id, kind, &slug, title, &text)?;
println!(
"Wrote {} ({kind}, {}) to {}",
written.slug,
plural(written.body.len(), "byte", "bytes"),
project.name
);
Ok(())
}
fn doc_edit(project: &str, slug: &str, title: Option<&str>, body: Option<&str>) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
let doc = open_document(&db, &project, slug)?;
let text = match (title, body) {
(Some(_), None) => doc.body.clone(),
_ => body_for(&project.name, &doc.slug, body, &doc.body)?,
};
let title = title.unwrap_or(&doc.title);
if text == doc.body && title == doc.title {
println!("{} is unchanged.", doc.slug);
return Ok(());
}
if text.trim().is_empty() {
bail!(
"the document was left empty; nothing was written. Remove it with `rigger doc remove {} {}`",
project.name,
doc.slug
);
}
let written = db.write_document(project.id, &doc.kind, &doc.slug, title, &text)?;
println!("Wrote {} ({}, {})", written.slug, written.kind, plural(written.body.len(), "byte", "bytes"));
Ok(())
}
fn doc_remove(project: &str, slug: &str) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, project)?;
let doc = open_document(&db, &project, slug)?;
db.delete_document(project.id, &doc.slug)?;
println!("Removed {} ({}) from {}", doc.slug, doc.kind, project.name);
Ok(())
}
fn doc_template(kind: &str, write: bool) -> Result<()> {
doc::check_kind(kind)?;
if !write {
let paths = doc::template_paths(kind)?;
let from = paths.iter().find(|p| p.is_file());
println!("{}", doc::template(kind, doc::TITLE_PLACEHOLDER).trim_end());
println!();
match from {
Some(path) => println!("(from {})", path.display()),
None => println!("(the built-in skeleton; `rigger doc template {kind} --write` to make it yours)"),
}
return Ok(());
}
let path = doc::template_paths(kind)?
.into_iter()
.next()
.context("the profile has no directory to write a skeleton into")?;
if path.exists() {
bail!("{} already exists; edit it, or delete it to go back to the built-in one", path.display());
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("cannot make {}", dir.display()))?;
}
std::fs::write(&path, doc::template(kind, doc::TITLE_PLACEHOLDER)).with_context(|| format!("cannot write {}", path.display()))?;
println!("Wrote {}", path.display());
println!("Edit it; `{}` in it becomes the document's title.", doc::TITLE_PLACEHOLDER);
Ok(())
}
fn rules(project: Option<&str>, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let line = db.service_project()?;
let line_rules = match &line {
Some(p) => db.singular_document(p.id, db::RULES_KIND)?,
None => None,
};
let project = project.map(|name| open_project(&db, name)).transpose()?;
let project_rules = match &project {
Some(p) => db.singular_document(p.id, db::RULES_KIND)?,
None => None,
};
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"line": line_rules.as_ref().map(|d| &d.body),
"project": project.as_ref().map(|p| &p.name),
"project_rules": project_rules.as_ref().map(|d| &d.body),
}))?
);
return Ok(());
}
let mut printed = false;
if let Some(doc) = &line_rules {
println!("{}", doc.body.trim_end());
printed = true;
}
if let Some(doc) = &project_rules {
if printed {
println!();
}
println!("{}", doc.body.trim_end());
printed = true;
}
if printed {
return Ok(());
}
let profile = profile::Config::load()?.current_name();
match &project {
Some(p) => println!("Neither the line nor {} has written down how the work is done.", p.name),
None => println!("The line has not written down how the work is done."),
}
println!("Write the line's with: rigger doc add {profile} \"Rituals\" --kind rituals");
if let Some(p) = &project {
println!("And what only {} says: rigger doc add {} \"Rituals\" --kind rituals", p.name, p.name);
}
Ok(())
}
fn open_document(db: &Db, project: &db::Project, slug: &str) -> Result<db::Document> {
if let Some(doc) = db.document(project.id, slug)? {
return Ok(doc);
}
let known = db.documents(project.id, None)?;
if known.is_empty() {
bail!("{} has no document at '{slug}', and none at all yet", project.name);
}
bail!(
"{} has no document at '{slug}'; it has {}",
project.name,
known.iter().map(|d| d.slug.as_str()).collect::<Vec<_>>().join(", ")
);
}
fn next_slug(db: &Db, project_id: i64, kind: &str) -> Result<String> {
if db.document(project_id, kind)?.is_none() {
return Ok(kind.to_string());
}
for n in 2.. {
let candidate = format!("{kind}-{n}");
if db.document(project_id, &candidate)?.is_none() {
return Ok(candidate);
}
}
unreachable!("the loop returns on the first free number")
}
const KEEP_BACKUPS: usize = 10;
const BACKUP_STALE_DAYS: i64 = 1;
const BACKUP_OLD_DAYS: i64 = 7;
fn backup_state(age_days: Option<i64>) -> &'static str {
match age_days {
None => "none",
Some(d) if d >= BACKUP_OLD_DAYS => "old",
Some(d) if d >= BACKUP_STALE_DAYS => "stale",
Some(_) => "fresh",
}
}
fn backup(keep: usize, list: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
if list {
let copies = db.backups()?;
if copies.is_empty() {
println!("No copies yet. Take one with: rigger backup");
return Ok(());
}
println!("{} newest first:", plural(copies.len(), "copy", "copies"));
for path in &copies {
let age = path
.file_name()
.and_then(|n| db::stamp_of(&n.to_string_lossy()))
.and_then(|at| days_since_utc(&at))
.map(|d| match d {
0 => "today".to_string(),
1 => "yesterday".to_string(),
d => format!("{d} days ago"),
})
.unwrap_or_else(|| "unknown".to_string());
println!(" {:<40} {age}", path.file_name().unwrap_or_default().to_string_lossy());
}
return Ok(());
}
let target = db.backup()?;
println!("Copied to {}", target.display());
let removed = db.prune_backups(keep)?;
if !removed.is_empty() {
println!("Kept the {keep} newest, deleted {}.", plural(removed.len(), "older copy", "older copies"));
}
Ok(())
}
fn init() -> Result<()> {
let config_path = profile::Config::path()?;
if !config_path.exists() {
profile::Config::default().save()?;
println!("Created {} with the '{}' profile", config_path.display(), profile::DEFAULT);
}
let path = paths::db_path()?;
if path.exists() {
Db::open(&path)?;
println!("Already initialised: {}", path.display());
return Ok(());
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
}
let db = Db::create(&path)?;
println!("Created {} (schema version {})", db.path().display(), db.schema_version()?);
println!("Next: rigger project add <path>");
Ok(())
}
fn profile_list(json: bool) -> Result<()> {
let config = profile::Config::load()?;
let current = config.current_name();
if json {
let rows: Vec<serde_json::Value> = config
.profiles
.iter()
.map(|(name, p)| serde_json::json!({ "name": name, "current": *name == current, "profile": p }))
.collect();
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
let width = config.profiles.keys().map(String::len).max().unwrap_or(0);
for (name, p) in &config.profiles {
let mark = if *name == current { "*" } else { " " };
println!("{mark} {name:width$} {} {}", p.kind.as_str(), profile::db_path_for(name)?.display());
}
Ok(())
}
fn profile_show(name: Option<&str>, json: bool) -> Result<()> {
let config = profile::Config::load()?;
let name = name.map(str::to_string).unwrap_or_else(|| config.current_name());
let Some(p) = config.profiles.get(&name) else {
bail!("no profile named '{name}'; see `rigger profile list`");
};
if json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "name": name, "profile": p }))?);
return Ok(());
}
println!("{name}");
println!(" kind: {}", p.kind.as_str());
println!(" database: {}", profile::db_path_for(&name)?.display());
if !p.roots.is_empty() {
println!(
" roots: {}",
p.roots.iter().map(|r| r.display().to_string()).collect::<Vec<_>>().join(", ")
);
}
if let Some(hubs) = &p.hubs {
println!(" hubs: {}", hubs.display());
}
if let Some(pattern) = &p.id_pattern {
println!(" ids: {pattern}");
}
if let Some(inbox) = &p.inbox {
println!(" inbox: {}", inbox.display());
}
println!(" config: {}", profile::Config::path()?.display());
Ok(())
}
fn profile_use(name: &str) -> Result<()> {
let mut config = profile::Config::load()?;
if !config.profiles.contains_key(name) {
bail!("no profile named '{name}'; see `rigger profile list`");
}
config.current = name.to_string();
config.save()?;
println!("Every command now uses the '{name}' profile");
if std::env::var_os(profile::PROFILE_ENV).is_some() {
println!(" note: {} is set and overrides this while it is", profile::PROFILE_ENV);
}
Ok(())
}
fn profile_add(name: &str, p: profile::Profile, use_it: bool) -> Result<()> {
if name.trim().is_empty() || name.contains(['/', '\\', ' ']) {
bail!("a profile name is one word, without slashes: '{name}' is not");
}
let mut config = profile::Config::load()?;
if config.profiles.contains_key(name) {
bail!("a profile named '{name}' already exists; see `rigger profile show {name}`");
}
let path = profile::db_path_for(name)?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
}
if !path.exists() {
Db::create(&path)?;
}
config.profiles.insert(name.to_string(), p.clone());
if use_it {
config.current = name.to_string();
}
config.save()?;
println!("Added the '{name}' profile ({}) with its database at {}", p.kind.as_str(), path.display());
match use_it {
true => println!("Every command now uses it"),
false => println!("Switch to it with: rigger profile use {name}"),
}
Ok(())
}
fn profile_set(name: Option<&str>, roots: Vec<PathBuf>, hubs: Option<PathBuf>, id_pattern: Option<String>, inbox: Option<PathBuf>) -> Result<()> {
let mut config = profile::Config::load()?;
let name = name.map(str::to_string).unwrap_or_else(|| config.current_name());
let Some(p) = config.profiles.get_mut(&name) else {
bail!("no profile named '{name}'; see `rigger profile list`");
};
if roots.is_empty() && hubs.is_none() && id_pattern.is_none() && inbox.is_none() {
bail!("nothing to set; give --root, --hubs, --id-pattern or --inbox");
}
if !roots.is_empty() {
p.roots = roots;
}
if let Some(hubs) = hubs {
p.hubs = Some(hubs);
}
if let Some(pattern) = id_pattern {
p.id_pattern = Some(pattern);
}
if let Some(inbox) = inbox {
p.inbox = Some(inbox);
}
config.save()?;
println!("Profile '{name}' updated");
profile_show(Some(&name), false)
}
const ACTIVE_CARD: &str = "active_card";
const ACTIVE_SINCE: &str = "active_since";
const STALE_HOURS: i64 = 8;
fn find_card(db: &Db, text: &str) -> Result<card::Card> {
match db.card_by_ref(text)? {
Some(card) => Ok(card),
None => bail!("no card named '{text}'; `rigger task find` looks one up, `rigger task new` makes one"),
}
}
fn find_task(db: &Db, text: &str) -> Result<(i64, i64)> {
if let Some(card) = db.card_by_ref(text)? {
return Ok((db.desk_project()?.id, card.id));
}
if let Ok(id) = text.trim().parse::<i64>()
&& let Some(project_id) = db.task_owner(id)?
{
return Ok((project_id, id));
}
bail!("no task named '{text}'; a card's key, or the id the packet lists")
}
fn task_status(task: &str, status: &str) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let (project_id, id) = find_task(&db, task)?;
let (title, was) = db.set_task_status(project_id, id, status)?;
match was == status {
true => println!("Task {task} was already {status}: {title}"),
false => println!("Task {task} is now {status} (was {was}): {title}"),
}
Ok(())
}
fn task_new(title: &str, key: Option<&str>, aliases: &[String], projects: &[String], branch: Option<&str>, summary: Option<&str>) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
if title.trim().is_empty() {
bail!("a card needs a title");
}
let card = db.new_card(key, title, aliases)?;
if let Some(summary) = summary {
db.set_card_summary(card.id, summary)?;
}
for name in projects {
let project = open_project(&db, name)?;
db.link_card(card.id, project.id, branch, None)?;
}
db.set_setting(ACTIVE_CARD, Some(&card.id.to_string()))?;
db.set_setting(ACTIVE_SINCE, Some(&db::now()))?;
println!("Made card {} and opened it: {}", card.key, card.title);
if key.is_none() {
println!(" a local key; `rigger task rename {} <ID>` when the tracker names it", card.key);
}
print_links(&db, card.id)?;
Ok(())
}
fn print_links(db: &Db, task_id: i64) -> Result<()> {
for link in db.card_links(task_id)? {
let branch = link.branch.as_deref().map(|b| format!(" on {b}")).unwrap_or_default();
let role = link.role.as_deref().map(|r| format!(" - {r}")).unwrap_or_default();
println!(" {} ({}){branch}{role}", link.project, link.path);
}
Ok(())
}
fn task_find(query: &str, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let parsed = card::parse(query);
let cards = db.cards(None)?;
let (hits, verdict) = card::find(&parsed, &cards, 8);
let shown: Vec<String> = hits.iter().map(|h| h.key.clone()).collect();
let mut mentions = Vec::new();
for needle in parsed.ids.iter().chain(parsed.numbers.iter()) {
for (key, title) in db.cards_mentioning(needle, &shown)? {
mentions.push((needle.clone(), key, title));
}
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"verdict": verdict,
"hits": hits,
"mentions": mentions.iter().map(|(n, k, t)| serde_json::json!({ "needle": n, "key": k, "title": t })).collect::<Vec<_>>(),
}))?
);
return Ok(());
}
if cards.is_empty() {
println!("No cards yet. Make one with: rigger task new \"<title>\" [--id <KEY>]");
return Ok(());
}
if hits.is_empty() {
println!("No match among {} cards.", cards.len());
} else {
let width = hits.iter().map(|h| h.key.len()).max().unwrap_or(0);
for h in &hits {
println!("{:>3} {:width$} {:<16} {} ({})", h.score, h.key, h.status, h.title, h.why);
}
}
for (needle, key, title) in &mentions {
println!("mentioned {needle}: {key} - {title}");
}
println!(
"\n{}",
match verdict {
card::Verdict::Take => format!("take {}: it is the one.", hits[0].key),
card::Verdict::Ask => "ask: show these and let the owner pick, or say it is new.".to_string(),
card::Verdict::New => "new: nothing is close; make a card.".to_string(),
}
);
Ok(())
}
fn active_card(db: &Db) -> Result<Option<card::Card>> {
let Some(id) = db.setting(ACTIVE_CARD)? else { return Ok(None) };
if let Some(since) = db.setting(ACTIVE_SINCE)?
&& let Ok(then) = since.parse::<jiff::Timestamp>()
&& (jiff::Timestamp::now().as_second() - then.as_second()) > STALE_HOURS * 3600
{
return Ok(None);
}
db.card(id.parse().unwrap_or_default())
}
fn task_open(task: &str) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let card = find_card(&db, task)?;
db.set_setting(ACTIVE_CARD, Some(&card.id.to_string()))?;
db.set_setting(ACTIVE_SINCE, Some(&db::now()))?;
println!("Card {} is in hand: {}", card.key, card.title);
Ok(())
}
fn task_active(json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let card = active_card(&db)?;
if json {
println!("{}", serde_json::to_string_pretty(&card)?);
return Ok(());
}
match card {
Some(card) => println!("{} {:<16} {}", card.key, card.status, card.title),
None => println!("No card is in hand. Open one with: rigger task open <KEY>"),
}
Ok(())
}
fn task_close(task: Option<&str>, status: &str) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let card = match task {
Some(text) => find_card(&db, text)?,
None => match active_card(&db)? {
Some(card) => card,
None => bail!("no card is in hand; name one: rigger task close <KEY>"),
},
};
let desk = db.desk_project()?;
let (title, was) = db.set_task_status(desk.id, card.id, status)?;
if db.setting(ACTIVE_CARD)?.as_deref() == Some(&card.id.to_string()) {
db.set_setting(ACTIVE_CARD, None)?;
db.set_setting(ACTIVE_SINCE, None)?;
}
println!("Closed {} as {status} (was {was}): {title}", card.key);
Ok(())
}
fn task_show(task: &str, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let card = find_card(&db, task)?;
let links = db.card_links(card.id)?;
let events = db.task_events(card.id)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({ "card": card, "links": links, "events": events.len() }))?
);
return Ok(());
}
println!("{} · {}", card.key, card.title);
println!(" status: {}", card.status);
if !card.aliases.is_empty() {
println!(" aliases: {}", card.aliases.join(", "));
}
println!(" since: {}", card.created_at);
if let Some(summary) = &card.summary {
println!(" summary: {summary}");
}
if !links.is_empty() {
println!(" worked in:");
print_links(&db, card.id)?;
}
let mut counts = std::collections::BTreeMap::new();
for e in &events {
*counts.entry(e.kind.as_str()).or_insert(0) += 1;
}
if !counts.is_empty() {
let parts: Vec<String> = counts.iter().map(|(k, n)| format!("{n} {k}")).collect();
println!(" recorded: {}", parts.join(", "));
}
if let Some(next) = events.iter().rev().find(|e| e.kind == "next") {
println!(" next: {}", first_line(&next.body));
}
Ok(())
}
fn render_card(db: &Db, card: &card::Card, budget: usize) -> Result<String> {
let links = db.card_links(card.id)?;
let events = db.task_events(card.id)?;
let mut out = format!("# {} · {}\n\n", card.key, card.title);
out.push_str(&format!("Status: {}", card.status));
if !card.aliases.is_empty() {
out.push_str(&format!(" · also {}", card.aliases.join(", ")));
}
out.push('\n');
if let Some(summary) = &card.summary {
out.push_str(&format!("\n{summary}\n"));
}
if !links.is_empty() {
out.push_str("\n## Worked in\n");
for link in &links {
let branch = link.branch.as_deref().map(|b| format!(" on `{b}`")).unwrap_or_default();
let role = link.role.as_deref().map(|r| format!(" - {r}")).unwrap_or_default();
out.push_str(&format!("- {} ({}){branch}{role}\n", link.project, link.path));
}
}
if let Some(next) = events.iter().rev().find(|e| e.kind == "next") {
out.push_str(&format!("\n## Next step\n{}\n", next.body.trim()));
}
let sections = [
("decision", "Decisions"),
("finding", "Findings"),
("pitfall", "Pitfalls"),
("plan", "Plan"),
("change", "Changes"),
("question", "Waiting for the owner"),
];
let mut left_out = 0usize;
for (kind, heading) in sections {
let mut items: Vec<&db::RecentEvent> = events.iter().filter(|e| e.kind == kind).collect();
items.reverse();
if items.is_empty() {
continue;
}
out.push_str(&format!("\n## {heading}\n"));
for item in items {
let line = format!("- {} · {}\n", item.date, item.body.trim());
if context::estimate_tokens(&out) + context::estimate_tokens(&line) > budget {
left_out += 1;
continue;
}
out.push_str(&line);
}
}
if left_out > 0 {
out.push_str(&format!("\n({left_out} older events left out by the budget)\n"));
}
Ok(out)
}
fn task_context(task: &str, budget: usize) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let card = find_card(&db, task)?;
print!("{}", render_card(&db, &card, budget)?);
Ok(())
}
fn task_link(task: &str, project: &str, branch: Option<&str>, role: Option<&str>) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let card = find_card(&db, task)?;
let project = open_project(&db, project)?;
db.link_card(card.id, project.id, branch, role)?;
println!("{} is worked in:", card.key);
print_links(&db, card.id)
}
fn task_list(status: &str, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let cards = db.cards(Some(status))?;
if json {
println!("{}", serde_json::to_string_pretty(&cards)?);
return Ok(());
}
if cards.is_empty() {
println!(
"No cards{}.",
if status == "all" { "" } else { " that are " }.to_string() + if status == "all" { "" } else { status }
);
return Ok(());
}
let width = cards.iter().map(|c| c.key.len()).max().unwrap_or(0);
for c in &cards {
println!("{:width$} {:<16} {}", c.key, c.status, c.title);
}
Ok(())
}
fn task_rename(task: &str, key: &str) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let card = find_card(&db, task)?;
let renamed = db.rename_card(card.id, key)?;
println!("{} is now {}; {} stays as an alias", card.key, renamed.key, card.key);
Ok(())
}
fn task_alias(task: &str, alias: &str) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let card = find_card(&db, task)?;
let card = db.add_alias(card.id, alias)?;
println!("{} also goes by: {}", card.key, card.aliases.join(", "));
Ok(())
}
fn task_summary(task: &str, text: &str) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let card = find_card(&db, task)?;
db.set_card_summary(card.id, text)?;
println!("{} summarised", card.key);
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)"),
}
refresh_line_skill(&db)?;
Ok(())
}
fn refresh_line_skill(db: &Db) -> Result<()> {
if !skill::may_refresh_installed() {
return Ok(());
}
let line = profile::Config::load()?.current_name().to_string();
let path = skill::skills_dir()?.join(&line).join("SKILL.md");
let before = match std::fs::read_to_string(&path) {
Ok(text) if skill::is_generated(&text) => text,
_ => return Ok(()),
};
let (template, _) = skill::load_line_template(None)?;
let listed = listed_projects(db)?;
let description = skill::line_description(&line, &listed);
let rendered = skill::render_line(&template, &line, &description, &listed)?;
if rendered.text == before {
return Ok(());
}
std::fs::write(&path, &rendered.text).with_context(|| format!("cannot write {}", path.display()))?;
println!(" {} now lists {} projects", path.display(), listed.len());
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);
if let Some(gate) = &project.gate {
println!(" gate: {gate}");
}
Ok(())
}
fn project_set(name: &str, gate: Option<&str>, no_gate: bool, on_session_end: Option<&str>, no_on_session_end: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, name)?;
if !no_gate && gate.is_none() && !no_on_session_end && on_session_end.is_none() {
match &project.gate {
Some(gate) => println!("{}: gate is `{gate}`", project.name),
None => println!("{} has no gate; set one with --gate \"<command>\"", project.name),
}
match &project.on_session_end {
Some(hook) => println!("{}: a closing session runs `{hook}`", project.name),
None => println!(
"{} runs nothing when a session closes; set it with --on-session-end \"<command>\"",
project.name
),
}
return Ok(());
}
if no_gate {
db.set_gate(project.id, None)?;
println!("{} has no gate now.", project.name);
} else if let Some(gate) = gate {
let gate = gate.trim();
if gate.is_empty() {
bail!("an empty gate is not a gate; use --no-gate to take it off");
}
db.set_gate(project.id, Some(gate))?;
println!("{}: gate is `{gate}`", project.name);
println!("Run it with: rigger gate {}", project.name);
}
if no_on_session_end {
db.set_on_session_end(project.id, None)?;
println!("{} runs nothing when a session closes now.", project.name);
} else if let Some(hook) = on_session_end {
let hook = hook.trim();
if hook.is_empty() {
bail!("an empty command is not a command; use --no-on-session-end to take it off");
}
db.set_on_session_end(project.id, Some(hook))?;
println!("{}: a closing session runs `{hook}`", project.name);
println!("It runs after the sitting is written down, and its outcome is recorded.");
}
Ok(())
}
fn gate(project: Option<&str>, check: bool, json: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = project_here(&db, project)?;
let Some(command) = project.gate.clone() else {
bail!(
"{} has no gate. Set it to what CI runs:
rigger project set {} --gate \"cargo fmt --all --check && cargo clippy --all-targets -- -D warnings && cargo test\"",
project.name,
project.name
);
};
let dir = Path::new(&project.path);
if !dir.is_dir() {
bail!("{} is recorded at {}, which is not there", project.name, project.path);
}
if check {
println!("{} would run, in {}:", project.name, project.path);
println!(" {command}");
return Ok(());
}
let run = gate::run(&command, dir).with_context(|| format!("cannot run the gate of {}", project.name))?;
let body = run.event_body(&command);
db.record_event(project.id, "gate", &body, &db::now(), "assistant")?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"project": project.name,
"command": command,
"passed": run.passed(),
"exit_code": run.code,
"seconds": run.seconds,
}))?
);
} else {
println!();
println!("{}: {body}", project.name);
}
if !run.passed() {
std::process::exit(1);
}
Ok(())
}
fn project_mark(
name: &str,
code: Option<&str>,
accent: Option<&str>,
accent2: Option<&str>,
form: Option<&str>,
docs: Option<&str>,
clear: bool,
) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let project = open_project(&db, name)?;
if clear {
db.set_mark(project.id, &db::Mark::default())?;
println!("{} has no mark recorded now.", project.name);
return Ok(());
}
if code.is_none() && accent.is_none() && accent2.is_none() && form.is_none() && docs.is_none() {
match (&project.mark_code, &project.accent) {
(None, None) => println!("{} has no mark recorded; `--code` and `--accent` start one", project.name),
_ => {
println!("{}:", project.name);
for (label, value) in [
("code", &project.mark_code),
("accent", &project.accent),
("accent 2", &project.accent2),
("form", &project.form),
("docs", &project.docs_url),
] {
if let Some(value) = value {
println!(" {label:<8} {value}");
}
}
}
}
return Ok(());
}
if let Some(code) = code {
line::check_code(code)?;
}
for accent in [accent, accent2].into_iter().flatten() {
line::check_accent(accent)?;
}
if let Some(form) = form {
line::check_form(form)?;
}
let mark = db::Mark {
code: code.map(str::to_string).or(project.mark_code),
accent: accent.map(str::to_string).or(project.accent),
accent2: accent2.map(str::to_string).or(project.accent2),
form: form.map(str::to_string).or(project.form),
docs_url: docs.map(str::to_string).or(project.docs_url),
};
db.set_mark(project.id, &mark)
.with_context(|| format!("cannot record the mark of {}", project.name))?;
println!("{}: mark recorded.", project.name);
for (label, value) in [
("code", &mark.code),
("accent", &mark.accent),
("accent 2", &mark.accent2),
("form", &mark.form),
("docs", &mark.docs_url),
] {
if let Some(value) = value {
println!(" {label:<8} {value}");
}
}
Ok(())
}
fn export_line(to: Option<&Path>, check: bool) -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let registry = line::build(&db, |p| repo::detect_about(Path::new(&p.path)))?;
line::check_public(®istry)?;
let json = format!(
"{}
",
serde_json::to_string_pretty(®istry)?
);
let Some(path) = to else {
print!("{json}");
return Ok(());
};
let before = std::fs::read_to_string(path).unwrap_or_default();
if before == json {
println!("{} is already what the record says.", path.display());
return Ok(());
}
if check {
println!("{} would change: {} products", path.display(), registry.products.len());
return Ok(());
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
}
std::fs::write(path, &json).with_context(|| format!("cannot write {}", path.display()))?;
let what = if before.is_empty() { "Wrote" } else { "Rewrote" };
println!("{what} {} - {} products", path.display(), registry.products.len());
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>,
parted: Vec<link::Parted>,
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),
parted: link::parted(&db.pairs()?),
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,
parted,
..
} = 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,
"parted": parted,
}))?
);
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);
print_parted(&parted);
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_parted(parted: &[link::Parted]) {
if parted.is_empty() {
return;
}
println!();
println!("Pairs out of step");
for item in parted {
println!(" {}", drift_line(item));
}
}
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,
"parted": facts.parted,
}))?
);
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);
print_parted(&facts.parted);
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);
let parted = link::parted(&db.pairs()?);
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(),
"parted": parted,
"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);
}
}
print_parted(&parted);
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(())
}
#[derive(Debug, serde::Serialize)]
struct HookRun {
command: String,
verdict: String,
passed: bool,
}
fn run_session_hook(db: &Db, project: &db::Project) -> Result<Option<HookRun>> {
let Some(command) = project.on_session_end.clone() else {
return Ok(None);
};
let dir = Path::new(&project.path);
if !dir.is_dir() {
eprintln!("{} is not a directory, so `{command}` was not run", project.path);
return Ok(None);
}
eprintln!("Running the end-of-session command: {command}");
let run = match gate::run(&command, dir) {
Ok(run) => run,
Err(e) => {
eprintln!("cannot run `{command}`: {e}");
return Ok(None);
}
};
let verdict = run.event_body(&command);
db.record_event(project.id, "change", &format!("end of session: {verdict}"), &db::now(), "rigger")?;
Ok(Some(HookRun {
command,
passed: run.passed(),
verdict,
}))
}
fn red_gate_reminder(db: &Db, project: &db::Project, session: i64) -> Result<Option<String>> {
let Some(last) = db.last_gate_in_session(session)? else {
return Ok(None);
};
if gate::body_is_green(&last.body) {
return Ok(None);
}
Ok(Some(format!(
"the gate was last {} - `rigger gate {}` to run it again",
last.body, project.name
)))
}
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 insured = match db.newest_backup_at()? {
Some(at) if days_since_utc(&at).is_some_and(|d| d < BACKUP_STALE_DAYS) => None,
_ => match db.backup() {
Ok(target) => {
db.prune_backups(KEEP_BACKUPS)?;
Some(target)
}
Err(e) => {
eprintln!("The session closed, but the database could not be copied: {e:#}");
None
}
},
};
let day = at.split('T').next().unwrap_or_default().to_string();
if !summary.empty() {
let entry = session::diary_entry(&summary, &day, heading);
let body = entry.split_once('\n').map(|(_, rest)| rest.trim()).unwrap_or_default();
let title = heading.map(str::trim).filter(|h| !h.is_empty()).map(|h| format!("{day} · {h}"));
db.write_session_diary(open.id, project.id, &day, title.as_deref(), body)?;
}
let written = match diary {
Some(path) => Some(write_diary(path, &summary, heading)?),
None => None,
};
let hook = run_session_hook(&db, &project)?;
let mut missing = summary.missing();
if let Some(red) = red_gate_reminder(&db, &project, open.id)? {
missing.push(red);
}
if let Some(run) = &hook
&& !run.passed
{
missing.push(format!("`{}` came back {}", run.command, run.verdict));
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"session": summary,
"missing": missing,
"diary": written,
"backup": insured,
"on_session_end": hook,
}))?
);
return Ok(());
}
if remind {
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));
}
if let Some(target) = &insured {
println!("copied the database to {}", target.display());
}
if let Some(run) = &hook {
println!("{}: {}", run.command, run.verdict);
}
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, docs: 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 hub_dir = &dunce::canonicalize(hub_dir).unwrap_or_else(|_| hub_dir.to_path_buf());
let mut files: Vec<(String, String)> = Vec::new();
for name in export::GENERATED {
files.push((name.to_string(), generate(&db, &project, name)?));
}
if docs {
for document in db.documents(project.id, None)? {
let Some(name) = hub_file_for(&document) else { continue };
files.push((name, format!("{}\n", document.body.trim_end())));
}
}
if !check {
db.set_hub_path(project.id, hub_dir)?;
}
let mut written = Vec::new();
let mut diffs: Vec<(String, Vec<String>)> = Vec::new();
for (name, text) in &files {
let path = hub_dir.join(name);
let is_document = !export::GENERATED.contains(&name.as_str());
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 && !is_document && !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 {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("cannot make {}", dir.display()))?;
}
std::fs::write(&path, text).with_context(|| format!("cannot write {}", path.display()))?;
}
if check && !unchanged {
diffs.push((name.clone(), export::diff(&before, text, 2)));
}
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);
}
for (file, lines) in &diffs {
if lines.is_empty() {
continue;
}
println!("\n{file}:");
for line in lines {
println!(" {line}");
}
}
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 hub_file_for(document: &db::Document) -> Option<String> {
if let Some(file) = document.source_file.as_deref()
&& !file.is_empty()
&& document.kind != "decisions"
{
return Some(file.to_string());
}
match document.kind.as_str() {
"vision" => Some("Видение.md".to_string()),
"rituals" => Some("Ритуалы.md".to_string()),
"decisions" | "other" => None,
"research" => {
let title = sanitise(&document.title);
let date = document.slug.split('-').take(3).collect::<Vec<_>>().join("-");
let named = match date.len() == 10 && date.bytes().all(|b| b.is_ascii_digit() || b == b'-') {
true if !title.starts_with(&date) => format!("{date} — {title}"),
_ => title,
};
Some(format!("{}/{}.md", hub::RESEARCH_DIR, named))
}
_ => None,
}
}
fn sanitise(title: &str) -> String {
let cleaned: String = title.chars().map(|c| if r#"\/:*?"<>|"#.contains(c) { ' ' } else { c }).collect();
cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
}
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;
}
let mut by_hand = Vec::new();
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) {
by_hand.push(name);
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"));
}
}
if !by_hand.is_empty() {
out.push((project.name.clone(), by_hand.join(", "), "kept by hand; `rigger export --adopt` hands it over"));
}
for document in db.documents(project.id, None)? {
let Some(name) = hub_file_for(&document) else { continue };
let path = dir.join(&name);
let Ok(text) = std::fs::read_to_string(&path) else { continue };
let want = export::with_line_ending(
&format!(
"{}
",
document.body.trim_end()
),
export::line_ending(&text),
);
if want != text {
out.push((project.name.clone(), name, "the file differs; `rigger import` takes the edit in"));
}
}
}
Ok(out)
}
fn hubs_with_wishes(db: &Db) -> Result<Vec<(String, usize)>> {
let mut out = Vec::new();
for project in db.projects()? {
let Some(hub) = &project.hub_path else { continue };
let path = Path::new(hub).join(hub::WISHES_FILE);
let Ok(text) = std::fs::read_to_string(&path) else { continue };
let wishes = hub::parse_wishes(&text);
if !wishes.is_empty() {
out.push((project.name, wishes.len()));
}
}
Ok(out)
}
fn doctor(hubs: bool, json: bool) -> Result<()> {
let config = profile::Config::load()?;
let (profile_name, _) = config.current()?;
let path = paths::db_path()?;
if !path.exists() {
if json {
println!("{}", serde_json::json!({ "profile": profile_name, "database": path, "initialised": false }));
} else {
println!("profile: {profile_name}");
println!("database: {} (missing - run `rigger init`)", path.display());
}
return Ok(());
}
if !json {
println!("profile: {profile_name} ({})", profile::Config::path()?.display());
}
let db = Db::open(&path)?;
let schema = db.schema_version()?;
let counts = db.counts()?;
let newest_backup = db.newest_backup_at()?;
let backup_age = newest_backup.as_deref().and_then(days_since_utc);
let server = mcp::self_check(&db);
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!({
"profile": profile_name,
"database": path,
"initialised": true,
"schema_version": schema,
"counts": counts,
"mcp": match &server {
Ok(check) => serde_json::to_value(check)?,
Err(e) => serde_json::json!({ "error": format!("{e:#}") }),
},
"backup": {
"newest_at": newest_backup,
"age_days": backup_age,
"copies": db.backups()?.len(),
"state": backup_state(backup_age),
},
"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,
"wishes_left_in_the_hub": hubs_with_wishes(&db)?
.iter()
.map(|(project, count)| serde_json::json!({ "project": project, "wishes": count }))
.collect::<Vec<_>>(),
}))?
);
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);
match &server {
Ok(check) => println!(
"mcp: answers - protocol {}, {}, {}",
check.protocol,
plural(check.tools, "tool", "tools"),
plural(check.prompts, "prompt", "prompts")
),
Err(e) => println!("mcp: does not answer: {e:#}"),
}
match backup_age {
None => println!("backup: none - one file, no copy of it; run `rigger backup`"),
Some(days) => {
let copies = db.backups()?.len();
let when = match days {
0 => "today".to_string(),
1 => "yesterday".to_string(),
d => format!("{d} days ago"),
};
let verdict = match backup_state(Some(days)) {
"old" => " - older than a week; run `rigger backup`",
"stale" => " - older than a day; run `rigger backup`",
_ => "",
};
println!("backup: {when}, {} kept{verdict}", plural(copies, "copy", "copies"));
}
}
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");
}
let waiting = hubs_with_wishes(&db)?;
if !waiting.is_empty() {
println!();
println!("wishes left in {} ({}):", hub::WISHES_FILE, waiting.len());
for (project, count) in &waiting {
println!(" {project:<12} {}", plural(*count, "wish", "wishes"));
}
println!(" the file is no longer read at every session: `rigger import <project> --hub <dir>` takes them in");
}
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, why)| file != "-" && why.starts_with("edited")) {
println!(" edited: `rigger import` takes the edit into the record; `rigger export` discards it");
}
}
}
Ok(())
}