Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
[dependencies]
lexopt = "0.3.2"
pci-ids = "0.2.6"
libc = "0.2"
rustix = { version = "1", features = ["fs"] }

[dev-dependencies]
tempfile = "3"

[lints.clippy]
all = "warn" # all lint group includes correctness, suspicious, style, complexity, and perf
all = "warn"
pedantic = "warn"
cognitive_complexity = "warn" # part of restriction lint group
cognitive_complexity = "warn"
1 change: 0 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ pub struct Opts {
pub slot_filter: SlotFilter, // Service selection and ordering
}

// ... [Rest of cli.rs remains exactly the same] ...
/// `Opts` implements the command-line options parser
impl Opts {
/// `from_args()` parses `argv` into [`Opts`], printing usage and exiting on any error
Expand Down
27 changes: 2 additions & 25 deletions src/core/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ use std::io;
/// `AppError` is the application-level error type returned by all service `collect()` calls
#[derive(Debug)]
pub enum AppError {
/// Wraps a standard I/O error (e.g., file not found, permission denied)
Io(io::Error),
/// Returned when a value cannot be parsed from its raw string representation
Parse(String),
/// Returned when an expected data source is absent or returns no usable content
DataUnavailable(String),
}

Expand All @@ -19,7 +15,6 @@ impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(e) => write!(f, "IO error: {e}"),
Self::Parse(e) => write!(f, "Parse error: {e}"),
Self::DataUnavailable(s) => write!(f, "Data unavailable: {s}"),
}
}
Expand Down Expand Up @@ -55,25 +50,6 @@ mod tests {
assert!(app_err.to_string().contains("file not found"));
}

// --- AppError::Parse test

#[test]
/// `display_parse_includes_message()` asserts that the `Parse` variant of `AppError` includes
/// the underlying parse error message
///
fn display_parse_includes_message() {
let err = AppError::Parse("bad integer value".to_string());
assert_eq!(err.to_string(), "Parse error: bad integer value");
}

#[test]
/// `display_parse_empty_message()` asserts that an empty message is handled correctly
///
fn display_parse_empty_message() {
let err = AppError::Parse(String::new());
assert_eq!(err.to_string(), "Parse error: ");
}

// AppError::DataUnavailable test

#[test]
Expand Down Expand Up @@ -113,7 +89,8 @@ mod tests {
/// `std::error::Error`
///
fn app_error_is_boxable_as_std_error() {
let err: Box<dyn std::error::Error> = Box::new(AppError::Parse("test".to_string()));
let err: Box<dyn std::error::Error> =
Box::new(AppError::DataUnavailable("test".to_string()));
assert!(!err.to_string().is_empty());
}
}
39 changes: 39 additions & 0 deletions src/core/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ pub fn read_hex_u16(path: &Path) -> Option<u16> {
u16::from_str_radix(value, 16).ok()
}

