use std::io::{Read, Write};
use anyhow::{Context, Result};
use aranet_store::{HistoryQuery, Store};
use time::OffsetDateTime;
use crate::cli::{CacheAction, ExportFormat, OutputArgs, OutputFormat};
use crate::config::Config;
use crate::format::{FormatOptions, format_history_csv, format_history_json, format_history_text};
pub fn cmd_cache(action: CacheAction, config: &Config) -> Result<()> {
if matches!(action, CacheAction::Info) {
return show_info();
}
let store = Store::open_default().context("Failed to open database")?;
match action {
CacheAction::Devices => list_devices(&store),
CacheAction::Stats { device } => show_stats(&store, device.as_deref()),
CacheAction::History {
device,
count,
since,
until,
output,
} => query_history(&store, &device, count, since, until, output, config),
CacheAction::Aggregate {
device,
since,
until,
format,
} => show_aggregate_stats(&store, &device, since, until, format),
CacheAction::Export {
device,
format,
output,
since,
until,
} => export_history(&store, &device, format, output, since, until),
CacheAction::Prune {
older_than,
history_only,
force,
vacuum,
} => prune_data(&store, &older_than, history_only, force, vacuum),
CacheAction::Info => unreachable!("Handled above"),
CacheAction::Import { format, input } => import_history(&store, format, input),
}
}
fn list_devices(store: &Store) -> Result<()> {
let devices = store.list_devices()?;
if devices.is_empty() {
println!("No devices in cache. Run 'aranet sync' to cache device data.");
return Ok(());
}
println!("Cached devices:\n");
for device in devices {
let name = device.name.as_deref().unwrap_or("(unnamed)");
let device_type = device
.device_type
.map(|dt| format!("{:?}", dt))
.unwrap_or_else(|| "Unknown".to_string());
println!(" {} - {} ({})", device.id, name, device_type);
println!(
" First seen: {}",
device
.first_seen
.format(&time::format_description::well_known::Rfc3339)?
);
println!(
" Last seen: {}",
device
.last_seen
.format(&time::format_description::well_known::Rfc3339)?
);
println!();
}
Ok(())
}
fn show_stats(store: &Store, device_id: Option<&str>) -> Result<()> {
let total_readings = store.count_readings(device_id)?;
let total_history = store.count_history(device_id)?;
match device_id {
Some(id) => {
println!("Cache statistics for {}:", id);
if let Some(state) = store.get_sync_state(id)? {
if let Some(last_sync) = state.last_sync_at {
println!(
" Last sync: {}",
last_sync.format(&time::format_description::well_known::Rfc3339)?
);
}
if let Some(idx) = state.last_history_index {
println!(" Last history index: {}", idx);
}
}
}
None => {
println!("Cache statistics (all devices):");
}
}
println!(" Readings: {}", total_readings);
println!(" History records: {}", total_history);
Ok(())
}
fn query_history(
store: &Store,
device_id: &str,
count: u32,
since: Option<String>,
until: Option<String>,
output: OutputArgs,
config: &Config,
) -> Result<()> {
let mut query = HistoryQuery::new().device(device_id);
if count > 0 {
query = query.limit(count);
}
if let Some(since_str) = since {
let ts = parse_datetime(&since_str)?;
query = query.since(ts);
}
if let Some(until_str) = until {
let ts = parse_datetime(&until_str)?;
query = query.until(ts);
}
let records = store.query_history(&query)?;
if records.is_empty() {
println!("No history records found for {}", device_id);
return Ok(());
}
let history: Vec<_> = records.iter().map(|r| r.to_history()).collect();
let fahrenheit = output.resolve_fahrenheit(config.fahrenheit);
let bq = output.resolve_bq(config.bq);
let inhg = output.resolve_inhg(config.inhg);
let opts = FormatOptions::new(false, fahrenheit, crate::cli::StyleMode::Rich)
.with_no_header(output.no_header)
.with_bq(bq)
.with_inhg(inhg);
let format = output.format.unwrap_or(crate::cli::OutputFormat::Text);
let formatted = match format {
crate::cli::OutputFormat::Json => format_history_json(&history, &opts)?,
crate::cli::OutputFormat::Csv => format_history_csv(&history, &opts),
crate::cli::OutputFormat::Text => format_history_text(&history, &opts),
};
print!("{}", formatted);
Ok(())
}
fn show_info() -> Result<()> {
let db_path = aranet_store::default_db_path();
println!("Database path: {}", db_path.display());
if db_path.exists() {
let metadata = std::fs::metadata(&db_path)?;
let size_kb = metadata.len() / 1024;
println!("Database size: {} KB", size_kb);
} else {
println!("Database does not exist yet. Run 'aranet sync' to create it.");
}
Ok(())
}
fn parse_datetime(s: &str) -> Result<OffsetDateTime> {
if let Ok(dt) = OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) {
return Ok(dt);
}
let format = time::format_description::parse("[year]-[month]-[day]")?;
if let Ok(date) = time::Date::parse(s, &format) {
return Ok(date.with_hms(0, 0, 0)?.assume_utc());
}
anyhow::bail!("Invalid date/time format: {}. Use RFC3339 or YYYY-MM-DD", s)
}
fn show_aggregate_stats(
store: &Store,
device_id: &str,
since: Option<String>,
until: Option<String>,
format: OutputFormat,
) -> Result<()> {
let mut query = HistoryQuery::new().device(device_id);
if let Some(since_str) = since {
let ts = parse_datetime(&since_str)?;
query = query.since(ts);
}
if let Some(until_str) = until {
let ts = parse_datetime(&until_str)?;
query = query.until(ts);
}
let stats = store.history_stats(&query)?;
if stats.count == 0 {
println!("No history records found for {}", device_id);
return Ok(());
}
match format {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&stats)?;
println!("{}", json);
}
_ => {
println!("Aggregate statistics for {}:", device_id);
println!(" Records: {}", stats.count);
if let Some((start, end)) = stats.time_range {
let rfc3339 = time::format_description::well_known::Rfc3339;
println!(
" Time range: {} to {}",
start.format(&rfc3339)?,
end.format(&rfc3339)?
);
}
println!();
println!(" {:12} {:>10} {:>10} {:>10}", "", "Min", "Max", "Avg");
println!(" {}", "-".repeat(46));
if let (Some(min), Some(max), Some(avg)) = (stats.min.co2, stats.max.co2, stats.avg.co2)
{
println!(
" {:12} {:>10.0} {:>10.0} {:>10.1} ppm",
"CO2", min, max, avg
);
}
if let (Some(min), Some(max), Some(avg)) = (
stats.min.temperature,
stats.max.temperature,
stats.avg.temperature,
) {
println!(
" {:12} {:>10.1} {:>10.1} {:>10.1} C",
"Temperature", min, max, avg
);
}
if let (Some(min), Some(max), Some(avg)) =
(stats.min.pressure, stats.max.pressure, stats.avg.pressure)
{
println!(
" {:12} {:>10.1} {:>10.1} {:>10.1} hPa",
"Pressure", min, max, avg
);
}
if let (Some(min), Some(max), Some(avg)) =
(stats.min.humidity, stats.max.humidity, stats.avg.humidity)
{
println!(
" {:12} {:>10.0} {:>10.0} {:>10.1} %",
"Humidity", min, max, avg
);
}
if let (Some(min), Some(max), Some(avg)) =
(stats.min.radon, stats.max.radon, stats.avg.radon)
{
println!(
" {:12} {:>10.0} {:>10.0} {:>10.1} Bq/m3",
"Radon", min, max, avg
);
}
}
}
Ok(())
}
fn export_history(
store: &Store,
device_id: &str,
format: ExportFormat,
output: Option<std::path::PathBuf>,
since: Option<String>,
until: Option<String>,
) -> Result<()> {
let mut query = HistoryQuery::new().device(device_id);
if let Some(since_str) = since {
let ts = parse_datetime(&since_str)?;
query = query.since(ts);
}
if let Some(until_str) = until {
let ts = parse_datetime(&until_str)?;
query = query.until(ts);
}
let content = match format {
ExportFormat::Csv => store.export_history_csv(&query)?,
ExportFormat::Json => store.export_history_json(&query)?,
};
match output {
Some(path) => {
let mut file = std::fs::File::create(&path)
.with_context(|| format!("Failed to create file: {}", path.display()))?;
file.write_all(content.as_bytes())?;
println!("Exported to {}", path.display());
}
None => {
print!("{}", content);
}
}
Ok(())
}
fn import_history(
store: &Store,
format: ExportFormat,
input: Option<std::path::PathBuf>,
) -> Result<()> {
let data = match input {
Some(path) => std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read file: {}", path.display()))?,
None => {
let mut buffer = String::new();
std::io::stdin()
.read_to_string(&mut buffer)
.context("Failed to read from stdin")?;
buffer
}
};
let result = match format {
ExportFormat::Csv => store.import_history_csv(&data)?,
ExportFormat::Json => store.import_history_json(&data)?,
};
println!("Import complete:");
println!(" Total records: {}", result.total);
println!(" Imported: {}", result.imported);
println!(" Skipped (duplicates): {}", result.skipped);
if !result.errors.is_empty() {
println!("\nErrors ({}):", result.errors.len());
for (i, err) in result.errors.iter().enumerate().take(10) {
println!(" {}", err);
if i == 9 && result.errors.len() > 10 {
println!(" ... and {} more errors", result.errors.len() - 10);
}
}
}
Ok(())
}
fn parse_duration(s: &str) -> Result<time::Duration> {
let s = s.trim();
if s.is_empty() {
anyhow::bail!("Duration cannot be empty");
}
let (num_str, unit) = s.split_at(s.len() - 1);
let num: i64 = num_str
.parse()
.with_context(|| format!("Invalid number in duration: {}", s))?;
match unit {
"h" => Ok(time::Duration::hours(num)),
"d" => Ok(time::Duration::days(num)),
"w" => Ok(time::Duration::weeks(num)),
"m" => Ok(time::Duration::days(num * 30)),
"y" => Ok(time::Duration::days(num * 365)),
_ => anyhow::bail!(
"Unknown duration unit '{}'. Use h (hours), d (days), w (weeks), m (months), y (years)",
unit
),
}
}
fn prune_data(
store: &Store,
older_than: &str,
history_only: bool,
force: bool,
vacuum: bool,
) -> Result<()> {
let duration = parse_duration(older_than)?;
let cutoff = OffsetDateTime::now_utc() - duration;
let cutoff_str = cutoff.format(&time::format_description::well_known::Rfc3339)?;
if !force {
println!(
"This will delete all {} records before {}",
if history_only {
"history"
} else {
"history and reading"
},
cutoff_str
);
print!("Continue? [y/N] ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(());
}
}
let history_deleted = store.prune_history(cutoff)?;
println!("Deleted {} history records", history_deleted);
if !history_only {
let readings_deleted = store.prune_readings(cutoff)?;
println!("Deleted {} readings", readings_deleted);
}
if vacuum {
println!("Running VACUUM to reclaim disk space...");
store.vacuum()?;
println!("Done.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_datetime_rfc3339() {
let result = parse_datetime("2024-01-15T10:30:00Z").unwrap();
assert_eq!(result.year(), 2024);
assert_eq!(result.month(), time::Month::January);
assert_eq!(result.day(), 15);
assert_eq!(result.hour(), 10);
assert_eq!(result.minute(), 30);
assert_eq!(result.second(), 0);
}
#[test]
fn test_parse_datetime_rfc3339_with_offset() {
let result = parse_datetime("2024-01-15T10:30:00+05:00").unwrap();
assert_eq!(result.year(), 2024);
assert_eq!(result.month(), time::Month::January);
assert_eq!(result.day(), 15);
}
#[test]
fn test_parse_datetime_date_only() {
let result = parse_datetime("2024-01-15").unwrap();
assert_eq!(result.year(), 2024);
assert_eq!(result.month(), time::Month::January);
assert_eq!(result.day(), 15);
assert_eq!(result.hour(), 0);
assert_eq!(result.minute(), 0);
assert_eq!(result.second(), 0);
}
#[test]
fn test_parse_datetime_invalid() {
assert!(parse_datetime("invalid").is_err());
assert!(parse_datetime("2024/01/15").is_err()); assert!(parse_datetime("").is_err());
assert!(parse_datetime("not-a-date").is_err());
}
#[test]
fn test_parse_datetime_error_message() {
let result = parse_datetime("bad-date");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Invalid date/time format"));
}
#[test]
fn test_parse_datetime_edge_dates() {
let result = parse_datetime("2024-01-01").unwrap();
assert_eq!(result.month(), time::Month::January);
assert_eq!(result.day(), 1);
let result = parse_datetime("2024-12-31").unwrap();
assert_eq!(result.month(), time::Month::December);
assert_eq!(result.day(), 31);
}
}