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 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 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 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 assert_eq!(parse_block_header("copying 12 files to /var"), None);
265 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 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 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 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 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 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 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 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 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 bulk.process_line_to_writer("copying path '/nix/store/abc' to 'store'...", false, &mut out);
426 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), ..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 assert!(!rendered.contains('\r'), "non-TTY sink must receive no carriage returns");
455 assert!(rendered.contains("copied 2/2 paths (0.1 MiB)\n"));
457 assert!(rendered.contains("setting up pam...\n"));
458 }
459}