/// `c_char_array_to_string()` safely converts a fixed-size C character array (null-terminated)
/// from a `libc` struct into a Rust `String`.
///
pub fn c_char_array_to_string(c_array: &[libc::c_char]) -> String {
c_array
.iter()
.map(|&c| c.cast_unsigned())
.take_while(|&c| c != 0)
.map(|c| c as char)
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -191,4 +203,31 @@ mod tests {
let f = temp_file_with(" 0x10DE \n");
assert_eq!(read_hex_u16(f.path()), Some(0x10DE_u16));
}

#[test]
/// `c_char_array_to_string_extracts_null_terminated_string()` asserts that it extracts up to
/// the null terminator
///
fn c_char_array_to_string_extracts_null_terminated_string() {
let arr: [libc::c_char; 8] = [116, 101, 115, 116, 0, 0, 0, 0]; // "test\0\0\0\0"
assert_eq!(c_char_array_to_string(&arr), "test");
}

#[test]
/// `c_char_array_to_string_handles_full_array_no_null()` asserts that it handles an array with
/// no null terminator
///
fn c_char_array_to_string_handles_full_array_no_null() {
let arr: [libc::c_char; 4] = [102, 111, 111, 0]; // "foo\0"
assert_eq!(c_char_array_to_string(&arr), "foo");
}

#[test]
/// `c_char_array_to_string_empty_array_returns_empty_string()` asserts that an array starting
/// with null returns empty
///
fn c_char_array_to_string_empty_array_returns_empty_string() {
let arr: [libc::c_char; 4] = [0, 0, 0, 0];
assert_eq!(c_char_array_to_string(&arr), "");
}
}
16 changes: 8 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,6 @@ impl ServiceEntry {
/// `build_registry()` constructs one [`ServiceEntry`] for every known service, wiring
/// in user-supplied options
///
/// When adding/removing services, this is the one location in this file that needs to change...
/// Also, one entry in `slot::SLOT_TABLE` needs to be added and a new module created
/// under `services/`
///
fn build_registry(opts: &Opts) -> HashMap<ServiceSlot, ServiceEntry> {
let mut map = HashMap::new();
map.insert(ServiceSlot::Os, ServiceEntry::new(OsService));
Expand Down Expand Up @@ -88,9 +84,9 @@ fn build_registry(opts: &Opts) -> HashMap<ServiceSlot, ServiceEntry> {
/// not be collected or rendered
///
fn render_service_error(id: ServiceSlot, error: &AppError, colors: &Colors) {
let label = format!(" {}:", id.token());
let value = format!("unavailable ({error})");
print_row(&label, &value, &Threshold::None, colors);
let label = id.label();
let value = format!("n/a - {error}");
print_row(label, &value, &Threshold::None, colors);
}

/// `render_labeled()` prints the token reference table (output of `-s` with no argument)
Expand Down Expand Up @@ -188,7 +184,11 @@ fn render_services(
continue;
};

if let Err(e) = entry.service.render_erased(data.as_ref(), colors) {
// Render the service
if let Err(e) = entry
.service
.render_erased(id.label(), data.as_ref(), colors)
{
render_service_error(id, &e, colors);
}
}
Expand Down
35 changes: 23 additions & 12 deletions src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ pub trait Service {
///
fn collect(&self) -> Result<Self::Data, AppError>;

/// `render()` formats and prints `data` to stdout using the provided colors
/// `render()` formats and prints `data` to stdout using the provided label and colors
///
fn render(&self, data: &Self::Data, colors: &Colors);
fn render(&self, label: &str, data: &Self::Data, colors: &Colors);
}

/// `AnyService` is an object-safe, type-erased counterpart to [`Service`]
Expand All @@ -46,10 +46,15 @@ pub trait AnyService: Send + Sync {
fn collect_erased(&self) -> Result<Box<dyn Any + Send>, AppError>;

/// `render_erased()` downcasts `data` back to the underlying service's `Data` type
/// and calls `render()`
/// and calls `render()` with the provided label
///
#[must_use = "a type mismatch produces Err; callers must handle the failure case"]
fn render_erased(&self, data: &(dyn Any + Send), colors: &Colors) -> Result<(), AppError>;
fn render_erased(
&self,
label: &str,
data: &(dyn Any + Send),
colors: &Colors,
) -> Result<(), AppError>;
}

/// Blanket implementation: every [`Service`] is automatically an [`AnyService`]
Expand All @@ -67,15 +72,20 @@ where

/// `render_erased()` downcasts `data` to `S::Data` and calls `render()`
///
fn render_erased(&self, data: &(dyn Any + Send), colors: &Colors) -> Result<(), AppError> {
fn render_erased(
&self,
label: &str,
data: &(dyn Any + Send),
colors: &Colors,
) -> Result<(), AppError> {
let typed = data.downcast_ref::<S::Data>().ok_or_else(|| {
AppError::DataUnavailable(format!(
"render type mismatch: expected {}, got incompatible Any",
std::any::type_name::<S::Data>()
))
})?;

self.render(typed, colors);
self.render(label, typed, colors);
Ok(())
}
}
Expand All @@ -97,7 +107,7 @@ mod tests {
Ok(DoubleData)
}

fn render(&self, _data: &Self::Data, _colors: &Colors) {
fn render(&self, _label: &str, _data: &Self::Data, _colors: &Colors) {
// no-op: render output goes to stdout; only the Result matters here
}
}
Expand Down Expand Up @@ -145,7 +155,7 @@ mod tests {
#[test]
fn render_erased_correct_type_returns_ok() {
let data: Box<dyn Any + Send> = Box::new(DoubleData);
let result = DoubleService.render_erased(data.as_ref(), &Colors::new(false));
let result = DoubleService.render_erased(" Test:", data.as_ref(), &Colors::new(false));
assert!(result.is_ok(), "render with the correct type must succeed");
}

Expand All @@ -158,7 +168,8 @@ mod tests {
let collected = DoubleService
.collect_erased()
.expect("collect must not fail");
let result = DoubleService.render_erased(collected.as_ref(), &Colors::new(false));
let result =
DoubleService.render_erased(" Test:", collected.as_ref(), &Colors::new(false));
assert!(
result.is_ok(),
"a correct collect→render round-trip must always succeed"
Expand All @@ -173,7 +184,7 @@ mod tests {
#[test]
fn render_erased_wrong_type_returns_err_not_panics() {
let wrong: Box<dyn Any + Send> = Box::new("this is not DoubleData");
let result = DoubleService.render_erased(wrong.as_ref(), &Colors::new(false));
let result = DoubleService.render_erased(" Test:", wrong.as_ref(), &Colors::new(false));
assert!(
result.is_err(),
"a type mismatch must produce Err, not a panic"
Expand All @@ -187,7 +198,7 @@ mod tests {
fn render_erased_type_mismatch_produces_data_unavailable() {
let wrong: Box<dyn Any + Send> = Box::new(99_u8);
let err = DoubleService
.render_erased(wrong.as_ref(), &Colors::new(false))
.render_erased(" Test:", wrong.as_ref(), &Colors::new(false))
.unwrap_err();
assert!(
matches!(err, AppError::DataUnavailable(_)),
Expand All @@ -202,7 +213,7 @@ mod tests {
fn render_erased_error_message_names_expected_type() {
let wrong: Box<dyn Any + Send> = Box::new(99_u8);
let err = DoubleService
.render_erased(wrong.as_ref(), &Colors::new(false))
.render_erased(" Test:", wrong.as_ref(), &Colors::new(false))
.unwrap_err();
let msg = err.to_string();
assert!(
Expand Down
6 changes: 3 additions & 3 deletions src/services/cpu_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ impl Service for CpuModelService {

/// `render()` renders the CPU model name
///
fn render(&self, data: &Self::Data, c: &Colors) {
fn render(&self, label: &str, data: &Self::Data, c: &Colors) {
print_row(
" CPU:",
label,
data.model.as_deref().unwrap_or("Unknown"),
&Threshold::None,
c,
Expand Down Expand Up @@ -87,6 +87,6 @@ mod tests {
///
fn render_does_not_panic() {
let data = CpuModelService.collect().unwrap();
CpuModelService.render(&data, &Colors::new(false));
CpuModelService.render(" CPU:", &data, &Colors::new(false));
}
}
4 changes: 2 additions & 2 deletions src/services/cpu_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ impl Service for CpuUsageService {

/// `render()` renders CPU utilization with threshold-based color coding
///
fn render(&self, data: &Self::Data, c: &Colors) {
fn render(&self, label: &str, data: &Self::Data, c: &Colors) {
let (cpu_str, cpu_thresh) = data.usage_pct.map_or_else(
|| ("n/a".to_string(), Threshold::None),
|v| {
Expand All @@ -50,7 +50,7 @@ impl Service for CpuUsageService {
},
);

print_row(" CPU usage:", &cpu_str, &cpu_thresh, c);
print_row(label, &cpu_str, &cpu_thresh, c);
}
}

Expand Down
Loading