use super::args::*;
use super::format::DEFAULT_TERM_WIDTH;
use clap::Parser;
use pinto::backlog::ItemId;
use pinto::error::Error;
use pinto::i18n::{Localizer, Message, current};
use pinto::service::SearchFilter;
use pinto::service::{LabelMatch, WipViolation, lock_board};
use std::path::Path;
use std::process::ExitCode;
mod automation;
mod board;
mod item;
mod maintenance;
mod relations;
mod session;
mod sprint;
#[cfg(test)]
mod tests;
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 => maintenance::cmd_init().await,
Command::Add(args) => item::cmd_add(args).await,
Command::List(args) => item::cmd_list(args).await,
Command::Next(args) => item::cmd_next(args).await,
Command::Show(args) => item::cmd_show(args).await,
Command::Move(args) => item::cmd_move(args).await,
Command::Reorder(args) => item::cmd_reorder(args).await,
Command::Edit(args) => item::cmd_edit(args).await,
Command::Remove(args) => item::cmd_rm(args).await,
Command::Restore(args) => item::cmd_restore(args).await,
Command::Dep(args) => relations::cmd_dep(args).await,
Command::Link(args) => relations::cmd_link(args).await,
Command::Dod(args) => relations::cmd_dod(args).await,
Command::Export(args) => board::cmd_export(args).await,
Command::Sprint(args) => sprint::cmd_sprint(args).await,
Command::Board(args) => board::cmd_board(args).await,
Command::CycleTime(args) => board::cmd_cycletime(args).await,
Command::Rebalance(args) => maintenance::cmd_rebalance(args).await,
Command::Migrate(args) => maintenance::cmd_migrate(args).await,
Command::Doctor(args) => maintenance::cmd_doctor(args).await,
Command::Automate(args) => automation::cmd_automate(args).await,
Command::Shell => session::cmd_shell().await,
Command::Kanban(args) => session::cmd_kanban(args, in_shell).await,
Command::Completion(args) => session::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(())
}
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()),
],
)
);
}
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)
}
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
})
}