Skip to main content

max / alloy

27.8 KB · 716 lines History Blame Raw
1 //! `alloy status --bar` — the swaybar status line.
2 //!
3 //! The one verb in the console that draws nothing. swaybar runs a
4 //! `status_command` and reads its stdout; everything else here is a ratatui
5 //! view, and this emits swaybar's JSON protocol instead. It is still a console
6 //! verb rather than a shell script in `usr/bin/` because the readers it needs
7 //! already exist: [`crate::audio`] fronts `pactl -f json` and [`crate::net`]
8 //! fronts `nmcli`, both already parsed into types, and a script would have been
9 //! a second parse of the same two contracts.
10 //!
11 //! What the bar shows, and why those four. The machine Alloy is validated on is
12 //! a laptop, and until this verb the bar was a clock, so the only way to learn
13 //! whether the machine was charging was to read
14 //! `/sys/class/power_supply/BAT1/status` by hand. Battery is the reason this
15 //! exists; network and volume are here because the same tick and the same
16 //! readers cover them for nearly nothing.
17 //!
18 //! Colors come from the theme, resolved through `makeover` to concrete hex,
19 //! because swaybar's protocol takes `#rrggbb` and nothing else. docs/TOKENS.md's
20 //! rule that no hex is hard-coded in Rust holds: the hex is looked up, never
21 //! written here.
22 //!
23 //! <!-- wiki: alloy-console -->
24
25 use std::fmt::Write as _;
26 use std::io::{BufRead, BufReader, Write};
27 use std::process::Stdio;
28 use std::sync::Arc;
29 use std::sync::atomic::{AtomicBool, Ordering};
30 use std::time::Duration;
31
32 use anyhow::Result;
33 use makeover::SemanticTokens;
34
35 use crate::audio::{self, Device};
36 use crate::cli::{CommandLog, child_command};
37 use crate::net::{self, Interface, Kind, State};
38 use crate::theme;
39
40 /// How long the loop sleeps between updates.
41 ///
42 /// The same second [`crate::shell::TICK`] uses, for the same reason: fast
43 /// enough that the clock is never visibly wrong, slow enough that a tick which
44 /// polls nothing costs nothing.
45 const TICK: Duration = Duration::from_secs(1);
46
47 /// Ticks between `nmcli` reads. An interface coming up or a network changing is
48 /// a human-scale event, and five seconds of staleness on it is not noticeable;
49 /// one spawn a second forever would be.
50 const NET_POLL_TICKS: u64 = 5;
51
52 /// Ticks between power reads. Cheaper than the others — sysfs, no subprocess —
53 /// but a battery percentage that moves faster than this would be lying about
54 /// its own resolution.
55 const POWER_POLL_TICKS: u64 = 5;
56
57 /// Ticks between re-resolving the theme.
58 ///
59 /// The bar outlives a day/night switch, which every other console verb does not:
60 /// they are opened, used and closed inside one mode. Re-reading means the bar
61 /// follows `alloy theme apply` rather than staying in the palette it was
62 /// launched in until the session ends.
63 const THEME_POLL_TICKS: u64 = 10;
64
65 /// Ticks between `pactl` reads when `pactl subscribe` is not available.
66 ///
67 /// The fallback cadence, not the normal one. With the subscription running,
68 /// volume is read on the event and this interval is a safety net for a
69 /// subscription that died quietly.
70 const AUDIO_POLL_TICKS: u64 = 10;
71
72 /// Where the kernel publishes battery and adapter state.
73 ///
74 /// Read directly rather than through `upower`, which is in the image and would
75 /// have worked. The kernel files are what upower itself reads, they need no
76 /// subprocess on a loop that runs all day, and they are readable on a machine
77 /// where the daemon has not come up yet.
78 const POWER_SUPPLY: &str = "/sys/class/power_supply";
79
80 /// Run the bar until stdout closes.
81 ///
82 /// Returns `Ok(())` when swaybar goes away, which is how this verb ends: sway
83 /// restarting, the session exiting, or the bar being reconfigured. A write
84 /// error on stdout is that, not a failure worth an error message nobody is
85 /// positioned to read.
86 pub(crate) fn run_bar() -> Result<()> {
87 let mut log = CommandLog::new();
88 let audio_backend = audio::detect();
89 let net_backend = net::detect();
90 let audio_changed = subscribe_audio();
91
92 let mut stdout = std::io::stdout().lock();
93
94 // The protocol's preamble: a header object, then an array that is never
95 // closed. Every update after this is one element of it.
96 writeln!(stdout, "{{\"version\":1}}")?;
97 writeln!(stdout, "[")?;
98
99 let mut tokens = theme::semantic(None).ok();
100 let mut power = read_power();
101 let mut network = read_network(net_backend.as_ref(), &mut log);
102 let mut volume = read_volume(audio_backend.as_ref(), &mut log);
103
104 for tick in 0u64.. {
105 if tick > 0 {
106 if tick % THEME_POLL_TICKS == 0 {
107 // Kept only when it loads. A theme deleted out from under a
108 // running bar leaves the last good palette in place, which is
109 // better than a bar that goes monochrome mid-session.
110 if let Ok(fresh) = theme::semantic(None) {
111 tokens = Some(fresh);
112 }
113 }
114 if tick % POWER_POLL_TICKS == 0 {
115 power = read_power();
116 }
117 if tick % NET_POLL_TICKS == 0 {
118 network = read_network(net_backend.as_ref(), &mut log);
119 }
120 let audio_due = audio_changed
121 .as_ref()
122 .is_some_and(|flag| flag.swap(false, Ordering::Relaxed))
123 || tick % AUDIO_POLL_TICKS == 0;
124 if audio_due {
125 volume = read_volume(audio_backend.as_ref(), &mut log);
126 }
127 }
128
129 let line = render(
130 tokens.as_ref(),
131 power.as_ref(),
132 network.as_ref(),
133 volume.as_ref(),
134 );
135 if writeln!(stdout, "{line},").is_err() || stdout.flush().is_err() {
136 return Ok(());
137 }
138
139 std::thread::sleep(TICK);
140 }
141
142 Ok(())
143 }
144
145 /// Watch `pactl subscribe` for sink events, so volume updates on the keypress.
146 ///
147 /// The alternative was reading `pactl` every second, which is what the bar
148 /// would have to do to keep up with the Fn keys, and which spends two spawns a
149 /// second all day to learn nothing almost every time. A subscription is one
150 /// long-lived process that says something only when there is something to say.
151 ///
152 /// Returns `None` when `pactl subscribe` will not start, which is the mock
153 /// backend's case and also a PipeWire that has not come up; the caller falls
154 /// back to [`AUDIO_POLL_TICKS`]. The thread is deliberately never joined: it
155 /// ends when the child's stdout closes, which is when the process this belongs
156 /// to is ending anyway.
157 fn subscribe_audio() -> Option<Arc<AtomicBool>> {
158 let mut child = child_command("pactl")
159 .arg("subscribe")
160 .stdin(Stdio::null())
161 .stdout(Stdio::piped())
162 .stderr(Stdio::null())
163 .spawn()
164 .ok()?;
165 let stdout = child.stdout.take()?;
166
167 let changed = Arc::new(AtomicBool::new(false));
168 let flag = Arc::clone(&changed);
169 std::thread::spawn(move || {
170 for line in BufReader::new(stdout).lines().map_while(Result::ok) {
171 // `pactl subscribe` narrates every object PipeWire touches: cards,
172 // clients, streams, modules. Only sink events can change what this
173 // bar shows, and matching on the noun keeps a busy playback session
174 // from re-reading `pactl` on every buffer.
175 if line.contains("on sink ") || line.contains("on server ") {
176 flag.store(true, Ordering::Relaxed);
177 }
178 }
179 // The child is reaped here rather than leaked: `pactl subscribe` exits
180 // when PipeWire restarts, and a bar that runs for a week would
181 // otherwise collect a zombie for each restart.
182 let _ = child.wait();
183 });
184
185 Some(changed)
186 }
187
188 /// A battery reading, as the bar states it.
189 struct Power {
190 percent: u8,
191 charging: bool,
192 /// Nothing is discharging and the battery is not below full: the machine is
193 /// on mains and topped up. Distinguished from `charging` because "100% AC"
194 /// and "94% charging" are different sentences.
195 full: bool,
196 }
197
198 /// Read the first battery the kernel lists, and whether it is charging.
199 ///
200 /// The first, not a sum across all of them. Both validated machines have one
201 /// battery; a machine with two would read low here, which is the safe direction
202 /// to be wrong in and is worth revisiting on the first machine that has two.
203 ///
204 /// `None` on a desktop, which is not an error: the bar drops the block rather
205 /// than showing a battery that does not exist. That is also what makes this
206 /// verb usable on the server profile.
207 fn read_power() -> Option<Power> {
208 let mut entries: Vec<_> = std::fs::read_dir(POWER_SUPPLY)
209 .ok()?
210 .filter_map(Result::ok)
211 .map(|entry| entry.path())
212 .collect();
213 // Directory order is not sorted, so BAT0 and BAT1 could arrive either way.
214 // Sorting makes "the first battery" mean the same thing on every boot.
215 entries.sort();
216
217 for path in entries {
218 let kind = std::fs::read_to_string(path.join("type")).ok()?;
219 if kind.trim() != "Battery" {
220 continue;
221 }
222 let percent = std::fs::read_to_string(path.join("capacity"))
223 .ok()
224 .and_then(|raw| raw.trim().parse::<u8>().ok())?;
225 // The kernel's own vocabulary, kept verbatim rather than mapped to a
226 // bool: "Not charging" is a real state (a plugged-in machine holding a
227 // charge limit) and it is neither charging nor discharging.
228 let status = std::fs::read_to_string(path.join("status")).unwrap_or_default();
229 let status = status.trim();
230 return Some(Power {
231 percent,
232 charging: status == "Charging",
233 full: status == "Full" || status == "Not charging",
234 });
235 }
236 None
237 }
238
239 /// The interface the bar names: the connected one, wireless first.
240 ///
241 /// Wireless first because a laptop that is on both is on wired for a reason the
242 /// user already knows about, and it is the wireless network whose name they
243 /// cannot otherwise see. Loopback is never a candidate — it is always up and
244 /// says nothing.
245 fn read_network(backend: &dyn net::Backend, log: &mut CommandLog) -> Option<Interface> {
246 let interfaces = log.quiet(|log| backend.list(log)).ok()?;
247 let candidates = interfaces
248 .into_iter()
249 .filter(|iface| iface.kind != Kind::Loopback && iface.state == State::Connected);
250 let mut best: Option<Interface> = None;
251 for iface in candidates {
252 let wins = best
253 .as_ref()
254 .is_none_or(|held| held.kind != Kind::Wireless && iface.kind == Kind::Wireless);
255 if wins {
256 best = Some(iface);
257 }
258 }
259 best
260 }
261
262 /// The default output device's volume and mute state.
263 ///
264 /// `None` covers both "no PipeWire" and "no default sink", which the bar treats
265 /// alike: it drops the block. A machine with no audio should not carry a block
266 /// reading `--`.
267 fn read_volume(backend: &dyn audio::Backend, log: &mut CommandLog) -> Option<Device> {
268 log.quiet(|log| backend.default_output(log)).ok().flatten()
269 }
270
271 /// One update: the JSON array swaybar reads as a full redraw of the line.
272 ///
273 /// Right to left in bar order, which is the reverse of urgency: the clock is
274 /// the thing the eye goes to by habit and sits at the end, and battery, the
275 /// block this verb was written for, sits furthest from it.
276 fn render(
277 tokens: Option<&SemanticTokens>,
278 power: Option<&Power>,
279 network: Option<&Interface>,
280 volume: Option<&Device>,
281 ) -> String {
282 let mut blocks: Vec<String> = Vec::new();
283
284 if let Some(power) = power {
285 let text = if power.charging {
286 format!("{}% charging", power.percent)
287 } else if power.full {
288 format!("{}% AC", power.percent)
289 } else {
290 format!("{}%", power.percent)
291 };
292 // The one place the bar uses a status color on its own text rather than
293 // on a marker beside it. docs/TOKENS.md's accent-on-glyph rule reserves
294 // `warning` and `danger` for glyphs and edge markers because Akari's
295 // amber only clears AA-UI on body text; a battery percentage is read as
296 // an indicator rather than as copy, which is the case that rule carves
297 // out. Worth revisiting if the bar ever grows a real glyph to hang it on.
298 let color = match () {
299 () if power.charging || power.full => "content",
300 () if power.percent <= 10 => "danger",
301 () if power.percent <= 20 => "warning",
302 () => "content",
303 };
304 blocks.push(block("battery", &text, color, tokens));
305 }
306
307 if let Some(iface) = network {
308 // The connection name, which for wifi is the SSID and for wired is
309 // whatever NetworkManager called the profile. The device name is the
310 // fallback rather than the first choice: `wlp166s0` identifies the card
311 // and answers no question anyone asks a bar.
312 let text = iface.connection.as_deref().unwrap_or(&iface.name);
313 blocks.push(block("network", text, "content", tokens));
314 }
315
316 if let Some(device) = volume {
317 let (text, color) = if device.muted {
318 (format!("vol {}% muted", device.volume), "content-muted")
319 } else {
320 (format!("vol {}%", device.volume), "content")
321 };
322 blocks.push(block("volume", &text, color, tokens));
323 }
324
325 blocks.push(block("clock", &clock(), "content", tokens));
326
327 format!("[{}]", blocks.join(","))
328 }
329
330 /// The date and time, formatted the way the placeholder did.
331 ///
332 /// `date +'%Y-%m-%d %H:%M'` is what the bar said before this verb, and the
333 /// format is kept rather than improved: the bar changing shape underneath
334 /// someone is the kind of churn a daily driver does not need, and ISO order is
335 /// already the right answer.
336 ///
337 /// Formatted by hand from the system clock. `chrono` and `time` are both real
338 /// crates and neither is a dependency of this one; a `date` spawn every second
339 /// is exactly the cost this module avoids everywhere else.
340 fn clock() -> String {
341 let now = std::time::SystemTime::now()
342 .duration_since(std::time::UNIX_EPOCH)
343 .map_or(0, |since| since.as_secs()) as i64;
344 let (year, month, day, hour, minute) = civil_from_unix(now + local_offset());
345 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
346 }
347
348 /// Seconds since the epoch to a civil date and time, in whatever zone the
349 /// caller already shifted them into.
350 ///
351 /// Takes shifted seconds rather than doing the shifting, so the calendar math
352 /// is testable against fixed instants without a test depending on the machine's
353 /// `TZ`. [`clock`] adds [`local_offset`] on the way in.
354 ///
355 /// The date algorithm is Howard Hinnant's `civil_from_days`, which is the
356 /// standard one and is where the constants come from: 719_468 shifts the epoch
357 /// to 0000-03-01 so leap days land at the end of the year and the month
358 /// arithmetic needs no special case for February.
359 fn civil_from_unix(seconds: i64) -> (i64, u32, u32, u32, u32) {
360 let days = seconds.div_euclid(86_400);
361 let secs = seconds.rem_euclid(86_400);
362
363 let z = days + 719_468;
364 let era = z.div_euclid(146_097);
365 let doe = z.rem_euclid(146_097);
366 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
367 let y = yoe + era * 400;
368 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
369 let mp = (5 * doy + 2) / 153;
370 let d = doy - (153 * mp + 2) / 5 + 1;
371 let m = if mp < 10 { mp + 3 } else { mp - 9 };
372 let y = if m <= 2 { y + 1 } else { y };
373
374 (
375 y,
376 m as u32,
377 d as u32,
378 (secs / 3600) as u32,
379 (secs / 60 % 60) as u32,
380 )
381 }
382
383 /// Seconds east of UTC for this machine, right now.
384 ///
385 /// Read from `/etc/localtime`'s TZif data rather than from libc, which this
386 /// crate does not link, and cached for the life of the process would be wrong:
387 /// a bar that runs across a DST boundary has to notice. So it is recomputed on
388 /// each call, which is once a second against a file the page cache has held
389 /// since the first read.
390 ///
391 /// Zero on anything unreadable, which shows UTC. A clock an hour off is a
392 /// visible, correctable wrong answer; a bar with no clock is not.
393 fn local_offset() -> i64 {
394 tzif_offset().unwrap_or(0)
395 }
396
397 /// Parse the current UTC offset out of `/etc/localtime`.
398 ///
399 /// TZif version 1 layout, which every version of the format still carries at
400 /// the front for exactly this reason: a v2 or v3 file repeats the data in
401 /// 64-bit form afterwards, and the v1 block stays valid through 2038. Reading
402 /// only the first block keeps this to one pass and no allocation past the file
403 /// itself.
404 fn tzif_offset() -> Option<i64> {
405 let data = std::fs::read("/etc/localtime").ok()?;
406 if data.len() < 44 || &data[..4] != b"TZif" {
407 return None;
408 }
409 let counts: Vec<u32> = (0..6)
410 .map(|i| {
411 let at = 20 + i * 4;
412 u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]])
413 })
414 .collect();
415 let (timecnt, typecnt) = (counts[3] as usize, counts[4] as usize);
416 if typecnt == 0 {
417 return None;
418 }
419
420 let times = 44;
421 let indices = times + timecnt * 4;
422 let types = indices + timecnt;
423
424 let now = std::time::SystemTime::now()
425 .duration_since(std::time::UNIX_EPOCH)
426 .ok()?
427 .as_secs() as i64;
428
429 // The last transition at or before now. Transitions are sorted, so walking
430 // forward and keeping the last match is enough; there are a few hundred of
431 // them in a zone with DST.
432 let mut chosen = 0usize;
433 for i in 0..timecnt {
434 let at = times + i * 4;
435 let when = i32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]) as i64;
436 if when > now {
437 break;
438 }
439 chosen = *data.get(indices + i)? as usize;
440 }
441 if chosen >= typecnt {
442 return None;
443 }
444
445 let at = types + chosen * 6;
446 let raw = data.get(at..at + 4)?;
447 Some(i32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]) as i64)
448 }
449
450 /// One block of the status line.
451 ///
452 /// `separator_block_width` is set narrower than swaybar's default so four
453 /// blocks fit a 12-inch panel without the line reading as four separate things.
454 /// The separator itself stays on: without it the blocks run together into one
455 /// string of numbers.
456 fn block(name: &str, text: &str, token: &str, tokens: Option<&SemanticTokens>) -> String {
457 let mut out = String::from("{\"name\":\"");
458 out.push_str(name);
459 out.push_str("\",\"full_text\":\"");
460 escape_into(&mut out, text);
461 out.push('"');
462 if let Some(hex) = tokens.and_then(|tokens| tokens.hex(token)) {
463 let _ = write!(out, ",\"color\":\"{hex}\"");
464 }
465 out.push_str(",\"separator_block_width\":14}");
466 out
467 }
468
469 /// JSON-escape into an existing buffer.
470 ///
471 /// Hand-rolled against `serde_json` because a block is four fixed keys and one
472 /// value, and building a map to serialize it would be more code than this. The
473 /// value is the one thing here that is not this module's own literal: an SSID
474 /// is user-controlled and can hold a quote or a backslash, which unescaped
475 /// would break the array and take the whole bar down with it.
476 fn escape_into(out: &mut String, text: &str) {
477 for ch in text.chars() {
478 match ch {
479 '"' => out.push_str("\\\""),
480 '\\' => out.push_str("\\\\"),
481 // Control characters have no business in a bar and no escape worth
482 // spelling out; dropped rather than emitted as \u00xx.
483 c if (c as u32) < 0x20 => {}
484 c => out.push(c),
485 }
486 }
487 }
488
489 #[cfg(test)]
490 mod tests {
491 use super::*;
492 use crate::audio::Direction;
493
494 fn power(percent: u8, charging: bool, full: bool) -> Power {
495 Power {
496 percent,
497 charging,
498 full,
499 }
500 }
501
502 fn tokens() -> SemanticTokens {
503 let dirs = crate::theme::search_path();
504 makeover::load_semantic(&dirs, crate::theme::DEFAULT_LIGHT).expect("a shipped theme loads")
505 }
506
507 // The reason the verb exists: a laptop on mains says so, and a laptop on
508 // battery says how much is left. The placeholder bar could say neither.
509 #[test]
510 fn the_battery_block_says_whether_it_is_charging() {
511 let line = render(None, Some(&power(64, true, false)), None, None);
512 assert!(line.contains("64% charging"), "{line}");
513
514 let line = render(None, Some(&power(100, false, true)), None, None);
515 assert!(line.contains("100% AC"), "{line}");
516
517 let line = render(None, Some(&power(64, false, false)), None, None);
518 assert!(line.contains("\"64%\""), "{line}");
519 }
520
521 // A desktop has no battery, and the server profile is a stated goal. The
522 // block is absent rather than present and empty.
523 #[test]
524 fn a_machine_with_no_battery_has_no_battery_block() {
525 let line = render(None, None, None, None);
526 assert!(!line.contains("battery"), "{line}");
527 assert!(line.contains("clock"), "the clock is unconditional: {line}");
528 }
529
530 // Low battery is the one state the bar has to be able to shout about, and
531 // `warning` and `danger` are the theme's words for it.
532 #[test]
533 fn a_low_battery_takes_the_status_colors() {
534 let tokens = tokens();
535 let danger = tokens.hex("danger").expect("a theme has a danger token");
536 let warning = tokens.hex("warning").expect("a theme has a warning token");
537
538 let line = render(Some(&tokens), Some(&power(8, false, false)), None, None);
539 assert!(line.contains(danger), "8% is danger: {line}");
540
541 let line = render(Some(&tokens), Some(&power(18, false, false)), None, None);
542 assert!(line.contains(warning), "18% is warning: {line}");
543
544 // Plugged in at 8% is not an emergency, it is the fix in progress.
545 let line = render(Some(&tokens), Some(&power(8, true, false)), None, None);
546 assert!(!line.contains(danger), "charging is not danger: {line}");
547 }
548
549 // docs/TOKENS.md: no hex in Rust. Without a theme the blocks carry no color
550 // key at all rather than falling back to one written here.
551 #[test]
552 fn no_theme_means_no_color_rather_than_a_hard_coded_one() {
553 let line = render(None, Some(&power(50, false, false)), None, None);
554 assert!(!line.contains("color"), "{line}");
555 assert!(!line.contains('#'), "{line}");
556 }
557
558 // An SSID is user-controlled. A quote in one would close the string and
559 // take down the whole bar, not just the block.
560 #[test]
561 fn an_ssid_cannot_break_the_json() {
562 let iface = Interface {
563 name: "wlp166s0".into(),
564 kind: Kind::Wireless,
565 state: State::Connected,
566 connection: Some("say \"hi\"\\ then\nnewline".into()),
567 addresses: Vec::new(),
568 };
569 let line = render(None, None, Some(&iface), None);
570 let parsed: serde_json::Value =
571 serde_json::from_str(&line).unwrap_or_else(|e| panic!("{e} in {line}"));
572 let text = parsed[0]["full_text"].as_str().expect("a string survived");
573 // The quotes and the backslash come through; the newline is dropped
574 // rather than escaped, because a bar has no second line to put it on.
575 assert_eq!(text, "say \"hi\"\\ thennewline");
576 }
577
578 // Every update swaybar reads has to be a JSON array of objects; a block
579 // gaining a field should not be able to break that silently.
580 #[test]
581 fn an_update_is_a_json_array() {
582 let device = Device {
583 index: 1,
584 name: "sink".into(),
585 description: "Analog".into(),
586 direction: Direction::Output,
587 volume: 40,
588 muted: true,
589 is_default: true,
590 };
591 let iface = Interface {
592 name: "wlp166s0".into(),
593 kind: Kind::Wireless,
594 state: State::Connected,
595 connection: Some("home".into()),
596 addresses: Vec::new(),
597 };
598 let line = render(
599 Some(&tokens()),
600 Some(&power(64, false, false)),
601 Some(&iface),
602 Some(&device),
603 );
604 let parsed: serde_json::Value = serde_json::from_str(&line).expect("valid JSON");
605 let blocks = parsed.as_array().expect("an array");
606 assert_eq!(blocks.len(), 4, "battery, network, volume, clock");
607 for block in blocks {
608 assert!(block["name"].is_string(), "{block}");
609 assert!(block["full_text"].is_string(), "{block}");
610 }
611 }
612
613 // The interface the bar names is the wireless one, because the wired name
614 // is the one the user can already infer from the cable.
615 #[test]
616 fn wireless_wins_when_both_are_connected() {
617 let wired = Interface {
618 name: "enp0s13f0u1".into(),
619 kind: Kind::Wired,
620 state: State::Connected,
621 connection: Some("dock".into()),
622 addresses: Vec::new(),
623 };
624 let wireless = Interface {
625 name: "wlp166s0".into(),
626 kind: Kind::Wireless,
627 state: State::Connected,
628 connection: Some("home".into()),
629 addresses: Vec::new(),
630 };
631 let mut log = CommandLog::new();
632 let backend = Fixture(vec![wired, wireless]);
633 let picked = read_network(&backend, &mut log).expect("one is connected");
634 assert_eq!(picked.kind, Kind::Wireless);
635
636 // Order in nmcli's output must not decide it.
637 let backend = Fixture(vec![
638 Interface {
639 name: "wlp166s0".into(),
640 kind: Kind::Wireless,
641 state: State::Connected,
642 connection: Some("home".into()),
643 addresses: Vec::new(),
644 },
645 Interface {
646 name: "enp0s13f0u1".into(),
647 kind: Kind::Wired,
648 state: State::Connected,
649 connection: Some("dock".into()),
650 addresses: Vec::new(),
651 },
652 ]);
653 let picked = read_network(&backend, &mut log).expect("one is connected");
654 assert_eq!(picked.kind, Kind::Wireless);
655 }
656
657 // Loopback is always up and names nothing, so a machine that is genuinely
658 // offline has no network block rather than one reading `lo`.
659 #[test]
660 fn loopback_is_not_a_network() {
661 let backend = Fixture(vec![Interface {
662 name: "lo".into(),
663 kind: Kind::Loopback,
664 state: State::Connected,
665 connection: None,
666 addresses: Vec::new(),
667 }]);
668 let mut log = CommandLog::new();
669 assert!(read_network(&backend, &mut log).is_none());
670 }
671
672 struct Fixture(Vec<Interface>);
673
674 impl net::Backend for Fixture {
675 fn name(&self) -> &'static str {
676 "fixture"
677 }
678 fn list(&self, _log: &mut CommandLog) -> Result<Vec<Interface>> {
679 Ok(self.0.clone())
680 }
681 }
682
683 // The clock is hand-rolled calendar math against no dependency, which makes
684 // it the part of this module most able to be quietly wrong. Fixed instants,
685 // already shifted, so the assertions do not depend on the machine's zone.
686 #[test]
687 fn the_clock_converts_fixed_instants() {
688 for (seconds, expect) in [
689 (0, (1970, 1, 1, 0, 0)),
690 // A leap day, the case the month arithmetic exists to handle.
691 (1_709_210_040, (2024, 2, 29, 12, 34)),
692 // The last second of a year, where the day-of-year rolls over.
693 (1_735_689_599, (2024, 12, 31, 23, 59)),
694 // West of UTC: the shift takes the instant into the previous day.
695 (-3600, (1969, 12, 31, 23, 0)),
696 ] {
697 assert_eq!(civil_from_unix(seconds), expect, "at {seconds}");
698 }
699 }
700
701 // The offset has to be a plausible zone rather than a parse that drifted:
702 // TZif's records are signed seconds east of UTC and every real zone is
703 // inside a day of it.
704 #[test]
705 fn the_local_offset_is_a_real_zone() {
706 let offset = local_offset();
707 assert!(
708 (-86_400..=86_400).contains(&offset),
709 "offset {offset} is not a zone",
710 );
711 // Whole minutes. Every zone in the TZ database has been since 1972, and
712 // a fractional answer means the record boundary was misread.
713 assert_eq!(offset % 60, 0, "offset {offset} is not on a minute");
714 }
715 }
716