Skip to main content

nxd_core/progress/
target.rs

1use std::collections::BTreeMap;
2use std::fs::File;
3use std::io::Write;
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant};
6
7use crate::progress::color::ColorMode;
8use crate::progress::log::{ProgressEvent, StatusLevel, normalize_multiline_message};
9use crate::progress::redaction::{ProgressRedactor, redact_sensitive_text};
10
11pub enum LogTarget {
12	Terminal,
13	File(File),
14	Batch {
15		prefix: String,
16		file: File,
17		started_at: Option<Instant>,
18		last_event_at: Option<Instant>,
19		outcome: Option<BatchOutcome>,
20	},
21	Routed {
22		fallback: Logger,
23		hosts: BTreeMap<String, Logger>,
24	},
25	Silent,
26}
27
28impl LogTarget {
29	pub fn log_event(&mut self, level: StatusLevel, message: &str) {
30		self.write_event(level, message, message);
31	}
32
33	pub fn log_status(&mut self, message: &str) {
34		self.write_event(StatusLevel::Info, message, message);
35	}
36
37	pub fn log_debug(&mut self, message: &str) {
38		self.write_event(StatusLevel::Debug, message, message);
39	}
40
41	fn write_event(&mut self, level: StatusLevel, message: &str, file_message: &str) {
42		let message = normalize_multiline_message(message);
43		let message = redact_sensitive_text(&message);
44		let file_message = normalize_multiline_message(file_message);
45		let file_message = redact_sensitive_text(&file_message);
46		let message_lines: Vec<&str> =
47			if message.is_empty() { vec![""] } else { message.lines().collect() };
48		let file_lines: Vec<&str> =
49			if file_message.is_empty() { vec![""] } else { file_message.lines().collect() };
50
51		match self {
52			LogTarget::Terminal => {
53				for line in message_lines {
54					let event = ProgressEvent::new(level, None, line);
55					event.log(ColorMode::Auto);
56				}
57			}
58			LogTarget::Batch { prefix, file, started_at, last_event_at, outcome } => {
59				let now = Instant::now();
60				let started = *started_at.get_or_insert(now);
61				let elapsed = now.saturating_duration_since(started);
62				*last_event_at = Some(now);
63				match level {
64					StatusLevel::Success => *outcome = Some(BatchOutcome::Succeeded),
65					StatusLevel::Failure | StatusLevel::Error => *outcome = Some(BatchOutcome::Failed),
66					_ => {}
67				}
68				let terminal_message = message.strip_prefix(&format!("{prefix}: ")).unwrap_or(&message);
69				let terminal_message = timed_batch_message(level, terminal_message, elapsed);
70				for line in terminal_message.lines() {
71					let event = ProgressEvent::new(level, Some(prefix), line);
72					event.log(ColorMode::Auto);
73				}
74				for line in file_lines {
75					let line = timed_batch_message(level, line, elapsed);
76					let _ = writeln!(file, "[{}] {}", level.label(), line);
77				}
78			}
79			LogTarget::File(file) => {
80				for line in file_lines {
81					let _ = writeln!(file, "[{}] {}", level.label(), line);
82				}
83			}
84			LogTarget::Routed { .. } => {}
85			LogTarget::Silent => {}
86		}
87	}
88}
89
90fn timed_batch_message(level: StatusLevel, message: &str, elapsed: Duration) -> String {
91	let elapsed = crate::progress::log::format_elapsed(elapsed);
92	match level {
93		StatusLevel::Success => format!("{message} in {elapsed}"),
94		StatusLevel::Failure | StatusLevel::Error => format!("{message} after {elapsed}"),
95		_ => message.to_string(),
96	}
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum BatchOutcome {
101	Succeeded,
102	Failed,
103}
104
105#[derive(Clone)]
106pub struct Logger {
107	pub target: Arc<Mutex<LogTarget>>,
108	redactor: Arc<Mutex<ProgressRedactor>>,
109}
110
111impl Logger {
112	pub fn new(target: Arc<Mutex<LogTarget>>) -> Self {
113		Self { target, redactor: Arc::new(Mutex::new(ProgressRedactor::default())) }
114	}
115
116	pub fn with_sensitive_values<'a>(&self, values: impl IntoIterator<Item = &'a [u8]>) -> Self {
117		Self {
118			target: self.target.clone(),
119			redactor: Arc::new(Mutex::new(ProgressRedactor::with_sensitive_values(values))),
120		}
121	}
122
123	pub fn sanitize(&self, message: &str) -> String {
124		self
125			.redactor
126			.lock()
127			.map(|mut redactor| redactor.redact(message))
128			.unwrap_or_else(|_| redact_sensitive_text(message))
129	}
130
131	pub fn lock(&self) -> std::sync::LockResult<std::sync::MutexGuard<'_, LogTarget>> {
132		self.target.lock()
133	}
134
135	pub fn silent() -> Self {
136		Self::new(Arc::new(Mutex::new(LogTarget::Silent)))
137	}
138
139	pub fn terminal() -> Self {
140		Self::new(Arc::new(Mutex::new(LogTarget::Terminal)))
141	}
142
143	pub fn file(file: File) -> Self {
144		Self::new(Arc::new(Mutex::new(LogTarget::File(file))))
145	}
146
147	pub fn batch(prefix: String, file: File) -> Self {
148		Self::new(Arc::new(Mutex::new(LogTarget::Batch {
149			prefix,
150			file,
151			started_at: None,
152			last_event_at: None,
153			outcome: None,
154		})))
155	}
156
157	pub fn routed(fallback: Logger, hosts: BTreeMap<String, Logger>) -> Self {
158		Self::new(Arc::new(Mutex::new(LogTarget::Routed { fallback, hosts })))
159	}
160
161	pub fn for_host(&self, hostname: &str) -> Self {
162		self
163			.target
164			.lock()
165			.ok()
166			.and_then(|target| match &*target {
167				LogTarget::Routed { fallback, hosts } => {
168					Some(hosts.get(hostname).unwrap_or(fallback).clone())
169				}
170				_ => None,
171			})
172			.unwrap_or_else(|| self.clone())
173	}
174
175	fn routed_fallback(&self) -> Option<Logger> {
176		self.target.lock().ok().and_then(|target| match &*target {
177			LogTarget::Routed { fallback, .. } => Some(fallback.clone()),
178			_ => None,
179		})
180	}
181
182	pub fn event(&self, level: StatusLevel, message: &str) {
183		if level == StatusLevel::Debug && !crate::config::get_runtime_options().debug {
184			return;
185		}
186		let message = self.sanitize(message);
187		if let Some(fallback) = self.routed_fallback() {
188			fallback.event(level, &message);
189			return;
190		}
191		if let Ok(mut lock) = self.target.lock() {
192			lock.log_event(level, &message);
193		}
194	}
195
196	/// Record command detail. Single-host terminal runs retain it; batch runs
197	/// write it only to the host log so concurrent subprocess output cannot
198	/// overwhelm the interactive fleet summary.
199	pub fn detail(&self, message: &str) {
200		let message = self.sanitize(message);
201		if let Some(fallback) = self.routed_fallback() {
202			fallback.detail(&message);
203			return;
204		}
205		if let Ok(mut target) = self.target.lock() {
206			match &mut *target {
207				LogTarget::Terminal => target.log_event(StatusLevel::Info, &message),
208				LogTarget::Batch { file, .. } | LogTarget::File(file) => {
209					for line in normalize_multiline_message(&message).lines() {
210						let _ = writeln!(file, "{}", line);
211					}
212				}
213				LogTarget::Routed { .. } | LogTarget::Silent => {}
214			}
215		}
216	}
217
218	/// Elapsed time between this host's first and most recent milestone.
219	/// Batch callers use this after apply so a fast host is not charged for
220	/// time spent waiting for another host in the fleet.
221	pub fn elapsed(&self) -> Option<Duration> {
222		self.target.lock().ok().and_then(|target| match &*target {
223			LogTarget::Batch { started_at: Some(started), last_event_at: Some(last), .. } => {
224				Some(last.saturating_duration_since(*started))
225			}
226			_ => None,
227		})
228	}
229
230	pub fn batch_outcome(&self) -> Option<BatchOutcome> {
231		self.target.lock().ok().and_then(|target| match &*target {
232			LogTarget::Batch { outcome, .. } => *outcome,
233			_ => None,
234		})
235	}
236
237	pub fn output_line(&self, line: &str, stderr: bool) {
238		let line = self.sanitize(line);
239		if let Some(fallback) = self.routed_fallback() {
240			fallback.output_line(&line, stderr);
241			return;
242		}
243		if let Ok(mut target) = self.target.lock() {
244			match &mut *target {
245				LogTarget::Terminal => {
246					if stderr {
247						eprintln!("{}", crate::progress::stream::format_output_line(&line));
248					} else {
249						println!("{}", crate::progress::stream::format_output_line(&line));
250					}
251				}
252				LogTarget::Batch { file, .. } | LogTarget::File(file) => {
253					let _ = writeln!(file, "{}", line);
254				}
255				LogTarget::Routed { .. } | LogTarget::Silent => {}
256			}
257		}
258	}
259
260	pub fn info(&self, message: &str) {
261		self.event(StatusLevel::Info, message);
262	}
263
264	pub fn success(&self, message: &str) {
265		self.event(StatusLevel::Success, message);
266	}
267
268	pub fn warn(&self, message: &str) {
269		self.event(StatusLevel::Warning, message);
270	}
271
272	pub fn error(&self, message: &str) {
273		self.event(StatusLevel::Error, message);
274	}
275
276	pub fn failure(&self, message: &str) {
277		self.event(StatusLevel::Failure, message);
278	}
279
280	pub fn debug(&self, message: &str) {
281		self.event(StatusLevel::Debug, message);
282	}
283}
284
285#[macro_export]
286macro_rules! info {
287    ($logger:expr, $fmt:literal) => { $logger.info(&format!($fmt)) };
288    ($logger:expr, $msg:expr) => { $logger.info($msg) };
289    ($logger:expr, $fmt:literal, $($arg:tt)*) => { $logger.info(&format!($fmt, $($arg)*)) };
290}
291
292#[macro_export]
293macro_rules! success {
294    ($logger:expr, $fmt:literal) => { $logger.success(&format!($fmt)) };
295    ($logger:expr, $msg:expr) => { $logger.success($msg) };
296    ($logger:expr, $fmt:literal, $($arg:tt)*) => { $logger.success(&format!($fmt, $($arg)*)) };
297}
298
299#[macro_export]
300macro_rules! warn {
301    ($logger:expr, $fmt:literal) => { $logger.warn(&format!($fmt)) };
302    ($logger:expr, $msg:expr) => { $logger.warn($msg) };
303    ($logger:expr, $fmt:literal, $($arg:tt)*) => { $logger.warn(&format!($fmt, $($arg)*)) };
304}
305
306#[macro_export]
307macro_rules! error {
308    ($logger:expr, $fmt:literal) => { $logger.error(&format!($fmt)) };
309    ($logger:expr, $msg:expr) => { $logger.error($msg) };
310    ($logger:expr, $fmt:literal, $($arg:tt)*) => { $logger.error(&format!($fmt, $($arg)*)) };
311}
312
313#[macro_export]
314macro_rules! failure {
315    ($logger:expr, $fmt:literal) => { $logger.failure(&format!($fmt)) };
316    ($logger:expr, $msg:expr) => { $logger.failure($msg) };
317    ($logger:expr, $fmt:literal, $($arg:tt)*) => { $logger.failure(&format!($fmt, $($arg)*)) };
318}
319
320#[macro_export]
321macro_rules! debug {
322    ($logger:expr, $fmt:literal) => { $logger.debug(&format!($fmt)) };
323    ($logger:expr, $msg:expr) => { $logger.debug($msg) };
324    ($logger:expr, $fmt:literal, $($arg:tt)*) => { $logger.debug(&format!($fmt, $($arg)*)) };
325}
326
327#[cfg(test)]
328#[path = "target_tests.rs"]
329mod tests;