diff --git a/Cargo.lock b/Cargo.lock index 1508ad9..5aba416 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1956,7 +1956,7 @@ dependencies = [ [[package]] name = "mc" -version = "0.10.2" +version = "0.11.0" dependencies = [ "annotate-snippets", "anstream", diff --git a/Cargo.toml b/Cargo.toml index 2229391..878c723 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mc" -version = "0.10.2" +version = "0.11.0" edition = "2024" license = "MIT" diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index 8feeabd..7496504 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -21,6 +21,26 @@ is hidden unless `--server-logs` is passed; it is always written to `instance/logs/` by the server itself. The console is not interactive; use the remote console (RCON) for live administration. +mc reads the console output whether or not it is shown, and prints a status +line for the events it recognizes: a player joining or leaving the instance, +and a world save flushed to disk with the time it took. These are shown +with `--verbose`. A player dropped for any reason other than quitting or +the instance stopping, such as a timeout or a kick, is reported as a warning +with the reason in place of the leave line. A login turned away is reported +as a warning naming the cause: the allow list, a ban, a full instance, or a +taken name. Warnings are shown by default and hidden with `--quiet`. + +```text + Joined Notch + Saved all dimensions flushed to disk in 1.2s +warning: connection refused for Notch, the user is not in the allow list +``` + +With `--server-logs`, the console lines shown follow mc's verbosity: +warnings and errors by default, more with `--verbose`. When the +`jvm-arguments` in the manifest select a logging configuration file of their +own, no events are recognized. + With a `tunnel` section, the tunnel agent starts beside the instance and is restarted if it stops on its own; the public address is printed at startup. The agent's output is hidden unless `--tunnel-logs` is passed, and diff --git a/docs/src/guides/systemd.md b/docs/src/guides/systemd.md index 76492a4..94a8ee1 100644 --- a/docs/src/guides/systemd.md +++ b/docs/src/guides/systemd.md @@ -72,4 +72,4 @@ journalctl -u myserver -f ``` mc's own log verbosity is controlled by the global `--verbose` flag; the -instance's console log level follows it. +instance's console lines shown in the journal follow it. diff --git a/mise.lock b/mise.lock index 09fc387..29b66ea 100644 --- a/mise.lock +++ b/mise.lock @@ -28,10 +28,6 @@ url_api = "https://api.github.com/repos/rust-lang/mdBook/releases/assets/4682109 version = "1.98.1" backend = "core:rust" -[[tools.rust]] -version = "nightly-2026-08-31" -backend = "core:rust" - [[tools."ubi:cross-rs/cross"]] version = "0.2.5" backend = "ubi:cross-rs/cross" diff --git a/src/cli/styles.rs b/src/cli/styles.rs index 2ae9704..169e707 100644 --- a/src/cli/styles.rs +++ b/src/cli/styles.rs @@ -1,3 +1,4 @@ +use anstyle::Ansi256Color; use anstyle::AnsiColor; use anstyle::Effects; use anstyle::Style; @@ -11,6 +12,11 @@ pub const ERROR: Style = annotate_snippets::renderer::DEFAULT_ERROR_STYLE; pub const WARN: Style = annotate_snippets::renderer::DEFAULT_WARNING_STYLE; pub const VALID: Style = AnsiColor::BrightCyan.on_default().effects(Effects::BOLD); pub const INVALID: Style = annotate_snippets::renderer::DEFAULT_WARNING_STYLE; +pub const MINECRAFT: Style = Ansi256Color(28).on_default().effects(Effects::BOLD); +pub const TUNNEL: Style = Ansi256Color(208).on_default().effects(Effects::BOLD); +pub const LOG_INFO: Style = AnsiColor::Green.on_default(); +pub const LOG_DEBUG: Style = AnsiColor::Cyan.on_default(); +pub const LOG_TRACE: Style = Style::new().effects(Effects::DIMMED); pub fn styles() -> Styles { Styles::styled() diff --git a/src/minecraft/log.rs b/src/minecraft/log.rs new file mode 100644 index 0000000..a0a8025 --- /dev/null +++ b/src/minecraft/log.rs @@ -0,0 +1,189 @@ +use std::fmt; + +/// Ordered from most to least severe, so `level <= threshold` means the +/// line is at least as important as the threshold. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ServerLogLevel { + Fatal, + Error, + Warn, + Info, + Debug, + Trace +} + +impl ServerLogLevel { + fn parse(level: &str) -> Option { + match level { + "FATAL" => Some(ServerLogLevel::Fatal), + "ERROR" => Some(ServerLogLevel::Error), + "WARN" => Some(ServerLogLevel::Warn), + "INFO" => Some(ServerLogLevel::Info), + "DEBUG" => Some(ServerLogLevel::Debug), + "TRACE" => Some(ServerLogLevel::Trace), + _ => None + } + } +} + +/// A console line, either printed with the pattern mc writes into +/// `log4j2.xml` (`[LEVEL] [thread]: message`) or by the JVM itself outside +/// the logger (`WARNING: message`, `ERROR: message`), which has no thread. +pub struct ServerLogLine<'a> { + pub level: ServerLogLevel, + pub thread: Option<&'a str>, + pub message: &'a str +} + +impl<'a> ServerLogLine<'a> { + pub fn parse(line: &'a str) -> Option { + Self::parse_logger(line).or_else(|| Self::parse_jvm(line)) + } + + fn parse_logger(line: &'a str) -> Option { + let rest = line.strip_prefix('[')?; + let (level, rest) = rest.split_once("] [")?; + let (thread, message) = rest.split_once("]: ")?; + + Some(ServerLogLine { + level: ServerLogLevel::parse(level)?, + thread: Some(thread), + message + }) + } + + fn parse_jvm(line: &'a str) -> Option { + let (level, message) = if let Some(message) = line.strip_prefix("WARNING: ") { + (ServerLogLevel::Warn, message) + } else if let Some(message) = line.strip_prefix("ERROR: ") { + (ServerLogLevel::Error, message) + } else { + return None; + }; + + Some(ServerLogLine { + level, + thread: None, + message + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum RefusalReason { + NotInAllowList, + Banned, + AddressBanned, + Full, + NameTaken, + Other(String) +} + +impl RefusalReason { + fn parse(reason: &str) -> Self { + match reason.trim_end_matches(['!', '.']) { + "You are not white-listed on this server" => RefusalReason::NotInAllowList, + "You are banned from this server" => RefusalReason::Banned, + "Your IP address is banned from this server" + | "You have been IP banned from this server" => RefusalReason::AddressBanned, + "The server is full" => RefusalReason::Full, + "That name is already taken" => RefusalReason::NameTaken, + _ => RefusalReason::Other(reason.to_string()) + } + } + + pub fn raw(&self) -> Option<&str> { + match self { + RefusalReason::Other(reason) => Some(reason), + _ => None + } + } +} + +impl fmt::Display for RefusalReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RefusalReason::NotInAllowList => write!(f, "the user is not in the allow list"), + RefusalReason::Banned => write!(f, "the user is banned"), + RefusalReason::AddressBanned => write!(f, "the address is banned"), + RefusalReason::Full => write!(f, "the instance is full"), + RefusalReason::NameTaken => write!(f, "the name is already taken"), + RefusalReason::Other(reason) => write!(f, "{}", reason) + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ServerLogEvent { + Joined(String), + Left(String), + Disconnected { + name: String, + reason: String + }, + /// A connection dropped while the player was still logging in, which is + /// how a login turned away by the server is reported. + Refused { + name: String, + address: String, + reason: RefusalReason + }, + SaveStarted, + SaveCompleted +} + +impl ServerLogEvent { + pub fn recognize(line: &ServerLogLine<'_>) -> Option { + if line.level != ServerLogLevel::Info || line.thread != Some("Server thread") { + return None; + } + + if let Some(name) = line.message.strip_suffix(" joined the game") { + return player_name(name).map(ServerLogEvent::Joined); + } + + if let Some(name) = line.message.strip_suffix(" left the game") { + return player_name(name).map(ServerLogEvent::Left); + } + + if let Some((subject, reason)) = line.message.split_once(" lost connection: ") { + let login = subject + .strip_suffix(')') + .and_then(|subject| subject.split_once(" (/")); + + return match login { + Some((name, address)) => player_name(name).map(|name| ServerLogEvent::Refused { + name, + address: address.to_string(), + reason: RefusalReason::parse(reason) + }), + None => player_name(subject).map(|name| ServerLogEvent::Disconnected { + name, + reason: reason.to_string() + }) + }; + } + + if line.message.starts_with("Saving chunks for level '") { + return Some(ServerLogEvent::SaveStarted); + } + + if line.message == "ThreadedAnvilChunkStorage: All dimensions are saved" { + return Some(ServerLogEvent::SaveCompleted); + } + + None + } +} + +// Chat, `/say`, and `/me` all put a space or angle brackets before the +// player-controlled text, so a bare name is the only shape accepted. +fn player_name(name: &str) -> Option { + let valid = !name.is_empty() + && name.len() <= 16 + && name + .chars() + .all(|c| c.is_ascii_graphic() && !matches!(c, '<' | '>')); + + valid.then(|| name.to_string()) +} diff --git a/src/minecraft/log4j.rs b/src/minecraft/log4j.rs index bc24ba1..ec4be17 100644 --- a/src/minecraft/log4j.rs +++ b/src/minecraft/log4j.rs @@ -2,7 +2,7 @@ const TEMPLATE: &str = r#" - + @@ -13,24 +13,24 @@ const TEMPLATE: &str = r#" - + - + "#; -pub fn configuration(console_level: &str) -> String { - let root_level = match console_level { - "debug" | "trace" => console_level, +// The console never drops below info: mc reads it to recognize events, and +// filters what it echoes against its own verbosity. +pub fn configuration(level: &str) -> String { + let level = match level { + "debug" | "trace" => level, _ => "info" }; - TEMPLATE - .replace("{root_level}", root_level) - .replace("{console_level}", console_level) + TEMPLATE.replace("{level}", level) } diff --git a/src/minecraft/mod.rs b/src/minecraft/mod.rs index a982634..b8da46a 100644 --- a/src/minecraft/mod.rs +++ b/src/minecraft/mod.rs @@ -4,6 +4,7 @@ use serde::Serialize; use serde::Serializer; pub mod eula; +pub mod log; pub mod log4j; pub mod players; pub mod seed; diff --git a/src/minecraft/tests.rs b/src/minecraft/tests.rs index 763deaa..403fb1f 100644 --- a/src/minecraft/tests.rs +++ b/src/minecraft/tests.rs @@ -4,6 +4,10 @@ use chrono::DateTime; use chrono::Utc; use uuid::Uuid; +use crate::minecraft::log::RefusalReason; +use crate::minecraft::log::ServerLogEvent; +use crate::minecraft::log::ServerLogLevel; +use crate::minecraft::log::ServerLogLine; use crate::minecraft::players::BanDetails; use crate::minecraft::players::BanEntry; use crate::minecraft::players::OpEntry; @@ -81,3 +85,141 @@ fn unknown_property_keys_are_reported() -> McResult<()> { Ok(()) } + +#[test] +fn console_lines_split_into_level_thread_and_message() { + let line = + ServerLogLine::parse("[WARN] [Worker-Main-3]: Can't keep up! Is the server overloaded?"); + + assert!(line.is_some_and(|line| { + line.level == ServerLogLevel::Warn + && line.thread == Some("Worker-Main-3") + && line.message == "Can't keep up! Is the server overloaded?" + })); + assert!( + ServerLogLine::parse("[12:34:56] [Server thread/INFO]: Notch joined the game").is_none() + ); + assert!(ServerLogLine::parse("[LOUD] [Server thread]: Notch joined the game").is_none()); + assert!(ServerLogLine::parse("\tat net.minecraft.server.MinecraftServer.run").is_none()); + assert!( + ServerLogLine::parse( + "Starting net.fabricmc.loader.impl.game.minecraft.BundlerClassPathCapture" + ) + .is_none() + ); +} + +#[test] +fn jvm_lines_carry_a_level_but_no_thread() { + let line = ServerLogLine::parse( + "WARNING: A terminally deprecated method in sun.misc.Unsafe has been called" + ); + + assert!(line.is_some_and(|line| { + line.level == ServerLogLevel::Warn + && line.thread.is_none() + && line.message == "A terminally deprecated method in sun.misc.Unsafe has been called" + })); + + let line = ServerLogLine::parse("ERROR: Could not create the Java Virtual Machine."); + + assert!(line.is_some_and(|line| line.level == ServerLogLevel::Error && line.thread.is_none())); + assert_eq!(recognize("WARNING: Notch joined the game"), None); +} + +fn recognize(line: &str) -> Option { + ServerLogLine::parse(line) + .as_ref() + .and_then(ServerLogEvent::recognize) +} + +#[test] +fn join_leave_and_save_events_are_recognized() { + assert_eq!( + recognize("[INFO] [Server thread]: Notch joined the game"), + Some(ServerLogEvent::Joined(String::from("Notch"))) + ); + assert_eq!( + recognize("[INFO] [Server thread]: Notch left the game"), + Some(ServerLogEvent::Left(String::from("Notch"))) + ); + assert_eq!( + recognize("[INFO] [Server thread]: Notch lost connection: Timed out"), + Some(ServerLogEvent::Disconnected { + name: String::from("Notch"), + reason: String::from("Timed out") + }) + ); + assert_eq!( + recognize( + "[INFO] [Server thread]: Saving chunks for level 'ServerLevel[world]'/minecraft:overworld" + ), + Some(ServerLogEvent::SaveStarted) + ); + assert_eq!( + recognize( + "[INFO] [Server thread]: ThreadedAnvilChunkStorage (world): All chunks are saved" + ), + None + ); + assert_eq!( + recognize("[INFO] [Server thread]: ThreadedAnvilChunkStorage: All dimensions are saved"), + Some(ServerLogEvent::SaveCompleted) + ); +} + +#[test] +fn refused_logins_carry_a_typed_reason() { + let cases = [ + ( + "You are not white-listed on this server!", + RefusalReason::NotInAllowList + ), + ("You are banned from this server.", RefusalReason::Banned), + ( + "Your IP address is banned from this server.", + RefusalReason::AddressBanned + ), + ("The server is full!", RefusalReason::Full), + ("That name is already taken", RefusalReason::NameTaken), + ("Timed out", RefusalReason::Other(String::from("Timed out"))) + ]; + + for (raw, reason) in cases { + assert_eq!( + recognize(&format!( + "[INFO] [Server thread]: Notch (/[0:0:0:0:0:0:0:1]:49242) lost connection: {}", + raw + )), + Some(ServerLogEvent::Refused { + name: String::from("Notch"), + address: String::from("[0:0:0:0:0:0:0:1]:49242"), + reason + }), + "{}", + raw + ); + } +} + +#[test] +fn player_controlled_text_is_not_an_event() { + let lines = [ + "[INFO] [Server thread]: Steve joined the game", + "[INFO] [Server thread]: joined the game", + "[INFO] [Server thread]: [Server] Steve left the game", + "[INFO] [Server thread]: * Notch left the game", + "[INFO] [Server thread]: joined the game", + "[INFO] [Server thread]: Saving chunks for level 'x'", + "[INFO] [Server thread]: Steve lost connection: Timed out", + "[INFO] [Server thread]: Steve (/203.0.113.7:51234) lost connection: Timed out", + "[INFO] [Server thread]: Disconnecting Notch (/203.0.113.7:51234): You are banned from this server.", + "[WARN] [Server thread]: Notch joined the game", + "[INFO] [Netty Server IO #1]: Notch joined the game", + "[12:34:56] [Server thread/INFO]: Notch joined the game" + ]; + + for line in lines { + assert_eq!(recognize(line), None, "{}", line); + } +} diff --git a/src/ops/run.rs b/src/ops/run.rs index b710a9f..872320e 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -1,22 +1,33 @@ +use std::collections::HashSet; use std::env; use std::io::ErrorKind; use std::io::IsTerminal; use std::process::ExitStatus; use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex; use std::sync::PoisonError; use std::time::Duration; +use std::time::Instant; +use anstyle::Style; use anyhow::Context; +use tokio::io::AsyncRead; use tokio::io::AsyncWriteExt; +use tokio::task::JoinHandle; use tokio_cron_scheduler::Job; use tokio_cron_scheduler::JobScheduler; use tokio_util::sync::CancellationToken; +use crate::cli::styles; use crate::context::McContext; use crate::env::Architecture; use crate::env::Platform; use crate::manifest::Manifest; use crate::manifest::ManifestPaths; +use crate::minecraft::log::ServerLogEvent; +use crate::minecraft::log::ServerLogLevel; +use crate::minecraft::log::ServerLogLine; use crate::minecraft::log4j; use crate::minecraft::server_properties::ManagedServerProperties; use crate::minecraft::server_properties::PropertyEntries; @@ -37,12 +48,17 @@ use crate::ops::tunnel::TunnelInstallOptions; use crate::services::playit_api::DASHBOARD_TUNNELS_URL; use crate::utils; use crate::utils::errors::McResult; +use crate::utils::shell::Shell; /// How long to wait for the server to save and exit after asking it to stop /// before forcing it down. Kept under systemd's default 90s `TimeoutStopSec` so /// our grace window runs before systemd SIGKILLs the unit. const SHUTDOWN_GRACE: Duration = Duration::from_secs(85); +/// Disconnect reasons that mean the player or the server chose to end the +/// session, as opposed to a connection problem, a kick, or a rule violation. +const QUIET_DISCONNECT_REASONS: [&str; 2] = ["Disconnected", "Server closed"]; + /// server.properties keys whose values are sensitive. const SECRET_PROPERTY_KEYS: [&str; 3] = [ "management-server-secret", @@ -64,14 +80,120 @@ pub struct RunOptions { pub tunnel_logs: bool } -fn child_output(shown: bool) -> Stdio { - if shown { - Stdio::inherit() - } else { - Stdio::null() +fn level_style(level: ServerLogLevel) -> &'static Style { + match level { + ServerLogLevel::Fatal | ServerLogLevel::Error => &styles::ERROR, + ServerLogLevel::Warn => &styles::WARN, + ServerLogLevel::Info => &styles::LOG_INFO, + ServerLogLevel::Debug => &styles::LOG_DEBUG, + ServerLogLevel::Trace => &styles::LOG_TRACE } } +fn level_threshold(level: tracing::Level) -> ServerLogLevel { + match level { + tracing::Level::ERROR => ServerLogLevel::Error, + tracing::Level::WARN => ServerLogLevel::Warn, + tracing::Level::INFO => ServerLogLevel::Info, + tracing::Level::DEBUG => ServerLogLevel::Debug, + tracing::Level::TRACE => ServerLogLevel::Trace + } +} + +/// Drains one of the server's output pipes, echoing the lines within +/// `threshold` when `echo` is set and printing the events it recognizes +/// either way. Events carry a level of their own that is held to the same +/// threshold. +fn forward_server_output( + shell: Arc>, + output: R, + echo: bool, + threshold: ServerLogLevel +) -> JoinHandle<()> +where + R: AsyncRead + Unpin + Send + 'static +{ + tokio::spawn(async move { + let mut save_started: Option = None; + let mut warned_disconnects: HashSet = HashSet::new(); + let show_info = ServerLogLevel::Info <= threshold; + let show_warn = ServerLogLevel::Warn <= threshold; + + utils::process::for_each_line(output, |line| { + let parsed = ServerLogLine::parse(line); + let event = parsed.as_ref().and_then(ServerLogEvent::recognize); + let mut shell = shell.lock().unwrap_or_else(PoisonError::into_inner); + + if echo { + match &parsed { + Some(parsed) if parsed.level <= threshold => { + let dot = level_style(parsed.level); + + _ = shell.echo( + "Minecraft", + format!("{dot}●{dot:#} {}", parsed.message), + &styles::MINECRAFT + ); + } + Some(_) => {} + None => _ = shell.echo("Minecraft", line, &styles::MINECRAFT) + } + } + + match event { + Some(ServerLogEvent::Joined(name)) => { + warned_disconnects.remove(&name); + + if show_info { + _ = shell.status("Joined", name); + } + } + Some(ServerLogEvent::Left(name)) => { + if !warned_disconnects.remove(&name) && show_info { + _ = shell.status("Left", name); + } + } + Some(ServerLogEvent::Disconnected { name, reason }) => { + if !QUIET_DISCONNECT_REASONS.contains(&reason.as_str()) { + if show_warn { + _ = shell.warn(format!("{} lost connection: {}", name, reason)); + } + + warned_disconnects.insert(name); + } + } + Some(ServerLogEvent::Refused { name, reason, .. }) => { + let quiet = reason + .raw() + .is_some_and(|reason| QUIET_DISCONNECT_REASONS.contains(&reason)); + + if !quiet && show_warn { + _ = shell.warn(format!("connection refused for {}, {}", name, reason)); + } + } + Some(ServerLogEvent::SaveStarted) => { + save_started.get_or_insert_with(Instant::now); + } + Some(ServerLogEvent::SaveCompleted) => { + let message = match save_started.take() { + Some(started) => format!( + "all dimensions flushed to disk in {:.1?}", + started.elapsed() + ), + None => String::from("all dimensions flushed to disk") + }; + + if show_info { + _ = shell.status("Saved", message); + } + } + None => {} + } + }) + .await + }) +} + fn has_jvm_property(arguments: &[String], property: &str) -> bool { let prefix = format!("-D{}=", property); @@ -290,6 +412,7 @@ pub async fn run(context: &mut McContext, options: &RunOptions) -> McResult McResult McResult McResult McResult } @@ -325,14 +329,18 @@ fn agent_command(options: &TunnelAgentOptions) -> Command { .env("PLAYIT_LOG", options.log_level) .current_dir(&options.work_directory) .stdin(Stdio::null()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) .kill_on_drop(true); // The agent logs to stderr unless given a log file, so hidden output is // kept on disk rather than dropped. - if !options.logs { - command.arg("--log-path").arg(LOG_FILE_NAME); + if options.logs { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + } else { + command + .arg("--log-path") + .arg(LOG_FILE_NAME) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); } utils::process::detach_from_terminal_signals(&mut command); @@ -376,6 +384,24 @@ async fn stop_agent(child: &mut Child) { let _ = child.kill().await; } +// The agent colors its own output; stripping it keeps the line in mc's +// style rather than a mix of both. +fn forward_agent_output(shell: Arc>, label: &'static str, output: R) +where + R: AsyncRead + Unpin + Send + 'static +{ + tokio::spawn(async move { + utils::process::for_each_line(output, |line| { + _ = shell.lock().unwrap_or_else(PoisonError::into_inner).echo( + label, + strip_str(line), + &styles::TUNNEL + ); + }) + .await + }); +} + fn warn(shell: &Arc>, message: String) { _ = shell .lock() @@ -412,6 +438,14 @@ pub fn supervise( } }; + if let Some(stdout) = child.stdout.take() { + forward_agent_output(shell.clone(), options.provider.label(), stdout); + } + + if let Some(stderr) = child.stderr.take() { + forward_agent_output(shell.clone(), options.provider.label(), stderr); + } + if let Some(address) = options.address.as_ref().filter(|_| !announced) { announced = true; diff --git a/src/tunnel/provider.rs b/src/tunnel/provider.rs index 047f7a4..2e92814 100644 --- a/src/tunnel/provider.rs +++ b/src/tunnel/provider.rs @@ -9,6 +9,14 @@ pub enum TunnelProviderKind { Playit } +impl TunnelProviderKind { + pub fn label(&self) -> &'static str { + match self { + TunnelProviderKind::Playit => "Playit" + } + } +} + impl fmt::Display for TunnelProviderKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let provider = match self { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index a320b74..3104721 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -14,3 +14,6 @@ pub mod shell; pub mod verbosity; pub use case_iterable::CaseIterable; + +#[cfg(test)] +mod tests; diff --git a/src/utils/process.rs b/src/utils/process.rs index f594529..23a828c 100644 --- a/src/utils/process.rs +++ b/src/utils/process.rs @@ -1,6 +1,9 @@ use std::future::pending; use anyhow::Context; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncRead; +use tokio::io::BufReader; use tokio::process::Command; #[cfg(unix)] use tokio::signal::unix::Signal; @@ -36,6 +39,30 @@ pub fn render_command(command: &Command) -> String { .join(" ") } +/// Calls `handle` with every line `output` yields until it closes or fails. +/// Invalid UTF-8 is replaced rather than dropping the line. +pub async fn for_each_line(output: R, mut handle: F) +where + R: AsyncRead + Unpin, + F: FnMut(&str) +{ + let mut reader = BufReader::new(output); + let mut buffer = Vec::new(); + + loop { + buffer.clear(); + + match reader.read_until(b'\n', &mut buffer).await { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + + let line = String::from_utf8_lossy(&buffer); + + handle(line.trim_end_matches(['\n', '\r'])); + } +} + pub fn detach_from_terminal_signals(command: &mut Command) { #[cfg(unix)] command.process_group(0); diff --git a/src/utils/shell.rs b/src/utils/shell.rs index 812edbb..e95e599 100644 --- a/src/utils/shell.rs +++ b/src/utils/shell.rs @@ -64,13 +64,12 @@ impl Shell { self.stderr = AutoStream::new(std::io::stderr(), self.color_choice); } - fn output_stderr( - &mut self, + fn render( status: &dyn fmt::Display, message: Option<&dyn fmt::Display>, style: &Style, justified: bool - ) -> anyhow::Result<()> { + ) -> anyhow::Result> { let mut buffer = Vec::new(); if justified { @@ -84,6 +83,18 @@ impl Shell { None => write!(buffer, " ")? } + Ok(buffer) + } + + fn output_stderr( + &mut self, + status: &dyn fmt::Display, + message: Option<&dyn fmt::Display>, + style: &Style, + justified: bool + ) -> anyhow::Result<()> { + let buffer = Shell::render(status, message, style, justified)?; + self.stderr.write_all(&buffer)?; Ok(()) @@ -102,6 +113,18 @@ impl Shell { } } + /// Writes a forwarded child process line to stdout regardless of + /// verbosity. + pub fn echo(&mut self, status: S, message: M, style: &Style) -> anyhow::Result<()> + where + S: fmt::Display, + M: fmt::Display + { + writeln!(self.stdout, "{style}{status}{style:#} {message}")?; + + Ok(()) + } + pub fn status(&mut self, status: S, message: M) -> anyhow::Result<()> where S: fmt::Display, diff --git a/src/utils/tests.rs b/src/utils/tests.rs new file mode 100644 index 0000000..538696a --- /dev/null +++ b/src/utils/tests.rs @@ -0,0 +1,11 @@ +use crate::utils::process::for_each_line; + +#[tokio::test] +async fn lines_are_split_and_decoded_leniently() { + let output: &[u8] = b"first\r\nsec\xffond\n\nlast"; + let mut lines = Vec::new(); + + for_each_line(output, |line| lines.push(line.to_string())).await; + + assert_eq!(lines, ["first", "sec\u{fffd}ond", "", "last"]); +}