Skip to main content

coreutils_rs/who/
core.rs

1/// who — show who is logged on
2///
3/// Reads utmpx records and displays information about currently logged-in users,
4/// boot time, dead processes, run level, etc.
5use std::ffi::CStr;
6use std::fmt::Write as FmtWrite;
7
8// utmpx entry type constants (from utmpx.h)
9const RUN_LVL: i16 = 1;
10const BOOT_TIME: i16 = 2;
11const NEW_TIME: i16 = 3;
12const OLD_TIME: i16 = 4;
13const INIT_PROCESS: i16 = 5;
14const LOGIN_PROCESS: i16 = 6;
15const USER_PROCESS: i16 = 7;
16const DEAD_PROCESS: i16 = 8;
17
18/// A decoded utmpx entry.
19#[derive(Clone, Debug)]
20pub struct UtmpxEntry {
21    pub ut_type: i16,
22    pub ut_pid: i32,
23    pub ut_line: String,
24    pub ut_id: String,
25    pub ut_user: String,
26    pub ut_host: String,
27    pub ut_tv_sec: i64,
28}
29
30/// Guess the pty name that was opened for a given UID right after the given
31/// start time (in microseconds since epoch). Matches the GNU coreutils
32/// `guess_pty_name()` algorithm: scan `/dev/pts/`, find the entry owned by
33/// `uid` whose ctime is >= start_time and closest to it (within 5 seconds).
34/// Returns e.g. "pts/0".
35fn guess_pty_name(uid: u32, start_us: u64) -> Option<String> {
36    let start_sec = (start_us / 1_000_000) as i64;
37    let start_nsec = ((start_us % 1_000_000) * 1000) as i64;
38
39    let dir = std::fs::read_dir("/dev/pts").ok()?;
40
41    let mut best_name: Option<String> = None;
42    let mut best_sec: i64 = 0;
43    let mut best_nsec: i64 = 0;
44
45    for entry in dir.flatten() {
46        let name = entry.file_name();
47        let name_str = name.to_string_lossy();
48        // Skip . entries and ptmx
49        if name_str.starts_with('.') || name_str == "ptmx" {
50            continue;
51        }
52
53        let path = format!("/dev/pts/{}", name_str);
54        let c_path = match std::ffi::CString::new(path.as_str()) {
55            Ok(p) => p,
56            Err(_) => continue,
57        };
58
59        let mut stat_buf: libc::stat = unsafe { std::mem::zeroed() };
60        let rc = unsafe { libc::stat(c_path.as_ptr(), &mut stat_buf) };
61        if rc != 0 {
62            continue;
63        }
64
65        // Must be owned by the session's UID
66        if stat_buf.st_uid != uid {
67            continue;
68        }
69
70        let ct_sec = stat_buf.st_ctime;
71        let ct_nsec = stat_buf.st_ctime_nsec;
72
73        // ctime must be >= start_time
74        if ct_sec < start_sec || (ct_sec == start_sec && ct_nsec < start_nsec) {
75            continue;
76        }
77
78        // Is this the best (earliest) candidate so far?
79        if best_name.is_none() || ct_sec < best_sec || (ct_sec == best_sec && ct_nsec < best_nsec) {
80            best_name = Some(format!("pts/{}", name_str));
81            best_sec = ct_sec;
82            best_nsec = ct_nsec;
83        }
84    }
85
86    // Must be within 5 seconds of the start time
87    if let Some(ref _name) = best_name {
88        if best_sec > start_sec + 5 || (best_sec == start_sec + 5 && best_nsec > start_nsec) {
89            return None;
90        }
91    }
92
93    best_name
94}
95
96/// Read session entries from systemd-logind session files.
97/// Used as a fallback when the traditional utmpx database has no USER_PROCESS
98/// entries. Implements the same algorithm as GNU coreutils' read_utmp_from_systemd().
99///
100/// If `check_pids` is true (used by who/users), filter out entries whose
101/// leader PID is no longer alive. If false (used by pinky), include all
102/// active sessions regardless of PID state.
103fn read_systemd_sessions(check_pids: bool) -> Vec<UtmpxEntry> {
104    let sessions_dir = std::path::Path::new("/run/systemd/sessions");
105    if !sessions_dir.exists() {
106        return Vec::new();
107    }
108
109    let mut entries = Vec::new();
110
111    let dir = match std::fs::read_dir(sessions_dir) {
112        Ok(d) => d,
113        Err(_) => return Vec::new(),
114    };
115
116    for entry in dir.flatten() {
117        let path = entry.path();
118        // Skip .ref files and other non-session files
119        if path.extension().is_some() {
120            continue;
121        }
122        let content = match std::fs::read_to_string(&path) {
123            Ok(c) => c,
124            Err(_) => continue,
125        };
126
127        let mut user = String::new();
128        let mut remote_host = String::new();
129        let mut service = String::new();
130        let mut realtime_us: u64 = 0;
131        let mut uid: u32 = 0;
132        let mut leader_pid: i32 = 0;
133        let mut active = false;
134        let mut session_type = String::new();
135        let mut session_class = String::new();
136        let mut session_id = String::new();
137
138        // Use filename as session ID
139        if let Some(fname) = path.file_name() {
140            session_id = fname.to_string_lossy().into_owned();
141        }
142
143        for line in content.lines() {
144            if let Some(val) = line.strip_prefix("USER=") {
145                user = val.to_string();
146            } else if let Some(val) = line.strip_prefix("REMOTE_HOST=") {
147                remote_host = val.to_string();
148            } else if let Some(val) = line.strip_prefix("SERVICE=") {
149                service = val.to_string();
150            } else if let Some(val) = line.strip_prefix("REALTIME=") {
151                realtime_us = val.parse().unwrap_or(0);
152            } else if let Some(val) = line.strip_prefix("UID=") {
153                uid = val.parse().unwrap_or(0);
154            } else if let Some(val) = line.strip_prefix("LEADER=") {
155                leader_pid = val.parse().unwrap_or(0);
156            } else if line == "ACTIVE=1" {
157                active = true;
158            } else if let Some(val) = line.strip_prefix("TYPE=") {
159                session_type = val.to_string();
160            } else if let Some(val) = line.strip_prefix("CLASS=") {
161                session_class = val.to_string();
162            }
163        }
164
165        // Skip inactive sessions
166        if !active || user.is_empty() {
167            continue;
168        }
169
170        // When check_pids is set (who/users), verify the leader PID is alive
171        // This matches GNU's READ_UTMP_CHECK_PIDS behavior
172        if check_pids && leader_pid > 0 {
173            let pid_alive = unsafe { libc::kill(leader_pid, 0) };
174            if pid_alive < 0 {
175                let err = std::io::Error::last_os_error();
176                if err.raw_os_error() == Some(libc::ESRCH) {
177                    continue; // Process does not exist
178                }
179            }
180        }
181
182        // Determine entry type: "manager" class → LOGIN_PROCESS, else USER_PROCESS
183        let entry_type = if session_class.starts_with("manager") {
184            LOGIN_PROCESS
185        } else {
186            USER_PROCESS
187        };
188
189        // Skip non-user, non-manager classes
190        if session_class != "user" && !session_class.starts_with("manager") {
191            continue;
192        }
193
194        // Determine TTY line (matching GNU coreutils algorithm)
195        let tty = if session_type == "tty" {
196            // Try to guess the pty device from /dev/pts/
197            let pty = guess_pty_name(uid, realtime_us);
198            match (service.is_empty(), pty) {
199                (false, Some(pty_name)) => format!("{} {}", service, pty_name),
200                (false, None) => service.clone(),
201                (true, Some(pty_name)) => pty_name,
202                (true, None) => continue, // No seat and no tty, skip
203            }
204        } else if session_type == "web" {
205            if service.is_empty() {
206                continue;
207            }
208            service.clone()
209        } else {
210            continue; // Unrecognized session type
211        };
212
213        let tv_sec = (realtime_us / 1_000_000) as i64;
214
215        entries.push(UtmpxEntry {
216            ut_type: entry_type,
217            ut_pid: leader_pid,
218            ut_line: tty,
219            ut_id: session_id,
220            ut_user: user,
221            ut_host: remote_host,
222            ut_tv_sec: tv_sec,
223        });
224    }
225
226    // Sort by realtime for consistent ordering
227    entries.sort_by_key(|e| e.ut_tv_sec);
228    entries
229}
230
231/// Read all utmpx entries from the system database.
232///
233/// # Safety
234/// Uses libc's setutxent/getutxent/endutxent which are not thread-safe.
235/// This function must not be called concurrently.
236pub fn read_utmpx() -> Vec<UtmpxEntry> {
237    let mut entries = Vec::new();
238
239    unsafe {
240        libc::setutxent();
241        loop {
242            let entry = libc::getutxent();
243            if entry.is_null() {
244                break;
245            }
246            let e = &*entry;
247
248            let user = cstr_from_buf(&e.ut_user);
249            let line = cstr_from_buf(&e.ut_line);
250            let host = cstr_from_buf(&e.ut_host);
251            let id = cstr_from_buf(&e.ut_id);
252
253            let tv_sec = e.ut_tv.tv_sec as i64;
254
255            entries.push(UtmpxEntry {
256                ut_type: e.ut_type as i16,
257                ut_pid: e.ut_pid,
258                ut_line: line,
259                ut_id: id,
260                ut_user: user,
261                ut_host: host,
262                ut_tv_sec: tv_sec,
263            });
264        }
265        libc::endutxent();
266    }
267
268    entries
269}
270
271/// Read utmpx entries, falling back to systemd sessions if the utmpx database
272/// has no USER_PROCESS entries. This matches GNU coreutils behavior: when
273/// /var/run/utmp is absent or empty, use systemd-logind session files.
274///
275/// If `check_pids` is true (who/users), filter out entries whose leader PID
276/// is dead (matches GNU's READ_UTMP_CHECK_PIDS). If false (pinky), include
277/// all active sessions.
278pub fn read_utmpx_with_systemd_fallback_ex(check_pids: bool) -> Vec<UtmpxEntry> {
279    let mut entries = read_utmpx();
280
281    // If check_pids is set, filter traditional utmpx entries by PID too
282    if check_pids {
283        entries.retain(|e| {
284            if e.ut_type == USER_PROCESS && e.ut_pid > 0 {
285                let rc = unsafe { libc::kill(e.ut_pid, 0) };
286                if rc < 0 {
287                    let err = std::io::Error::last_os_error();
288                    if err.raw_os_error() == Some(libc::ESRCH) {
289                        return false;
290                    }
291                }
292            }
293            true
294        });
295    }
296
297    let has_user_entries = entries.iter().any(|e| e.ut_type == USER_PROCESS);
298    if !has_user_entries {
299        let systemd_entries = read_systemd_sessions(check_pids);
300        entries.extend(systemd_entries);
301    }
302    entries
303}
304
305/// Read utmpx with systemd fallback, checking PIDs (for who/users).
306pub fn read_utmpx_with_systemd_fallback() -> Vec<UtmpxEntry> {
307    read_utmpx_with_systemd_fallback_ex(true)
308}
309
310/// Read utmpx with systemd fallback, without PID checking (for pinky).
311pub fn read_utmpx_with_systemd_fallback_no_pid_check() -> Vec<UtmpxEntry> {
312    read_utmpx_with_systemd_fallback_ex(false)
313}
314
315/// Extract a Rust String from a fixed-size C char buffer.
316unsafe fn cstr_from_buf(buf: &[libc::c_char]) -> String {
317    // Find the first NUL byte or use the entire buffer length
318    let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
319    let bytes: Vec<u8> = buf[..len].iter().map(|&c| c as u8).collect();
320    String::from_utf8_lossy(&bytes).into_owned()
321}
322
323/// Configuration for the who command, derived from CLI flags.
324#[derive(Clone, Debug, Default)]
325pub struct WhoConfig {
326    pub show_boot: bool,
327    pub show_dead: bool,
328    pub show_heading: bool,
329    pub show_login: bool,
330    pub only_current: bool,      // -m
331    pub show_init_spawn: bool,   // -p
332    pub show_count: bool,        // -q
333    pub show_runlevel: bool,     // -r
334    pub short_format: bool,      // -s (default)
335    pub show_clock_change: bool, // -t
336    pub show_mesg: bool,         // -T, -w
337    pub show_users: bool,        // -u
338    pub show_all: bool,          // -a
339    pub show_ips: bool,          // --ips
340    pub show_lookup: bool,       // --lookup
341    pub am_i: bool,              // "who am i"
342}
343
344impl WhoConfig {
345    /// Apply the --all flag: equivalent to -b -d --login -p -r -t -T -u.
346    pub fn apply_all(&mut self) {
347        self.show_boot = true;
348        self.show_dead = true;
349        self.show_login = true;
350        self.show_init_spawn = true;
351        self.show_runlevel = true;
352        self.show_clock_change = true;
353        self.show_mesg = true;
354        self.show_users = true;
355    }
356
357    /// Returns true if no specific filter flags are set,
358    /// meaning only USER_PROCESS entries should be shown (default behavior).
359    pub fn is_default_filter(&self) -> bool {
360        !self.show_boot
361            && !self.show_dead
362            && !self.show_login
363            && !self.show_init_spawn
364            && !self.show_runlevel
365            && !self.show_clock_change
366            && !self.show_users
367    }
368}
369
370/// Format a Unix timestamp as "YYYY-MM-DD HH:MM".
371pub fn format_time(tv_sec: i64) -> String {
372    if tv_sec == 0 {
373        return String::new();
374    }
375    let t = tv_sec as libc::time_t;
376    let tm = unsafe {
377        let mut tm: libc::tm = std::mem::zeroed();
378        libc::localtime_r(&t, &mut tm);
379        tm
380    };
381    format!(
382        "{:04}-{:02}-{:02} {:02}:{:02}",
383        tm.tm_year + 1900,
384        tm.tm_mon + 1,
385        tm.tm_mday,
386        tm.tm_hour,
387        tm.tm_min,
388    )
389}
390
391/// Extract the actual device path from a ut_line field.
392/// For systemd-logind entries, ut_line may be "sshd pts/0" (service + space + tty).
393/// For traditional utmpx entries, it's just "pts/0" or "tty1".
394fn extract_device_path(line: &str) -> Option<String> {
395    if line.is_empty() {
396        return None;
397    }
398    // For lines like "sshd pts/0", extract "pts/0"
399    let tty_part = if let Some(idx) = line.find("pts/") {
400        &line[idx..]
401    } else if let Some(idx) = line.find("tty") {
402        &line[idx..]
403    } else if line.starts_with('/') {
404        return Some(line.to_string());
405    } else {
406        line
407    };
408    if tty_part.starts_with('/') {
409        Some(tty_part.to_string())
410    } else {
411        Some(format!("/dev/{}", tty_part))
412    }
413}
414
415/// Determine the message status character for a terminal line.
416/// '+' means writable (mesg y), '-' means not writable (mesg n), '?' means unknown.
417fn mesg_status(line: &str) -> char {
418    let dev_path = match extract_device_path(line) {
419        Some(p) => p,
420        None => return '?',
421    };
422
423    let mut stat_buf: libc::stat = unsafe { std::mem::zeroed() };
424    let c_path = std::ffi::CString::new(dev_path).unwrap_or_default();
425    let rc = unsafe { libc::stat(c_path.as_ptr(), &mut stat_buf) };
426    if rc != 0 {
427        return '?';
428    }
429    if stat_buf.st_mode & libc::S_IWGRP != 0 {
430        '+'
431    } else {
432        '-'
433    }
434}
435
436/// Compute idle time string for a terminal.
437/// Returns "." if active within the last minute, "old" if more than 24h,
438/// or "HH:MM" otherwise.
439fn idle_str(line: &str) -> String {
440    let dev_path = match extract_device_path(line) {
441        Some(p) => p,
442        None => return "?".to_string(),
443    };
444
445    let mut stat_buf: libc::stat = unsafe { std::mem::zeroed() };
446    let c_path = std::ffi::CString::new(dev_path).unwrap_or_default();
447    let rc = unsafe { libc::stat(c_path.as_ptr(), &mut stat_buf) };
448    if rc != 0 {
449        return "?".to_string();
450    }
451
452    let now = unsafe { libc::time(std::ptr::null_mut()) };
453    let atime = stat_buf.st_atime;
454    let idle_secs = now - atime;
455
456    if idle_secs < 60 {
457        ".".to_string()
458    } else if idle_secs >= 86400 {
459        "old".to_string()
460    } else {
461        let hours = idle_secs / 3600;
462        let mins = (idle_secs % 3600) / 60;
463        format!("{:02}:{:02}", hours, mins)
464    }
465}
466
467/// Get the terminal device for the current process (for "who am i" / -m).
468pub fn current_tty() -> Option<String> {
469    unsafe {
470        let name = libc::ttyname(0); // stdin
471        if name.is_null() {
472            None
473        } else {
474            let s = CStr::from_ptr(name).to_string_lossy().into_owned();
475            // Strip /dev/ prefix to match utmpx ut_line
476            Some(s.strip_prefix("/dev/").unwrap_or(&s).to_string())
477        }
478    }
479}
480
481/// Check if an entry should be displayed given the config.
482pub fn should_show(entry: &UtmpxEntry, config: &WhoConfig) -> bool {
483    if config.am_i || config.only_current {
484        // Only show entries matching the current terminal
485        if let Some(tty) = current_tty() {
486            // For systemd entries, ut_line may be "sshd pts/0" — match if it contains the tty
487            return entry.ut_type == USER_PROCESS
488                && (entry.ut_line == tty || entry.ut_line.ends_with(&format!(" {}", tty)));
489        }
490        return false;
491    }
492
493    if config.show_count {
494        return entry.ut_type == USER_PROCESS;
495    }
496
497    if config.is_default_filter() {
498        return entry.ut_type == USER_PROCESS;
499    }
500
501    match entry.ut_type {
502        BOOT_TIME => config.show_boot,
503        DEAD_PROCESS => config.show_dead,
504        LOGIN_PROCESS => config.show_login,
505        INIT_PROCESS => config.show_init_spawn,
506        RUN_LVL => config.show_runlevel,
507        NEW_TIME | OLD_TIME => config.show_clock_change,
508        USER_PROCESS => config.show_users || config.is_default_filter(),
509        _ => false,
510    }
511}
512
513/// Format a single utmpx entry as an output line.
514pub fn format_entry(entry: &UtmpxEntry, config: &WhoConfig) -> String {
515    let mut out = String::new();
516
517    // Determine name and line based on entry type
518    let (name, line) = match entry.ut_type {
519        BOOT_TIME => (String::new(), "system boot".to_string()),
520        RUN_LVL => {
521            let current = (entry.ut_pid & 0xFF) as u8 as char;
522            (String::new(), format!("run-level {}", current))
523        }
524        LOGIN_PROCESS => ("LOGIN".to_string(), entry.ut_line.clone()),
525        NEW_TIME => (String::new(), entry.ut_line.clone()),
526        OLD_TIME => (String::new(), entry.ut_line.clone()),
527        _ => (entry.ut_user.clone(), entry.ut_line.clone()),
528    };
529
530    // NAME column (left-aligned, 8 chars min)
531    let _ = write!(out, "{:<8}", name);
532
533    // Mesg status column
534    if config.show_mesg {
535        let status = if entry.ut_type == USER_PROCESS {
536            mesg_status(&entry.ut_line)
537        } else {
538            // BOOT_TIME, RUN_LVL, LOGIN_PROCESS, DEAD_PROCESS, etc: show space
539            ' '
540        };
541        let _ = write!(out, " {}", status);
542    }
543
544    // LINE column
545    let _ = write!(out, " {:<12}", line);
546
547    // TIME column
548    let time_str = format_time(entry.ut_tv_sec);
549    let _ = write!(out, " {}", time_str);
550
551    // IDLE + PID for -u, -a, or -l (login process entries)
552    if config.show_users || config.show_all || config.show_login {
553        match entry.ut_type {
554            USER_PROCESS => {
555                let idle = idle_str(&entry.ut_line);
556                let _ = write!(out, " {:>5}", idle);
557                let _ = write!(out, " {:>11}", entry.ut_pid);
558            }
559            LOGIN_PROCESS => {
560                let _ = write!(out, " {:>5} {:>11}", " ", entry.ut_pid);
561            }
562            DEAD_PROCESS => {
563                let _ = write!(out, "      {:>10}", entry.ut_pid);
564            }
565            _ => {}
566        }
567    }
568
569    // For LOGIN_PROCESS, always show id
570    if entry.ut_type == LOGIN_PROCESS {
571        let _ = write!(out, " id={}", entry.ut_id);
572    }
573
574    // COMMENT (host) column — skip host for boot/runlevel entries (GNU compat)
575    if !entry.ut_host.is_empty() && entry.ut_type != BOOT_TIME && entry.ut_type != RUN_LVL {
576        if config.show_ips {
577            let _ = write!(out, " ({})", entry.ut_host);
578        } else if config.show_lookup {
579            let resolved = lookup_host(&entry.ut_host);
580            let _ = write!(out, " ({})", resolved);
581        } else {
582            let _ = write!(out, " ({})", entry.ut_host);
583        }
584    }
585
586    out
587}
588
589/// Attempt to resolve a hostname via DNS. Falls back to original on failure.
590fn lookup_host(host: &str) -> String {
591    let c_host = match std::ffi::CString::new(host) {
592        Ok(s) => s,
593        Err(_) => return host.to_string(),
594    };
595
596    unsafe {
597        let mut hints: libc::addrinfo = std::mem::zeroed();
598        hints.ai_flags = libc::AI_CANONNAME;
599        hints.ai_family = libc::AF_UNSPEC;
600
601        let mut result: *mut libc::addrinfo = std::ptr::null_mut();
602        let rc = libc::getaddrinfo(c_host.as_ptr(), std::ptr::null(), &hints, &mut result);
603        if rc != 0 || result.is_null() {
604            return host.to_string();
605        }
606
607        let canonical = if !(*result).ai_canonname.is_null() {
608            CStr::from_ptr((*result).ai_canonname)
609                .to_string_lossy()
610                .into_owned()
611        } else {
612            host.to_string()
613        };
614
615        libc::freeaddrinfo(result);
616        canonical
617    }
618}
619
620/// Format output for the -q / --count mode.
621pub fn format_count(entries: &[UtmpxEntry]) -> String {
622    let users: Vec<&str> = entries
623        .iter()
624        .filter(|e| e.ut_type == USER_PROCESS)
625        .map(|e| e.ut_user.as_str())
626        .collect();
627
628    let mut out = String::new();
629    let _ = writeln!(out, "{}", users.join(" "));
630    let _ = write!(out, "# users={}", users.len());
631    out
632}
633
634/// Format heading line.
635pub fn format_heading(config: &WhoConfig) -> String {
636    let mut out = String::new();
637    let _ = write!(out, "{:<8}", "NAME");
638    if config.show_mesg {
639        let _ = write!(out, " S");
640    }
641    let _ = write!(out, " {:<12}", "LINE");
642    let _ = write!(out, " {:<16}", "TIME");
643    if config.show_users || config.show_all {
644        let _ = write!(out, " {:<6}", "IDLE");
645        let _ = write!(out, " {:>10}", "PID");
646    }
647    let _ = write!(out, " {}", "COMMENT");
648    out
649}
650
651/// Read boot time from /proc/stat (Linux-specific fallback).
652/// Returns the boot timestamp in seconds since epoch, or None if unavailable.
653#[cfg(target_os = "linux")]
654fn read_boot_time_from_proc() -> Option<i64> {
655    let data = std::fs::read_to_string("/proc/stat").ok()?;
656    for line in data.lines() {
657        if let Some(val) = line.strip_prefix("btime ") {
658            return val.trim().parse::<i64>().ok();
659        }
660    }
661    None
662}
663
664#[cfg(not(target_os = "linux"))]
665fn read_boot_time_from_proc() -> Option<i64> {
666    None
667}
668
669/// Run the who command and return the formatted output.
670pub fn run_who(config: &WhoConfig) -> String {
671    let mut entries = read_utmpx_with_systemd_fallback();
672
673    // If no BOOT_TIME entry was found in utmpx (common in containers and some
674    // Linux configurations), synthesize one from /proc/stat btime.
675    if !entries.iter().any(|e| e.ut_type == BOOT_TIME) {
676        if let Some(btime) = read_boot_time_from_proc() {
677            entries.push(UtmpxEntry {
678                ut_type: BOOT_TIME,
679                ut_pid: 0,
680                ut_line: String::new(),
681                ut_id: String::new(),
682                ut_user: String::new(),
683                ut_host: String::new(),
684                ut_tv_sec: btime,
685            });
686        }
687    }
688
689    // Sort entries by time (oldest first) to match utmpx file order (boot before sessions).
690    entries.sort_by_key(|e| e.ut_tv_sec);
691
692    let mut output = String::new();
693
694    if config.show_count {
695        return format_count(&entries);
696    }
697
698    if config.show_heading {
699        let _ = writeln!(output, "{}", format_heading(config));
700    }
701
702    for entry in &entries {
703        if should_show(entry, config) {
704            let _ = writeln!(output, "{}", format_entry(entry, config));
705        }
706    }
707
708    // Remove trailing newline for consistency
709    if output.ends_with('\n') {
710        output.pop();
711    }
712
713    output
714}