Skip to main content

nxd_core/progress/
bulk.rs

1use std::io::{IsTerminal, Write};
2
3#[derive(Debug, Clone, PartialEq, Eq, Default)]
4pub enum BulkState {
5	#[default]
6	Idle,
7	BareCopying {
8		count: usize,
9	},
10	Block {
11		total: usize,
12		count: usize,
13		lines_seen: usize,
14		bytes_seen: usize,
15		size: Option<String>,
16	},
17}
18
19pub struct TerminalBulkState {
20	pub state: BulkState,
21	pub active_counter_rendered: bool,
22	pub suppressed_tail: Vec<String>,
23	pub is_tty_override: Option<bool>,
24}
25
26impl Default for TerminalBulkState {
27	fn default() -> Self {
28		Self {
29			state: BulkState::Idle,
30			active_counter_rendered: false,
31			suppressed_tail: Vec::new(),
32			is_tty_override: None,
33		}
34	}
35}
36
37impl TerminalBulkState {
38	pub fn is_tty(&self) -> bool {
39		self.is_tty_override.unwrap_or_else(|| std::io::stdout().is_terminal())
40	}
41
42	pub fn clear_active_counter(&mut self) {
43		let mut out = std::io::stdout();
44		self.clear_active_counter_to_writer(&mut out);
45	}
46
47	pub fn clear_active_counter_to_writer<W: Write>(&mut self, writer: &mut W) {
48		if self.active_counter_rendered {
49			if self.is_tty() {
50				let _ = write!(writer, "\r\x1b[K");
51				let _ = writer.flush();
52			}
53			self.active_counter_rendered = false;
54		}
55	}
56
57	pub fn flush_summary(&mut self) {
58		let mut out = std::io::stdout();
59		self.flush_summary_to_writer(&mut out);
60	}
61
62	pub fn flush_summary_to_writer<W: Write>(&mut self, writer: &mut W) {
63		let summary_line = match &self.state {
64			BulkState::BareCopying { count } if *count > 0 => Some(format!("copied {} paths", count)),
65			BulkState::Block { count, total, size, .. } if *count > 0 => {
66				if let Some(size_str) = size {
67					Some(format!("copied {}/{} paths ({})", count, total, size_str))
68				} else {
69					Some(format!("copied {}/{} paths", count, total))
70				}
71			}
72			_ => None,
73		};
74
75		if let Some(line) = summary_line {
76			self.clear_active_counter_to_writer(writer);
77			let formatted = crate::progress::stream::format_output_line(&line);
78			let _ = writeln!(writer, "{}", formatted);
79			let _ = writer.flush();
80		}
81		self.state = BulkState::Idle;
82	}
83
84	pub fn process_line(&mut self, line: &str) {
85		let mut out = std::io::stdout();
86		self.process_line_to_writer(line, false, &mut out);
87	}
88
89	pub fn process_line_to_writer<W: Write>(&mut self, line: &str, stderr: bool, writer: &mut W) {
90		if self.suppressed_tail.len() >= 64 {
91			self.suppressed_tail.remove(0);
92		}
93		self.suppressed_tail.push(line.to_string());
94
95		if stderr || crate::config::get_runtime_options().verbose {
96			self.clear_active_counter_to_writer(writer);
97			self.flush_summary_to_writer(writer);
98			let formatted = crate::progress::stream::format_output_line(line);
99			let _ = writeln!(writer, "{}", formatted);
100			let _ = writer.flush();
101			return;
102		}
103
104		let is_tty = self.is_tty();
105
106		if let Some((total, size)) = parse_block_header(line) {
107			self.flush_summary_to_writer(writer);
108			self.state =
109				BulkState::Block { total, count: 0, lines_seen: 0, bytes_seen: line.len(), size };
110			if is_tty {
111				self.render_counter_in_place_to_writer(writer);
112			}
113			return;
114		}
115
116		if is_bare_copying_line(line) {
117			match &mut self.state {
118				BulkState::BareCopying { count } => {
119					*count += 1;
120				}
121				// Nix lists a block's members once, then narrates the same paths
122				// again as it transfers them. Counting both passes would report
123				// more paths than the header promised.
124				BulkState::Block { count, total, lines_seen, bytes_seen, .. } => {
125					if *count < *total {
126						*count += 1;
127					}
128					*lines_seen += 1;
129					*bytes_seen += line.len();
130				}
131				BulkState::Idle => {
132					self.state = BulkState::BareCopying { count: 1 };
133				}
134			}
135			if is_tty {
136				self.render_counter_in_place_to_writer(writer);
137			}
138			return;
139		}
140
141		let is_block_cont = is_block_continuation_line(line);
142		let is_in_block = matches!(&self.state, BulkState::Block { .. });
143		if is_block_cont && is_in_block {
144			let BulkState::Block { count, total, lines_seen, bytes_seen, .. } = &mut self.state else {
145				unreachable!()
146			};
147			if *count < *total {
148				*count += 1;
149			}
150			*lines_seen += 1;
151			*bytes_seen += line.len();
152
153			if *lines_seen >= 4096 || *bytes_seen >= 256 * 1024 {
154				self.flush_summary_to_writer(writer);
155				self.clear_active_counter_to_writer(writer);
156				let formatted = crate::progress::stream::format_output_line(line);
157				let _ = writeln!(writer, "{}", formatted);
158				let _ = writer.flush();
159				return;
160			}
161
162			if is_tty {
163				self.render_counter_in_place_to_writer(writer);
164			}
165			return;
166		}
167
168		self.flush_summary_to_writer(writer);
169		self.clear_active_counter_to_writer(writer);
170		let formatted = crate::progress::stream::format_output_line(line);
171		let _ = writeln!(writer, "{}", formatted);
172		let _ = writer.flush();
173	}
174
175	fn render_counter_in_place_to_writer<W: Write>(&mut self, writer: &mut W) {
176		let line = match &self.state {
177			BulkState::BareCopying { count } => {
178				format!("copying {} paths…", count)
179			}
180			BulkState::Block { count, total, size, .. } => {
181				if let Some(size_str) = size {
182					format!("copying {}/{} paths… ({})", count, total, size_str)
183				} else {
184					format!("copying {}/{} paths…", count, total)
185				}
186			}
187			BulkState::Idle => return,
188		};
189
190		let formatted = crate::progress::stream::format_output_line(&line);
191		let _ = write!(writer, "\r{}", formatted);
192		let _ = writer.flush();
193		self.active_counter_rendered = true;
194	}
195
196	pub fn take_suppressed_tail(&mut self) -> Vec<String> {
197		std::mem::take(&mut self.suppressed_tail)
198	}
199}
200
201pub fn parse_block_header(line: &str) -> Option<(usize, Option<String>)> {
202	let trimmed = line.trim();
203	// Nix announces a transfer as "copying N paths..." or "copying N paths" and a
204	// realisation as "these N paths will be fetched" or "these N derivations will be built";
205	// both open a listing. The token after the integer must be exactly "paths" (optionally
206	// followed by "..."); any other token (e.g. "files") is not a bulk header.
207	if let Some(rest) = trimmed.strip_prefix("copying ") {
208		let mut parts = rest.split_whitespace();
209		if let (Some(count_str), Some(next_token)) = (parts.next(), parts.next())
210			&& parts.next().is_none()
211			&& let Ok(count) = count_str.parse::<usize>()
212			&& next_token.strip_suffix("...").unwrap_or(next_token) == "paths"
213		{
214			return Some((count, None));
215		}
216	}
217	if !trimmed.starts_with("these ") {
218		return None;
219	}
220	if !(trimmed.contains(" paths will be fetched") || trimmed.contains(" derivations will be built"))
221	{
222		return None;
223	}
224	let after_these = trimmed.strip_prefix("these ")?;
225	let count_str = after_these.split_whitespace().next()?;
226	let count: usize = count_str.parse().ok()?;
227
228	let size = trimmed.find('(').and_then(|start| {
229		trimmed[start..].find(')').map(|end| trimmed[start + 1..start + end].to_string())
230	});
231
232	Some((count, size))
233}
234
235pub fn is_bare_copying_line(line: &str) -> bool {
236	let trimmed = line.trim();
237	(trimmed.starts_with("copying path '") || trimmed.starts_with("copying path "))
238		&& (trimmed.contains(" from ")
239			|| trimmed.contains(" to ")
240			|| trimmed.contains("' from '")
241			|| trimmed.contains("' to '"))
242}
243
244pub fn is_block_continuation_line(line: &str) -> bool {
245	// Only a store-path entry continues a block. Indentation alone is not enough:
246	// Nix indents error context too, and treating that as bulk would hide a real
247	// failure reported in the middle of a fetch listing.
248	let trimmed = line.trim();
249	trimmed.starts_with("/nix/store/")
250}
251
252#[cfg(test)]
253mod tests {
254	use super::*;
255
256	#[test]
257	fn a_copying_header_opens_a_block_instead_of_printing_beside_its_counter() {
258		assert_eq!(parse_block_header("copying 2 paths..."), Some((2, None)));
259		assert_eq!(parse_block_header("copying 2 paths"), Some((2, None)));
260		assert_eq!(parse_block_header("copying 12 paths"), Some((12, None)));
261		assert_eq!(parse_block_header("copying 12 paths..."), Some((12, None)));
262		assert_eq!(parse_block_header("copying 0 paths..."), Some((0, None)));
263		// "files" is not "paths" — must be rejected so the line stays visible.
264		assert_eq!(parse_block_header("copying 12 files to /var"), None);
265		// A per-path line is not a header; it increments the counter.
266		assert_eq!(parse_block_header("copying path '/nix/store/x' from 'https://c'"), None);
267		assert!(is_bare_copying_line("copying path '/nix/store/x' from 'https://c'"));
268	}
269
270	#[test]
271	fn rejected_copying_files_line_and_ordinary_lines_remain_visible() {
272		// "copying N files" must not be classified as bulk — it must appear in output.
273		let mut bulk = TerminalBulkState { is_tty_override: Some(false), ..Default::default() };
274		let mut out = Vec::new();
275		bulk.process_line_to_writer("copying 12 files to /var", false, &mut out);
276		let rendered = String::from_utf8(out).unwrap();
277		assert!(
278			rendered.contains("copying 12 files to /var"),
279			"rejected line must remain visible: {rendered:?}"
280		);
281
282		// Ordinary non-bulk output must remain visible.
283		let mut bulk2 = TerminalBulkState { is_tty_override: Some(false), ..Default::default() };
284		let mut out2 = Vec::new();
285		bulk2.process_line_to_writer("Activating configuration...", false, &mut out2);
286		let rendered2 = String::from_utf8(out2).unwrap();
287		assert!(
288			rendered2.contains("Activating configuration..."),
289			"ordinary non-bulk line must remain visible: {rendered2:?}"
290		);
291	}
292
293	#[test]
294	fn indented_error_inside_a_block_is_never_suppressed() {
295		// Nix indents error context. If a block swallows every indented line, a real
296		// failure reported mid-block disappears from the terminal.
297		assert!(
298			!is_block_continuation_line("       error: build of '/nix/store/x.drv' failed"),
299			"indented error must not be treated as block continuation"
300		);
301	}
302
303	#[test]
304	fn test_logp15_bare_copying_stream_pinned_fixture() {
305		// Pinned real Nix output from `nix copy --to file:///tmp/bulk-fixture`
306		let lines = [
307			"copying path '/nix/store/jspv3c5l2zx4kiwzhq0zgxcwp34cqifz-libiconv-115.100.1' to 'file:///tmp/bulk-fixture'...",
308			"copying path '/nix/store/v4f0jj9sz97ckskvacf40llz4nfr19jf-hello-2.12.3' to 'file:///tmp/bulk-fixture'...",
309			"setting up pam...",
310		];
311
312		let mut bulk = TerminalBulkState { is_tty_override: Some(true), ..Default::default() };
313		let mut out = Vec::new();
314
315		for line in lines {
316			bulk.process_line_to_writer(line, false, &mut out);
317		}
318
319		let rendered = String::from_utf8(out).unwrap();
320		assert!(rendered.contains("\rcopying 1 paths…"));
321		assert!(rendered.contains("\rcopying 2 paths…"));
322		assert!(rendered.contains("copied 2 paths\n"));
323		assert!(rendered.contains("setting up pam...\n"));
324	}
325
326	#[test]
327	fn test_logp15_these_n_paths_fetched_block_pinned_fixture() {
328		// Pinned real Nix fetch block output
329		let lines = [
330			"these 2 paths will be fetched (0.1 MiB download, 0.4 MiB unpacked):",
331			"  /nix/store/jspv3c5l2zx4kiwzhq0zgxcwp34cqifz-libiconv-115.100.1",
332			"  /nix/store/v4f0jj9sz97ckskvacf40llz4nfr19jf-hello-2.12.3",
333			"Activating home-manager...",
334		];
335
336		let mut bulk = TerminalBulkState { is_tty_override: Some(true), ..Default::default() };
337		let mut out = Vec::new();
338
339		for line in lines {
340			bulk.process_line_to_writer(line, false, &mut out);
341		}
342
343		let rendered = String::from_utf8(out).unwrap();
344		assert!(rendered.contains("\rcopying 0/2 paths… (0.1 MiB download, 0.4 MiB unpacked)"));
345		assert!(rendered.contains("\rcopying 1/2 paths… (0.1 MiB download, 0.4 MiB unpacked)"));
346		assert!(rendered.contains("\rcopying 2/2 paths… (0.1 MiB download, 0.4 MiB unpacked)"));
347		assert!(rendered.contains("copied 2/2 paths (0.1 MiB download, 0.4 MiB unpacked)\n"));
348		assert!(rendered.contains("Activating home-manager...\n"));
349	}
350
351	#[test]
352	fn a_copying_header_counts_its_own_paths_once() {
353		// Pinned from a real `nix copy --to ssh://…` run: the header promises N,
354		// then every path is narrated. The counter must track the header, not
355		// restart or overshoot it.
356		let lines = [
357			"copying 3 paths...",
358			"copying path '/nix/store/aaa-source' to 'ssh://deploy@host'...",
359			"copying path '/nix/store/bbb-source' to 'ssh://deploy@host'...",
360			"copying path '/nix/store/ccc-source' to 'ssh://deploy@host'...",
361			"building '/nix/store/x.drv'...",
362		];
363
364		let mut bulk = TerminalBulkState { is_tty_override: Some(true), ..Default::default() };
365		let mut out = Vec::new();
366
367		for line in lines {
368			bulk.process_line_to_writer(line, false, &mut out);
369		}
370
371		let rendered = String::from_utf8(out).unwrap();
372		assert!(rendered.contains("\rcopying 1/3 paths…"));
373		assert!(rendered.contains("\rcopying 3/3 paths…"));
374		assert!(!rendered.contains("copying 1 paths…"), "must not restart a bare tally mid-block");
375		assert!(rendered.contains("copied 3/3 paths\n"));
376	}
377
378	#[test]
379	fn a_block_never_counts_past_the_total_it_announced() {
380		// Nix lists a fetch block's members, then narrates the same paths again
381		// while transferring them. Both passes reaching the counter would report
382		// more paths than the header promised.
383		let lines = [
384			"these 2 paths will be fetched (0.1 MiB):",
385			"  /nix/store/aaa-hello",
386			"  /nix/store/bbb-world",
387			"copying path '/nix/store/aaa-hello' from 'https://cache.nixos.org'...",
388			"copying path '/nix/store/bbb-world' from 'https://cache.nixos.org'...",
389			"setting up pam...",
390		];
391
392		let mut bulk = TerminalBulkState { is_tty_override: Some(true), ..Default::default() };
393		let mut out = Vec::new();
394
395		for line in lines {
396			bulk.process_line_to_writer(line, false, &mut out);
397		}
398
399		let rendered = String::from_utf8(out).unwrap();
400		assert!(rendered.contains("copied 2/2 paths (0.1 MiB)\n"));
401		assert!(!rendered.contains("3/2"), "counter must not exceed the announced total");
402		assert!(!rendered.contains("4/2"), "counter must not exceed the announced total");
403	}
404
405	#[test]
406	fn test_logp15_unrecognised_line_passes_through() {
407		let line = "Activating home-manager for user lamt...";
408		let mut bulk = TerminalBulkState { is_tty_override: Some(true), ..Default::default() };
409		let mut out = Vec::new();
410
411		bulk.process_line_to_writer(line, false, &mut out);
412
413		let rendered = String::from_utf8(out).unwrap();
414		// Unrecognised line must pass through immediately without carriage returns
415		assert_eq!(rendered, "Activating home-manager for user lamt...\n");
416	}
417
418	#[test]
419	fn test_logp15_stderr_never_suppressed() {
420		let line = "warning: Git tree '/Users/lamt/lamt-nixconfig' is dirty";
421		let mut bulk = TerminalBulkState { is_tty_override: Some(true), ..Default::default() };
422		let mut out = Vec::new();
423
424		// Start a bulk block
425		bulk.process_line_to_writer("copying path '/nix/store/abc' to 'store'...", false, &mut out);
426		// Stderr line arrives
427		bulk.process_line_to_writer(line, true, &mut out);
428
429		let rendered = String::from_utf8(out).unwrap();
430		assert!(rendered.contains("warning: Git tree '/Users/lamt/lamt-nixconfig' is dirty\n"));
431	}
432
433	#[test]
434	fn test_logp15_non_tty_sink_receives_no_carriage_returns() {
435		let lines = [
436			"these 2 paths will be fetched (0.1 MiB):",
437			"  /nix/store/jspv3c5l2zx4kiwzhq0zgxcwp34cqifz-libiconv-115.100.1",
438			"  /nix/store/v4f0jj9sz97ckskvacf40llz4nfr19jf-hello-2.12.3",
439			"setting up pam...",
440		];
441
442		let mut bulk = TerminalBulkState {
443			is_tty_override: Some(false), // Non-TTY!
444			..Default::default()
445		};
446		let mut out = Vec::new();
447
448		for line in lines {
449			bulk.process_line_to_writer(line, false, &mut out);
450		}
451
452		let rendered = String::from_utf8(out).unwrap();
453		// Must not contain any '\r' carriage returns!
454		assert!(!rendered.contains('\r'), "non-TTY sink must receive no carriage returns");
455		// Must contain durable summary line and unrecognised line
456		assert!(rendered.contains("copied 2/2 paths (0.1 MiB)\n"));
457		assert!(rendered.contains("setting up pam...\n"));
458	}
459}