mod api;
mod article;
mod auth;
mod board;
pub(crate) mod completion;
mod config;
mod ctx;
mod globals;
mod issue;
mod profile;
mod project;
mod shared;
mod time;
use std::process::ExitCode;
use usage_rs::{Cli, Subcommands};
use yt::env::Env;
pub(crate) use ctx::Ctx;
pub(crate) use globals::Globals;
#[derive(Cli)]
#[usage(
bin = "yt",
version,
completion,
unknown_flags = "error",
config = yt::config::settings::Settings,
run_async_with
)]
pub(crate) struct Yt {
#[usage(flatten)]
pub(crate) globals: Globals,
#[usage(subcommand)]
pub(crate) command: Command,
}
#[derive(Subcommands)]
#[usage(run_async_with)]
pub(crate) enum Command {
Auth(auth::Auth),
Issue(issue::Issue),
Project(project::ProjectGroup),
Time(time::TimeGroup),
Article(article::ArticleGroup),
Board(board::BoardGroup),
Profile(profile::Profile),
Config(config::Config),
Api(api::Api),
Completion(completion::Completion),
}
pub(crate) fn run(cli: Yt) -> ExitCode {
let env = Env::from_process();
yt::logger::init(yt::logger::level(
cli.globals.verbose,
cli.globals.quiet,
&env,
));
let ctx = match Ctx::new(&cli.globals, env) {
Ok(ctx) => ctx,
Err(error) => return report(error),
};
configure_terminal(&cli.globals, &ctx);
let runtime = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
return report(miette::miette!(
"could not start the async runtime: {error}"
));
}
};
let globals = cli.globals.clone();
let mut result = runtime.block_on(cli.run_command_async_with(&ctx));
match expired_sign_in(&result, ctx.has_written()) {
Some(Expired::Rerun(profile)) if ctx.ui.is_interactive() => {
result = match runtime.block_on(sign_in_again(&ctx, &profile)) {
Ok(true) => match Ctx::new(&globals, Env::from_process()) {
Ok(fresh) => runtime.block_on(Yt::parse().run_command_async_with(&fresh)),
Err(error) => Err(error),
},
Ok(false) => result,
Err(error) => Err(error),
};
}
Some(Expired::AfterChanges(profile)) => ctx.note(&format!(
"Changes were already sent before the sign-in to {profile} expired, so yt does not run the command again. Sign in with `yt auth login --profile {profile}` and check them before retrying."
)),
_ => {}
}
match result {
Ok(()) => ExitCode::SUCCESS,
Err(error) => report(error),
}
}
#[derive(Debug, PartialEq)]
enum Expired {
Rerun(String),
AfterChanges(String),
}
fn expired_sign_in(result: &yt::Result<()>, written: bool) -> Option<Expired> {
match result.as_ref().err()?.downcast_ref::<yt::YtError>() {
Some(yt::YtError::SessionExpired { profile }) if written => {
Some(Expired::AfterChanges(profile.clone()))
}
Some(yt::YtError::SessionExpired { profile }) => Some(Expired::Rerun(profile.clone())),
_ => None,
}
}
async fn sign_in_again(ctx: &Ctx, profile: &str) -> yt::Result<bool> {
let question = format!("Your sign-in to {profile} has expired. Sign in again?");
if !ctx.ui.confirm(&question, true, "--yes").await? {
return Ok(false);
}
let existing = ctx.global_config()?.profiles.get(profile).cloned();
let request = yt::auth::login::LoginRequest {
url: existing.as_ref().map(|existing| existing.url.clone()),
method: Some(yt::auth::login::Method::Browser),
client_id: existing
.as_ref()
.and_then(|existing| existing.oauth_client_id.clone()),
profile: Some(profile.to_string()),
revocable: existing.as_ref().is_some_and(|existing| existing.revocable),
..yt::auth::login::LoginRequest::default()
};
let outcome = auth::sign_in(ctx, request).await?;
ctx.note(&format!(
"Signed in to {} ({}) as {}",
outcome.profile, outcome.url, outcome.login
));
Ok(true)
}
fn configure_terminal(globals: &Globals, ctx: &Ctx) {
let setting = ctx
.settings()
.map(|resolved| resolved.settings.color.clone())
.unwrap_or_default();
let enabled = if globals.no_color {
Some(false)
} else {
match setting.as_str() {
"always" => Some(true),
"never" => Some(false),
_ if ctx.env.get("NO_COLOR").is_some() => Some(false),
_ => None,
}
};
if let Some(enabled) = enabled {
console::set_colors_enabled(enabled);
console::set_colors_enabled_stderr(enabled);
}
let stderr_color = console::colors_enabled_stderr();
let _ = miette::set_hook(Box::new(move |_| {
Box::new(miette::MietteHandlerOpts::new().color(stderr_color).build())
}));
}
fn report(error: miette::Report) -> ExitCode {
yt::ui::eprint_raw(&render(&error));
ExitCode::from(yt::error::exit_code(&error))
}
pub(crate) fn details(heading: &str, rows: Vec<(String, String)>, body: Option<&str>) -> String {
let width = rows
.iter()
.map(|(label, _)| label.chars().count())
.max()
.unwrap_or(0)
+ 1;
let mut lines = vec![heading.to_string()];
lines.extend(
rows.into_iter()
.map(|(label, value)| format!("{:<width$} {value}", format!("{label}:"))),
);
if let Some(body) = body.map(str::trim_end).filter(|body| !body.is_empty()) {
lines.push(String::new());
lines.push(body.to_string());
}
lines.join("\n")
}
pub(crate) fn check_limit(limit: usize) -> yt::Result<()> {
if limit == 0 {
return Err(yt::YtError::Usage {
message: "--limit must be at least 1".into(),
}
.into());
}
Ok(())
}
fn render(error: &miette::Report) -> String {
yt::redact::redact(&format!("{:?}", yt::error::printable(error)))
}
#[cfg(test)]
mod tests {
use usage_rs::test::{self as harness, Page};
use yt::output::Format;
use super::*;
#[test]
fn details_align_labels_and_add_the_body() {
let text = super::details(
"TEST Test project",
vec![
("Leader".into(), "jane".into()),
("Time tracking".into(), "on".into()),
],
Some("About it\n"),
);
assert_eq!(
text,
"TEST Test project\nLeader: jane\nTime tracking: on\n\nAbout it"
);
}
#[test]
fn help_tree() {
let mut settings = insta::Settings::clone_current();
settings.add_filter(r"(?m)^yt \d+\.\d+\.\d+\S*$", "yt [version]");
settings.bind(|| insta::assert_snapshot!(harness::help_tree(Yt::spec(), Page::Long)));
}
#[test]
fn global_flags_parse_after_the_subcommand() {
let words = harness::argv(["completion", "zsh", "--format", "json", "-vv"]);
let cli = harness::parse(Yt::spec(), &words.words(), Yt::parse_from).unwrap();
assert_eq!(cli.globals.format, Some(Format::Json));
assert_eq!(cli.globals.verbose, 2);
}
#[test]
fn reports_never_show_a_registered_secret() {
yt::redact::register("perm-cmVwb3J0.dGVzdA==.report123456");
let error = miette::miette!("server said: perm-cmVwb3J0.dGVzdA==.report123456");
assert!(!render(&error).contains("report123456"));
}
#[test]
fn an_expired_sign_in_reruns_the_command_only_before_any_change() {
let expired: yt::Result<()> = Err(yt::YtError::SessionExpired {
profile: "acme".into(),
}
.into());
assert_eq!(
expired_sign_in(&expired, false),
Some(Expired::Rerun("acme".into()))
);
assert_eq!(
expired_sign_in(&expired, true),
Some(Expired::AfterChanges("acme".into()))
);
let other: yt::Result<()> = Err(yt::YtError::NoProfile.into());
assert_eq!(expired_sign_in(&other, false), None);
assert_eq!(expired_sign_in(&Ok(()), false), None);
}
}