Standardize error messages, formatting, and code style. Refine --help text for consistency. Update comments for clarity and tone alignment.
This commit is contained in:
@@ -14,10 +14,12 @@ pub fn write(path: &Path, contents: &[u8]) -> std::io::Result<()>
|
|||||||
path.extension().and_then(|e| e.to_str()).unwrap_or(""),
|
path.extension().and_then(|e| e.to_str()).unwrap_or(""),
|
||||||
std::process::id()
|
std::process::id()
|
||||||
));
|
));
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut f = std::fs::File::create(&tmp)?;
|
let mut f = std::fs::File::create(&tmp)?;
|
||||||
f.write_all(contents)?;
|
f.write_all(contents)?;
|
||||||
f.sync_all()?;
|
f.sync_all()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::rename(&tmp, path)
|
std::fs::rename(&tmp, path)
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/cli.rs
16
src/cli.rs
@@ -11,7 +11,7 @@ pub struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
pub enum Commands {
|
pub enum Commands {
|
||||||
/// Save a new forward profile (doesn't open it).
|
/// Save a new forward profile.
|
||||||
#[command(visible_alias = "create", visible_alias = "new")]
|
#[command(visible_alias = "create", visible_alias = "new")]
|
||||||
Add(AddArgs),
|
Add(AddArgs),
|
||||||
|
|
||||||
@@ -54,11 +54,11 @@ pub enum Commands {
|
|||||||
/// struct (`#[command(flatten)]`ed into both) so the two can never drift.
|
/// struct (`#[command(flatten)]`ed into both) so the two can never drift.
|
||||||
#[derive(Args, Default)]
|
#[derive(Args, Default)]
|
||||||
pub struct MappingArgs {
|
pub struct MappingArgs {
|
||||||
/// Local forward: your machine -> remote.
|
/// Local forward (current machine -> remote machine).
|
||||||
#[arg(short, long, value_name = "[BIND:]PORT:HOST:PORT")]
|
#[arg(short, long, value_name = "[BIND:]PORT:HOST:PORT")]
|
||||||
pub local: Option<String>,
|
pub local: Option<String>,
|
||||||
|
|
||||||
/// Remote forward: remote -> your machine.
|
/// Remote forward (remote machine -> current machine).
|
||||||
#[arg(short, long, value_name = "[BIND:]PORT:HOST:PORT")]
|
#[arg(short, long, value_name = "[BIND:]PORT:HOST:PORT")]
|
||||||
pub remote: Option<String>,
|
pub remote: Option<String>,
|
||||||
|
|
||||||
@@ -78,23 +78,23 @@ pub struct MappingArgs {
|
|||||||
#[arg(short, long, value_name = "PATH")]
|
#[arg(short, long, value_name = "PATH")]
|
||||||
pub identity: Option<String>,
|
pub identity: Option<String>,
|
||||||
|
|
||||||
/// SSH port on the final target only.
|
/// SSH port of the final target.
|
||||||
#[arg(short, long, value_name = "PORT")]
|
#[arg(short, long, value_name = "PORT")]
|
||||||
pub port: Option<u16>,
|
pub port: Option<u16>,
|
||||||
|
|
||||||
/// Auto-reconnect on drop.
|
/// Auto-reconnect on connection drop.
|
||||||
#[arg(long, num_args = 0..=1, default_missing_value = "true", value_name = "BOOL")]
|
#[arg(long, num_args = 0..=1, default_missing_value = "true", value_name = "BOOL")]
|
||||||
pub reconnect: Option<bool>,
|
pub reconnect: Option<bool>,
|
||||||
|
|
||||||
/// Base delay between reconnect attempts, in seconds.
|
/// Base delay between reconnection attempts, in seconds.
|
||||||
#[arg(long = "retry-interval", value_name = "SECONDS")]
|
#[arg(long = "retry-interval", value_name = "SECONDS")]
|
||||||
pub retry_interval: Option<u32>,
|
pub retry_interval: Option<u32>,
|
||||||
|
|
||||||
/// Cap on the doubling reconnect delay, in seconds.
|
/// Cap on the doubling reconnection delay, in seconds.
|
||||||
#[arg(long = "backoff-max", value_name = "SECONDS")]
|
#[arg(long = "backoff-max", value_name = "SECONDS")]
|
||||||
pub backoff_max: Option<u32>,
|
pub backoff_max: Option<u32>,
|
||||||
|
|
||||||
/// ServerAliveInterval, in seconds.
|
/// SSH ServerAliveInterval, in seconds\.
|
||||||
#[arg(long, value_name = "SECONDS")]
|
#[arg(long, value_name = "SECONDS")]
|
||||||
pub keepalive: Option<u32>,
|
pub keepalive: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ pub enum PortholeError {
|
|||||||
#[error("only one of -l/--local, -r/--remote, -d/--dynamic may be given")]
|
#[error("only one of -l/--local, -r/--remote, -d/--dynamic may be given")]
|
||||||
MultipleMappingKinds,
|
MultipleMappingKinds,
|
||||||
|
|
||||||
#[error("invalid forward spec '{0}': expected [bind:]port:host:hostport (or [bind:]port for -d)")]
|
#[error("invalid forward spec '{0}': expected [BIND:]PORT:HOST:PORT (or [BIND:]PORT for -d)")]
|
||||||
InvalidMapping(String),
|
InvalidMapping(String),
|
||||||
|
|
||||||
#[error("invalid --via hop '{0}': expected [user@]host[:port]")]
|
#[error("invalid --via hop '{0}': expected [USER@]HOST[:PORT]")]
|
||||||
InvalidVia(String),
|
InvalidVia(String),
|
||||||
|
|
||||||
#[error("--via is required: at least one hop (its last entry is the ssh connection target)")]
|
#[error("--via is required: at least one hop (connection target)")]
|
||||||
NoViaHosts,
|
NoViaHosts,
|
||||||
|
|
||||||
#[error("nothing to do: {0}")]
|
#[error("nothing to do: {0}")]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub fn build(profile: &Profile) -> Command {
|
|||||||
let mut cmd = Command::new("ssh");
|
let mut cmd = Command::new("ssh");
|
||||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
||||||
// Forced flags - see spec §3.1 for why each of these is non-negotiable.
|
// Forced flags, see spec §3.1.
|
||||||
cmd.args([
|
cmd.args([
|
||||||
"-o",
|
"-o",
|
||||||
"BatchMode=yes",
|
"BatchMode=yes",
|
||||||
@@ -47,7 +47,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::profile::{Kind, ProfileEdits};
|
use crate::profile::{Kind, ProfileEdits};
|
||||||
|
|
||||||
fn profile_with(via: Vec<&str>) -> Profile {
|
fn profile_with(via: Vec<&str>) -> Profile
|
||||||
|
{
|
||||||
Profile::new(
|
Profile::new(
|
||||||
"t".into(),
|
"t".into(),
|
||||||
&ProfileEdits {
|
&ProfileEdits {
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ const LOG_ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
|||||||
|
|
||||||
static SHUTDOWN: AtomicBool = AtomicBool::new(false);
|
static SHUTDOWN: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
extern "C" fn handle_sigterm(_sig: libc::c_int) {
|
extern "C" fn handle_sigterm(_sig: libc::c_int) { SHUTDOWN.store(true, Ordering::SeqCst); }
|
||||||
SHUTDOWN.store(true, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Traps SIGTERM and SIGINT into a flag instead of the default
|
/// Traps SIGTERM and SIGINT into a flag instead of the default
|
||||||
/// terminate-immediately behavior. This is how `close` (SIGTERM) and
|
/// terminate-immediately behavior. This is how `close` (SIGTERM) and
|
||||||
@@ -64,10 +62,10 @@ pub fn run(name: &str) -> Result<()> {
|
|||||||
|
|
||||||
let profile = profile::load(name)?;
|
let profile = profile::load(name)?;
|
||||||
|
|
||||||
// Holding this for our entire lifetime is what makes "is <name>
|
// Holding this for our entire lifetime is what makes
|
||||||
// already open" a reliable, race-free check for `open` (spec §2.3/§3).
|
// for a reliable, race-free check for `open`.
|
||||||
let Some(_lock) = Lock::try_acquire(name)? else {
|
let Some(_lock) = Lock::try_acquire(name)? else {
|
||||||
return Ok(()); // another supervisor beat us to it; nothing to do
|
return Ok(()); // another supervisor; nothing to do
|
||||||
};
|
};
|
||||||
|
|
||||||
let pid = std::process::id() as i32;
|
let pid = std::process::id() as i32;
|
||||||
@@ -77,33 +75,40 @@ pub fn run(name: &str) -> Result<()> {
|
|||||||
let once = std::env::var_os("PORTHOLE_SUPERVISE_ONCE").is_some();
|
let once = std::env::var_os("PORTHOLE_SUPERVISE_ONCE").is_some();
|
||||||
let base_delay = profile.retry_interval.max(1) as u64;
|
let base_delay = profile.retry_interval.max(1) as u64;
|
||||||
let max_delay = (profile.backoff_max as u64).max(base_delay);
|
let max_delay = (profile.backoff_max as u64).max(base_delay);
|
||||||
let mut delay = base_delay;
|
|
||||||
|
let mut delay: u64 = base_delay;
|
||||||
let mut unrecognized_streak: u32 = 0;
|
let mut unrecognized_streak: u32 = 0;
|
||||||
|
|
||||||
loop {
|
loop
|
||||||
|
{
|
||||||
let attempt_started = timefmt::now();
|
let attempt_started = timefmt::now();
|
||||||
match run_ssh_once(name, &profile, &mut inst) {
|
|
||||||
|
match run_ssh_once(name, &profile, &mut inst)
|
||||||
|
{
|
||||||
Outcome::ShutdownRequested => {
|
Outcome::ShutdownRequested => {
|
||||||
instance::delete(name)?;
|
instance::delete(name)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Outcome::Failed { class, message } => {
|
Outcome::Failed { class, message } =>
|
||||||
|
{
|
||||||
let uptime = timefmt::now() - attempt_started;
|
let uptime = timefmt::now() - attempt_started;
|
||||||
|
|
||||||
if uptime >= STABLE_THRESHOLD_SECS {
|
if uptime >= STABLE_THRESHOLD_SECS {
|
||||||
delay = base_delay;
|
delay = base_delay;
|
||||||
unrecognized_streak = 0;
|
unrecognized_streak = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
match class {
|
match class {
|
||||||
Class::Unrecognized => unrecognized_streak += 1,
|
Class::Unrecognized => unrecognized_streak += 1,
|
||||||
Class::KnownTransient => unrecognized_streak = 0,
|
Class::KnownTransient => unrecognized_streak = 0,
|
||||||
Class::Fatal => {}
|
Class::Fatal => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
inst.last_error =
|
inst.last_error = Some(if message.is_empty() { "ssh exited unexpectedly (no output captured)".to_string() } else { message });
|
||||||
Some(if message.is_empty() { "ssh exited unexpectedly (no output captured)".to_string() } else { message });
|
|
||||||
|
|
||||||
let fatal = matches!(class, Class::Fatal);
|
let fatal = matches!(class, Class::Fatal);
|
||||||
let give_up = fatal || !profile.reconnect || once || unrecognized_streak > MAX_UNRECOGNIZED_STREAK;
|
let give_up = fatal || !profile.reconnect || once || unrecognized_streak > MAX_UNRECOGNIZED_STREAK;
|
||||||
|
|
||||||
if give_up {
|
if give_up {
|
||||||
inst.state = State::Error;
|
inst.state = State::Error;
|
||||||
instance::save(&inst)?;
|
instance::save(&inst)?;
|
||||||
@@ -120,6 +125,7 @@ pub fn run(name: &str) -> Result<()> {
|
|||||||
instance::delete(name)?;
|
instance::delete(name)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
delay = (delay * 2).min(max_delay);
|
delay = (delay * 2).min(max_delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,14 +135,17 @@ pub fn run(name: &str) -> Result<()> {
|
|||||||
/// Sleeps for `dur`, polling `SHUTDOWN` periodically so a `close` that
|
/// Sleeps for `dur`, polling `SHUTDOWN` periodically so a `close` that
|
||||||
/// arrives during a reconnect backoff window is honored promptly instead
|
/// arrives during a reconnect backoff window is honored promptly instead
|
||||||
/// of waiting out the full delay. Returns `true` if shutdown was requested.
|
/// of waiting out the full delay. Returns `true` if shutdown was requested.
|
||||||
fn sleep_or_shutdown(dur: Duration) -> bool {
|
fn sleep_or_shutdown(dur: Duration) -> bool
|
||||||
|
{
|
||||||
let deadline = Instant::now() + dur;
|
let deadline = Instant::now() + dur;
|
||||||
|
|
||||||
while Instant::now() < deadline {
|
while Instant::now() < deadline {
|
||||||
if SHUTDOWN.load(Ordering::SeqCst) {
|
if SHUTDOWN.load(Ordering::SeqCst) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
std::thread::sleep(POLL_INTERVAL.min(dur));
|
std::thread::sleep(POLL_INTERVAL.min(dur));
|
||||||
}
|
}
|
||||||
|
|
||||||
SHUTDOWN.load(Ordering::SeqCst)
|
SHUTDOWN.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +155,8 @@ fn sleep_or_shutdown(dur: Duration) -> bool {
|
|||||||
/// without parsing `-v` debug output; a real failure exits near-instantly
|
/// without parsing `-v` debug output; a real failure exits near-instantly
|
||||||
/// under `ExitOnForwardFailure=yes` (§3.1), so staying alive past the grace
|
/// under `ExitOnForwardFailure=yes` (§3.1), so staying alive past the grace
|
||||||
/// window is used as a proxy for connected.
|
/// window is used as a proxy for connected.
|
||||||
fn run_ssh_once(name: &str, profile: &Profile, inst: &mut Instance) -> Outcome {
|
fn run_ssh_once(name: &str, profile: &Profile, inst: &mut Instance) -> Outcome
|
||||||
|
{
|
||||||
rotate_log_if_large(name);
|
rotate_log_if_large(name);
|
||||||
|
|
||||||
let mut cmd = ssh::build(profile);
|
let mut cmd = ssh::build(profile);
|
||||||
@@ -162,17 +172,22 @@ fn run_ssh_once(name: &str, profile: &Profile, inst: &mut Instance) -> Outcome {
|
|||||||
let grace_deadline = Instant::now() + CONNECT_GRACE;
|
let grace_deadline = Instant::now() + CONNECT_GRACE;
|
||||||
let mut marked_up = false;
|
let mut marked_up = false;
|
||||||
|
|
||||||
loop {
|
loop
|
||||||
|
{
|
||||||
if SHUTDOWN.load(Ordering::SeqCst) {
|
if SHUTDOWN.load(Ordering::SeqCst) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
join_all([stdout_thread, stderr_thread]);
|
join_all([stdout_thread, stderr_thread]);
|
||||||
return Outcome::ShutdownRequested;
|
return Outcome::ShutdownRequested;
|
||||||
}
|
}
|
||||||
match child.try_wait() {
|
|
||||||
|
match child.try_wait()
|
||||||
|
{
|
||||||
Ok(Some(_status)) => break,
|
Ok(Some(_status)) => break,
|
||||||
Ok(None) => {
|
Ok(None) =>
|
||||||
if !marked_up && Instant::now() >= grace_deadline {
|
{
|
||||||
|
if !marked_up && Instant::now() >= grace_deadline
|
||||||
|
{
|
||||||
marked_up = true;
|
marked_up = true;
|
||||||
inst.state = State::Up;
|
inst.state = State::Up;
|
||||||
inst.connected_at = Some(timefmt::now());
|
inst.connected_at = Some(timefmt::now());
|
||||||
@@ -187,7 +202,9 @@ fn run_ssh_once(name: &str, profile: &Profile, inst: &mut Instance) -> Outcome {
|
|||||||
join_all([stdout_thread, stderr_thread]);
|
join_all([stdout_thread, stderr_thread]);
|
||||||
|
|
||||||
let tail = stderr_tail.lock().map(|s| s.clone()).unwrap_or_default();
|
let tail = stderr_tail.lock().map(|s| s.clone()).unwrap_or_default();
|
||||||
|
|
||||||
let (class, message) = classify(&tail);
|
let (class, message) = classify(&tail);
|
||||||
|
|
||||||
Outcome::Failed { class, message }
|
Outcome::Failed { class, message }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +218,8 @@ fn join_all<const N: usize>(handles: [Option<JoinHandle<()>>; N]) {
|
|||||||
/// the reconnect loop outright; known-transient patterns retry without
|
/// the reconnect loop outright; known-transient patterns retry without
|
||||||
/// counting toward the unrecognized-failure escalation; anything else
|
/// counting toward the unrecognized-failure escalation; anything else
|
||||||
/// still retries, but does count toward it.
|
/// still retries, but does count toward it.
|
||||||
fn classify(stderr_tail: &str) -> (Class, String) {
|
fn classify(stderr_tail: &str) -> (Class, String)
|
||||||
|
{
|
||||||
const FATAL: &[&str] = &["Permission denied", "Host key verification failed", "bind: Address already in use"];
|
const FATAL: &[&str] = &["Permission denied", "Host key verification failed", "bind: Address already in use"];
|
||||||
const KNOWN_TRANSIENT: &[&str] = &[
|
const KNOWN_TRANSIENT: &[&str] = &[
|
||||||
"Connection refused",
|
"Connection refused",
|
||||||
@@ -213,17 +231,15 @@ fn classify(stderr_tail: &str) -> (Class, String) {
|
|||||||
|
|
||||||
let message = stderr_tail.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string();
|
let message = stderr_tail.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string();
|
||||||
|
|
||||||
if FATAL.iter().any(|p| stderr_tail.contains(p)) {
|
if FATAL.iter().any(|p| stderr_tail.contains(p)) { (Class::Fatal, message) }
|
||||||
(Class::Fatal, message)
|
else if KNOWN_TRANSIENT.iter().any(|p| stderr_tail.contains(p)) { (Class::KnownTransient, message) }
|
||||||
} else if KNOWN_TRANSIENT.iter().any(|p| stderr_tail.contains(p)) {
|
else { (Class::Unrecognized, message) }
|
||||||
(Class::KnownTransient, message)
|
|
||||||
} else {
|
|
||||||
(Class::Unrecognized, message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rotate_log_if_large(name: &str) {
|
fn rotate_log_if_large(name: &str)
|
||||||
|
{
|
||||||
let path = instance::log_path(name);
|
let path = instance::log_path(name);
|
||||||
|
|
||||||
if let Ok(meta) = std::fs::metadata(&path) {
|
if let Ok(meta) = std::fs::metadata(&path) {
|
||||||
if meta.len() > LOG_ROTATE_BYTES {
|
if meta.len() > LOG_ROTATE_BYTES {
|
||||||
let _ = std::fs::rename(&path, path.with_extension("log.1"));
|
let _ = std::fs::rename(&path, path.with_extension("log.1"));
|
||||||
@@ -237,8 +253,10 @@ fn append_log(name: &str, line: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_log_drain(name: &str, out: std::process::ChildStdout) -> JoinHandle<()> {
|
fn spawn_log_drain(name: &str, out: std::process::ChildStdout) -> JoinHandle<()>
|
||||||
|
{
|
||||||
let name = name.to_string();
|
let name = name.to_string();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for line in BufReader::new(out).lines().map_while(std::result::Result::ok) {
|
for line in BufReader::new(out).lines().map_while(std::result::Result::ok) {
|
||||||
append_log(&name, &line);
|
append_log(&name, &line);
|
||||||
@@ -246,11 +264,15 @@ fn spawn_log_drain(name: &str, out: std::process::ChildStdout) -> JoinHandle<()>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_stderr_drain(name: &str, err: std::process::ChildStderr, tail: Arc<Mutex<String>>) -> JoinHandle<()> {
|
fn spawn_stderr_drain(name: &str, err: std::process::ChildStderr, tail: Arc<Mutex<String>>) -> JoinHandle<()>
|
||||||
|
{
|
||||||
let name = name.to_string();
|
let name = name.to_string();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for line in BufReader::new(err).lines().map_while(std::result::Result::ok) {
|
for line in BufReader::new(err).lines().map_while(std::result::Result::ok)
|
||||||
|
{
|
||||||
append_log(&name, &line);
|
append_log(&name, &line);
|
||||||
|
|
||||||
if let Ok(mut t) = tail.lock() {
|
if let Ok(mut t) = tail.lock() {
|
||||||
if !t.is_empty() {
|
if !t.is_empty() {
|
||||||
t.push('\n');
|
t.push('\n');
|
||||||
|
|||||||
@@ -13,40 +13,43 @@ pub fn now() -> i64 {
|
|||||||
/// (public domain, http://howardhinnant.github.io/date_algorithms.html) -
|
/// (public domain, http://howardhinnant.github.io/date_algorithms.html) -
|
||||||
/// avoids pulling in a whole calendar/timezone crate for what's otherwise a
|
/// avoids pulling in a whole calendar/timezone crate for what's otherwise a
|
||||||
/// handful of integer operations.
|
/// handful of integer operations.
|
||||||
pub fn fmt_timestamp(unix_secs: i64) -> String {
|
pub fn fmt_timestamp(unix_secs: i64) -> String
|
||||||
|
{
|
||||||
let days = unix_secs.div_euclid(86_400);
|
let days = unix_secs.div_euclid(86_400);
|
||||||
let secs_of_day = unix_secs.rem_euclid(86_400);
|
let secs_of_day = unix_secs.rem_euclid(86_400);
|
||||||
let (h, m, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
|
let (h, m, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
|
||||||
|
|
||||||
let z = days + 719_468;
|
let z = days + 719_468;
|
||||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
|
|
||||||
let doe = (z - era * 146_097) as i64; // [0, 146096]
|
let doe = (z - era * 146_097) as i64; // [0, 146096]
|
||||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
|
||||||
|
|
||||||
let y = yoe + era * 400;
|
let y = yoe + era * 400;
|
||||||
|
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||||
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
|
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
|
||||||
let m_num = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
let m_num = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
||||||
|
|
||||||
let y = if m_num <= 2 { y + 1 } else { y };
|
let y = if m_num <= 2 { y + 1 } else { y };
|
||||||
|
|
||||||
format!("{y:04}-{m_num:02}-{d:02} {h:02}:{m:02}:{s:02} UTC")
|
format!("{y:04}-{m_num:02}-{d:02} {h:02}:{m:02}:{s:02} UTC")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compact `1d 02h 03m 04s`-style duration, dropping leading zero units.
|
/// Compact `1d 02h 03m 04s`-style duration, dropping leading zero units.
|
||||||
pub fn fmt_duration(secs: i64) -> String {
|
pub fn fmt_duration(secs: i64) -> String
|
||||||
|
{
|
||||||
let secs = secs.max(0);
|
let secs = secs.max(0);
|
||||||
let (d, rem) = (secs / 86_400, secs % 86_400);
|
let (d, rem) = (secs / 86_400, secs % 86_400);
|
||||||
let (h, rem) = (rem / 3600, rem % 3600);
|
let (h, rem) = (rem / 3600, rem % 3600);
|
||||||
let (m, s) = (rem / 60, rem % 60);
|
let (m, s) = (rem / 60, rem % 60);
|
||||||
|
|
||||||
if d > 0 {
|
match (d, h, m) {
|
||||||
format!("{d}d {h:02}h {m:02}m {s:02}s")
|
(d, _, _) if d > 0 => format!("{d}d {h:02}h {m:02}m {s:02}s"),
|
||||||
} else if h > 0 {
|
(_, h, _) if h > 0 => format!("{h}h {m:02}m {s:02}s"),
|
||||||
format!("{h}h {m:02}m {s:02}s")
|
(_, _, m) if m > 0 => format!("{m}m {s:02}s"),
|
||||||
} else if m > 0 {
|
_ => format!("{s}s"),
|
||||||
format!("{m}m {s:02}s")
|
|
||||||
} else {
|
|
||||||
format!("{s}s")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
58
src/ui.rs
58
src/ui.rs
@@ -1,9 +1,10 @@
|
|||||||
//! Colorized status output, honoring `NO_COLOR` and terminal detection.
|
//! Colorized status output, honouring `NO_COLOR` and terminal detection.
|
||||||
|
|
||||||
use std::io::IsTerminal;
|
use std::io::IsTerminal;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
fn color_enabled() -> bool {
|
fn color_enabled() -> bool
|
||||||
|
{
|
||||||
static ENABLED: OnceLock<bool> = OnceLock::new();
|
static ENABLED: OnceLock<bool> = OnceLock::new();
|
||||||
*ENABLED.get_or_init(|| {
|
*ENABLED.get_or_init(|| {
|
||||||
std::env::var_os("NO_COLOR").is_none()
|
std::env::var_os("NO_COLOR").is_none()
|
||||||
@@ -13,46 +14,19 @@ fn color_enabled() -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paint(code: &str, s: &str) -> String {
|
fn paint(code: &str, s: &str) -> String
|
||||||
if color_enabled() {
|
{
|
||||||
format!("\x1b[{code}m{s}\x1b[0m")
|
if color_enabled() { format!("\x1b[{code}m{s}\x1b[0m") }
|
||||||
} else {
|
else { s.to_string() }
|
||||||
s.to_string()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn red(s: &str) -> String {
|
pub fn red(s: &str) -> String { paint("31", s) }
|
||||||
paint("31", s)
|
pub fn yellow(s: &str) -> String { paint("33", s) }
|
||||||
}
|
pub fn green(s: &str) -> String { paint("32", s) }
|
||||||
|
pub fn blue(s: &str) -> String { paint("34", s) }
|
||||||
|
pub fn cyan(s: &str) -> String { paint("36", s) }
|
||||||
|
|
||||||
pub fn yellow(s: &str) -> String {
|
pub fn err(msg: &str) { eprintln!("{}", red(&format!("Error: {msg}"))); }
|
||||||
paint("33", s)
|
pub fn warn(msg: &str) { eprintln!("{}", yellow(&format!("Warning: {msg}"))); }
|
||||||
}
|
pub fn info(msg: &str) { println!("{}", blue(msg)); }
|
||||||
|
pub fn ok(msg: &str) { println!("{}", green(msg)); }
|
||||||
pub fn green(s: &str) -> String {
|
|
||||||
paint("32", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn blue(s: &str) -> String {
|
|
||||||
paint("34", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cyan(s: &str) -> String {
|
|
||||||
paint("36", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn err(msg: &str) {
|
|
||||||
eprintln!("{}", red(&format!("Error: {msg}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn warn(msg: &str) {
|
|
||||||
eprintln!("{}", yellow(&format!("Warning: {msg}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn info(msg: &str) {
|
|
||||||
println!("{}", blue(msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ok(msg: &str) {
|
|
||||||
println!("{}", green(msg));
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user