use std::io::IsTerminal as _;
use std::io::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock, mpsc};
use std::time::{Duration, Instant};
static INVOCATION_OUTPUT: OnceLock<InvocationOutput> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum OutputProtocol {
Text,
Json,
Raw,
}
#[derive(Debug)]
pub struct InvocationOutput {
protocol: OutputProtocol,
quiet: bool,
progress: bool,
verbose: bool,
color: bool,
stdout_terminal: bool,
stderr_terminal: bool,
}
impl InvocationOutput {
pub fn capture(quiet: bool, verbose: bool, json: bool) -> Self {
Self::capture_protocol(
quiet,
verbose,
if json {
OutputProtocol::Json
} else {
OutputProtocol::Text
},
)
}
#[doc(hidden)]
pub fn capture_protocol(quiet: bool, verbose: bool, protocol: OutputProtocol) -> Self {
Self::capture_protocol_with_progress(quiet, verbose, protocol, false)
}
#[doc(hidden)]
pub fn capture_protocol_with_progress(
quiet: bool,
verbose: bool,
protocol: OutputProtocol,
redirected_progress: bool,
) -> Self {
let stdout_terminal = std::io::stdout().is_terminal();
let stderr_terminal = std::io::stderr().is_terminal();
let raw_or_json = protocol != OutputProtocol::Text;
Self {
protocol,
quiet: quiet || raw_or_json,
progress: !quiet && (!raw_or_json || redirected_progress),
verbose: verbose && !raw_or_json,
color: !raw_or_json && stderr_terminal && std::env::var_os("NO_COLOR").is_none(),
stdout_terminal,
stderr_terminal,
}
}
pub const fn protocol(&self) -> OutputProtocol {
self.protocol
}
pub const fn stdout_is_terminal(&self) -> bool {
self.stdout_terminal
}
pub const fn stderr_is_terminal(&self) -> bool {
self.stderr_terminal
}
pub const fn verbose(&self) -> bool {
self.verbose
}
pub const fn color_enabled(&self) -> bool {
self.color
}
pub const fn progress_enabled(&self) -> bool {
self.progress
}
}
pub(crate) const PROGRESS_INTERVAL: Duration = Duration::from_secs(30);
#[doc(hidden)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Activity {
Analysis,
Cargo,
}
impl Activity {
const fn as_str(self) -> &'static str {
match self {
Self::Analysis => "analysis",
Self::Cargo => "cargo",
}
}
}
#[derive(Debug, Clone)]
struct Phase {
label: String,
activity: Activity,
started: Instant,
lock_wait: Option<String>,
}
#[derive(Debug)]
struct Phases {
base: Option<Phase>,
nested: Vec<(u64, Phase)>,
next_id: u64,
}
impl Phases {
fn current(&mut self) -> Option<&mut Phase> {
match self.nested.last_mut() {
Some((_, phase)) => Some(phase),
None => self.base.as_mut(),
}
}
}
static PHASES: Mutex<Phases> = Mutex::new(Phases {
base: None,
nested: Vec::new(),
next_id: 0,
});
static PROGRESS_EPOCH: OnceLock<Instant> = OnceLock::new();
static LAST_PROGRESS_MILLIS: AtomicU64 = AtomicU64::new(0);
fn progress_epoch() -> Instant {
*PROGRESS_EPOCH.get_or_init(Instant::now)
}
fn phases() -> std::sync::MutexGuard<'static, Phases> {
PHASES.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[doc(hidden)]
pub fn record_progress() {
let elapsed = u64::try_from(progress_epoch().elapsed().as_millis()).unwrap_or(u64::MAX);
LAST_PROGRESS_MILLIS.store(elapsed, Ordering::Relaxed);
}
fn quiet_for() -> Duration {
let last = Duration::from_millis(LAST_PROGRESS_MILLIS.load(Ordering::Relaxed));
progress_epoch().elapsed().saturating_sub(last)
}
impl Phase {
fn record(&self) {
crate::instrumentation::record_progress_phase(
&self.label,
self.activity.as_str(),
self.lock_wait.is_some(),
self.started.elapsed(),
);
}
}
fn new_phase(activity: Activity, label: String) -> Phase {
Phase {
label,
activity,
started: Instant::now(),
lock_wait: None,
}
}
#[doc(hidden)]
pub fn begin_phase(activity: Activity, label: String) {
crate::status!("{label}");
let label = label.trim().trim_end_matches("...").to_string();
let previous = phases().base.replace(new_phase(activity, label));
if let Some(previous) = previous {
previous.record();
}
}
#[doc(hidden)]
pub fn end_phase() {
let phase = phases().base.take();
if let Some(phase) = phase {
phase.record();
}
}
pub(crate) fn command_phase() -> Option<String> {
phases().base.as_ref().map(|phase| phase.label.clone())
}
#[must_use = "the nested phase ends when the guard is dropped"]
pub(crate) struct NestedPhase {
id: u64,
}
pub(crate) fn nested_phase(activity: Activity, label: impl Into<String>) -> NestedPhase {
let mut phases = phases();
let id = phases.next_id;
phases.next_id += 1;
phases.nested.push((id, new_phase(activity, label.into())));
NestedPhase { id }
}
impl Drop for NestedPhase {
fn drop(&mut self) {
let ended = {
let mut phases = phases();
let index = phases.nested.iter().position(|(id, _)| *id == self.id);
index.map(|index| phases.nested.remove(index).1)
};
if let Some(phase) = ended {
phase.record();
}
}
}
pub(crate) fn report_lock_wait(subject: &str, lock: &str) {
crate::status!(" {subject}: Cargo is waiting for a file lock on {lock}");
if let Some(phase) = phases().current() {
phase.lock_wait = Some(lock.to_string());
}
}
pub(crate) fn cargo_lock_wait(line: &str) -> Option<&str> {
let lock = line.trim().strip_prefix("Blocking waiting for file lock")?.trim();
Some(lock.strip_prefix("on ").unwrap_or(lock)).filter(|lock| !lock.is_empty())
}
fn heartbeat_line(phase: &Phase) -> String {
let activity = match (&phase.lock_wait, phase.activity) {
(Some(lock), _) => format!("waiting for Cargo's file lock on {lock}"),
(None, Activity::Cargo) => "waiting for a Cargo subprocess".to_string(),
(None, Activity::Analysis) => "in-process analysis".to_string(),
};
format!(
" Still running: {} ({activity}; {}s in this phase)",
phase.label,
phase.started.elapsed().as_secs()
)
}
#[derive(Debug)]
#[must_use = "the heartbeat stops when dropped"]
pub struct Heartbeat {
stop: Option<mpsc::Sender<()>>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Heartbeat {
#[doc(hidden)]
pub fn start() -> Self {
if !progress_enabled() {
return Self {
stop: None,
thread: None,
};
}
Self::start_with(PROGRESS_INTERVAL, |line| crate::status!("{line}"))
}
fn start_with(interval: Duration, emit: impl Fn(String) + Send + 'static) -> Self {
record_progress();
let (stop, stopped) = mpsc::channel::<()>();
let thread = std::thread::Builder::new()
.name("cargo-rail-heartbeat".to_string())
.spawn(move || {
loop {
let wait = interval.saturating_sub(quiet_for()).max(Duration::from_millis(1));
match stopped.recv_timeout(wait) {
Err(mpsc::RecvTimeoutError::Timeout) => {}
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
if quiet_for() < interval {
continue;
}
let line = phases().current().map(|phase| heartbeat_line(phase));
if let Some(line) = line {
emit(line);
}
record_progress();
}
})
.ok();
Self {
stop: thread.as_ref().map(|_| stop),
thread,
}
}
}
impl Drop for Heartbeat {
fn drop(&mut self) {
drop(self.stop.take());
if let Some(thread) = self.thread.take() {
let _joined = thread.join();
}
}
}
pub const MACHINE_OUTPUT_SCHEMA_VERSION: u32 = 1;
#[doc(hidden)]
pub fn init(output: InvocationOutput) {
INVOCATION_OUTPUT
.set(output)
.expect("invocation output must be initialized exactly once");
}
fn invocation() -> &'static InvocationOutput {
INVOCATION_OUTPUT.get_or_init(|| InvocationOutput::capture(false, false, false))
}
pub fn is_quiet() -> bool {
invocation().quiet
}
pub fn is_json_mode() -> bool {
invocation().protocol == OutputProtocol::Json
}
pub fn is_verbose() -> bool {
invocation().verbose()
}
pub fn color_enabled() -> bool {
invocation().color_enabled()
}
#[doc(hidden)]
pub fn progress_enabled() -> bool {
invocation().progress_enabled()
}
#[doc(hidden)]
pub fn write_stdout(arguments: std::fmt::Arguments<'_>, newline: bool) {
let mut stdout = std::io::stdout().lock();
let result = stdout
.write_fmt(arguments)
.and_then(|()| if newline { stdout.write_all(b"\n") } else { Ok(()) });
if let Err(error) = result {
if error.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
if !is_json_mode()
&& let Err(_stderr_error) = writeln!(std::io::stderr().lock(), "error: failed writing stdout: {error}")
{
}
std::process::exit(1);
}
}
pub fn machine_json_envelope(
command: &str,
mode: &str,
result: &str,
exit_code: i32,
payload: serde_json::Value,
) -> serde_json::Value {
let mut out = serde_json::Map::new();
out.insert(
"schema_version".to_string(),
serde_json::Value::Number(serde_json::Number::from(MACHINE_OUTPUT_SCHEMA_VERSION)),
);
out.insert("command".to_string(), serde_json::Value::String(command.to_string()));
out.insert("mode".to_string(), serde_json::Value::String(mode.to_string()));
out.insert("result".to_string(), serde_json::Value::String(result.to_string()));
out.insert(
"exit_code".to_string(),
serde_json::Value::Number(serde_json::Number::from(exit_code)),
);
match payload {
serde_json::Value::Object(map) => {
for (key, value) in map {
if !out.contains_key(&key) {
out.insert(key, value);
}
}
}
other => {
out.insert("payload".to_string(), other);
}
}
serde_json::Value::Object(out)
}
#[macro_export]
macro_rules! error {
($($arg:tt)*) => {
if !$crate::output::is_json_mode() {
eprintln!("error: {}", format_args!($($arg)*))
}
};
}
#[macro_export]
macro_rules! warn {
($($arg:tt)*) => {
if !$crate::output::is_json_mode() {
eprintln!("warning: {}", format_args!($($arg)*))
}
};
}
#[macro_export]
macro_rules! help {
($($arg:tt)*) => {
if !$crate::output::is_json_mode() {
eprintln!("help: {}", format_args!($($arg)*))
}
};
}
#[macro_export]
macro_rules! status {
($($arg:tt)*) => {
if $crate::output::progress_enabled() {
eprintln!($($arg)*);
$crate::output::record_progress();
}
};
}
#[doc(hidden)]
#[macro_export]
macro_rules! phase {
($activity:expr, $($arg:tt)*) => {
$crate::output::begin_phase($activity, format!($($arg)*))
};
}
#[macro_export]
macro_rules! note {
($($arg:tt)*) => {
if !$crate::output::is_quiet() {
eprintln!("note: {}", format_args!($($arg)*))
}
};
}
#[macro_export]
macro_rules! progress {
($($arg:tt)*) => {
$crate::status!($($arg)*)
};
}
#[macro_export]
macro_rules! verbose_progress {
($($arg:tt)*) => {
if $crate::output::is_verbose() {
$crate::status!($($arg)*)
}
};
}
#[cfg(test)]
mod tests {
use super::{Activity, Heartbeat, InvocationOutput, OutputProtocol};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[test]
fn heartbeat_fills_each_quiet_interval_and_names_the_current_activity() {
const INTERVAL: Duration = Duration::from_millis(100);
let lines = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = Arc::clone(&lines);
let taken = || std::mem::take(&mut *lines.lock().unwrap());
let taken_once = |done: &dyn Fn(&[String]) -> bool| {
let deadline = Instant::now() + Duration::from_secs(10);
while !done(&lines.lock().unwrap()) && Instant::now() < deadline {
std::thread::sleep(INTERVAL / 10);
}
taken()
};
let heartbeat = Heartbeat::start_with(INTERVAL, move |line| sink.lock().unwrap().push(line));
crate::phase!(Activity::Analysis, "Detecting unused dependencies...");
let analysis = taken_once(&|lines| lines.len() >= 2);
assert!(analysis.len() >= 2, "each quiet interval needs one line: {analysis:?}");
assert!(
analysis
.iter()
.all(|line| line.contains("Detecting unused dependencies (in-process analysis;")),
"{analysis:?}"
);
let subprocess = |line: &String| line.contains("running cargo metadata (waiting for a Cargo subprocess;");
let lock =
|line: &String| line.contains("running cargo metadata (waiting for Cargo's file lock on package cache;");
let mut nested = Vec::new();
{
let _metadata = super::nested_phase(Activity::Cargo, "running cargo metadata");
nested.extend(taken_once(&|lines| lines.iter().any(subprocess)));
super::report_lock_wait("cargo metadata", "package cache");
nested.extend(taken_once(&|lines| lines.iter().any(lock)));
}
assert!(nested.iter().any(subprocess), "{nested:?}");
assert!(nested.iter().any(lock), "{nested:?}");
super::record_progress();
let mut last = std::time::Instant::now();
drop(taken());
let mut longest = Duration::ZERO;
for _ in 0..6 {
super::record_progress();
longest = longest.max(last.elapsed());
last = std::time::Instant::now();
std::thread::sleep(INTERVAL / 4);
}
let suppressed = taken();
longest = longest.max(last.elapsed());
if longest < INTERVAL {
assert!(
suppressed.is_empty(),
"progress within the interval must suppress the heartbeat"
);
}
let resumed = taken_once(&|lines| !lines.is_empty());
assert!(
!resumed.is_empty()
&& resumed
.iter()
.all(|line| line.contains("Detecting unused dependencies (in-process analysis;")),
"the enclosing phase resumes when the nested phase ends: {resumed:?}"
);
drop(heartbeat);
std::thread::sleep(INTERVAL * 2);
assert!(taken().is_empty(), "no line after the heartbeat stops");
}
#[test]
fn raw_protocol_suppresses_advisory_output_without_becoming_json() {
let output = InvocationOutput::capture_protocol(false, true, OutputProtocol::Raw);
assert_eq!(output.protocol(), OutputProtocol::Raw);
assert!(output.quiet, "raw streams must suppress progress and advisory output");
assert!(!output.verbose(), "raw streams must not enable text detail");
assert!(!output.color_enabled(), "raw streams must remain byte-stable");
assert!(!output.progress_enabled(), "raw streams must suppress progress");
}
#[test]
fn redirected_json_can_retain_progress_without_enabling_advisories() {
let output = InvocationOutput::capture_protocol_with_progress(false, true, OutputProtocol::Json, true);
assert_eq!(output.protocol(), OutputProtocol::Json);
assert!(output.quiet, "JSON must continue suppressing advisory output");
assert!(
output.progress_enabled(),
"redirected JSON must retain operational progress"
);
assert!(!output.verbose(), "JSON must not enable text detail");
assert!(!output.color_enabled(), "JSON diagnostics must remain byte-stable");
let quiet = InvocationOutput::capture_protocol_with_progress(true, false, OutputProtocol::Json, true);
assert!(!quiet.progress_enabled(), "--quiet must suppress redirected progress");
}
}