use anyhow::Result;
use aranet_core::DiscoveredDevice;
use aranet_types::{CurrentReading, DeviceInfo, HistoryRecord, Status};
use owo_colors::OwoColorize;
use serde::Serialize;
use crate::cli::StyleMode;
use crate::style;
#[derive(Debug, Clone, Copy)]
pub struct FormatOptions {
pub no_color: bool,
pub fahrenheit: bool,
pub no_header: bool,
pub compact: bool,
pub bq: bool,
pub inhg: bool,
pub style: StyleMode,
}
impl Default for FormatOptions {
fn default() -> Self {
Self {
no_color: false,
fahrenheit: false,
no_header: false,
compact: false,
bq: false,
inhg: false,
style: StyleMode::Rich,
}
}
}
impl FormatOptions {
pub fn new(no_color: bool, fahrenheit: bool, style: StyleMode) -> Self {
let effective_no_color = no_color || style == StyleMode::Plain;
Self {
no_color: effective_no_color,
fahrenheit,
no_header: false,
compact: false,
bq: false,
inhg: false,
style,
}
}
pub fn is_rich(&self) -> bool {
self.style == StyleMode::Rich
}
#[allow(dead_code)]
pub fn is_plain(&self) -> bool {
self.style == StyleMode::Plain
}
pub fn with_no_header(mut self, no_header: bool) -> Self {
self.no_header = no_header;
self
}
pub fn with_compact(mut self, compact: bool) -> Self {
self.compact = compact;
self
}
pub fn with_bq(mut self, bq: bool) -> Self {
self.bq = bq;
self
}
pub fn with_inhg(mut self, inhg: bool) -> Self {
self.inhg = inhg;
self
}
pub fn as_json<T: serde::Serialize>(&self, value: &T) -> Result<String> {
let json = if self.compact {
serde_json::to_string(value)?
} else {
serde_json::to_string_pretty(value)?
};
Ok(json + "\n")
}
#[must_use]
pub fn format_temp(&self, celsius: f32) -> String {
let value = self.convert_temp(celsius);
let unit = if self.fahrenheit { "F" } else { "C" };
if self.is_plain() {
format!("{:.1}{}", value, unit)
} else {
format!("{:.1}°{}", value, unit)
}
}
#[must_use]
pub fn convert_temp(&self, celsius: f32) -> f32 {
if self.fahrenheit {
celsius * 9.0 / 5.0 + 32.0
} else {
celsius
}
}
#[must_use]
pub fn format_radon(&self, bq: u32) -> String {
if self.bq {
if self.is_plain() {
format!("{} Bq/m3", bq)
} else {
format!("{} Bq/m³", bq)
}
} else {
format!("{:.2} pCi/L", self.convert_radon(bq))
}
}
#[must_use]
pub fn radon_csv_header(&self) -> &'static str {
if self.bq { "radon_bq" } else { "radon_pci" }
}
#[must_use]
pub fn radon_unit(&self) -> &'static str {
if self.bq { "Bq/m3" } else { "pCi/L" }
}
#[must_use]
pub fn radon_display_unit(&self) -> &'static str {
if self.bq {
if self.is_plain() { "Bq/m3" } else { "Bq/m³" }
} else {
"pCi/L"
}
}
#[must_use]
pub fn convert_radon(&self, bq: u32) -> f32 {
if self.bq { bq as f32 } else { bq_to_pci(bq) }
}
#[must_use]
pub fn format_pressure(&self, hpa: f32) -> String {
if self.inhg {
format!("{:.2} inHg", self.convert_pressure(hpa))
} else {
format!("{:.1} hPa", hpa)
}
}
#[must_use]
pub fn pressure_csv_header(&self) -> &'static str {
if self.inhg {
"pressure_inhg"
} else {
"pressure_hpa"
}
}
#[must_use]
pub fn convert_pressure(&self, hpa: f32) -> f32 {
if self.inhg { hpa_to_inhg(hpa) } else { hpa }
}
}
#[must_use]
pub fn bq_to_pci(bq: u32) -> f32 {
bq as f32 * 0.027
}
#[must_use]
pub fn hpa_to_inhg(hpa: f32) -> f32 {
hpa * 0.02953
}
#[must_use]
pub fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
#[must_use]
pub fn format_status(status: Status, no_color: bool) -> String {
let label = match status {
Status::Green => "GREEN",
Status::Yellow => "AMBER",
Status::Red => "RED",
Status::Error => "ERROR",
_ => "UNKNOWN",
};
if no_color {
format!("[{}]", label)
} else {
match status {
Status::Green => format!("[{}]", label.green()),
Status::Yellow => format!("[{}]", label.yellow()),
Status::Red => format!("[{}]", label.red()),
Status::Error => format!("[{}]", label.dimmed()),
_ => format!("[{}]", label.dimmed()),
}
}
}
#[must_use]
pub fn format_age(seconds: u16) -> String {
if seconds < 60 {
format!("{}s ago", seconds)
} else {
format!("{}m {}s ago", seconds / 60, seconds % 60)
}
}
pub fn format_scan_json(devices: &[DiscoveredDevice], opts: &FormatOptions) -> Result<String> {
#[derive(Serialize)]
struct ScanResult<'a> {
count: usize,
devices: Vec<DeviceJson<'a>>,
}
#[derive(Serialize)]
struct DeviceJson<'a> {
name: Option<&'a str>,
address: &'a str,
identifier: &'a str,
rssi: Option<i16>,
device_type: Option<String>,
}
let result = ScanResult {
count: devices.len(),
devices: devices
.iter()
.map(|d| DeviceJson {
name: d.name.as_deref(),
address: &d.address,
identifier: &d.identifier,
rssi: d.rssi,
device_type: d.device_type.map(|t| format!("{:?}", t)),
})
.collect(),
};
opts.as_json(&result)
}
#[must_use]
pub fn format_scan_text_with_aliases(
devices: &[DiscoveredDevice],
opts: &FormatOptions,
aliases: Option<&std::collections::HashMap<String, String>>,
show_tips: bool,
) -> String {
use tabled::{Table, Tabled};
if devices.is_empty() {
return "No Aranet devices found.\n".to_string();
}
let use_plain_signal = opts.is_plain();
let has_aliases = aliases.is_some_and(|a| {
devices.iter().any(|d| {
let id_lower = d.identifier.to_lowercase();
a.values().any(|v| v.to_lowercase() == id_lower)
})
});
let header = if opts.is_rich() {
let count_display = if opts.no_color {
format!("{}", devices.len())
} else {
format!("{}", devices.len().to_string().green().bold())
};
format!("Found {} Aranet device(s)\n\n", count_display)
} else {
format!("Found {} Aranet device(s)\n\n", devices.len())
};
if has_aliases {
#[derive(Tabled)]
struct DeviceRowWithAlias {
#[tabled(rename = "Name")]
name: String,
#[tabled(rename = "Alias")]
alias: String,
#[tabled(rename = "Type")]
device_type: String,
#[tabled(rename = "Signal")]
signal: String,
#[tabled(rename = "Identifier")]
identifier: String,
}
let rows: Vec<DeviceRowWithAlias> = devices
.iter()
.map(|d| {
let name = d.name.as_deref().unwrap_or("Unknown");
let id_lower = d.identifier.to_lowercase();
let alias = aliases
.and_then(|a| {
a.iter()
.find(|(_, v)| v.to_lowercase() == id_lower)
.map(|(k, _)| k.clone())
})
.unwrap_or_else(|| "-".to_string());
DeviceRowWithAlias {
name: if opts.no_color {
name.to_string()
} else {
format!("{}", name.cyan())
},
alias,
device_type: d
.device_type
.map(|t| format!("{:?}", t))
.unwrap_or_else(|| "Unknown".to_string()),
signal: if use_plain_signal {
d.rssi
.map(|r| r.to_string())
.unwrap_or_else(|| "N/A".to_string())
} else {
style::format_signal_bar(d.rssi, opts.no_color)
},
identifier: d.identifier.clone(),
}
})
.collect();
let mut table = Table::new(rows);
style::apply_table_style(&mut table, opts.style);
let mut output = format!("{}{}\n", header, table);
if show_tips && !opts.is_plain() {
output.push_str(&format_scan_tips(opts.no_color));
}
output
} else {
#[derive(Tabled)]
struct DeviceRow {
#[tabled(rename = "Name")]
name: String,
#[tabled(rename = "Type")]
device_type: String,
#[tabled(rename = "Signal")]
signal: String,
#[tabled(rename = "Identifier")]
identifier: String,
}
let rows: Vec<DeviceRow> = devices
.iter()
.map(|d| {
let name = d.name.as_deref().unwrap_or("Unknown");
DeviceRow {
name: if opts.no_color {
name.to_string()
} else {
format!("{}", name.cyan())
},
device_type: d
.device_type
.map(|t| format!("{:?}", t))
.unwrap_or_else(|| "Unknown".to_string()),
signal: if use_plain_signal {
d.rssi
.map(|r| r.to_string())
.unwrap_or_else(|| "N/A".to_string())
} else {
style::format_signal_bar(d.rssi, opts.no_color)
},
identifier: d.identifier.clone(),
}
})
.collect();
let mut table = Table::new(rows);
style::apply_table_style(&mut table, opts.style);
let mut output = format!("{}{}\n", header, table);
if show_tips && !opts.is_plain() {
output.push_str(&format_scan_tips(opts.no_color));
}
output
}
}
#[must_use]
pub fn format_scan_tips(no_color: bool) -> String {
let tip_label = if no_color {
"Tip:".to_string()
} else {
format!("{}", "Tip:".yellow().bold())
};
format!(
"\n{} Use 'aranet alias set <name> <identifier>' to save a device alias\n Use 'aranet config set device <identifier>' to set as default\n",
tip_label
)
}
#[must_use]
#[allow(dead_code)]
pub fn format_scan_text(devices: &[DiscoveredDevice], opts: &FormatOptions) -> String {
format_scan_text_with_aliases(devices, opts, None, false)
}
#[must_use]
pub fn format_scan_csv(devices: &[DiscoveredDevice], opts: &FormatOptions) -> String {
let mut output = if opts.no_header {
String::new()
} else {
"name,address,identifier,rssi,device_type\n".to_string()
};
for device in devices {
output.push_str(&format!(
"{},{},{},{},{}\n",
csv_escape(device.name.as_deref().unwrap_or("")),
csv_escape(&device.address),
csv_escape(&device.identifier),
device.rssi.map(|r| r.to_string()).unwrap_or_default(),
device
.device_type
.map(|t| format!("{:?}", t))
.unwrap_or_default()
));
}
output
}
#[must_use]
pub fn format_reading_text(reading: &CurrentReading, opts: &FormatOptions) -> String {
format_reading_text_with_name(reading, opts, None)
}
#[must_use]
pub fn format_reading_text_with_name(
reading: &CurrentReading,
opts: &FormatOptions,
device_name: Option<&str>,
) -> String {
if opts.is_rich() {
return format_reading_rich(reading, opts, device_name);
}
let mut output = String::new();
if reading.co2 > 0 {
let co2_display = style::format_co2_colored(reading.co2, opts.no_color);
output.push_str(&format!(
"CO2: {:>5} ppm {}\n",
co2_display,
format_status(reading.status, opts.no_color)
));
}
if let Some(radon) = reading.radon {
let radon_display = if opts.bq {
style::format_radon_colored(radon, opts.no_color)
} else {
style::format_radon_pci_colored(radon, bq_to_pci(radon), opts.no_color)
};
output.push_str(&format!(
"Radon: {:>6} {} {}\n",
radon_display,
opts.radon_display_unit(),
format_status(reading.status, opts.no_color)
));
}
if let Some(avg_24h) = reading.radon_avg_24h {
output.push_str(&format!(
" 24h Avg: {:>10}\n",
opts.format_radon(avg_24h)
));
}
if let Some(avg_7d) = reading.radon_avg_7d {
output.push_str(&format!(" 7d Avg: {:>10}\n", opts.format_radon(avg_7d)));
}
if let Some(avg_30d) = reading.radon_avg_30d {
output.push_str(&format!(
" 30d Avg: {:>10}\n",
opts.format_radon(avg_30d)
));
}
if let Some(rate) = reading.radiation_rate {
output.push_str(&format!("Radiation: {:>5.3} uSv/h\n", rate));
}
if let Some(total) = reading.radiation_total {
output.push_str(&format!("Total Dose: {:>5.3} mSv\n", total));
}
if reading.temperature != 0.0 {
let unit = if opts.fahrenheit { "°F" } else { "°C" };
let temp_value = opts.convert_temp(reading.temperature);
let temp_display = if opts.no_color {
format!("{:.1}", temp_value)
} else {
style::format_temp_colored(temp_value, opts.no_color)
};
output.push_str(&format!("Temperature: {:>6} {}\n", temp_display, unit));
}
if reading.humidity > 0 {
let humidity_display = style::format_humidity_colored(reading.humidity, opts.no_color);
output.push_str(&format!("Humidity: {:>6}\n", humidity_display));
}
if reading.pressure != 0.0 {
output.push_str(&format!(
"Pressure: {:>10}\n",
opts.format_pressure(reading.pressure)
));
}
let battery_display = style::format_battery_colored(reading.battery, opts.no_color);
output.push_str(&format!("Battery: {:>6}\n", battery_display));
output.push_str(&format!("Last Update: {}\n", format_age(reading.age)));
output.push_str(&format!("Interval: {} minutes\n", reading.interval / 60));
output
}
#[must_use]
fn format_reading_rich(
reading: &CurrentReading,
opts: &FormatOptions,
device_name: Option<&str>,
) -> String {
use owo_colors::OwoColorize;
let mut output = String::new();
let title = device_name.unwrap_or("Sensor Reading");
if opts.no_color {
output.push_str(&format!(" {}\n", title));
output.push_str(&format!(" {}\n\n", "─".repeat(title.len())));
} else {
output.push_str(&format!(" {}\n", title.cyan().bold()));
output.push_str(&format!(" {}\n\n", "─".repeat(title.len()).dimmed()));
}
if reading.co2 > 0 {
let quality = style::air_quality_summary_colored(reading.co2, opts.no_color);
let bar = style::format_air_quality_bar(reading.co2, opts.no_color);
output.push_str(&format!(" Air Quality: {} {}\n\n", quality, bar));
}
let kv = |key: &str, value: &str| -> String {
if opts.no_color {
format!(" {:>11}: {}\n", key, value)
} else {
format!(" {:>11}: {}\n", key.dimmed(), value)
}
};
if reading.co2 > 0 {
let co2_display = style::format_co2_colored(reading.co2, opts.no_color);
let status = format_status(reading.status, opts.no_color);
output.push_str(&kv("CO2", &format!("{} ppm {}", co2_display, status)));
}
if let Some(radon) = reading.radon {
let radon_display = if opts.bq {
style::format_radon_colored(radon, opts.no_color)
} else {
style::format_radon_pci_colored(radon, bq_to_pci(radon), opts.no_color)
};
let status = format_status(reading.status, opts.no_color);
output.push_str(&kv(
"Radon",
&format!("{} {} {}", radon_display, opts.radon_display_unit(), status),
));
if let Some(avg_24h) = reading.radon_avg_24h {
output.push_str(&kv("24h Avg", &opts.format_radon(avg_24h)));
}
if let Some(avg_7d) = reading.radon_avg_7d {
output.push_str(&kv("7d Avg", &opts.format_radon(avg_7d)));
}
if let Some(avg_30d) = reading.radon_avg_30d {
output.push_str(&kv("30d Avg", &opts.format_radon(avg_30d)));
}
}
if let Some(rate) = reading.radiation_rate {
output.push_str(&kv("Radiation", &format!("{:.3} uSv/h", rate)));
}
if let Some(total) = reading.radiation_total {
output.push_str(&kv("Total Dose", &format!("{:.3} mSv", total)));
}
if reading.temperature != 0.0 {
let unit = if opts.fahrenheit { "°F" } else { "°C" };
let temp_value = opts.convert_temp(reading.temperature);
let temp_display = style::format_temp_colored(temp_value, opts.no_color);
output.push_str(&kv("Temperature", &format!("{} {}", temp_display, unit)));
}
if reading.humidity > 0 {
let humidity_display = style::format_humidity_colored(reading.humidity, opts.no_color);
output.push_str(&kv("Humidity", &humidity_display));
}
if reading.pressure != 0.0 {
output.push_str(&kv("Pressure", &opts.format_pressure(reading.pressure)));
}
output.push('\n');
let battery_display = style::format_battery_colored(reading.battery, opts.no_color);
output.push_str(&kv("Battery", &battery_display));
output.push_str(&kv("Updated", &format_age(reading.age)));
output.push_str(&kv("Interval", &format!("{} min", reading.interval / 60)));
output
}
#[must_use]
pub fn format_reading_csv(reading: &CurrentReading, opts: &FormatOptions) -> String {
let temp_header = if opts.fahrenheit {
"temperature_f"
} else {
"temperature_c"
};
let radon_value = reading
.radon
.map(|r| format!("{:.2}", opts.convert_radon(r)))
.unwrap_or_default();
let radiation_rate = reading
.radiation_rate
.map(|r| format!("{:.3}", r))
.unwrap_or_default();
let radiation_total = reading
.radiation_total
.map(|r| format!("{:.3}", r))
.unwrap_or_default();
if opts.no_header {
format!(
"{},{:.1},{},{:.2},{},{:?},{},{},{},{},{}\n",
reading.co2,
opts.convert_temp(reading.temperature),
reading.humidity,
opts.convert_pressure(reading.pressure),
reading.battery,
reading.status,
reading.age,
reading.interval,
radon_value,
radiation_rate,
radiation_total
)
} else {
format!(
"co2,{},humidity,{},battery,status,age,interval,{},radiation_usvh,radiation_msv\n\
{},{:.1},{},{:.2},{},{:?},{},{},{},{},{}\n",
temp_header,
opts.pressure_csv_header(),
opts.radon_csv_header(),
reading.co2,
opts.convert_temp(reading.temperature),
reading.humidity,
opts.convert_pressure(reading.pressure),
reading.battery,
reading.status,
reading.age,
reading.interval,
radon_value,
radiation_rate,
radiation_total
)
}
}
#[derive(Serialize)]
struct ReadingJsonCore {
co2: u16,
temperature: f32,
temperature_unit: &'static str,
humidity: u8,
pressure: f32,
pressure_unit: &'static str,
battery: u8,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
radon_bq: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
radon_pci: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
radiation_rate: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
radiation_total: Option<f64>,
}
impl ReadingJsonCore {
fn from_reading(reading: &CurrentReading, opts: &FormatOptions) -> Self {
Self {
co2: reading.co2,
temperature: opts.convert_temp(reading.temperature),
temperature_unit: if opts.fahrenheit { "F" } else { "C" },
humidity: reading.humidity,
pressure: opts.convert_pressure(reading.pressure),
pressure_unit: if opts.inhg { "inHg" } else { "hPa" },
battery: reading.battery,
status: format!("{:?}", reading.status),
radon_bq: reading.radon,
radon_pci: reading.radon.map(bq_to_pci),
radiation_rate: reading.radiation_rate,
radiation_total: reading.radiation_total,
}
}
}
pub fn format_reading_json(reading: &CurrentReading, opts: &FormatOptions) -> Result<String> {
#[derive(Serialize)]
struct ReadingJson {
#[serde(flatten)]
core: ReadingJsonCore,
age: u16,
interval: u16,
#[serde(skip_serializing_if = "Option::is_none")]
radon_avg_24h_bq: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
radon_avg_7d_bq: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
radon_avg_30d_bq: Option<u32>,
}
let json = ReadingJson {
core: ReadingJsonCore::from_reading(reading, opts),
age: reading.age,
interval: reading.interval,
radon_avg_24h_bq: reading.radon_avg_24h,
radon_avg_7d_bq: reading.radon_avg_7d,
radon_avg_30d_bq: reading.radon_avg_30d,
};
opts.as_json(&json)
}
use crate::commands::DeviceReading;
#[must_use]
pub fn format_multi_reading_text(readings: &[DeviceReading], opts: &FormatOptions) -> String {
let mut output = String::new();
for (i, dr) in readings.iter().enumerate() {
if i > 0 {
output.push('\n');
}
let dash = if opts.is_plain() { "--" } else { "──" };
if opts.no_color {
output.push_str(&format!("{} {} {}\n", dash, dr.identifier, dash));
} else {
output.push_str(&format!("{} {} {}\n", dash, dr.identifier.cyan(), dash));
}
output.push_str(&format_reading_text(&dr.reading, opts));
}
output
}
pub fn format_multi_reading_json(
readings: &[DeviceReading],
opts: &FormatOptions,
) -> Result<String> {
#[derive(Serialize)]
struct MultiReadingJson {
count: usize,
readings: Vec<DeviceReadingJson>,
}
#[derive(Serialize)]
struct DeviceReadingJson {
device: String,
#[serde(flatten)]
core: ReadingJsonCore,
age: u16,
interval: u16,
}
let json = MultiReadingJson {
count: readings.len(),
readings: readings
.iter()
.map(|dr| DeviceReadingJson {
device: dr.identifier.clone(),
core: ReadingJsonCore::from_reading(&dr.reading, opts),
age: dr.reading.age,
interval: dr.reading.interval,
})
.collect(),
};
opts.as_json(&json)
}
#[must_use]
pub fn format_multi_reading_csv(readings: &[DeviceReading], opts: &FormatOptions) -> String {
let temp_header = if opts.fahrenheit {
"temperature_f"
} else {
"temperature_c"
};
let mut output = if opts.no_header {
String::new()
} else {
format!(
"device,co2,{},humidity,{},battery,status,age,interval,{},radiation_usvh,radiation_msv\n",
temp_header,
opts.pressure_csv_header(),
opts.radon_csv_header()
)
};
for dr in readings {
let radon_value = dr
.reading
.radon
.map(|r| format!("{:.2}", opts.convert_radon(r)))
.unwrap_or_default();
let radiation_rate = dr
.reading
.radiation_rate
.map(|r| format!("{:.3}", r))
.unwrap_or_default();
let radiation_total = dr
.reading
.radiation_total
.map(|r| format!("{:.3}", r))
.unwrap_or_default();
output.push_str(&format!(
"{},{},{:.1},{},{:.2},{},{:?},{},{},{},{},{}\n",
csv_escape(&dr.identifier),
dr.reading.co2,
opts.convert_temp(dr.reading.temperature),
dr.reading.humidity,
opts.convert_pressure(dr.reading.pressure),
dr.reading.battery,
dr.reading.status,
dr.reading.age,
dr.reading.interval,
radon_value,
radiation_rate,
radiation_total
));
}
output
}
#[must_use]
pub fn format_info_text(info: &DeviceInfo, opts: &FormatOptions) -> String {
use tabled::builder::Builder;
let mut builder = Builder::default();
builder.push_record(["Property", "Value"]);
builder.push_record(["Name", &info.name]);
builder.push_record(["Model", &info.model]);
builder.push_record(["Serial", &info.serial]);
builder.push_record(["Firmware", &info.firmware]);
builder.push_record(["Hardware", &info.hardware]);
builder.push_record(["Software", &info.software]);
builder.push_record(["Manufacturer", &info.manufacturer]);
let mut table = builder.build();
style::apply_table_style(&mut table, opts.style);
let title = if opts.no_color {
"Device Information".to_string()
} else {
format!("{}", "Device Information".bold())
};
format!("{}\n{}\n", title, table)
}
#[must_use]
pub fn format_info_csv(info: &DeviceInfo, opts: &FormatOptions) -> String {
if opts.no_header {
format!(
"{},{},{},{},{},{},{}\n",
csv_escape(&info.name),
csv_escape(&info.model),
csv_escape(&info.serial),
csv_escape(&info.firmware),
csv_escape(&info.hardware),
csv_escape(&info.software),
csv_escape(&info.manufacturer)
)
} else {
format!(
"name,model,serial,firmware,hardware,software,manufacturer\n\
{},{},{},{},{},{},{}\n",
csv_escape(&info.name),
csv_escape(&info.model),
csv_escape(&info.serial),
csv_escape(&info.firmware),
csv_escape(&info.hardware),
csv_escape(&info.software),
csv_escape(&info.manufacturer)
)
}
}
#[must_use]
pub fn format_history_text(history: &[HistoryRecord], opts: &FormatOptions) -> String {
use tabled::builder::Builder;
if history.is_empty() {
return "No history records found.\n".to_string();
}
let is_radon = history.first().is_some_and(|r| r.radon.is_some());
let temp_header = if opts.fahrenheit {
"Temp (F)"
} else {
"Temp (C)"
};
let term_width = style::terminal_width();
let max_records = if term_width < 80 { 10 } else { 20 };
let mut output = format!("History ({} records):\n\n", history.len());
let mut builder = Builder::default();
let use_compact_ts = term_width < 100;
if is_radon {
builder.push_record(["Timestamp", "Radon", temp_header, "Humidity", "Pressure"]);
} else {
builder.push_record(["Timestamp", "CO2", temp_header, "Humidity", "Pressure"]);
}
for record in history.iter().take(max_records) {
let ts = if use_compact_ts {
record
.timestamp
.format(
&time::format_description::parse("[year]-[month]-[day] [hour]:[minute]")
.expect("valid format"),
)
.unwrap_or_else(|_| "Unknown".to_string())
} else {
record
.timestamp
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "Unknown".to_string())
};
let value = if let Some(radon) = record.radon {
opts.format_radon(radon)
} else {
format!("{} ppm", record.co2)
};
builder.push_record([
ts,
value,
opts.format_temp(record.temperature),
format!("{}%", record.humidity),
opts.format_pressure(record.pressure),
]);
}
let mut table = builder.build();
style::apply_table_style(&mut table, opts.style);
output.push_str(&table.to_string());
output.push('\n');
if history.len() > max_records {
output.push_str(&format!(
"... and {} more records\n",
history.len() - max_records
));
output.push_str("(Use --format csv or --format json for full data)\n");
}
output
}
#[must_use]
pub fn format_history_csv(history: &[HistoryRecord], opts: &FormatOptions) -> String {
let temp_header = if opts.fahrenheit {
"temperature_f"
} else {
"temperature_c"
};
let mut output = if opts.no_header {
String::new()
} else {
format!(
"timestamp,co2,{},humidity,{},{}\n",
temp_header,
opts.pressure_csv_header(),
opts.radon_csv_header()
)
};
for record in history {
let ts = record
.timestamp
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| String::new());
let radon_value = record
.radon
.map(|r| format!("{:.2}", opts.convert_radon(r)))
.unwrap_or_default();
output.push_str(&format!(
"{},{},{:.1},{},{:.2},{}\n",
ts,
record.co2,
opts.convert_temp(record.temperature),
record.humidity,
opts.convert_pressure(record.pressure),
radon_value
));
}
output
}
pub fn format_history_json(history: &[HistoryRecord], opts: &FormatOptions) -> Result<String> {
#[derive(Serialize)]
struct HistoryRecordJson {
timestamp: String,
co2: u16,
temperature: f32,
temperature_unit: &'static str,
humidity: u8,
pressure: f32,
pressure_unit: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
radon_bq: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
radon_pci: Option<f32>,
}
let records: Vec<HistoryRecordJson> = history
.iter()
.map(|r| {
let ts = r
.timestamp
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| String::new());
HistoryRecordJson {
timestamp: ts,
co2: r.co2,
temperature: opts.convert_temp(r.temperature),
temperature_unit: if opts.fahrenheit { "F" } else { "C" },
humidity: r.humidity,
pressure: opts.convert_pressure(r.pressure),
pressure_unit: if opts.inhg { "inHg" } else { "hPa" },
radon_bq: r.radon,
radon_pci: r.radon.map(bq_to_pci),
}
})
.collect();
opts.as_json(&records)
}
#[must_use]
#[allow(dead_code)] pub fn format_watch_line(reading: &CurrentReading, opts: &FormatOptions) -> String {
let now = time::OffsetDateTime::now_utc();
let ts = now
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "???".to_string());
let status = format_status(reading.status, opts.no_color);
let primary = if let Some(radon) = reading.radon {
opts.format_radon(radon)
} else if let Some(rate) = reading.radiation_rate {
format!("{:.3} µSv/h", rate)
} else {
format!("{} ppm", reading.co2)
};
let mut parts = vec![ts, status, primary];
if reading.temperature != 0.0 {
parts.push(opts.format_temp(reading.temperature));
}
if reading.humidity > 0 {
parts.push(format!("{}%", reading.humidity));
}
if reading.pressure > 0.0 {
parts.push(opts.format_pressure(reading.pressure));
}
parts.push(format!("BAT {}%", reading.battery));
parts.join(" ") + "\n"
}
#[must_use]
pub fn format_watch_csv_header(opts: &FormatOptions) -> String {
let temp_header = if opts.fahrenheit {
"temperature_f"
} else {
"temperature_c"
};
format!(
"timestamp,co2,{},humidity,{},battery,status,{},radiation_usvh\n",
temp_header,
opts.pressure_csv_header(),
opts.radon_csv_header()
)
}
#[must_use]
pub fn format_watch_csv_line(reading: &CurrentReading, opts: &FormatOptions) -> String {
let now = time::OffsetDateTime::now_utc();
let ts = now
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "???".to_string());
let radon_value = reading
.radon
.map(|r| format!("{:.2}", opts.convert_radon(r)))
.unwrap_or_default();
let radiation_rate = reading
.radiation_rate
.map(|r| format!("{:.3}", r))
.unwrap_or_default();
format!(
"{},{},{:.1},{},{:.2},{},{:?},{},{}\n",
ts,
reading.co2,
opts.convert_temp(reading.temperature),
reading.humidity,
opts.convert_pressure(reading.pressure),
reading.battery,
reading.status,
radon_value,
radiation_rate
)
}
#[must_use]
pub fn format_watch_line_with_device(
reading: &CurrentReading,
device_name: &str,
opts: &FormatOptions,
) -> String {
let timestamp = aranet_cli::local_now_fmt("[hour]:[minute]:[second]");
let status = format_status(reading.status, opts.no_color);
let device_display = if opts.no_color {
format!("[{}]", device_name)
} else {
format!("[{}]", device_name.cyan())
};
let primary = if let Some(radon) = reading.radon {
let radon_display = if opts.bq {
style::format_radon_colored(radon, opts.no_color)
} else {
style::format_radon_pci_colored(radon, bq_to_pci(radon), opts.no_color)
};
format!("{} {}", radon_display, opts.radon_unit())
} else if let Some(rate) = reading.radiation_rate {
format!("{:.3} uSv/h", rate)
} else if reading.co2 > 0 {
let co2_display = style::format_co2_colored(reading.co2, opts.no_color);
format!("{} ppm", co2_display)
} else {
String::new()
};
let mut parts = vec![format!("[{}]", timestamp), device_display, status];
if !primary.is_empty() {
parts.push(primary);
}
if reading.temperature != 0.0 {
let temp_display =
style::format_temp_colored(opts.convert_temp(reading.temperature), opts.no_color);
let unit = if opts.fahrenheit { "°F" } else { "°C" };
parts.push(format!("{}{}", temp_display, unit));
}
if reading.humidity > 0 {
let humidity_display = style::format_humidity_colored(reading.humidity, opts.no_color);
parts.push(humidity_display);
}
if reading.pressure > 0.0 {
parts.push(opts.format_pressure(reading.pressure));
}
let battery_display = style::format_battery_colored(reading.battery, opts.no_color);
parts.push(format!("BAT {}", battery_display));
parts.join(" ") + "\n"
}
#[must_use]
pub fn format_watch_csv_header_with_device(opts: &FormatOptions) -> String {
let temp_header = if opts.fahrenheit {
"temperature_f"
} else {
"temperature_c"
};
format!(
"timestamp,device,co2,{},humidity,{},battery,status,{},radiation_usvh\n",
temp_header,
opts.pressure_csv_header(),
opts.radon_csv_header()
)
}
#[must_use]
pub fn format_watch_csv_line_with_device(
reading: &CurrentReading,
device_name: &str,
opts: &FormatOptions,
) -> String {
let now = time::OffsetDateTime::now_utc();
let ts = now
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "???".to_string());
let radon_value = reading
.radon
.map(|r| format!("{:.2}", opts.convert_radon(r)))
.unwrap_or_default();
let radiation_rate = reading
.radiation_rate
.map(|r| format!("{:.3}", r))
.unwrap_or_default();
format!(
"{},{},{},{:.1},{},{:.2},{},{:?},{},{}\n",
ts,
device_name,
reading.co2,
opts.convert_temp(reading.temperature),
reading.humidity,
opts.convert_pressure(reading.pressure),
reading.battery,
reading.status,
radon_value,
radiation_rate
)
}
pub fn format_reading_json_with_device(
reading: &CurrentReading,
device_name: &str,
opts: &FormatOptions,
) -> Result<String> {
#[derive(Serialize)]
struct WatchReadingJson {
timestamp: String,
device: String,
co2: u16,
temperature: f32,
temperature_unit: &'static str,
humidity: u8,
pressure: f32,
pressure_unit: &'static str,
battery: u8,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
radon: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
radon_unit: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
radiation_rate: Option<f32>,
}
let now = time::OffsetDateTime::now_utc();
let ts = now
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "???".to_string());
let json = WatchReadingJson {
timestamp: ts,
device: device_name.to_string(),
co2: reading.co2,
temperature: opts.convert_temp(reading.temperature),
temperature_unit: if opts.fahrenheit { "F" } else { "C" },
humidity: reading.humidity,
pressure: opts.convert_pressure(reading.pressure),
pressure_unit: if opts.inhg { "inHg" } else { "hPa" },
battery: reading.battery,
status: format!("{:?}", reading.status),
radon: reading.radon.map(|r| opts.convert_radon(r)),
radon_unit: reading.radon.map(|_| opts.radon_unit()),
radiation_rate: reading.radiation_rate,
};
let output = if opts.compact {
serde_json::to_string(&json)?
} else {
serde_json::to_string_pretty(&json)?
};
Ok(output + "\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "macos")]
use aranet_types::DeviceType;
#[test]
fn test_format_status_green_no_color() {
let result = format_status(Status::Green, true);
assert_eq!(result, "[GREEN]");
}
#[test]
fn test_format_status_yellow_no_color() {
let result = format_status(Status::Yellow, true);
assert_eq!(result, "[AMBER]");
}
#[test]
fn test_format_status_red_no_color() {
let result = format_status(Status::Red, true);
assert_eq!(result, "[RED]");
}
#[test]
fn test_format_status_error_no_color() {
let result = format_status(Status::Error, true);
assert_eq!(result, "[ERROR]");
}
#[test]
fn test_format_status_with_color_contains_label() {
let result = format_status(Status::Green, false);
assert!(result.contains("GREEN"));
let result = format_status(Status::Yellow, false);
assert!(result.contains("AMBER"));
let result = format_status(Status::Red, false);
assert!(result.contains("RED"));
}
#[test]
fn test_format_age_seconds_only() {
assert_eq!(format_age(0), "0s ago");
assert_eq!(format_age(30), "30s ago");
assert_eq!(format_age(59), "59s ago");
}
#[test]
fn test_format_age_minutes_and_seconds() {
assert_eq!(format_age(60), "1m 0s ago");
assert_eq!(format_age(90), "1m 30s ago");
assert_eq!(format_age(125), "2m 5s ago");
assert_eq!(format_age(3600), "60m 0s ago");
}
#[cfg(target_os = "macos")]
fn make_test_peripheral_id() -> btleplug::platform::PeripheralId {
btleplug::platform::PeripheralId::from(uuid::Uuid::nil())
}
#[cfg(target_os = "macos")]
fn make_test_device(
name: Option<&str>,
address: &str,
rssi: Option<i16>,
device_type: Option<DeviceType>,
) -> DiscoveredDevice {
DiscoveredDevice {
name: name.map(|s| s.to_string()),
id: make_test_peripheral_id(),
address: address.to_string(),
identifier: address.to_string(),
rssi,
device_type,
is_aranet: true,
manufacturer_data: None,
}
}
fn test_opts() -> FormatOptions {
FormatOptions {
no_color: true,
..FormatOptions::default()
}
}
#[test]
fn test_format_scan_text_empty() {
let devices: Vec<DiscoveredDevice> = vec![];
let opts = test_opts();
let result = format_scan_text(&devices, &opts);
assert_eq!(result, "No Aranet devices found.\n");
}
#[test]
#[cfg(target_os = "macos")]
fn test_format_scan_text_single_device() {
let devices = vec![make_test_device(
Some("Aranet4 12345"),
"AA:BB:CC:DD:EE:FF",
Some(-50),
Some(DeviceType::Aranet4),
)];
let opts = test_opts();
let result = format_scan_text(&devices, &opts);
assert!(result.contains("Found 1 Aranet device(s)"));
assert!(result.contains("Aranet4 12345"));
assert!(result.contains("-50"));
assert!(result.contains("Aranet4"));
}
#[test]
fn test_format_scan_csv_header() {
let devices: Vec<DiscoveredDevice> = vec![];
let opts = test_opts();
let result = format_scan_csv(&devices, &opts);
assert!(result.starts_with("name,address,identifier,rssi,device_type\n"));
}
#[test]
#[cfg(target_os = "macos")]
fn test_format_scan_csv_no_header() {
let devices = vec![make_test_device(
Some("Aranet4 12345"),
"AA:BB:CC:DD:EE:FF",
Some(-50),
Some(DeviceType::Aranet4),
)];
let opts = FormatOptions {
no_header: true,
..test_opts()
};
let result = format_scan_csv(&devices, &opts);
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("Aranet4 12345"));
}
#[test]
#[cfg(target_os = "macos")]
fn test_format_scan_json_structure() {
let devices = vec![make_test_device(
Some("Aranet4 12345"),
"AA:BB:CC:DD:EE:FF",
Some(-50),
Some(DeviceType::Aranet4),
)];
let opts = FormatOptions::default();
let result = format_scan_json(&devices, &opts).unwrap();
assert!(result.contains("\"count\": 1"));
assert!(result.contains("\"devices\""));
assert!(result.contains("\"name\": \"Aranet4 12345\""));
}
fn make_aranet4_reading() -> CurrentReading {
CurrentReading {
co2: 800,
temperature: 22.5,
humidity: 45,
pressure: 1013.2,
battery: 85,
status: Status::Green,
interval: 300,
age: 120,
captured_at: None,
radon: None,
radiation_rate: None,
radiation_total: None,
radon_avg_24h: None,
radon_avg_7d: None,
radon_avg_30d: None,
}
}
#[test]
fn test_format_reading_text_aranet4() {
let reading = make_aranet4_reading();
let opts = test_opts();
let result = format_reading_text(&reading, &opts);
assert!(result.contains("CO2:"));
assert!(result.contains("800"));
assert!(result.contains("ppm"));
assert!(result.contains("[GREEN]"));
assert!(result.contains("Temperature:"));
assert!(result.contains("22.5"));
}
#[test]
fn test_format_reading_csv_header() {
let reading = make_aranet4_reading();
let opts = test_opts();
let result = format_reading_csv(&reading, &opts);
assert!(result.starts_with("co2,temperature_c,humidity,pressure_hpa,battery,status,age,interval,radon_pci,radiation_usvh,radiation_msv\n"));
}
#[test]
fn test_format_reading_json() {
let reading = make_aranet4_reading();
let opts = test_opts();
let result = format_reading_json(&reading, &opts).unwrap();
assert!(result.contains("\"co2\": 800"));
assert!(result.contains("\"temperature\": 22.5"));
assert!(result.contains("\"temperature_unit\": \"C\""));
}
fn make_test_device_info() -> DeviceInfo {
DeviceInfo {
name: "Aranet4 12345".to_string(),
model: "Aranet4".to_string(),
serial: "SN12345678".to_string(),
firmware: "v1.2.3".to_string(),
hardware: "v2.0".to_string(),
software: "v1.0".to_string(),
manufacturer: "SAF Tehnika".to_string(),
}
}
#[test]
fn test_format_info_text_contains_all_fields() {
let info = make_test_device_info();
let opts = test_opts();
let result = format_info_text(&info, &opts);
assert!(result.contains("Device Information"));
assert!(result.contains("Aranet4 12345"));
assert!(result.contains("SN12345678"));
assert!(result.contains("SAF Tehnika"));
}
#[test]
fn test_format_info_csv_header() {
let info = make_test_device_info();
let opts = test_opts();
let result = format_info_csv(&info, &opts);
assert!(result.starts_with("name,model,serial,firmware,hardware,software,manufacturer\n"));
}
#[test]
fn test_format_temp_celsius() {
let opts = test_opts();
assert_eq!(opts.format_temp(22.5), "22.5°C");
}
#[test]
fn test_format_temp_fahrenheit() {
let opts = FormatOptions {
fahrenheit: true,
..test_opts()
};
assert_eq!(opts.format_temp(0.0), "32.0°F");
assert_eq!(opts.format_temp(100.0), "212.0°F");
}
#[test]
fn test_convert_temp_celsius() {
let opts = test_opts();
assert_eq!(opts.convert_temp(22.5), 22.5);
}
#[test]
fn test_convert_temp_fahrenheit() {
let opts = FormatOptions {
fahrenheit: true,
..test_opts()
};
assert!((opts.convert_temp(0.0) - 32.0).abs() < 0.01);
assert!((opts.convert_temp(100.0) - 212.0).abs() < 0.01);
}
#[test]
fn test_bq_to_pci_zero() {
assert_eq!(bq_to_pci(0), 0.0);
}
#[test]
fn test_bq_to_pci_100() {
assert!((bq_to_pci(100) - 2.7).abs() < 0.01);
}
#[test]
fn test_bq_to_pci_epa_action_level() {
assert!((bq_to_pci(148) - 4.0).abs() < 0.01);
}
#[test]
fn test_bq_to_pci_large_value() {
assert!((bq_to_pci(1000) - 27.0).abs() < 0.01);
}
#[test]
fn test_hpa_to_inhg_zero() {
assert_eq!(hpa_to_inhg(0.0), 0.0);
}
#[test]
fn test_hpa_to_inhg_standard_pressure() {
assert!((hpa_to_inhg(1013.25) - 29.92).abs() < 0.01);
}
#[test]
fn test_hpa_to_inhg_high_pressure() {
assert!((hpa_to_inhg(1030.0) - 30.42).abs() < 0.01);
}
#[test]
fn test_hpa_to_inhg_low_pressure() {
assert!((hpa_to_inhg(980.0) - 28.94).abs() < 0.01);
}
#[test]
fn test_csv_escape_simple() {
assert_eq!(csv_escape("hello"), "hello");
assert_eq!(csv_escape("hello world"), "hello world");
}
#[test]
fn test_csv_escape_empty() {
assert_eq!(csv_escape(""), "");
}
#[test]
fn test_csv_escape_comma() {
assert_eq!(csv_escape("a,b"), "\"a,b\"");
assert_eq!(csv_escape("one,two,three"), "\"one,two,three\"");
}
#[test]
fn test_csv_escape_quote() {
assert_eq!(csv_escape("say \"hello\""), "\"say \"\"hello\"\"\"");
assert_eq!(csv_escape("\""), "\"\"\"\"");
}
#[test]
fn test_csv_escape_newline() {
assert_eq!(csv_escape("line1\nline2"), "\"line1\nline2\"");
assert_eq!(csv_escape("line1\rline2"), "\"line1\rline2\"");
}
#[test]
fn test_csv_escape_combined() {
assert_eq!(csv_escape("a,\"b\"\nc"), "\"a,\"\"b\"\"\nc\"");
}
#[test]
fn test_format_options_default() {
let opts = FormatOptions::default();
assert!(!opts.no_color);
assert!(!opts.fahrenheit);
assert!(!opts.no_header);
assert!(!opts.compact);
assert!(!opts.bq);
assert!(!opts.inhg);
assert_eq!(opts.style, StyleMode::Rich);
}
#[test]
fn test_format_options_with_no_header() {
let opts = FormatOptions::default().with_no_header(true);
assert!(opts.no_header);
}
#[test]
fn test_format_options_with_compact() {
let opts = FormatOptions::default().with_compact(true);
assert!(opts.compact);
}
#[test]
fn test_format_options_with_bq() {
let opts = FormatOptions::default().with_bq(true);
assert!(opts.bq);
}
#[test]
fn test_format_options_with_inhg() {
let opts = FormatOptions::default().with_inhg(true);
assert!(opts.inhg);
}
#[test]
fn test_format_options_chained() {
let opts = FormatOptions::default()
.with_no_header(true)
.with_compact(true)
.with_bq(true)
.with_inhg(true);
assert!(opts.no_header);
assert!(opts.compact);
assert!(opts.bq);
assert!(opts.inhg);
}
#[test]
fn test_format_options_is_rich() {
let opts = FormatOptions::default();
assert!(opts.is_rich());
let opts = FormatOptions::new(false, false, StyleMode::Plain);
assert!(!opts.is_rich());
}
#[test]
fn test_format_options_is_plain() {
let opts = FormatOptions::new(false, false, StyleMode::Plain);
assert!(opts.is_plain());
let opts = FormatOptions::default();
assert!(!opts.is_plain());
}
#[test]
fn test_format_radon_pci_mode() {
let opts = test_opts(); let result = opts.format_radon(100);
assert!(result.contains("2.70 pCi/L"));
}
#[test]
fn test_format_radon_bq_mode() {
let opts = FormatOptions {
bq: true,
..test_opts()
};
let result = opts.format_radon(100);
assert!(result.contains("100 Bq/m"));
}
#[test]
fn test_format_radon_plain_mode_ascii() {
let opts = FormatOptions {
bq: true,
style: StyleMode::Plain,
..test_opts()
};
let result = opts.format_radon(100);
assert_eq!(result, "100 Bq/m3");
}
#[test]
fn test_convert_radon_pci() {
let opts = test_opts();
assert!((opts.convert_radon(100) - 2.7).abs() < 0.01);
}
#[test]
fn test_convert_radon_bq() {
let opts = FormatOptions {
bq: true,
..test_opts()
};
assert_eq!(opts.convert_radon(100), 100.0);
}
#[test]
fn test_format_pressure_hpa() {
let opts = test_opts(); let result = opts.format_pressure(1013.25);
assert_eq!(result, "1013.2 hPa");
}
#[test]
fn test_format_pressure_inhg() {
let opts = FormatOptions {
inhg: true,
..test_opts()
};
let result = opts.format_pressure(1013.25);
assert!(result.contains("29.92 inHg"));
}
#[test]
fn test_convert_pressure_hpa() {
let opts = test_opts();
assert_eq!(opts.convert_pressure(1013.25), 1013.25);
}
#[test]
fn test_convert_pressure_inhg() {
let opts = FormatOptions {
inhg: true,
..test_opts()
};
assert!((opts.convert_pressure(1013.25) - 29.92).abs() < 0.01);
}
#[test]
fn test_format_options_new_plain_mode_disables_color() {
let opts = FormatOptions::new(false, false, StyleMode::Plain);
assert!(opts.no_color);
}
#[test]
fn test_format_options_new_with_fahrenheit() {
let opts = FormatOptions::new(false, true, StyleMode::Rich);
assert!(opts.fahrenheit);
assert!(!opts.no_color);
}
#[test]
fn test_format_temp_plain_mode_no_degree_symbol() {
let opts = FormatOptions::new(false, false, StyleMode::Plain);
let result = opts.format_temp(22.5);
assert_eq!(result, "22.5C");
}
#[test]
fn test_format_temp_plain_fahrenheit() {
let opts = FormatOptions::new(false, true, StyleMode::Plain);
let result = opts.format_temp(0.0);
assert_eq!(result, "32.0F");
}
#[test]
fn test_radon_csv_header() {
let opts = test_opts();
assert_eq!(opts.radon_csv_header(), "radon_pci");
let opts = FormatOptions {
bq: true,
..test_opts()
};
assert_eq!(opts.radon_csv_header(), "radon_bq");
}
#[test]
fn test_radon_unit() {
let opts = test_opts();
assert_eq!(opts.radon_unit(), "pCi/L");
let opts = FormatOptions {
bq: true,
..test_opts()
};
assert_eq!(opts.radon_unit(), "Bq/m3");
}
#[test]
fn test_radon_display_unit() {
let opts = test_opts();
assert_eq!(opts.radon_display_unit(), "pCi/L");
let opts = FormatOptions {
bq: true,
..FormatOptions::default()
};
assert_eq!(opts.radon_display_unit(), "Bq/m³");
let opts = FormatOptions {
bq: true,
style: StyleMode::Plain,
..test_opts()
};
assert_eq!(opts.radon_display_unit(), "Bq/m3");
}
#[test]
fn test_pressure_csv_header() {
let opts = test_opts();
assert_eq!(opts.pressure_csv_header(), "pressure_hpa");
let opts = FormatOptions {
inhg: true,
..test_opts()
};
assert_eq!(opts.pressure_csv_header(), "pressure_inhg");
}
}