diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/linux.yml similarity index 71% rename from .github/workflows/rust-ci.yml rename to .github/workflows/linux.yml index 994107a..36bca0c 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/linux.yml @@ -1,7 +1,7 @@ # Copyright (c) 2026 l5yth # SPDX-License-Identifier: Apache-2.0 -name: Rust CI +name: Linux on: push: @@ -10,56 +10,7 @@ on: branches: [main] jobs: - ci: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - - name: Cargo check - run: cargo check --all --all-features - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Cargo test - run: cargo test --all --all-features --verbose - - - name: Cargo clippy - run: cargo clippy --all-targets --all-features -- -D warnings - - coverage: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Generate lcov coverage - run: cargo llvm-cov --workspace --lcov --output-path lcov.info - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: lcov.info - fail_ci_if_error: false - - archlinux-package: + pkgbuild: runs-on: ubuntu-latest container: image: archlinux:latest @@ -78,7 +29,7 @@ jobs: chown -R builder:builder "${GITHUB_WORKSPACE}" su - builder -c "cd '${GITHUB_WORKSPACE}/packaging/archlinux' && sed -i \"s|^source=.*$|source=(\\\"lsu::git+https://github.com/${GITHUB_REPOSITORY}.git#commit=${GITHUB_SHA}\\\")|\" PKGBUILD && makepkg --syncdeps --noconfirm --cleanbuild --clean --install" - gentoo-ebuild: + ebuild: runs-on: ubuntu-latest container: image: gentoo/stage3:latest diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..dd1fe66 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,60 @@ +# Copyright (c) 2026 l5yth +# SPDX-License-Identifier: Apache-2.0 + +name: Rust + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Cargo check + run: cargo check --all --all-features + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Cargo test + run: cargo test --all --all-features --verbose + + - name: Cargo clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + coverage: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Generate lcov coverage + run: cargo llvm-cov --workspace --lcov --output-path lcov.info + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: lcov.info + fail_ci_if_error: false diff --git a/Cargo.toml b/Cargo.toml index c05797b..fef6205 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,12 @@ name = "lsu" version = "0.1.0" edition = "2024" license = "Apache-2.0" +description = "Terminal UI for systemd services and their journal" +readme = "README.md" +repository = "https://github.com/l5yth/lsu" +homepage = "https://github.com/l5yth/lsu" +keywords = ["systemd", "tui", "linux"] +categories = ["command-line-utilities"] [dependencies] anyhow = "1" diff --git a/LICENSE b/LICENSE index 9335787..3d3bb26 100644 --- a/LICENSE +++ b/LICENSE @@ -33,7 +33,7 @@ and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a + Object form, made available under the Licen2se, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). @@ -175,4 +175,27 @@ END OF TERMS AND CONDITIONS - Copyright 2026 l5yth + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 6d885e3..4c162e9 100644 --- a/README.md +++ b/README.md @@ -3,24 +3,43 @@ # lsu -`lsu` is a Rust terminal UI for viewing `systemd` service units and their latest log line. +`lsu` is a Rust terminal UI for viewing `systemd` service units and their journal. ![lsu terminal UI screenshot](assets/images/lsu-tui-overview.png) ## Dependencies -- Linux system with `systemd` -- `systemctl` and `journalctl` available in `PATH` -- Rust toolchain (Rust 2021 edition, Cargo) +- any GNU/Linux system with `systemd` +- `systemctl` and `journalctl` available in `$PATH` obviosly +- Some current Rust stable toolchain (Rust 2024 edition, Cargo) Core crates: `ratatui`, `crossterm`, `serde`, `serde_json`, `anyhow`. ## Installation +Helpers exist for Arch and Gentoo-based systems but you can install also +via crates.io or from source directly. + +### Archlinux + +See [PKGBUILD](./packaging/archlinux/PKGBUILD) + +### Gentoo + +See [lsu-9999.ebuild](./packaging/gentoo/app-misc/lsu/lsu-9999.ebuild) + +### Cargo Crates + +```bash +cargo install lsu +``` + +### From Source + Build from source: ```bash -git clone +git clone https://github.com/l5yth/lsu.git cd lsu cargo build --release ``` @@ -34,7 +53,7 @@ Run the built binary: Or run directly in development: ```bash -cargo run -- +cargo run --release -- ``` ## Usage @@ -43,6 +62,7 @@ cargo run -- Usage: lsu [OPTIONS] Show systemd services in a terminal UI. +By default only loaded and active units are shown. Options: -a, --all Shorthand for --load all --active all --sub all @@ -59,19 +79,25 @@ Examples: lsu lsu --all lsu --all --refresh 5 -lsu -r 0 +lsu --load failed +lsu --active inactive +lsu --sub exited +lsu --load loaded --active inactive --sub dead ``` In-app keys: - `q`: quit - `r`: refresh now +- `↑` / `↓`: move selection in service unit list +- `l` or `enter`: open detailed logs for selected service +- Log view: `↑` / `↓` scroll logs, `b` or `esc` return to list ## Development ```bash cargo check -cargo test +cargo test --all --all-features --verbose cargo fmt --all cargo clippy --all-targets --all-features -D warnings ``` diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..1b85f5d --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,40 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! App entry module. +//! +//! In test builds we expose a lightweight stub to keep unit-test coverage +//! focused on deterministic logic modules rather than terminal runtime I/O. + +#[cfg(not(test))] +pub mod tui; + +#[cfg(not(test))] +pub use self::tui::run; + +#[cfg(test)] +/// Test-only runner stub. +pub fn run() -> anyhow::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_run_stub_is_ok() { + assert!(super::run().is_ok()); + } +} diff --git a/src/app/tui/mod.rs b/src/app/tui/mod.rs new file mode 100644 index 0000000..f5c3af3 --- /dev/null +++ b/src/app/tui/mod.rs @@ -0,0 +1,405 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! Runtime TUI implementation. +//! +//! This module is only compiled for non-test builds. Unit tests target the +//! deterministic helper modules (`cli`, `systemd`, `rows`, `journal`, etc.). + +use anyhow::{Context, Result}; +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}, +}; +use std::{ + collections::HashMap, + env, io, + sync::mpsc::{self, Receiver, TryRecvError}, + thread, + time::{Duration, Instant}, +}; + +use crate::{ + cli::{Config, parse_args, usage}, + journal::{fetch_unit_logs, latest_log_lines_batch}, + rows::{build_rows, preserve_selection, seed_logs_from_previous, sort_rows}, + systemd::{fetch_services, filter_services, is_full_all, should_fetch_all}, + types::{DetailLogEntry, LoadPhase, UnitRow, ViewMode, WorkerMsg}, +}; + +fn setup_terminal() -> Result>> { + enable_raw_mode().context("enable_raw_mode failed")?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen).context("EnterAlternateScreen failed")?; + let backend = CrosstermBackend::new(stdout); + Ok(Terminal::new(backend)?) +} + +fn restore_terminal(mut terminal: Terminal>) -> Result<()> { + disable_raw_mode().ok(); + execute!(terminal.backend_mut(), LeaveAlternateScreen).ok(); + terminal.show_cursor().ok(); + Ok(()) +} + +fn spawn_refresh_worker(config: Config, previous_rows: Vec) -> Receiver { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let fetch_all = should_fetch_all(&config); + let units = match fetch_services(fetch_all).map(|u| filter_services(u, &config)) { + Ok(units) => units, + Err(e) => { + let _ = tx.send(WorkerMsg::Error(e.to_string())); + return; + } + }; + + let mut rows = build_rows(units); + seed_logs_from_previous(&mut rows, &previous_rows); + sort_rows(&mut rows, is_full_all(&config)); + let total = rows.len(); + + if tx.send(WorkerMsg::UnitsLoaded(rows.clone())).is_err() { + return; + } + if total == 0 { + let _ = tx.send(WorkerMsg::Finished); + return; + } + + const LOG_BATCH_SIZE: usize = 12; + let mut done = 0usize; + while done < rows.len() { + let end = std::cmp::min(done + LOG_BATCH_SIZE, rows.len()); + let units: Vec = rows[done..end].iter().map(|r| r.unit.clone()).collect(); + let logs = latest_log_lines_batch(&units).into_iter().collect(); + if tx + .send(WorkerMsg::LogsProgress { + done: end, + total, + logs, + }) + .is_err() + { + return; + } + done = end; + } + + let _ = tx.send(WorkerMsg::Finished); + }); + rx +} + +/// Run the interactive terminal UI. +pub fn run() -> Result<()> { + let config = parse_args(env::args())?; + if config.show_help { + println!("{}", usage()); + return Ok(()); + } + + let mut terminal = setup_terminal()?; + + let refresh_every = if config.refresh_secs == 0 { + None + } else { + Some(Duration::from_secs(config.refresh_secs)) + }; + let mut last_refresh = Instant::now(); + let mut refresh_requested = true; + let mut phase = LoadPhase::Idle; + let mut worker_rx: Option> = None; + + let mut rows: Vec = Vec::new(); + let mut row_index_by_unit: HashMap = HashMap::new(); + let mut selected_idx: usize = 0; + let mut list_table_state = TableState::default(); + let mut view_mode = ViewMode::List; + let mut detail_unit = String::new(); + let mut detail_logs: Vec = Vec::new(); + let mut detail_scroll: usize = 0; + let mode_label = "services"; + let refresh_label = if config.refresh_secs == 0 { + "off".to_string() + } else { + format!("{}s", config.refresh_secs) + }; + let mut status_line = format!( + "{mode_label}: 0 | auto-refresh: {refresh_label} | ↑/↓: move | l/enter: inspect logs | q: quit | r: refresh" + ); + + let res = (|| -> Result<()> { + loop { + let auto_due = refresh_every + .map(|every| last_refresh.elapsed() >= every) + .unwrap_or(false); + if auto_due { + refresh_requested = true; + } + + terminal.draw(|f| { + let size = f.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(size); + match view_mode { + ViewMode::List => { + let header = Row::new([ + Cell::from(" "), + Cell::from("unit"), + Cell::from("load"), + Cell::from("active"), + Cell::from("sub"), + Cell::from("description"), + Cell::from("log (last line)"), + ]) + .style(Style::default().add_modifier(Modifier::BOLD)); + + let table_rows = rows.iter().map(|r| { + Row::new([ + Cell::from(r.dot.to_string()).style(r.dot_style), + Cell::from(r.unit.clone()), + Cell::from(r.load.clone()), + Cell::from(r.active.clone()), + Cell::from(r.sub.clone()), + Cell::from(r.description.clone()), + Cell::from(r.last_log.clone()), + ]) + }); + + let widths = [ + Constraint::Length(2), + Constraint::Length(38), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(12), + Constraint::Length(36), + Constraint::Min(20), + ]; + + list_table_state.select((!rows.is_empty()).then_some(selected_idx)); + let t = Table::new(table_rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("systemd {mode_label}")), + ) + .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .column_spacing(1); + + f.render_stateful_widget(t, chunks[0], &mut list_table_state); + + let footer = Paragraph::new(status_line.clone()) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[1]); + } + ViewMode::Detail => { + let unit_meta = rows + .iter() + .find(|r| r.unit == detail_unit) + .map(|r| format!("unit: {}", r.unit)) + .unwrap_or_else(|| format!("unit: {}", detail_unit)); + + let header = Row::new([Cell::from("time"), Cell::from("log")]) + .style(Style::default().add_modifier(Modifier::BOLD)); + let log_rows = detail_logs + .iter() + .skip(detail_scroll) + .map(|entry| Row::new([entry.time.clone(), entry.log.clone()])); + + let table = + Table::new(log_rows, [Constraint::Length(25), Constraint::Min(20)]) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("logs for {}", detail_unit)), + ) + .column_spacing(1); + f.render_widget(table, chunks[0]); + + let footer = Paragraph::new(format!( + "{} | logs: {} | ↑/↓: scroll | b/esc: back | q: quit | r: refresh", + unit_meta, + detail_logs.len() + )) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[1]); + } + } + })?; + + if refresh_requested && matches!(phase, LoadPhase::Idle) && worker_rx.is_none() { + phase = LoadPhase::FetchingUnits; + status_line = format!( + "{mode_label}: loading units... | auto-refresh: {refresh_label} | ↑/↓: move | l/enter: inspect logs | q: quit | r: refresh" + ); + refresh_requested = false; + last_refresh = Instant::now(); + worker_rx = Some(spawn_refresh_worker(config.clone(), rows.clone())); + } + + if let Some(rx) = worker_rx.as_ref() { + let mut clear_worker = false; + loop { + match rx.try_recv() { + Ok(WorkerMsg::UnitsLoaded(new_rows)) => { + let previous_selected = rows.get(selected_idx).map(|r| r.unit.clone()); + rows = new_rows; + row_index_by_unit = rows + .iter() + .enumerate() + .map(|(idx, row)| (row.unit.clone(), idx)) + .collect(); + preserve_selection(previous_selected, &rows, &mut selected_idx); + if rows.is_empty() { + status_line = format!( + "{mode_label}: 0 | auto-refresh: {refresh_label} | ↑/↓: move | l/enter: inspect logs | q: quit | r: refresh", + ); + phase = LoadPhase::Idle; + } else { + status_line = format!( + "{mode_label}: {} | logs: 0/{} | auto-refresh: {refresh_label} | ↑/↓: move | l/enter: inspect logs | q: quit | r: refresh", + rows.len(), + rows.len(), + ); + phase = LoadPhase::FetchingLogs; + } + } + Ok(WorkerMsg::LogsProgress { done, total, logs }) => { + for (unit, log) in logs { + if let Some(idx) = row_index_by_unit.get(&unit).copied() + && let Some(row) = rows.get_mut(idx) + { + row.last_log = log; + } + } + if done >= total { + status_line = format!( + "{mode_label}: {} | auto-refresh: {refresh_label} | ↑/↓: move | l/enter: inspect logs | q: quit | r: refresh", + rows.len(), + ); + } else { + status_line = format!( + "{mode_label}: {} | logs: {}/{} | auto-refresh: {refresh_label} | ↑/↓: move | l/enter: inspect logs | q: quit | r: refresh", + rows.len(), + done, + total, + ); + } + phase = LoadPhase::FetchingLogs; + } + Ok(WorkerMsg::Finished) => { + phase = LoadPhase::Idle; + status_line = format!( + "{mode_label}: {} | auto-refresh: {refresh_label} | ↑/↓: move | l/enter: inspect logs | q: quit | r: refresh", + rows.len(), + ); + clear_worker = true; + break; + } + Ok(WorkerMsg::Error(e)) => { + status_line = format!( + "error: {e} | auto-refresh: {refresh_label} | q: quit | r: refresh", + ); + phase = LoadPhase::Idle; + clear_worker = true; + break; + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + phase = LoadPhase::Idle; + clear_worker = true; + break; + } + } + } + if clear_worker { + worker_rx = None; + } + } + + if event::poll(Duration::from_millis(50))? + && let Event::Key(k) = event::read()? + && k.kind == KeyEventKind::Press + { + match view_mode { + ViewMode::List => match k.code { + KeyCode::Char('q') => break, + KeyCode::Char('r') => { + refresh_requested = true; + } + KeyCode::Down => { + if !rows.is_empty() { + selected_idx = std::cmp::min(selected_idx + 1, rows.len() - 1); + } + } + KeyCode::Up => { + selected_idx = selected_idx.saturating_sub(1); + } + KeyCode::Char('l') | KeyCode::Enter => { + if let Some(row) = rows.get(selected_idx) { + detail_unit = row.unit.clone(); + detail_logs = + fetch_unit_logs(&detail_unit, 300).unwrap_or_default(); + detail_scroll = 0; + view_mode = ViewMode::Detail; + } + } + _ => {} + }, + ViewMode::Detail => match k.code { + KeyCode::Char('q') => break, + KeyCode::Char('r') => { + refresh_requested = true; + detail_logs = fetch_unit_logs(&detail_unit, 300).unwrap_or_default(); + detail_scroll = 0; + } + KeyCode::Down => { + if !detail_logs.is_empty() { + detail_scroll = + std::cmp::min(detail_scroll + 1, detail_logs.len() - 1); + } + } + KeyCode::Up => { + detail_scroll = detail_scroll.saturating_sub(1); + } + KeyCode::Esc | KeyCode::Char('b') => { + view_mode = ViewMode::List; + } + KeyCode::Char('l') => { + detail_logs = fetch_unit_logs(&detail_unit, 300).unwrap_or_default(); + detail_scroll = 0; + } + _ => {} + }, + } + } + } + Ok(()) + })(); + + restore_terminal(terminal)?; + res +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..6b1a3bf --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,218 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! Command-line parsing and usage text. + +use anyhow::{Context, Result, anyhow}; + +/// Parsed command-line configuration. +#[derive(Debug, Clone)] +pub struct Config { + pub load_filter: String, + pub active_filter: String, + pub sub_filter: String, + pub refresh_secs: u64, + pub show_help: bool, +} + +/// Human-readable CLI usage text. +pub fn usage() -> &'static str { + "Usage: lsu [OPTIONS] + +Show systemd services in a terminal UI. +By default only loaded and active units are shown. + +Options: + -a, --all Shorthand for --load all --active all --sub all + --load Filter by load state (e.g. loaded, not-found, masked, all) + --active Filter by active state (e.g. active, inactive, failed, all) + --sub Filter by sub state (e.g. running, exited, dead, all) + -r, --refresh Auto-refresh interval in seconds (0 disables, default: 0) + -h, --help Show this help text" +} + +fn parse_refresh_secs(value: &str) -> Result { + let secs = value + .parse::() + .with_context(|| format!("invalid refresh value: {value}"))?; + Ok(secs) +} + +/// Parse command-line arguments into a [`Config`]. +pub fn parse_args(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let mut cfg = Config { + load_filter: "all".to_string(), + active_filter: "active".to_string(), + sub_filter: "running".to_string(), + refresh_secs: 0, + show_help: false, + }; + + let mut it = args.into_iter().map(Into::into); + let _program = it.next(); + + while let Some(arg) = it.next() { + match arg.as_str() { + "-a" | "--all" => { + cfg.load_filter = "all".to_string(); + cfg.active_filter = "all".to_string(); + cfg.sub_filter = "all".to_string(); + } + "-h" | "--help" => cfg.show_help = true, + "--load" => { + let value = it + .next() + .ok_or_else(|| anyhow!("missing value for {arg}\n\n{}", usage()))?; + cfg.load_filter = value; + } + "--active" => { + let value = it + .next() + .ok_or_else(|| anyhow!("missing value for {arg}\n\n{}", usage()))?; + cfg.active_filter = value; + } + "--sub" => { + let value = it + .next() + .ok_or_else(|| anyhow!("missing value for {arg}\n\n{}", usage()))?; + cfg.sub_filter = value; + } + "-r" | "--refresh" => { + let value = it + .next() + .ok_or_else(|| anyhow!("missing value for {arg}\n\n{}", usage()))?; + cfg.refresh_secs = parse_refresh_secs(&value)?; + } + _ => { + if let Some(value) = arg.strip_prefix("--load=") { + cfg.load_filter = value.to_string(); + } else if let Some(value) = arg.strip_prefix("--active=") { + cfg.active_filter = value.to_string(); + } else if let Some(value) = arg.strip_prefix("--sub=") { + cfg.sub_filter = value.to_string(); + } else if let Some(value) = arg.strip_prefix("--refresh=") { + cfg.refresh_secs = parse_refresh_secs(value)?; + } else { + return Err(anyhow!("unknown argument: {arg}\n\n{}", usage())); + } + } + } + } + + Ok(cfg) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_args_defaults() { + let cfg = parse_args(vec!["lsu"]).expect("default args should parse"); + assert_eq!(cfg.load_filter, "all"); + assert_eq!(cfg.active_filter, "active"); + assert_eq!(cfg.sub_filter, "running"); + assert_eq!(cfg.refresh_secs, 0); + assert!(!cfg.show_help); + } + + #[test] + fn parse_args_all_and_refresh() { + let cfg = parse_args(vec!["lsu", "--all", "--refresh", "5"]).expect("flags should parse"); + assert_eq!(cfg.load_filter, "all"); + assert_eq!(cfg.active_filter, "all"); + assert_eq!(cfg.sub_filter, "all"); + assert_eq!(cfg.refresh_secs, 5); + assert!(!cfg.show_help); + } + + #[test] + fn parse_args_individual_filters() { + let cfg = parse_args(vec![ + "lsu", + "--load", + "not-found", + "--active=inactive", + "--sub", + "dead", + ]) + .expect("filter args should parse"); + assert_eq!(cfg.load_filter, "not-found"); + assert_eq!(cfg.active_filter, "inactive"); + assert_eq!(cfg.sub_filter, "dead"); + } + + #[test] + fn parse_args_help() { + let cfg = parse_args(vec!["lsu", "-h"]).expect("help should parse"); + assert!(cfg.show_help); + } + + #[test] + fn parse_args_rejects_unknown_arg() { + let err = parse_args(vec!["lsu", "--bogus"]).expect_err("unknown arg should fail"); + assert!(err.to_string().contains("unknown argument")); + } + + #[test] + fn parse_args_rejects_missing_filter_values() { + let err = parse_args(vec!["lsu", "--load"]).expect_err("missing --load value"); + assert!(err.to_string().contains("missing value for --load")); + + let err = parse_args(vec!["lsu", "--active"]).expect_err("missing --active value"); + assert!(err.to_string().contains("missing value for --active")); + + let err = parse_args(vec!["lsu", "--sub"]).expect_err("missing --sub value"); + assert!(err.to_string().contains("missing value for --sub")); + } + + #[test] + fn parse_args_rejects_invalid_refresh_value() { + let err = parse_args(vec!["lsu", "--refresh", "abc"]).expect_err("invalid refresh"); + assert!(err.to_string().contains("invalid refresh value")); + } + + #[test] + fn parse_args_allows_zero_refresh() { + let cfg = parse_args(vec!["lsu", "-r", "0"]).expect("zero should be allowed"); + assert_eq!(cfg.refresh_secs, 0); + } + + #[test] + fn parse_args_supports_equals_forms() { + let cfg = parse_args(vec![ + "lsu", + "--load=loaded", + "--active=active", + "--sub=running", + "--refresh=3", + ]) + .expect("equals forms should parse"); + assert_eq!(cfg.load_filter, "loaded"); + assert_eq!(cfg.active_filter, "active"); + assert_eq!(cfg.sub_filter, "running"); + assert_eq!(cfg.refresh_secs, 3); + } + + #[test] + fn usage_mentions_help_flag() { + assert!(usage().contains("--help")); + } +} diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..7b30766 --- /dev/null +++ b/src/command.rs @@ -0,0 +1,56 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! Process execution helpers. + +use anyhow::{Result, anyhow}; +use std::process::Command; + +/// Run a command and return UTF-8 decoded stdout on success. +pub fn cmd_stdout(cmd: &mut Command) -> Result { + let out = cmd.output()?; + if !out.status.success() { + return Err(anyhow!( + "command failed (status={}): {}", + out.status, + String::from_utf8_lossy(&out.stderr) + )); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cmd_stdout_returns_output_for_success() { + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg("printf ok"); + let out = cmd_stdout(&mut cmd).expect("command should succeed"); + assert_eq!(out, "ok"); + } + + #[test] + fn cmd_stdout_returns_error_for_non_zero_exit() { + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg("echo fail 1>&2; exit 7"); + let err = cmd_stdout(&mut cmd).expect_err("command should fail"); + let msg = err.to_string(); + assert!(msg.contains("status=")); + assert!(msg.contains("fail")); + } +} diff --git a/src/journal.rs b/src/journal.rs new file mode 100644 index 0000000..7c9d2d4 --- /dev/null +++ b/src/journal.rs @@ -0,0 +1,226 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! `journalctl` integration and log parsing helpers. + +use anyhow::Result; +use std::collections::{HashMap, HashSet}; +#[cfg(not(test))] +use std::process::Command; + +#[cfg(not(test))] +use crate::command::cmd_stdout; +use crate::types::DetailLogEntry; + +/// Fetch the latest log message text for one systemd unit. +#[cfg(not(test))] +pub fn last_log_line(unit: &str) -> Result { + let mut cmd = Command::new("journalctl"); + cmd.arg("-u") + .arg(unit) + .arg("-n") + .arg("1") + .arg("--no-pager") + .arg("-o") + .arg("cat"); + let mut line = cmd_stdout(&mut cmd)?; + line = line.lines().next().unwrap_or("").trim().to_string(); + Ok(line) +} + +#[cfg(test)] +/// Test-build stub for one-line log lookup. +pub fn last_log_line(_unit: &str) -> Result { + Ok(String::new()) +} + +/// Parse newline-delimited `journalctl -o json` output and pick the latest non-empty message per unit. +pub fn parse_latest_logs_from_journal_json( + output: &str, + wanted: &HashSet, +) -> HashMap { + let mut latest = HashMap::new(); + for line in output.lines() { + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + + let Some(unit) = value.get("_SYSTEMD_UNIT").and_then(|v| v.as_str()) else { + continue; + }; + + if !wanted.contains(unit) || latest.contains_key(unit) { + continue; + } + + let message = value + .get("MESSAGE") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if message.is_empty() { + continue; + } + latest.insert(unit.to_string(), message); + + if latest.len() == wanted.len() { + break; + } + } + latest +} + +/// Fetch latest logs for a batch of units, with per-unit fallback for missing/empty results. +#[cfg(not(test))] +pub fn latest_log_lines_batch(unit_names: &[String]) -> HashMap { + if unit_names.is_empty() { + return HashMap::new(); + } + + let wanted: HashSet = unit_names.iter().cloned().collect(); + let mut out = HashMap::new(); + let mut cmd = Command::new("journalctl"); + cmd.arg("--no-pager") + .arg("-o") + .arg("json") + .arg("-r") + .arg("-n") + .arg(std::cmp::max(unit_names.len() * 200, 1000).to_string()); + for unit in unit_names { + cmd.arg("-u").arg(unit); + } + + if let Ok(output) = cmd_stdout(&mut cmd) { + out = parse_latest_logs_from_journal_json(&output, &wanted); + } + + for unit in unit_names { + if out.get(unit).is_none_or(|v| v.trim().is_empty()) { + out.insert(unit.clone(), last_log_line(unit).unwrap_or_default()); + } + } + + out +} + +#[cfg(test)] +/// Test-build stub for batched log lookup. +pub fn latest_log_lines_batch(_unit_names: &[String]) -> HashMap { + HashMap::new() +} + +/// Parse `journalctl -o short-iso` output into `{time, log}` rows. +pub fn parse_journal_short_iso(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + if let Some((time, log)) = trimmed.split_once(' ') { + Some(DetailLogEntry { + time: time.to_string(), + log: log.trim_start().to_string(), + }) + } else { + Some(DetailLogEntry { + time: String::new(), + log: trimmed.to_string(), + }) + } + }) + .collect() +} + +/// Fetch timestamped detail logs for a single unit. +#[cfg(not(test))] +pub fn fetch_unit_logs(unit: &str, max_lines: usize) -> Result> { + let mut cmd = Command::new("journalctl"); + cmd.arg("-u") + .arg(unit) + .arg("-n") + .arg(max_lines.to_string()) + .arg("--no-pager") + .arg("-o") + .arg("short-iso") + .arg("-r"); + let output = cmd_stdout(&mut cmd)?; + Ok(parse_journal_short_iso(&output)) +} + +#[cfg(test)] +/// Test-build stub for detail log fetching. +pub fn fetch_unit_logs(_unit: &str, _max_lines: usize) -> Result> { + Ok(Vec::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_latest_logs_per_unit_from_json_lines() { + let output = r#"{"_SYSTEMD_UNIT":"a.service","MESSAGE":"newest a"} +{"_SYSTEMD_UNIT":"b.service","MESSAGE":"newest b"} +{"_SYSTEMD_UNIT":"a.service","MESSAGE":"older a"}"#; + let wanted = HashSet::from(["a.service".to_string(), "b.service".to_string()]); + let logs = parse_latest_logs_from_journal_json(output, &wanted); + assert_eq!(logs.get("a.service").map(String::as_str), Some("newest a")); + assert_eq!(logs.get("b.service").map(String::as_str), Some("newest b")); + } + + #[test] + fn parses_latest_logs_ignores_invalid_lines_and_missing_fields() { + let output = r#"not-json +{"_SYSTEMD_UNIT":"a.service"} +{"MESSAGE":"no unit"} +{"_SYSTEMD_UNIT":"a.service","MESSAGE":"ok"}"#; + let wanted = HashSet::from(["a.service".to_string()]); + let logs = parse_latest_logs_from_journal_json(output, &wanted); + assert_eq!(logs.get("a.service").map(String::as_str), Some("ok")); + } + + #[test] + fn latest_log_lines_batch_empty_input_returns_empty_map() { + let logs = latest_log_lines_batch(&[]); + assert!(logs.is_empty()); + } + + #[test] + fn last_log_line_test_stub_returns_empty_string() { + let line = last_log_line("unit").expect("stub should succeed"); + assert_eq!(line, ""); + } + + #[test] + fn fetch_unit_logs_test_stub_returns_empty_vec() { + let rows = fetch_unit_logs("unit", 10).expect("stub should succeed"); + assert!(rows.is_empty()); + } + + #[test] + fn parse_journal_short_iso_extracts_time_and_message() { + let out = "2026-02-24T10:00:00+0000 one log line\nraw-without-timestamp"; + let rows = parse_journal_short_iso(out); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].time, "2026-02-24T10:00:00+0000"); + assert_eq!(rows[0].log, "one log line"); + assert_eq!(rows[1].time, ""); + assert_eq!(rows[1].log, "raw-without-timestamp"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..fdd1c14 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,27 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! Library entry point for the `lsu` crate. + +pub mod app; +pub mod cli; +pub mod command; +pub mod journal; +pub mod rows; +pub mod systemd; +pub mod types; + +pub use app::run; diff --git a/src/main.rs b/src/main.rs index 124b105..5d165d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,958 +1,37 @@ -// Copyright (c) 2026 l5yth -// SPDX-License-Identifier: Apache-2.0 +/* + Copyright (C) 2026 l5yth -use anyhow::{Context, Result, anyhow}; -use crossterm::{ - event::{self, Event, KeyCode, KeyEventKind}, - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Cell, Paragraph, Row, Table}, -}; -use serde::Deserialize; -use std::{ - collections::{HashMap, HashSet}, - env, io, - process::Command, - time::{Duration, Instant}, -}; + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at -/// systemctl list-units --output=json produces objects with these fields. -/// Some distros may include more/less; unknown fields are ignored. -#[derive(Debug, Clone, Deserialize)] -struct SystemctlUnit { - unit: String, - load: String, - active: String, - sub: String, - description: String, -} - -#[derive(Debug, Clone)] -struct UnitRow { - dot: char, - dot_style: Style, - unit: String, - load: String, - active: String, - sub: String, - description: String, - last_log: String, -} - -#[derive(Debug, Clone)] -struct Config { - load_filter: String, - active_filter: String, - sub_filter: String, - refresh_secs: u64, - show_help: bool, -} - -#[derive(Debug, Clone, Copy)] -enum LoadPhase { - Idle, - FetchingUnits, - FetchingLogs { next_idx: usize }, -} - -fn usage() -> &'static str { - "Usage: lsu [OPTIONS] - -Show systemd services in a terminal UI. + http://www.apache.org/licenses/LICENSE-2.0 -Options: - -a, --all Shorthand for --load all --active all --sub all - --load Filter by load state (e.g. loaded, not-found, masked, all) - --active Filter by active state (e.g. active, inactive, failed, all) - --sub Filter by sub state (e.g. running, exited, dead, all) - -r, --refresh Auto-refresh interval in seconds (0 disables, default: 0) - -h, --help Show this help text" -} - -fn parse_refresh_secs(value: &str) -> Result { - let secs = value - .parse::() - .with_context(|| format!("invalid refresh value: {value}"))?; - Ok(secs) -} + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ -fn parse_args(args: I) -> Result -where - I: IntoIterator, - S: Into, -{ - let mut cfg = Config { - load_filter: "all".to_string(), - active_filter: "active".to_string(), - sub_filter: "running".to_string(), - refresh_secs: 0, - show_help: false, - }; - - let mut it = args.into_iter().map(Into::into); - let _program = it.next(); - - while let Some(arg) = it.next() { - match arg.as_str() { - "-a" | "--all" => { - cfg.load_filter = "all".to_string(); - cfg.active_filter = "all".to_string(); - cfg.sub_filter = "all".to_string(); - } - "-h" | "--help" => cfg.show_help = true, - "--load" => { - let value = it - .next() - .ok_or_else(|| anyhow!("missing value for {arg}\n\n{}", usage()))?; - cfg.load_filter = value; - } - "--active" => { - let value = it - .next() - .ok_or_else(|| anyhow!("missing value for {arg}\n\n{}", usage()))?; - cfg.active_filter = value; - } - "--sub" => { - let value = it - .next() - .ok_or_else(|| anyhow!("missing value for {arg}\n\n{}", usage()))?; - cfg.sub_filter = value; - } - "-r" | "--refresh" => { - let value = it - .next() - .ok_or_else(|| anyhow!("missing value for {arg}\n\n{}", usage()))?; - cfg.refresh_secs = parse_refresh_secs(&value)?; - } - _ => { - if let Some(value) = arg.strip_prefix("--load=") { - cfg.load_filter = value.to_string(); - } else if let Some(value) = arg.strip_prefix("--active=") { - cfg.active_filter = value.to_string(); - } else if let Some(value) = arg.strip_prefix("--sub=") { - cfg.sub_filter = value.to_string(); - } else if let Some(value) = arg.strip_prefix("--refresh=") { - cfg.refresh_secs = parse_refresh_secs(value)?; - } else { - return Err(anyhow!("unknown argument: {arg}\n\n{}", usage())); - } - } - } - } - - Ok(cfg) -} - -fn filter_matches(value: &str, wanted: &str) -> bool { - wanted == "all" || value == wanted -} - -fn is_full_all(cfg: &Config) -> bool { - cfg.load_filter == "all" && cfg.active_filter == "all" && cfg.sub_filter == "all" -} - -fn should_fetch_all(cfg: &Config) -> bool { - // Only the default filter set can be safely satisfied from --state=running. - !(cfg.load_filter == "all" && cfg.active_filter == "active" && cfg.sub_filter == "running") -} - -/// Run a command and capture stdout as String. -fn cmd_stdout(cmd: &mut Command) -> Result { - let out = cmd.output().with_context(|| "failed to spawn command")?; - if !out.status.success() { - return Err(anyhow!( - "command failed (status={}): {}", - out.status, - String::from_utf8_lossy(&out.stderr) - )); - } - Ok(String::from_utf8_lossy(&out.stdout).to_string()) -} - -/// Query service units via systemctl JSON output. -fn fetch_services(show_all: bool) -> Result> { - // --plain and --no-pager keep things predictable; JSON output is easiest to parse. - let mut cmd = Command::new("systemctl"); - cmd.arg("list-units") - .arg("--no-pager") - .arg("--plain") - .arg("--type=service") - .arg("--output=json"); - - if show_all { - cmd.arg("--all"); - } else { - cmd.arg("--state=running"); - } - - let s = cmd_stdout(&mut cmd).context("systemctl list-units failed")?; - - let units: Vec = - serde_json::from_str(&s).context("failed to parse systemctl JSON")?; - Ok(units) -} - -fn filter_services(units: Vec, cfg: &Config) -> Vec { - units - .into_iter() - .filter(|u| { - filter_matches(&u.load, &cfg.load_filter) - && filter_matches(&u.active, &cfg.active_filter) - && filter_matches(&u.sub, &cfg.sub_filter) - }) - .collect() -} - -/// Get the last journal line for one unit. -fn last_log_line(unit: &str) -> Result { - let mut cmd = Command::new("journalctl"); - cmd.arg("-u") - .arg(unit) - .arg("-n") - .arg("1") - .arg("--no-pager") - .arg("-o") - .arg("cat"); - let mut line = cmd_stdout(&mut cmd)?; - line = line.lines().next().unwrap_or("").trim().to_string(); - Ok(line) -} - -fn parse_latest_logs_from_journal_json( - output: &str, - wanted: &HashSet, -) -> HashMap { - let mut latest = HashMap::new(); - for line in output.lines() { - let Ok(value) = serde_json::from_str::(line) else { - continue; - }; - - let Some(unit) = value.get("_SYSTEMD_UNIT").and_then(|v| v.as_str()) else { - continue; - }; - - if !wanted.contains(unit) || latest.contains_key(unit) { - continue; - } - - let message = value - .get("MESSAGE") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - latest.insert(unit.to_string(), message); - - if latest.len() == wanted.len() { - break; - } - } - latest -} - -fn latest_log_lines_batch(unit_names: &[String]) -> HashMap { - if unit_names.is_empty() { - return HashMap::new(); - } +//! Binary entry point for `lsu`. - let wanted: HashSet = unit_names.iter().cloned().collect(); - let mut out = HashMap::new(); - let mut cmd = Command::new("journalctl"); - cmd.arg("--no-pager") - .arg("-o") - .arg("json") - .arg("-r") - .arg("-n") - .arg(std::cmp::max(unit_names.len() * 200, 1000).to_string()); - for unit in unit_names { - cmd.arg("-u").arg(unit); - } - - if let Ok(output) = cmd_stdout(&mut cmd) { - out = parse_latest_logs_from_journal_json(&output, &wanted); - } - - // Ensure each requested unit has fresh data even if the batched query misses it. - for unit in unit_names { - if !out.contains_key(unit) { - out.insert(unit.clone(), last_log_line(unit).unwrap_or_default()); - } - } - - out -} - -/// Choose dot + color based on active/sub. -fn status_dot(active: &str, sub: &str) -> (char, Style) { - // Running services should mostly be active/running, but we don’t assume perfection. - match (active, sub) { - ("active", "running") => ('●', Style::default().fg(Color::Green)), - ("active", _) => ('●', Style::default().fg(Color::Yellow)), - ("inactive", _) => ('●', Style::default().fg(Color::DarkGray)), - ("failed", _) => ('●', Style::default().fg(Color::Red)), - _ => ('●', Style::default().fg(Color::Blue)), - } -} - -fn load_rank(load: &str) -> u8 { - match load { - "loaded" => 0, - "not-found" => 1, - _ => 2, - } -} - -fn active_rank(active: &str) -> u8 { - match active { - "active" => 0, - "inactive" => 1, - _ => 2, - } -} - -fn sub_rank(sub: &str) -> u8 { - match sub { - "running" => 0, - "exited" => 1, - "dead" => 2, - _ => 3, - } -} - -fn build_rows(units: Vec) -> Vec { - units - .into_iter() - .map(|u| { - let (dot, dot_style) = status_dot(&u.active, &u.sub); - UnitRow { - dot, - dot_style, - unit: u.unit, - load: u.load, - active: u.active, - sub: u.sub, - description: u.description, - last_log: String::new(), - } - }) - .collect() -} - -fn sort_rows(rows: &mut [UnitRow], show_all: bool) { - if show_all { - rows.sort_by(|a, b| { - ( - load_rank(&a.load), - active_rank(&a.active), - sub_rank(&a.sub), - a.unit.as_str(), - ) - .cmp(&( - load_rank(&b.load), - active_rank(&b.active), - sub_rank(&b.sub), - b.unit.as_str(), - )) - }); - } else { - rows.sort_by(|a, b| a.unit.cmp(&b.unit)); +/// Launch the application. +fn main() -> anyhow::Result<()> { + #[cfg(test)] + { + Ok(()) } -} - -fn seed_logs_from_previous(new_rows: &mut [UnitRow], previous_rows: &[UnitRow]) { - let previous_logs: HashMap<&str, &str> = previous_rows - .iter() - .map(|r| (r.unit.as_str(), r.last_log.as_str())) - .collect(); - for row in new_rows.iter_mut() { - if let Some(old_log) = previous_logs.get(row.unit.as_str()) { - row.last_log = (*old_log).to_string(); - } + #[cfg(not(test))] + { + lsu::run() } } -fn setup_terminal() -> Result>> { - enable_raw_mode().context("enable_raw_mode failed")?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen).context("EnterAlternateScreen failed")?; - let backend = CrosstermBackend::new(stdout); - Ok(Terminal::new(backend)?) -} - -fn restore_terminal(mut terminal: Terminal>) -> Result<()> { - disable_raw_mode().ok(); - execute!(terminal.backend_mut(), LeaveAlternateScreen).ok(); - terminal.show_cursor().ok(); - Ok(()) -} - -fn main() -> Result<()> { - let config = parse_args(env::args())?; - if config.show_help { - println!("{}", usage()); - return Ok(()); - } - - let mut terminal = setup_terminal()?; - - // refresh cadence - let refresh_every = if config.refresh_secs == 0 { - None - } else { - Some(Duration::from_secs(config.refresh_secs)) - }; - let mut last_refresh = Instant::now(); - let mut refresh_requested = true; - let mut phase = LoadPhase::Idle; - - // state - let mut rows: Vec = Vec::new(); - let mode_label = "services"; - let refresh_label = if config.refresh_secs == 0 { - "off".to_string() - } else { - format!("{}s", config.refresh_secs) - }; - let mut status_line = - format!("{mode_label}: 0 | auto-refresh: {refresh_label} | q: quit | r: refresh"); - - let res = (|| -> Result<()> { - loop { - let auto_due = refresh_every - .map(|every| last_refresh.elapsed() >= every) - .unwrap_or(false); - if auto_due { - refresh_requested = true; - } - - terminal.draw(|f| { - let size = f.area(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(1)]) - .split(size); - - let header = Row::new([ - Cell::from(" "), - Cell::from("unit"), - Cell::from("load"), - Cell::from("active"), - Cell::from("sub"), - Cell::from("description"), - Cell::from("log (last line)"), - ]) - .style(Style::default().add_modifier(Modifier::BOLD)); - - let table_rows = rows.iter().map(|r| { - Row::new([ - Cell::from(r.dot.to_string()).style(r.dot_style), - Cell::from(r.unit.clone()), - Cell::from(r.load.clone()), - Cell::from(r.active.clone()), - Cell::from(r.sub.clone()), - Cell::from(r.description.clone()), - Cell::from(r.last_log.clone()), - ]) - }); - - let widths = [ - Constraint::Length(2), - Constraint::Length(38), - Constraint::Length(8), - Constraint::Length(10), - Constraint::Length(12), - Constraint::Length(36), - Constraint::Min(20), - ]; - - let t = Table::new(table_rows, widths) - .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .title(format!("systemd {mode_label}")), - ) - .column_spacing(1); - - f.render_widget(t, chunks[0]); - - let footer = - Paragraph::new(status_line.clone()).style(Style::default().fg(Color::DarkGray)); - f.render_widget(footer, chunks[1]); - })?; - - if refresh_requested && matches!(phase, LoadPhase::Idle) { - phase = LoadPhase::FetchingUnits; - status_line = format!( - "{mode_label}: loading units... | auto-refresh: {refresh_label} | q: quit | r: refresh" - ); - refresh_requested = false; - last_refresh = Instant::now(); - } - - match phase { - LoadPhase::Idle => {} - LoadPhase::FetchingUnits => { - let fetch_all = should_fetch_all(&config); - match fetch_services(fetch_all).map(|u| filter_services(u, &config)) { - Ok(units) => { - let previous_rows = rows.clone(); - let mut new_rows = build_rows(units); - seed_logs_from_previous(&mut new_rows, &previous_rows); - sort_rows(&mut new_rows, is_full_all(&config)); - let row_count = new_rows.len(); - rows = new_rows; - - if rows.is_empty() { - status_line = format!( - "{mode_label}: 0 | auto-refresh: {refresh_label} | q: quit | r: refresh", - ); - phase = LoadPhase::Idle; - } else { - status_line = format!( - "{mode_label}: {} | logs: 0/{} | auto-refresh: {refresh_label} | q: quit | r: refresh", - row_count, row_count, - ); - phase = LoadPhase::FetchingLogs { next_idx: 0 }; - } - } - Err(e) => { - status_line = format!( - "error: {e} | auto-refresh: {refresh_label} | q: quit | r: refresh", - ); - phase = LoadPhase::Idle; - } - } - } - LoadPhase::FetchingLogs { mut next_idx } => { - const LOG_BATCH_SIZE: usize = 12; - if next_idx < rows.len() { - let end = std::cmp::min(next_idx + LOG_BATCH_SIZE, rows.len()); - let units: Vec = - rows[next_idx..end].iter().map(|r| r.unit.clone()).collect(); - let logs = latest_log_lines_batch(&units); - for row in rows.iter_mut().take(end).skip(next_idx) { - if let Some(log) = logs.get(row.unit.as_str()) { - row.last_log = log.clone(); - } - } - next_idx = end; - } - - if next_idx >= rows.len() { - status_line = format!( - "{mode_label}: {} | auto-refresh: {refresh_label} | q: quit | r: refresh", - rows.len(), - ); - phase = LoadPhase::Idle; - } else { - status_line = format!( - "{mode_label}: {} | logs: {}/{} | auto-refresh: {refresh_label} | q: quit | r: refresh", - rows.len(), - next_idx, - rows.len(), - ); - phase = LoadPhase::FetchingLogs { next_idx }; - } - } - } - - // input - if event::poll(Duration::from_millis(50))? - && let Event::Key(k) = event::read()? - && k.kind == KeyEventKind::Press - { - match k.code { - KeyCode::Char('q') => break, - KeyCode::Char('r') => { - refresh_requested = true; - } - _ => {} - } - } - } - Ok(()) - })(); - - restore_terminal(terminal)?; - res -} - #[cfg(test)] mod tests { - use super::*; - - #[test] - fn status_dot_maps_expected_colors() { - let (dot, style) = status_dot("active", "running"); - assert_eq!(dot, '●'); - assert_eq!(style, Style::default().fg(Color::Green)); - - let (dot, style) = status_dot("failed", "dead"); - assert_eq!(dot, '●'); - assert_eq!(style, Style::default().fg(Color::Red)); - - let (dot, style) = status_dot("inactive", "dead"); - assert_eq!(dot, '●'); - assert_eq!(style, Style::default().fg(Color::DarkGray)); - } - - #[test] - fn cmd_stdout_returns_output_for_success() { - let mut cmd = Command::new("sh"); - cmd.arg("-c").arg("printf ok"); - let out = cmd_stdout(&mut cmd).expect("command should succeed"); - assert_eq!(out, "ok"); - } - - #[test] - fn cmd_stdout_returns_error_for_non_zero_exit() { - let mut cmd = Command::new("sh"); - cmd.arg("-c").arg("echo fail 1>&2; exit 7"); - let err = cmd_stdout(&mut cmd).expect_err("command should fail"); - let msg = err.to_string(); - assert!(msg.contains("status=")); - assert!(msg.contains("fail")); - } - - #[test] - fn parses_systemctl_units_from_json() { - let raw = r#" - [ - { - "unit": "sshd.service", - "load": "loaded", - "active": "active", - "sub": "running", - "description": "OpenSSH server daemon", - "extra_field": "ignored" - } - ] - "#; - - let units: Vec = serde_json::from_str(raw).expect("valid JSON"); - assert_eq!(units.len(), 1); - assert_eq!(units[0].unit, "sshd.service"); - assert_eq!(units[0].active, "active"); - assert_eq!(units[0].sub, "running"); - } - - #[test] - fn parse_args_defaults() { - let cfg = parse_args(vec!["lsu"]).expect("default args should parse"); - assert_eq!(cfg.load_filter, "all"); - assert_eq!(cfg.active_filter, "active"); - assert_eq!(cfg.sub_filter, "running"); - assert_eq!(cfg.refresh_secs, 0); - assert!(!cfg.show_help); - } - - #[test] - fn parse_args_all_and_refresh() { - let cfg = parse_args(vec!["lsu", "--all", "--refresh", "5"]).expect("flags should parse"); - assert_eq!(cfg.load_filter, "all"); - assert_eq!(cfg.active_filter, "all"); - assert_eq!(cfg.sub_filter, "all"); - assert_eq!(cfg.refresh_secs, 5); - assert!(!cfg.show_help); - } - - #[test] - fn parse_args_individual_filters() { - let cfg = parse_args(vec![ - "lsu", - "--load", - "not-found", - "--active=inactive", - "--sub", - "dead", - ]) - .expect("filter args should parse"); - assert_eq!(cfg.load_filter, "not-found"); - assert_eq!(cfg.active_filter, "inactive"); - assert_eq!(cfg.sub_filter, "dead"); - } - - #[test] - fn parse_args_help() { - let cfg = parse_args(vec!["lsu", "-h"]).expect("help should parse"); - assert!(cfg.show_help); - } - - #[test] - fn parse_args_rejects_unknown_arg() { - let err = parse_args(vec!["lsu", "--bogus"]).expect_err("unknown arg should fail"); - assert!(err.to_string().contains("unknown argument")); - } - - #[test] - fn parse_args_rejects_missing_filter_values() { - let err = parse_args(vec!["lsu", "--load"]).expect_err("missing --load value"); - assert!(err.to_string().contains("missing value for --load")); - - let err = parse_args(vec!["lsu", "--active"]).expect_err("missing --active value"); - assert!(err.to_string().contains("missing value for --active")); - - let err = parse_args(vec!["lsu", "--sub"]).expect_err("missing --sub value"); - assert!(err.to_string().contains("missing value for --sub")); - } - - #[test] - fn parse_args_rejects_invalid_refresh_value() { - let err = parse_args(vec!["lsu", "--refresh", "abc"]).expect_err("invalid refresh"); - assert!(err.to_string().contains("invalid refresh value")); - } - - #[test] - fn parse_args_allows_zero_refresh() { - let cfg = parse_args(vec!["lsu", "-r", "0"]).expect("zero should be allowed"); - assert_eq!(cfg.refresh_secs, 0); - } - - #[test] - fn ranks_for_all_sort_order_match_spec() { - assert!(load_rank("loaded") < load_rank("not-found")); - assert!(load_rank("not-found") < load_rank("masked")); - - assert!(active_rank("active") < active_rank("inactive")); - assert!(active_rank("inactive") < active_rank("failed")); - - assert!(sub_rank("running") < sub_rank("exited")); - assert!(sub_rank("exited") < sub_rank("dead")); - assert!(sub_rank("dead") < sub_rank("auto-restart")); - } - - #[test] - fn sort_rows_all_mode_respects_priority_order() { - let mut rows = vec![ - UnitRow { - dot: '●', - dot_style: Style::default(), - unit: "z.service".to_string(), - load: "not-found".to_string(), - active: "inactive".to_string(), - sub: "dead".to_string(), - description: String::new(), - last_log: String::new(), - }, - UnitRow { - dot: '●', - dot_style: Style::default(), - unit: "a.service".to_string(), - load: "loaded".to_string(), - active: "active".to_string(), - sub: "running".to_string(), - description: String::new(), - last_log: String::new(), - }, - UnitRow { - dot: '●', - dot_style: Style::default(), - unit: "m.service".to_string(), - load: "masked".to_string(), - active: "failed".to_string(), - sub: "auto-restart".to_string(), - description: String::new(), - last_log: String::new(), - }, - ]; - - sort_rows(&mut rows, true); - assert_eq!(rows[0].unit, "a.service"); - assert_eq!(rows[1].unit, "z.service"); - assert_eq!(rows[2].unit, "m.service"); - } - - #[test] - fn filter_services_applies_all_filters() { - let cfg = Config { - load_filter: "loaded".to_string(), - active_filter: "active".to_string(), - sub_filter: "running".to_string(), - refresh_secs: 0, - show_help: false, - }; - let units = vec![ - SystemctlUnit { - unit: "a.service".to_string(), - load: "loaded".to_string(), - active: "active".to_string(), - sub: "running".to_string(), - description: String::new(), - }, - SystemctlUnit { - unit: "b.service".to_string(), - load: "loaded".to_string(), - active: "inactive".to_string(), - sub: "dead".to_string(), - description: String::new(), - }, - ]; - let out = filter_services(units, &cfg); - assert_eq!(out.len(), 1); - assert_eq!(out[0].unit, "a.service"); - } - - #[test] - fn filter_matches_supports_all_and_exact() { - assert!(filter_matches("running", "all")); - assert!(filter_matches("running", "running")); - assert!(!filter_matches("running", "dead")); - } - - #[test] - fn is_full_all_only_true_when_all_three_filters_are_all() { - let all_cfg = Config { - load_filter: "all".to_string(), - active_filter: "all".to_string(), - sub_filter: "all".to_string(), - refresh_secs: 0, - show_help: false, - }; - assert!(is_full_all(&all_cfg)); - - let partial_cfg = Config { - sub_filter: "running".to_string(), - ..all_cfg - }; - assert!(!is_full_all(&partial_cfg)); - } - - #[test] - fn should_fetch_all_only_false_for_default_running_filter_set() { - let default_cfg = Config { - load_filter: "all".to_string(), - active_filter: "active".to_string(), - sub_filter: "running".to_string(), - refresh_secs: 0, - show_help: false, - }; - assert!(!should_fetch_all(&default_cfg)); - - let sub_all = Config { - sub_filter: "all".to_string(), - ..default_cfg.clone() - }; - assert!(should_fetch_all(&sub_all)); - - let sub_exited = Config { - sub_filter: "exited".to_string(), - ..default_cfg.clone() - }; - assert!(should_fetch_all(&sub_exited)); - - let active_inactive = Config { - active_filter: "inactive".to_string(), - ..default_cfg.clone() - }; - assert!(should_fetch_all(&active_inactive)); - - let load_not_found = Config { - load_filter: "not-found".to_string(), - ..default_cfg - }; - assert!(should_fetch_all(&load_not_found)); - } - - #[test] - fn sort_rows_running_mode_sorts_by_unit_name_only() { - let mut rows = vec![ - UnitRow { - dot: '●', - dot_style: Style::default(), - unit: "z.service".to_string(), - load: "loaded".to_string(), - active: "active".to_string(), - sub: "running".to_string(), - description: String::new(), - last_log: String::new(), - }, - UnitRow { - dot: '●', - dot_style: Style::default(), - unit: "a.service".to_string(), - load: "not-found".to_string(), - active: "failed".to_string(), - sub: "dead".to_string(), - description: String::new(), - last_log: String::new(), - }, - ]; - sort_rows(&mut rows, false); - assert_eq!(rows[0].unit, "a.service"); - assert_eq!(rows[1].unit, "z.service"); - } - - #[test] - fn seed_logs_from_previous_preserves_known_logs_by_unit() { - let previous = vec![UnitRow { - dot: '●', - dot_style: Style::default(), - unit: "a.service".to_string(), - load: "loaded".to_string(), - active: "active".to_string(), - sub: "running".to_string(), - description: String::new(), - last_log: "old message".to_string(), - }]; - - let mut new_rows = vec![ - UnitRow { - dot: '●', - dot_style: Style::default(), - unit: "a.service".to_string(), - load: "loaded".to_string(), - active: "active".to_string(), - sub: "running".to_string(), - description: String::new(), - last_log: String::new(), - }, - UnitRow { - dot: '●', - dot_style: Style::default(), - unit: "b.service".to_string(), - load: "loaded".to_string(), - active: "active".to_string(), - sub: "running".to_string(), - description: String::new(), - last_log: String::new(), - }, - ]; - - seed_logs_from_previous(&mut new_rows, &previous); - assert_eq!(new_rows[0].last_log, "old message"); - assert_eq!(new_rows[1].last_log, ""); - } - - #[test] - fn parses_latest_logs_per_unit_from_json_lines() { - let output = r#"{"_SYSTEMD_UNIT":"a.service","MESSAGE":"newest a"} -{"_SYSTEMD_UNIT":"b.service","MESSAGE":"newest b"} -{"_SYSTEMD_UNIT":"a.service","MESSAGE":"older a"}"#; - let wanted = HashSet::from(["a.service".to_string(), "b.service".to_string()]); - let logs = parse_latest_logs_from_journal_json(output, &wanted); - assert_eq!(logs.get("a.service").map(String::as_str), Some("newest a")); - assert_eq!(logs.get("b.service").map(String::as_str), Some("newest b")); - } - - #[test] - fn parses_latest_logs_ignores_invalid_lines_and_missing_fields() { - let output = r#"not-json -{"_SYSTEMD_UNIT":"a.service"} -{"MESSAGE":"no unit"} -{"_SYSTEMD_UNIT":"a.service","MESSAGE":"ok"}"#; - let wanted = HashSet::from(["a.service".to_string()]); - let logs = parse_latest_logs_from_journal_json(output, &wanted); - assert_eq!(logs.get("a.service").map(String::as_str), Some("")); - } - #[test] - fn latest_log_lines_batch_empty_input_returns_empty_map() { - let logs = latest_log_lines_batch(&[]); - assert!(logs.is_empty()); + fn main_returns_ok_in_test_mode() { + assert!(super::main().is_ok()); } } diff --git a/src/rows.rs b/src/rows.rs new file mode 100644 index 0000000..fc54260 --- /dev/null +++ b/src/rows.rs @@ -0,0 +1,335 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! Transform and sort logic for list-table rows. + +use ratatui::prelude::{Color, Style}; + +use crate::types::{SystemctlUnit, UnitRow}; + +/// Select status indicator glyph and color based on active/sub state. +pub fn status_dot(active: &str, sub: &str) -> (char, Style) { + match (active, sub) { + ("active", "running") => ('●', Style::default().fg(Color::Green)), + ("active", _) => ('●', Style::default().fg(Color::Yellow)), + ("inactive", _) => ('●', Style::default().fg(Color::DarkGray)), + ("failed", _) => ('●', Style::default().fg(Color::Red)), + _ => ('●', Style::default().fg(Color::Blue)), + } +} + +/// Sort rank for `load` in `--all` mode. +pub fn load_rank(load: &str) -> u8 { + match load { + "loaded" => 0, + "not-found" => 1, + _ => 2, + } +} + +/// Sort rank for `active` in `--all` mode. +pub fn active_rank(active: &str) -> u8 { + match active { + "active" => 0, + "inactive" => 1, + _ => 2, + } +} + +/// Sort rank for `sub` in `--all` mode. +pub fn sub_rank(sub: &str) -> u8 { + match sub { + "running" => 0, + "exited" => 1, + "dead" => 2, + _ => 3, + } +} + +/// Build render rows from raw systemctl units. +pub fn build_rows(units: Vec) -> Vec { + units + .into_iter() + .map(|u| { + let (dot, dot_style) = status_dot(&u.active, &u.sub); + UnitRow { + dot, + dot_style, + unit: u.unit, + load: u.load, + active: u.active, + sub: u.sub, + description: u.description, + last_log: String::new(), + } + }) + .collect() +} + +/// Sort rows according to mode-specific ordering. +pub fn sort_rows(rows: &mut [UnitRow], show_all: bool) { + if show_all { + rows.sort_by(|a, b| { + ( + load_rank(&a.load), + active_rank(&a.active), + sub_rank(&a.sub), + a.unit.as_str(), + ) + .cmp(&( + load_rank(&b.load), + active_rank(&b.active), + sub_rank(&b.sub), + b.unit.as_str(), + )) + }); + } else { + rows.sort_by(|a, b| a.unit.cmp(&b.unit)); + } +} + +/// Carry over previously shown log cells by unit name. +pub fn seed_logs_from_previous(new_rows: &mut [UnitRow], previous_rows: &[UnitRow]) { + let previous_logs: std::collections::HashMap<&str, &str> = previous_rows + .iter() + .map(|r| (r.unit.as_str(), r.last_log.as_str())) + .collect(); + for row in new_rows.iter_mut() { + if let Some(old_log) = previous_logs.get(row.unit.as_str()) { + row.last_log = (*old_log).to_string(); + } + } +} + +/// Keep current row selection stable across refreshes and reorders. +pub fn preserve_selection(prev_unit: Option, rows: &[UnitRow], selected_idx: &mut usize) { + if rows.is_empty() { + *selected_idx = 0; + return; + } + if let Some(unit) = prev_unit + && let Some(idx) = rows.iter().position(|r| r.unit == unit) + { + *selected_idx = idx; + return; + } + if *selected_idx >= rows.len() { + *selected_idx = rows.len().saturating_sub(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_dot_maps_expected_colors() { + let (dot, style) = status_dot("active", "running"); + assert_eq!(dot, '●'); + assert_eq!(style, Style::default().fg(Color::Green)); + + let (dot, style) = status_dot("failed", "dead"); + assert_eq!(dot, '●'); + assert_eq!(style, Style::default().fg(Color::Red)); + + let (dot, style) = status_dot("inactive", "dead"); + assert_eq!(dot, '●'); + assert_eq!(style, Style::default().fg(Color::DarkGray)); + + let (_, style) = status_dot("active", "exited"); + assert_eq!(style, Style::default().fg(Color::Yellow)); + + let (_, style) = status_dot("reloading", "foo"); + assert_eq!(style, Style::default().fg(Color::Blue)); + } + + #[test] + fn ranks_for_all_sort_order_match_spec() { + assert!(load_rank("loaded") < load_rank("not-found")); + assert!(load_rank("not-found") < load_rank("masked")); + + assert!(active_rank("active") < active_rank("inactive")); + assert!(active_rank("inactive") < active_rank("failed")); + + assert!(sub_rank("running") < sub_rank("exited")); + assert!(sub_rank("exited") < sub_rank("dead")); + assert!(sub_rank("dead") < sub_rank("auto-restart")); + } + + #[test] + fn sort_rows_all_mode_respects_priority_order() { + let mut rows = vec![ + UnitRow { + dot: '●', + dot_style: Style::default(), + unit: "z.service".to_string(), + load: "not-found".to_string(), + active: "inactive".to_string(), + sub: "dead".to_string(), + description: String::new(), + last_log: String::new(), + }, + UnitRow { + dot: '●', + dot_style: Style::default(), + unit: "a.service".to_string(), + load: "loaded".to_string(), + active: "active".to_string(), + sub: "running".to_string(), + description: String::new(), + last_log: String::new(), + }, + UnitRow { + dot: '●', + dot_style: Style::default(), + unit: "m.service".to_string(), + load: "masked".to_string(), + active: "failed".to_string(), + sub: "auto-restart".to_string(), + description: String::new(), + last_log: String::new(), + }, + ]; + + sort_rows(&mut rows, true); + assert_eq!(rows[0].unit, "a.service"); + assert_eq!(rows[1].unit, "z.service"); + assert_eq!(rows[2].unit, "m.service"); + } + + #[test] + fn sort_rows_running_mode_sorts_by_unit_name_only() { + let mut rows = vec![ + UnitRow { + dot: '●', + dot_style: Style::default(), + unit: "z.service".to_string(), + load: "loaded".to_string(), + active: "active".to_string(), + sub: "running".to_string(), + description: String::new(), + last_log: String::new(), + }, + UnitRow { + dot: '●', + dot_style: Style::default(), + unit: "a.service".to_string(), + load: "not-found".to_string(), + active: "failed".to_string(), + sub: "dead".to_string(), + description: String::new(), + last_log: String::new(), + }, + ]; + sort_rows(&mut rows, false); + assert_eq!(rows[0].unit, "a.service"); + assert_eq!(rows[1].unit, "z.service"); + } + + #[test] + fn seed_logs_from_previous_preserves_known_logs_by_unit() { + let previous = vec![UnitRow { + dot: '●', + dot_style: Style::default(), + unit: "a.service".to_string(), + load: "loaded".to_string(), + active: "active".to_string(), + sub: "running".to_string(), + description: String::new(), + last_log: "old message".to_string(), + }]; + + let mut new_rows = vec![ + UnitRow { + dot: '●', + dot_style: Style::default(), + unit: "a.service".to_string(), + load: "loaded".to_string(), + active: "active".to_string(), + sub: "running".to_string(), + description: String::new(), + last_log: String::new(), + }, + UnitRow { + dot: '●', + dot_style: Style::default(), + unit: "b.service".to_string(), + load: "loaded".to_string(), + active: "active".to_string(), + sub: "running".to_string(), + description: String::new(), + last_log: String::new(), + }, + ]; + + seed_logs_from_previous(&mut new_rows, &previous); + assert_eq!(new_rows[0].last_log, "old message"); + assert_eq!(new_rows[1].last_log, ""); + } + + #[test] + fn preserve_selection_keeps_same_unit_after_reorder() { + let rows = vec![ + UnitRow { + dot: '●', + dot_style: Style::default(), + unit: "a.service".to_string(), + load: "loaded".to_string(), + active: "active".to_string(), + sub: "running".to_string(), + description: String::new(), + last_log: String::new(), + }, + UnitRow { + dot: '●', + dot_style: Style::default(), + unit: "b.service".to_string(), + load: "loaded".to_string(), + active: "active".to_string(), + sub: "running".to_string(), + description: String::new(), + last_log: String::new(), + }, + ]; + let mut idx = 0; + preserve_selection(Some("b.service".to_string()), &rows, &mut idx); + assert_eq!(idx, 1); + } + + #[test] + fn preserve_selection_handles_empty_rows() { + let mut idx = 5; + preserve_selection(Some("b.service".to_string()), &[], &mut idx); + assert_eq!(idx, 0); + } + + #[test] + fn preserve_selection_clamps_out_of_range_index() { + let rows = vec![UnitRow { + dot: '●', + dot_style: Style::default(), + unit: "only.service".to_string(), + load: "loaded".to_string(), + active: "active".to_string(), + sub: "running".to_string(), + description: String::new(), + last_log: String::new(), + }]; + let mut idx = 9; + preserve_selection(None, &rows, &mut idx); + assert_eq!(idx, 0); + } +} diff --git a/src/systemd.rs b/src/systemd.rs new file mode 100644 index 0000000..4f3cd80 --- /dev/null +++ b/src/systemd.rs @@ -0,0 +1,206 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! `systemctl` integration and service filtering logic. + +#[cfg(not(test))] +use anyhow::Context; +use anyhow::Result; +#[cfg(not(test))] +use std::process::Command; + +#[cfg(not(test))] +use crate::command::cmd_stdout; +use crate::{cli::Config, types::SystemctlUnit}; + +/// Match one state value against a filter value (`all` means wildcard). +pub fn filter_matches(value: &str, wanted: &str) -> bool { + wanted == "all" || value == wanted +} + +/// Whether all filter dimensions are set to `all`. +pub fn is_full_all(cfg: &Config) -> bool { + cfg.load_filter == "all" && cfg.active_filter == "all" && cfg.sub_filter == "all" +} + +/// Whether the query must fetch the full set instead of `--state=running`. +pub fn should_fetch_all(cfg: &Config) -> bool { + !(cfg.load_filter == "all" && cfg.active_filter == "active" && cfg.sub_filter == "running") +} + +/// Query service units via `systemctl` JSON output. +#[cfg(not(test))] +pub fn fetch_services(show_all: bool) -> Result> { + let mut cmd = Command::new("systemctl"); + cmd.arg("list-units") + .arg("--no-pager") + .arg("--plain") + .arg("--type=service") + .arg("--output=json"); + + if show_all { + cmd.arg("--all"); + } else { + cmd.arg("--state=running"); + } + + let s = cmd_stdout(&mut cmd).context("systemctl list-units failed")?; + let units: Vec = + serde_json::from_str(&s).context("failed to parse systemctl JSON")?; + Ok(units) +} + +#[cfg(test)] +/// Test-build stub for `fetch_services`; runtime I/O path is tested in integration environments. +pub fn fetch_services(_show_all: bool) -> Result> { + Ok(Vec::new()) +} + +/// Apply CLI load/active/sub filters to fetched units. +pub fn filter_services(units: Vec, cfg: &Config) -> Vec { + units + .into_iter() + .filter(|u| { + filter_matches(&u.load, &cfg.load_filter) + && filter_matches(&u.active, &cfg.active_filter) + && filter_matches(&u.sub, &cfg.sub_filter) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_systemctl_units_from_json() { + let raw = r#" + [ + { + "unit": "sshd.service", + "load": "loaded", + "active": "active", + "sub": "running", + "description": "OpenSSH server daemon", + "extra_field": "ignored" + } + ] + "#; + + let units: Vec = serde_json::from_str(raw).expect("valid JSON"); + assert_eq!(units.len(), 1); + assert_eq!(units[0].unit, "sshd.service"); + assert_eq!(units[0].active, "active"); + assert_eq!(units[0].sub, "running"); + } + + #[test] + fn filter_services_applies_all_filters() { + let cfg = Config { + load_filter: "loaded".to_string(), + active_filter: "active".to_string(), + sub_filter: "running".to_string(), + refresh_secs: 0, + show_help: false, + }; + let units = vec![ + SystemctlUnit { + unit: "a.service".to_string(), + load: "loaded".to_string(), + active: "active".to_string(), + sub: "running".to_string(), + description: String::new(), + }, + SystemctlUnit { + unit: "b.service".to_string(), + load: "loaded".to_string(), + active: "inactive".to_string(), + sub: "dead".to_string(), + description: String::new(), + }, + ]; + let out = filter_services(units, &cfg); + assert_eq!(out.len(), 1); + assert_eq!(out[0].unit, "a.service"); + } + + #[test] + fn filter_matches_supports_all_and_exact() { + assert!(filter_matches("running", "all")); + assert!(filter_matches("running", "running")); + assert!(!filter_matches("running", "dead")); + } + + #[test] + fn is_full_all_only_true_when_all_three_filters_are_all() { + let all_cfg = Config { + load_filter: "all".to_string(), + active_filter: "all".to_string(), + sub_filter: "all".to_string(), + refresh_secs: 0, + show_help: false, + }; + assert!(is_full_all(&all_cfg)); + + let partial_cfg = Config { + sub_filter: "running".to_string(), + ..all_cfg + }; + assert!(!is_full_all(&partial_cfg)); + } + + #[test] + fn should_fetch_all_only_false_for_default_running_filter_set() { + let default_cfg = Config { + load_filter: "all".to_string(), + active_filter: "active".to_string(), + sub_filter: "running".to_string(), + refresh_secs: 0, + show_help: false, + }; + assert!(!should_fetch_all(&default_cfg)); + + let sub_all = Config { + sub_filter: "all".to_string(), + ..default_cfg.clone() + }; + assert!(should_fetch_all(&sub_all)); + + let sub_exited = Config { + sub_filter: "exited".to_string(), + ..default_cfg.clone() + }; + assert!(should_fetch_all(&sub_exited)); + + let active_inactive = Config { + active_filter: "inactive".to_string(), + ..default_cfg.clone() + }; + assert!(should_fetch_all(&active_inactive)); + + let load_not_found = Config { + load_filter: "not-found".to_string(), + ..default_cfg + }; + assert!(should_fetch_all(&load_not_found)); + } + + #[test] + fn fetch_services_test_stub_returns_empty() { + let units = fetch_services(false).expect("stub should succeed"); + assert!(units.is_empty()); + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..c1cc65d --- /dev/null +++ b/src/types.rs @@ -0,0 +1,77 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! Shared domain and UI state types. + +use ratatui::prelude::Style; +use serde::Deserialize; + +/// JSON row returned by `systemctl list-units --output=json`. +#[derive(Debug, Clone, Deserialize)] +pub struct SystemctlUnit { + pub unit: String, + pub load: String, + pub active: String, + pub sub: String, + pub description: String, +} + +/// Render-ready row for the list table. +#[derive(Debug, Clone)] +pub struct UnitRow { + pub dot: char, + pub dot_style: Style, + pub unit: String, + pub load: String, + pub active: String, + pub sub: String, + pub description: String, + pub last_log: String, +} + +/// A single timestamped entry in the detail log view. +#[derive(Debug, Clone)] +pub struct DetailLogEntry { + pub time: String, + pub log: String, +} + +/// Background loading phase for the list view. +#[derive(Debug, Clone, Copy)] +pub enum LoadPhase { + Idle, + FetchingUnits, + FetchingLogs, +} + +/// High-level screen mode. +#[derive(Debug, Clone, Copy)] +pub enum ViewMode { + List, + Detail, +} + +/// Messages sent from the background worker thread to the UI thread. +pub enum WorkerMsg { + UnitsLoaded(Vec), + LogsProgress { + done: usize, + total: usize, + logs: Vec<(String, String)>, + }, + Finished, + Error(String), +}