use super::args::*;
use super::format::DEFAULT_TERM_WIDTH;
use super::format::board::{format_board, format_board_long};
use super::format::item::{
DetailOptions, ListLongOptions, format_detail, format_list, format_list_long,
};
use super::format::report::{format_burndown, format_cycletime};
use super::format::sprint::{
format_sprint_capacity, format_sprints_with_timezone, format_velocity,
};
use super::json::{
board_json, burndown_json, cycletime_json, detail_json, export_json, list_json,
sprint_capacity_json, sprints_json,
};
use clap::{CommandFactory, Parser};
use clap_complete::generate;
#[cfg(test)]
use pinto::automation::AutomationPlan;
use pinto::backlog::ItemId;
use pinto::error::Error;
use pinto::i18n::{Localizer, Message, current};
use pinto::service::SearchFilter;
use pinto::service::{
BoardQuery, CycleTimeFilter, EditOutcome, InitOutcome, ItemEdit, LabelMatch, ListFilter,
MigrateOutcome, MoveOutcome, NewItem, NextFilter, RemoveOutcome, ReorderTarget, SortKey,
SprintCloseAction, WipViolation, add_dependency, add_item_with_outcome, apply_item_edit,
archived_item_detail, assign_sprint_by_status, assign_sprint_raw, board, burndown, check_wip,
clear_common_dod, close_sprint, common_dod, create_sprint, cycle_time, delete_sprint,
display_settings, edit_item, edit_sprint, export_snapshot, init_board, item_detail,
item_edit_template, link_commits, list_items, list_sprints, lock_board, migrate_storage,
move_item_with_outcome, next_items, rebalance, remove_dependency, remove_item, reorder_item,
restore_item, set_common_dod, set_sprint_capacity, sprint_capacity, sprint_load_warnings,
start_sprint, sync_commits, template_body, unassign_sprint, unlink_commits, velocity,
};
use std::io::IsTerminal;
use pinto::sprint::SprintId;
use pinto::storage::StorageBackend;
use pinto::template::{TemplateKind, TemplateName};
use std::path::Path;
use std::process::ExitCode;
mod automation;
#[cfg(test)]
use automation::{
AutomationExecution, ValidatedAutomationCommand, automation_command_name,
automation_execution_result, automation_target_ids, first_item_id_in_output, parsed_item_id,
read_automation_plan,
};
pub(crate) async fn entrypoint() -> ExitCode {
let localizer = current();
let cli = match try_parse_localized(localizer) {
Ok(cli) => cli,
Err(e) => {
let _ = e.print();
return if e.use_stderr() {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
};
}
};
match dispatch(cli, false).await {
Ok(code) => code,
Err(e) => {
eprintln!(
"{} {}",
localizer.text(Message::ErrorPrefix),
format_anyhow_error(&e, localizer)
);
match e.downcast_ref::<Error>() {
Some(err) if err.is_user_error() => ExitCode::from(1),
_ => ExitCode::from(2),
}
}
}
}
pub(crate) fn format_anyhow_error(error: &anyhow::Error, localizer: &Localizer) -> String {
error
.chain()
.map(|cause| {
cause
.downcast_ref::<Error>()
.map_or_else(|| cause.to_string(), |error| error.localized(localizer))
})
.collect::<Vec<_>>()
.join(": ")
}
async fn dispatch(mut cli: Cli, in_shell: bool) -> anyhow::Result<ExitCode> {
let invocation_dir = std::env::current_dir()?;
anchor_automation_plan_path(&mut cli, &invocation_dir).await?;
let init = matches!(&cli.command, Command::Init);
let completion = matches!(&cli.command, Command::Completion(_));
let automation = matches!(&cli.command, Command::Automate(_));
super::location::prepare_working_directory(cli.dir.as_deref(), init, completion, automation)
.await?;
let result = match cli.command {
Command::Init => cmd_init().await,
Command::Add(args) => cmd_add(args).await,
Command::List(args) => cmd_list(args).await,
Command::Next(args) => cmd_next(args).await,
Command::Show(args) => cmd_show(args).await,
Command::Move(args) => cmd_move(args).await,
Command::Reorder(args) => cmd_reorder(args).await,
Command::Edit(args) => cmd_edit(args).await,
Command::Remove(args) => cmd_rm(args).await,
Command::Restore(args) => cmd_restore(args).await,
Command::Dep(args) => cmd_dep(args).await,
Command::Link(args) => cmd_link(args).await,
Command::Dod(args) => cmd_dod(args).await,
Command::Export(args) => cmd_export(args).await,
Command::Sprint(args) => cmd_sprint(args).await,
Command::Board(args) => cmd_board(args).await,
Command::CycleTime(args) => cmd_cycletime(args).await,
Command::Rebalance(args) => cmd_rebalance(args).await,
Command::Migrate(args) => cmd_migrate(args).await,
Command::Doctor(args) => cmd_doctor(args).await,
Command::Automate(args) => automation::cmd_automate(args).await,
Command::Shell => cmd_shell().await,
Command::Kanban(args) => cmd_kanban(args, in_shell).await,
Command::Completion(args) => cmd_completion(args),
};
if let Err(error) = std::env::set_current_dir(&invocation_dir) {
return Err(anyhow::Error::new(error).context(format!(
"failed to restore invocation directory {}",
invocation_dir.display()
)));
}
result
}
async fn anchor_automation_plan_path(cli: &mut Cli, invocation_dir: &Path) -> anyhow::Result<()> {
let Command::Automate(args) = &mut cli.command else {
return Ok(());
};
let Some(plan) = args.plan.as_mut() else {
return Ok(());
};
if plan == "-" || Path::new(plan).is_absolute() {
return Ok(());
}
let inline_json = plan.trim_start().starts_with('{');
let candidate = invocation_dir.join(&*plan);
let exists = match tokio::fs::try_exists(&candidate).await {
Ok(exists) => exists,
Err(_error) if inline_json => false,
Err(error) => {
return Err(Error::Io {
path: candidate,
message: error.to_string(),
}
.into());
}
};
if exists || !inline_json {
*plan = candidate.to_string_lossy().into_owned();
}
Ok(())
}
async fn cmd_shell() -> anyhow::Result<ExitCode> {
use rustyline::error::ReadlineError;
let localizer = current();
let mut editor = super::shell::build_editor()?;
loop {
let readline = tokio::task::block_in_place(|| editor.readline("pinto> "));
let line = match readline {
Ok(line) => line,
Err(ReadlineError::Interrupted) => continue,
Err(ReadlineError::Eof) => break,
Err(e) => {
eprintln!("{} {e}", localizer.text(Message::ErrorPrefix));
break;
}
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let _ = editor.add_history_entry(trimmed);
if trimmed == "exit" || trimmed == "quit" || trimmed == "q" {
break;
}
let argv = match super::shell::split_args(trimmed) {
Ok(argv) => argv,
Err(e) => {
eprintln!(
"{}",
localizer.format(
Message::ShellParseError,
[("error", e.to_string().as_str())]
)
);
continue;
}
};
if argv.is_empty() {
continue;
}
let parsed = Cli::try_parse_from(std::iter::once("pinto".to_string()).chain(argv));
match parsed {
Err(e) => {
let _ = e.print();
}
Ok(cli) if matches!(cli.command, Command::Shell) => {
eprintln!("{}", current().text(Message::AlreadyInInteractiveShell));
}
Ok(cli) => {
if let Err(e) = Box::pin(dispatch(cli, true)).await {
eprintln!(
"{} {}",
localizer.text(Message::ErrorPrefix),
format_anyhow_error(&e, localizer)
);
}
}
}
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_kanban(args: KanbanArgs, in_shell: bool) -> anyhow::Result<ExitCode> {
let labels = resolve_label_filter("--label", args.label, false)?;
let label_match = resolve_label_match(args.all_labels, &labels)?;
let query = BoardQuery {
sprint: args.sprint,
labels,
label_match,
search: build_search_filter(args.search, args.regex)?,
..BoardQuery::default()
};
let mode = super::kanban::run(args.column.as_deref(), args.maximize, query).await?;
if mode == super::kanban::ExitMode::Shell && !in_shell {
return cmd_shell().await;
}
Ok(ExitCode::SUCCESS)
}
fn cmd_completion(args: CompletionArgs) -> anyhow::Result<ExitCode> {
let mut cmd = Cli::command();
let bin_name = cmd.get_name().to_string();
generate(args.shell, &mut cmd, bin_name, &mut std::io::stdout());
Ok(ExitCode::SUCCESS)
}
async fn cmd_rebalance(args: RebalanceArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let outcome = rebalance(&dir, args.dry_run).await?;
if outcome.changed == 0 {
println!(
"{}",
current().format(
Message::RebalanceAlreadyBalanced,
[
("count", outcome.total.to_string().as_str()),
("max_length", outcome.before.max_len.to_string().as_str()),
],
)
);
} else {
let message = if args.dry_run {
Message::RebalanceDryRun
} else {
Message::RebalanceCompleted
};
println!(
"{}",
current().format(
message,
[
("changed", outcome.changed.to_string().as_str()),
("total", outcome.total.to_string().as_str()),
("before", outcome.before.max_len.to_string().as_str()),
("after", outcome.after.max_len.to_string().as_str()),
],
)
);
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_migrate(args: MigrateArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let target = match args.to {
MigrateTarget::File => StorageBackend::File,
MigrateTarget::Git => StorageBackend::Git,
MigrateTarget::Sqlite => {
#[cfg(feature = "sqlite")]
{
StorageBackend::Sqlite
}
#[cfg(not(feature = "sqlite"))]
{
eprintln!(
"{} {}",
current().text(Message::ErrorPrefix),
current().text(Message::MigrationSqliteUnavailable)
);
return Ok(ExitCode::from(1));
}
}
};
match migrate_storage(&dir, target).await? {
MigrateOutcome::Migrated {
from,
to,
items,
sprints,
} => {
let localizer = current();
println!(
"{}",
localizer.format(
Message::MigrationCompleted,
[
("items", items.to_string().as_str()),
("sprints", sprints.to_string().as_str()),
("from", from.to_string().as_str()),
("to", to.to_string().as_str()),
],
)
);
println!(
"{}",
localizer.format(
Message::MigrationBackendUpdated,
[("backend", to.to_string().as_str())],
)
);
}
MigrateOutcome::AlreadyUsing(backend) => {
println!(
"{}",
current().format(
Message::MigrationAlreadyUsing,
[("backend", backend.to_string().as_str())],
)
);
}
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_doctor(args: DoctorArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let report = pinto::service::doctor(&dir, args.fix).await?;
let localizer = current();
if report.issues.is_empty() {
println!("{}", localizer.text(Message::DoctorHealthy));
} else {
println!(
"{}",
localizer.format(
Message::DoctorIssues,
[("count", report.issues.len().to_string().as_str())],
)
);
}
for fix in &report.fixes {
println!(
"{}",
localizer.format(
Message::DoctorFixed,
[("description", fix.description.as_str())]
)
);
}
for issue in &report.issues {
let kind = doctor_issue_kind_name(issue.kind, localizer);
println!(
"{}",
localizer.format(
Message::DoctorIssue,
[
("kind", kind.as_str()),
("location", issue.location.as_str()),
("detail", issue.detail.as_str()),
("repair", issue.repair.as_str()),
],
)
);
}
Ok(if report.issues.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
})
}
fn doctor_issue_kind_name(
kind: pinto::service::DoctorIssueKind,
localizer: &pinto::i18n::Localizer,
) -> String {
match kind {
pinto::service::DoctorIssueKind::DanglingDependency => {
localizer.text(Message::DoctorKindDanglingDependency)
}
pinto::service::DoctorIssueKind::DanglingParent => {
localizer.text(Message::DoctorKindDanglingParent)
}
pinto::service::DoctorIssueKind::DanglingSprint => {
localizer.text(Message::DoctorKindDanglingSprint)
}
pinto::service::DoctorIssueKind::ParentCycle => {
localizer.text(Message::DoctorKindParentCycle)
}
pinto::service::DoctorIssueKind::DependencyCycle => {
localizer.text(Message::DoctorKindDependencyCycle)
}
pinto::service::DoctorIssueKind::DuplicateId => {
localizer.text(Message::DoctorKindDuplicateId)
}
pinto::service::DoctorIssueKind::IssuedId => localizer.text(Message::DoctorKindIssuedId),
pinto::service::DoctorIssueKind::InvalidStatus => {
localizer.text(Message::DoctorKindInvalidStatus)
}
pinto::service::DoctorIssueKind::RankAnomaly => {
localizer.text(Message::DoctorKindRankAnomaly)
}
pinto::service::DoctorIssueKind::Collision => localizer.text(Message::DoctorKindCollision),
pinto::service::DoctorIssueKind::MalformedRecord => {
localizer.text(Message::DoctorKindMalformedRecord)
}
pinto::service::DoctorIssueKind::Filename => localizer.text(Message::DoctorKindFilename),
}
}
async fn cmd_init() -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
match init_board(&dir).await? {
InitOutcome::Created(path) => {
println!(
"{}",
current().format(
Message::InitializedBoardAt,
[("path", path.display().to_string().as_str())],
)
);
}
InitOutcome::AlreadyInitialized(path) => {
println!(
"{}",
current().format(
Message::AlreadyInitialized,
[("path", path.display().to_string().as_str())],
)
);
}
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_add(args: AddArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let parent = args
.parent
.as_deref()
.map(str::parse::<ItemId>)
.transpose()?;
let depends_on = args
.depends_on
.iter()
.map(|id| id.parse::<ItemId>())
.collect::<Result<Vec<_>, _>>()?;
let template_body = if let Some(template) = args.template {
let template: TemplateName = template.parse()?;
Some(template_body(&dir, TemplateKind::Item, &template).await?)
} else {
None
};
let body = if args.edit {
let initial = template_body.unwrap_or_default();
let slug = format!("add-{}", args.title);
tokio::task::spawn_blocking(move || super::editor::edit_in_editor(&initial, &slug))
.await??
} else {
match (template_body, args.body) {
(Some(template), Some(body)) => combine_template_body(template, body),
(Some(template), None) => template,
(None, Some(body)) => body,
(None, None) => String::new(),
}
};
let new = NewItem {
points: args.points,
labels: args.labels,
sprint: args.sprint,
body,
parent,
depends_on,
};
let outcome = add_item_with_outcome(&dir, &args.title, new).await?;
let item = outcome.item;
if outcome.cycle_warning {
eprintln!("{}", current().text(Message::DependencyCycleWarningGeneric));
}
let id = item.id.to_string();
println!(
"{}",
current().format(
Message::Created,
[("id", id.as_str()), ("title", item.title.as_str())]
)
);
Ok(ExitCode::SUCCESS)
}
fn combine_template_body(template: String, body: String) -> String {
if template.is_empty() {
return body;
}
if body.is_empty() {
return template;
}
let separator = if template.ends_with('\n') {
"\n"
} else {
"\n\n"
};
format!("{template}{separator}{body}")
}
async fn cmd_list(args: ListArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let long = args.long;
let show_labels = args.label.is_some();
let show_sprint = args.sprint.is_some();
let labels = resolve_label_filter("--label", args.label, long)?;
let label_match = resolve_label_match(args.all_labels, &labels)?;
let sprint = resolve_optional_filter("--sprint", args.sprint, long)?;
let stale_before = args
.stale
.map(|duration| {
chrono::Utc::now()
.checked_sub_signed(duration)
.ok_or_else(|| {
Error::InvalidFilterOption(
"--stale duration is too large for a UTC timestamp".to_string(),
)
})
})
.transpose()?;
let filter = ListFilter {
roots_only: args.roots_only,
archived: args.archived,
status: args.status,
sprint,
assignee: args.assignee,
labels,
label_match,
search: build_search_filter(args.search, args.regex)?,
stale_before,
};
let items = list_items(&dir, &filter).await?;
if args.json {
println!("{}", list_json(&items)?);
} else if items.is_empty() {
println!("{}", current().text(Message::NoBacklogItems));
} else if long {
let timezone = display_settings(&dir).await?.timezone;
print!(
"{}",
format_list_long(
&items,
terminal_width(),
ListLongOptions::new(show_labels, show_sprint)
.with_acceptance_criteria(args.acceptance_criteria)
.with_timezone(timezone),
)
);
} else {
print!("{}", format_list(&items));
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_next(args: NextArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let items = next_items(
&dir,
&NextFilter {
count: args.count,
sprint: args.sprint,
},
)
.await?;
if args.json {
println!("{}", list_json(&items)?);
} else if items.is_empty() {
println!("{}", current().text(Message::NoActionableItems));
} else {
print!("{}", format_list(&items));
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_export(_args: ExportArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let snapshot = export_snapshot(&dir).await?;
println!("{}", export_json(&snapshot)?);
Ok(ExitCode::SUCCESS)
}
async fn cmd_show(args: ShowArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let ids: Vec<ItemId> = args
.ids
.iter()
.map(|id| id.parse())
.collect::<Result<_, _>>()?;
let mut details = Vec::with_capacity(ids.len());
for id in &ids {
details.push(if args.archived {
archived_item_detail(&dir, id).await?
} else {
item_detail(&dir, id).await?
});
}
if args.json {
println!("{}", detail_json(&details)?);
} else {
let dod = common_dod(&dir).await?;
let display = display_settings(&dir).await?;
let markdown = display.markdown && !args.plain;
let options = DetailOptions {
markdown,
width: terminal_width(),
color: std::io::stdout().is_terminal(),
timezone: display.timezone,
};
let output = details
.iter()
.map(|detail| format_detail(detail, dod.as_deref(), options))
.collect::<Vec<_>>()
.join("\n---\n");
print!("{output}");
}
Ok(ExitCode::SUCCESS)
}
fn acceptance_criteria_warning(outcome: &MoveOutcome) -> Option<String> {
if !outcome.entered_done_column || !outcome.acceptance_criteria.is_incomplete() {
return None;
}
let progress = outcome.acceptance_criteria.to_string();
Some(current().format(
Message::AcceptanceCriteriaIncomplete,
[
("id", outcome.item.id.to_string().as_str()),
("progress", progress.as_str()),
],
))
}
async fn cmd_move(args: MoveArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let Some((status, raw_ids)) = args.destination_and_ids() else {
return Ok(ExitCode::SUCCESS);
};
let mut failures = Vec::new();
let mut moved_any = false;
for raw_id in raw_ids {
let id: ItemId = match raw_id.parse() {
Ok(id) => id,
Err(error) => {
failures.push((raw_id.clone(), error));
continue;
}
};
match move_item_with_outcome(&dir, &id, status).await {
Ok(outcome) => {
let item = &outcome.item;
println!(
"{}",
current().format(
Message::Moved,
[
("id", item.id.to_string().as_str()),
("status", item.status.as_str()),
],
)
);
if let Some(warning) = acceptance_criteria_warning(&outcome) {
eprintln!("{warning}");
}
moved_any = true;
}
Err(error) => failures.push((raw_id.clone(), error)),
}
}
if moved_any && !args.no_wip_check {
for v in check_wip(&dir).await?.iter().filter(|v| v.column == status) {
warn_wip(v);
}
}
report_failures(failures, "move")
}
fn report_failures(mut failures: Vec<(String, Error)>, action: &str) -> anyhow::Result<ExitCode> {
let localizer = current();
if let Some(internal_index) = failures
.iter()
.position(|(_, error)| !error.is_user_error())
{
let (id, error) = failures.swap_remove(internal_index);
for (failed_id, error) in failures {
eprintln!(
"{} {}: {}",
localizer.text(Message::ErrorPrefix),
failed_id,
error.localized(localizer)
);
}
let context = localizer.format(
Message::FailedToAction,
[("action", action), ("id", id.as_str())],
);
return Err(anyhow::Error::new(error).context(context));
}
let has_failures = !failures.is_empty();
for (id, error) in failures {
eprintln!(
"{} {}: {}",
localizer.text(Message::ErrorPrefix),
id,
error.localized(localizer)
);
}
if has_failures {
return Ok(ExitCode::from(1));
}
Ok(ExitCode::SUCCESS)
}
fn warn_wip(v: &WipViolation) {
eprintln!(
"{}",
current().format(
Message::WipLimitExceeded,
[
("column", v.column.as_str()),
("count", v.count.to_string().as_str()),
("limit", v.limit.to_string().as_str()),
],
)
);
}
async fn warn_sprint_load(dir: &Path, id: &SprintId) -> anyhow::Result<()> {
for warning in sprint_load_warnings(dir, id).await? {
let points = warning.points.to_string();
let threshold = format!("{:.1} {}", warning.threshold, warning.kind.unit());
eprintln!(
"{}",
current().format(
Message::SprintLoadWarning,
[
("sprint", id.as_str()),
("points", points.as_str()),
("kind", warning.kind.as_str()),
("threshold", threshold.as_str()),
],
)
);
}
Ok(())
}
async fn cmd_reorder(args: ReorderArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let id: ItemId = args.id.parse()?;
let target = if let Some(reference) = args.before {
ReorderTarget::Before(reference.parse()?)
} else if let Some(reference) = args.after {
ReorderTarget::After(reference.parse()?)
} else if args.top {
ReorderTarget::Top
} else {
ReorderTarget::Bottom
};
let item = reorder_item(&dir, &id, target).await?;
println!(
"{}",
current().format(Message::Reordered, [("id", item.id.to_string().as_str())],)
);
Ok(ExitCode::SUCCESS)
}
async fn cmd_edit(args: EditArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let id: ItemId = args.id.parse()?;
if !has_field_edits(&args) {
return cmd_edit_in_editor(&dir, &id).await;
}
let parent = if args.no_parent {
Some(None)
} else if let Some(p) = args.parent {
Some(Some(p.parse::<ItemId>()?))
} else {
None
};
let edit = ItemEdit {
title: args.title,
points: args.points,
labels: if args.labels.is_empty() {
None
} else {
Some(args.labels)
},
assignee: args.assignee,
sprint: args.sprint,
body: args.body,
parent,
};
let item = edit_item(&dir, &id, edit).await?;
println!(
"{}",
current().format(
Message::Updated,
[
("id", item.id.to_string().as_str()),
("title", item.title.as_str()),
],
)
);
Ok(ExitCode::SUCCESS)
}
fn has_field_edits(args: &EditArgs) -> bool {
args.title.is_some()
|| args.points.is_some()
|| !args.labels.is_empty()
|| args.assignee.is_some()
|| args.sprint.is_some()
|| args.body.is_some()
|| args.parent.is_some()
|| args.no_parent
}
async fn cmd_edit_in_editor(dir: &Path, id: &ItemId) -> anyhow::Result<ExitCode> {
if super::editor::resolve_editor().is_none() {
return Err(pinto::error::Error::EditorNotSet.into());
}
let template = item_edit_template(dir, id).await?;
let slug = id.to_string();
let edited =
tokio::task::spawn_blocking(move || super::editor::edit_in_editor(&template, &slug))
.await??;
match apply_item_edit(dir, id, &edited).await? {
EditOutcome::Updated(item) => println!(
"{}",
current().format(
Message::Updated,
[
("id", item.id.to_string().as_str()),
("title", item.title.as_str()),
],
)
),
EditOutcome::Unchanged => println!(
"{}",
current().format(Message::NoChangesTo, [("id", id.to_string().as_str())])
),
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_dep(args: DepArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
match args.command {
DepCommand::Add { id, depends_on } => {
let id: ItemId = id.parse()?;
let dep: ItemId = depends_on.parse()?;
let outcome = add_dependency(&dir, &id, &dep).await?;
let localizer = current();
println!(
"{}",
localizer.format(
Message::DependencyAdded,
[
("id", id.to_string().as_str()),
("dependency", dep.to_string().as_str()),
],
)
);
if outcome.cycle_warning {
eprintln!(
"{}",
localizer.format(
Message::DependencyCycleWarning,
[
("id", id.to_string().as_str()),
("dependency", dep.to_string().as_str()),
],
)
);
}
}
DepCommand::Rm { id, depends_on } => {
let id: ItemId = id.parse()?;
let dep: ItemId = depends_on.parse()?;
let item = remove_dependency(&dir, &id, &dep).await?;
println!(
"{}",
current().format(
Message::DependencyRemoved,
[
("id", item.id.to_string().as_str()),
("dependency", dep.to_string().as_str()),
],
)
);
}
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_link(args: LinkArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
match args.command {
LinkCommand::Add { id, shas } => {
let id: ItemId = id.parse()?;
let outcome = link_commits(&dir, &id, &shas).await?;
let localizer = current();
if outcome.changed.is_empty() {
println!(
"{}",
localizer.format(
Message::LinkAlreadyLinked,
[("id", id.to_string().as_str())]
)
);
} else {
let commits = outcome.changed.join(", ");
println!(
"{}",
localizer.format(
Message::LinkAdded,
[
("id", id.to_string().as_str()),
("commits", commits.as_str())
],
)
);
}
}
LinkCommand::Rm { id, shas } => {
let id: ItemId = id.parse()?;
let outcome = unlink_commits(&dir, &id, &shas).await?;
let localizer = current();
if outcome.changed.is_empty() {
println!(
"{}",
localizer.format(
Message::LinkNoMatchingCommit,
[("id", id.to_string().as_str())],
)
);
} else {
let commits = outcome.changed.join(", ");
println!(
"{}",
localizer.format(
Message::LinkRemoved,
[
("id", id.to_string().as_str()),
("commits", commits.as_str())
],
)
);
}
}
LinkCommand::Sync { since } => {
let outcome = sync_commits(&dir, since.as_deref()).await?;
let localizer = current();
if outcome.links.is_empty() {
println!("{}", localizer.text(Message::LinkNoNewCommits));
} else {
for (id, sha) in &outcome.links {
let short: String = sha.chars().take(8).collect();
let id = id.to_string();
println!(
"{}",
localizer.format(
Message::LinkCommitLinked,
[("id", id.as_str()), ("commit", short.as_str())],
)
);
}
println!(
"{}",
localizer.format(
Message::LinkSummary,
[("count", outcome.links.len().to_string().as_str())],
)
);
}
}
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_dod(args: DodArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
match args.command {
None => match common_dod(&dir).await? {
Some(text) => println!("{text}"),
None => println!("{}", current().text(Message::DodUnset)),
},
Some(DodCommand::Set { text }) => {
set_common_dod(&dir, &text).await?;
println!("{}", current().text(Message::DodUpdated));
}
Some(DodCommand::Clear) => {
if clear_common_dod(&dir).await? {
println!("{}", current().text(Message::DodCleared));
} else {
println!("{}", current().text(Message::DodNoCommonToClear));
}
}
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_rm(args: RemoveArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let mut ids = Vec::with_capacity(args.ids.len());
let mut failures = Vec::new();
for raw_id in args.ids {
let id: ItemId = match raw_id.parse() {
Ok(id) => id,
Err(error) => {
failures.push((raw_id, error));
continue;
}
};
ids.push((raw_id, id));
}
if !failures.is_empty() {
return report_failures(failures, "remove");
}
for (raw_id, id) in ids {
match remove_item(&dir, &id, args.force).await {
Ok(RemoveOutcome::Archived(path)) => println!(
"{} {} ({})",
current().text(Message::Archived),
raw_id,
path.display()
),
Ok(RemoveOutcome::Deleted) => {
println!("{} {}", current().text(Message::Deleted), raw_id)
}
Err(error) => failures.push((raw_id, error)),
}
}
report_failures(failures, "remove")
}
async fn cmd_restore(args: RestoreArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let id: ItemId = args.id.parse()?;
let item = restore_item(&dir, &id).await?;
println!("{} {}", current().text(Message::Restored), item.id);
Ok(ExitCode::SUCCESS)
}
async fn cmd_sprint(args: SprintArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
match args.command {
SprintCommand::New {
id,
title,
goal,
template,
start,
end,
} => {
let id: SprintId = id.parse()?;
let period = match (start, end) {
(Some(s), Some(e)) => Some((s, e)),
_ => None,
};
let goal = match template {
Some(template) => {
let template: TemplateName = template.parse()?;
Some(template_body(&dir, TemplateKind::Sprint, &template).await?)
}
None => goal,
};
let sprint = create_sprint(&dir, &id, &title, goal, period).await?;
println!(
"{}",
current().format(
Message::CreatedSprint,
[
("id", sprint.id.to_string().as_str()),
("title", sprint.title.as_str()),
],
)
);
}
SprintCommand::Edit {
id,
title,
goal,
start,
end,
} => {
let id: SprintId = id.parse()?;
let period = match (start, end) {
(Some(start), Some(end)) => Some((start, end)),
_ => None,
};
let sprint = edit_sprint(&dir, &id, title, goal, period).await?;
println!(
"{}",
current().format(
Message::UpdatedSprint,
[("id", sprint.id.to_string().as_str())],
)
);
}
SprintCommand::Remove { id } => {
let id: SprintId = id.parse()?;
delete_sprint(&dir, &id).await?;
println!(
"{}",
current().format(Message::DeletedSprint, [("id", id.to_string().as_str())])
);
}
SprintCommand::Start { id } => {
let id: SprintId = id.parse()?;
let sprint = start_sprint(&dir, &id).await?;
println!(
"{}",
current().format(
Message::StartedSprint,
[("id", sprint.id.to_string().as_str())],
)
);
warn_sprint_load(&dir, &sprint.id).await?;
}
SprintCommand::Close {
id,
rollover,
release,
} => {
let id: SprintId = id.parse()?;
let action = if let Some(target) = rollover {
SprintCloseAction::Rollover(target.parse()?)
} else if release {
SprintCloseAction::Release
} else {
SprintCloseAction::Retain
};
let sprint = close_sprint(&dir, &id, action).await?;
println!(
"{}",
current().format(
Message::ClosedSprint,
[("id", sprint.id.to_string().as_str())],
)
);
}
SprintCommand::Add {
sprint_id,
item_id,
status,
limit,
} => {
if let Some(item_id) = item_id {
let item_id: ItemId = item_id.parse()?;
let sprint_id: SprintId = sprint_id.parse()?;
let item = assign_sprint_raw(&dir, sprint_id.as_str(), &item_id).await?;
println!(
"{}",
current().format(
Message::AssignedToSprint,
[
("id", item.id.to_string().as_str()),
("sprint", sprint_id.as_str()),
],
)
);
warn_sprint_load(&dir, &sprint_id).await?;
} else if let Some(status) = status {
let sprint_id: SprintId = sprint_id.parse()?;
let assigned = assign_sprint_by_status(&dir, &sprint_id, &status, limit).await?;
for item in &assigned {
println!(
"{}",
current().format(
Message::AssignedToSprint,
[
("id", item.id.to_string().as_str()),
("sprint", sprint_id.to_string().as_str()),
],
)
);
}
if !assigned.is_empty() {
warn_sprint_load(&dir, &sprint_id).await?;
}
} else {
return Err(anyhow::anyhow!(
"{}",
current().text(Message::SprintAddRequiresItemOrStatus)
));
}
}
SprintCommand::Unassign { sprint_id, item_id } => {
let sprint_id: SprintId = sprint_id.parse()?;
let item_id: ItemId = item_id.parse()?;
let item = unassign_sprint(&dir, &sprint_id, &item_id).await?;
println!(
"{}",
current().format(
Message::UnassignedFromSprint,
[
("id", item.id.to_string().as_str()),
("sprint", sprint_id.to_string().as_str()),
],
)
);
}
SprintCommand::List { json } => {
let sprints = list_sprints(&dir).await?;
if json {
println!("{}", sprints_json(&sprints)?);
} else if sprints.is_empty() {
println!("{}", current().text(Message::NoSprints));
} else {
let timezone = display_settings(&dir).await?.timezone;
print!("{}", format_sprints_with_timezone(&sprints, timezone));
}
}
SprintCommand::Burndown { id, json } => {
let id: SprintId = id.parse()?;
let chart = burndown(&dir, &id).await?;
if json {
println!("{}", burndown_json(&chart)?);
} else {
print!("{}", format_burndown(&chart, terminal_width()));
}
}
SprintCommand::Velocity { recent } => {
let report = velocity(&dir, recent).await?;
if report.sprints.is_empty() {
println!("{}", current().text(Message::NoSprints));
} else {
print!("{}", format_velocity(&report, recent));
}
}
SprintCommand::Capacity {
id,
daily_hours,
holidays,
deduction_factor,
json,
} => {
let id: SprintId = id.parse()?;
let capacity = match (daily_hours, holidays, deduction_factor) {
(Some(hours), Some(holidays), Some(factor)) => {
set_sprint_capacity(&dir, &id, hours, holidays, factor).await?
}
(None, None, None) => sprint_capacity(&dir, &id).await?,
_ => {
return Err(anyhow::anyhow!(
"{}",
current().text(Message::InvalidCapacityOptions)
));
}
};
if json {
println!("{}", sprint_capacity_json(&capacity)?);
} else {
print!("{}", format_sprint_capacity(&capacity));
}
}
}
Ok(ExitCode::SUCCESS)
}
fn terminal_width() -> usize {
terminal_size::terminal_size()
.map(|(terminal_size::Width(w), _)| usize::from(w))
.filter(|&w| w > 0)
.unwrap_or(DEFAULT_TERM_WIDTH)
}
async fn cmd_board(args: BoardArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let long = args.long;
let show_labels = args.label.is_some();
let show_sprint = args.sprint.is_some();
let labels = resolve_label_filter("--label", args.label, long)?;
let label_match = resolve_label_match(args.all_labels, &labels)?;
let sprint = resolve_optional_filter("--sprint", args.sprint, long)?;
let query = BoardQuery {
roots_only: args.roots_only,
sprint,
assignee: args.assignee,
labels,
label_match,
statuses: args.status,
sort: args.sort.map(|s| match s {
SortArg::Rank => SortKey::Rank,
SortArg::Done => SortKey::Done,
SortArg::Created => SortKey::Created,
}),
reverse: args.reverse,
search: build_search_filter(args.search, args.regex)?,
};
let board = board(&dir, &query).await?;
if args.json {
println!("{}", board_json(&board)?);
return Ok(ExitCode::SUCCESS);
}
let width = if args.no_truncate {
usize::MAX
} else {
terminal_width()
};
if long {
let timezone = display_settings(&dir).await?.timezone;
print!(
"{}",
format_board_long(
&board,
width,
ListLongOptions::new(show_labels, show_sprint)
.with_acceptance_criteria(args.acceptance_criteria)
.with_timezone(timezone),
)
);
} else {
print!("{}", format_board(&board, width));
}
if !board.orphaned.is_empty() {
let ids = board
.orphaned
.iter()
.map(|it| it.id.to_string())
.collect::<Vec<_>>()
.join(", ");
eprintln!(
"{}",
current().format(
Message::OrphanedItemsWarning,
[
("count", board.orphaned.len().to_string().as_str()),
("ids", ids.as_str()),
],
)
);
}
if !args.no_wip_check {
for v in check_wip(&dir).await? {
warn_wip(&v);
}
}
Ok(ExitCode::SUCCESS)
}
fn build_search_filter(
pattern: Option<String>,
regex: bool,
) -> anyhow::Result<Option<SearchFilter>> {
pattern
.map(|pattern| SearchFilter::new(pattern, regex))
.transpose()
.map_err(Into::into)
}
fn resolve_optional_filter(
option: &str,
value: Option<Option<String>>,
long: bool,
) -> anyhow::Result<Option<String>> {
match value {
Some(Some(value)) => Ok(Some(value)),
Some(None) if long => Ok(None),
Some(None) => Err(Error::InvalidFilterOption(format!(
"{option} requires a value unless --long is specified"
))
.into()),
None => Ok(None),
}
}
fn resolve_label_filter(
option: &str,
value: Option<Vec<String>>,
long: bool,
) -> anyhow::Result<Vec<String>> {
match value {
Some(values) if !values.is_empty() => Ok(values),
Some(_) if long => Ok(Vec::new()),
Some(_) => Err(Error::InvalidFilterOption(format!(
"{option} requires a value unless --long is specified"
))
.into()),
None => Ok(Vec::new()),
}
}
fn resolve_label_match(all_labels: bool, labels: &[String]) -> anyhow::Result<LabelMatch> {
if all_labels && labels.is_empty() {
return Err(Error::InvalidFilterOption(
"--all-labels requires at least one --label value".to_string(),
)
.into());
}
Ok(if all_labels {
LabelMatch::All
} else {
LabelMatch::Any
})
}
async fn cmd_cycletime(args: CycleTimeArgs) -> anyhow::Result<ExitCode> {
let dir = std::env::current_dir()?;
let filter = CycleTimeFilter {
sprint: args.sprint,
since: args.since,
until: args.until,
};
let report = cycle_time(&dir, &filter).await?;
if args.json {
println!("{}", cycletime_json(&report)?);
} else {
print!("{}", format_cycletime(&report));
}
Ok(ExitCode::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn automation_names_and_target_ids_cover_command_shapes() {
assert_eq!(automation_command_name(&argv(&["dep", "add"])), "dep add");
assert_eq!(
automation_command_name(&argv(&["link", "sync"])),
"link sync"
);
assert_eq!(
automation_command_name(&argv(&["sprint", "new"])),
"sprint new"
);
assert_eq!(automation_command_name(&argv(&["list"])), "list");
assert_eq!(automation_command_name(&[]), "unknown");
assert!(automation_target_ids(&[]).is_empty());
assert_eq!(
automation_target_ids(&argv(&["move", "T-1", "invalid", "T-2"])),
["T-1", "T-2"]
);
assert_eq!(automation_target_ids(&argv(&["edit", "T-3"])), ["T-3"]);
assert_eq!(
automation_target_ids(&argv(&["reorder", "T-4", "--top"])),
["T-4"]
);
assert_eq!(
automation_target_ids(&argv(&["remove", "T-5", "T-6"])),
["T-5", "T-6"]
);
assert_eq!(
automation_target_ids(&argv(&["dep", "add", "T-7", "T-8"])),
["T-7"]
);
assert_eq!(
automation_target_ids(&argv(&["link", "add", "T-9", "abc"])),
["T-9"]
);
assert_eq!(
automation_target_ids(&argv(&["sprint", "add", "S-1", "T-10"])),
["T-10"]
);
assert!(automation_target_ids(&argv(&["unknown", "T-11"])).is_empty());
}
#[test]
fn automation_schema_tracks_safe_cli_commands_and_aliases() {
let schema = AutomationPlan::json_schema();
let names = schema["$defs"]["command"]["prefixItems"][0]["enum"]
.as_array()
.expect("schema command names are an array");
let is_unsafe = |name: &str| {
matches!(
name,
"automate" | "auto" | "shell" | "kanban" | "k" | "completion"
)
};
for command in Cli::command().get_subcommands() {
if command.get_name() == "help" {
continue;
}
let command_names =
std::iter::once(command.get_name()).chain(command.get_visible_aliases());
for name in command_names {
if is_unsafe(name) {
assert!(!names.iter().any(|value| value == name));
assert!(
AutomationPlan::parse(&format!(r#"{{"commands":[["{name}"]]}}"#)).is_err(),
"unsafe command must be rejected: {name}"
);
} else {
assert!(
names.iter().any(|value| value == name),
"safe CLI command is missing from the schema: {name}"
);
}
}
}
}
#[test]
fn automation_results_extract_created_ids_and_sanitize_errors() {
let command = ValidatedAutomationCommand {
index: 1,
argv: argv(&["add", "Task"]),
name: "add".to_string(),
error: None,
};
let created = automation_execution_result(
&command,
&AutomationExecution {
success: true,
exit_code: Some(0),
stdout: "Created T-42: Task".to_string(),
stderr: String::new(),
},
"succeeded",
);
assert_eq!(created.created_ids, ["T-42"]);
assert_eq!(created.error, None);
for (exit_code, expected) in [
(Some(1), "command exited with status 1"),
(None, "command exited with status unknown"),
] {
let failed = automation_execution_result(
&command,
&AutomationExecution {
success: false,
exit_code,
stdout: String::new(),
stderr: String::new(),
},
"failed",
);
assert_eq!(failed.error.as_deref(), Some(expected));
}
let stderr = automation_execution_result(
&command,
&AutomationExecution {
success: false,
exit_code: Some(1),
stdout: String::new(),
stderr: " user-facing failure\n".to_string(),
},
"failed",
);
assert_eq!(stderr.error.as_deref(), Some("user-facing failure"));
assert_eq!(
first_item_id_in_output("created: [T-1], next T-2"),
Some("T-1".to_string())
);
assert_eq!(parsed_item_id(Some(&"bad".to_string())), None);
}
#[test]
fn template_body_and_failure_reporting_keep_edge_cases_explicit() {
assert_eq!(
combine_template_body(String::new(), "body".to_string()),
"body"
);
assert_eq!(
combine_template_body("template".to_string(), String::new()),
"template"
);
assert_eq!(
combine_template_body("template\n".to_string(), "body".to_string()),
"template\n\nbody"
);
assert_eq!(
combine_template_body("template".to_string(), "body".to_string()),
"template\n\nbody"
);
let item_id = "T-1".parse::<ItemId>().expect("valid item id");
let result = report_failures(
vec![
("T-1".to_string(), Error::NotFound(item_id)),
(
"T-2".to_string(),
Error::Io {
path: "/tmp/pinto-test".into(),
message: "read failed".to_string(),
},
),
],
"move",
);
assert!(result.is_err());
}
#[tokio::test]
async fn sprint_capacity_rejects_an_incomplete_programmatic_argument_set() {
let error = cmd_sprint(SprintArgs {
command: SprintCommand::Capacity {
id: "S-1".to_string(),
daily_hours: Some(8.0),
holidays: None,
deduction_factor: None,
json: false,
},
})
.await
.expect_err("partial capacity settings must be rejected");
assert!(error.to_string().contains("must be provided together"));
}
#[tokio::test]
async fn automation_plan_source_handles_inline_and_invalid_sources() {
let inline = "{\0\"commands\":[]}";
assert_eq!(read_automation_plan(inline).await.unwrap(), inline);
let invalid_path = "automation\0plan.json";
let error = read_automation_plan(invalid_path)
.await
.expect_err("invalid path should return a structured source error");
assert!(matches!(
error.downcast_ref::<Error>(),
Some(Error::AutomationPlanSource { path, .. })
if path == Path::new(invalid_path)
));
let directory = tempfile::tempdir().expect("temporary directory");
let directory_path = directory.path().to_str().expect("temporary path is UTF-8");
let error = read_automation_plan(directory_path)
.await
.expect_err("a directory is not a readable plan file");
assert!(matches!(
error.downcast_ref::<Error>(),
Some(Error::AutomationPlanSource { path, .. })
if path == directory.path()
));
let missing_path = directory.path().join("missing.json");
let missing_path = missing_path.to_str().expect("temporary path is UTF-8");
let error = read_automation_plan(missing_path)
.await
.expect_err("missing path should return a structured source error");
assert!(matches!(
error.downcast_ref::<Error>(),
Some(Error::AutomationPlanSource { path, message })
if path == Path::new(missing_path) && message == "file does not exist"
));
}
}