use std::time::Duration;
use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::cli::{SnapshotArgs, SnapshotFormat, SnapshotIncludes};
use crate::common::config_file::SnapshotSettings;
use crate::device::{ChassisInfo, CpuInfo, GpuInfo, MemoryInfo, ProcessInfo};
use crate::storage::info::StorageInfo;
pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone)]
pub struct SnapshotOptions {
pub format: SnapshotFormat,
pub pretty: Option<bool>,
pub includes: SnapshotIncludes,
pub query: Vec<String>,
pub samples: u32,
pub interval: Duration,
pub timeout_per_reader: Duration,
pub output: Option<String>,
}
impl Default for SnapshotOptions {
fn default() -> Self {
Self {
format: SnapshotFormat::Json,
pretty: None,
includes: SnapshotIncludes {
gpu: true,
cpu: true,
memory: true,
chassis: true,
process: false,
storage: false,
},
query: Vec::new(),
samples: 1,
interval: Duration::from_secs(0),
timeout_per_reader: Duration::from_millis(5_000),
output: None,
}
}
}
impl SnapshotOptions {
#[allow(dead_code)]
pub fn from_args(args: &SnapshotArgs) -> Result<Self> {
Self::from_args_with_settings(args, None)
}
pub fn from_args_with_settings(
args: &SnapshotArgs,
settings: Option<&SnapshotSettings>,
) -> Result<Self> {
let includes = args
.includes()
.map_err(|msg| anyhow::anyhow!("invalid --include: {msg}"))?;
if includes.is_empty() {
anyhow::bail!("at least one section must be requested via --include");
}
if args.samples == 0 {
anyhow::bail!("--samples must be >= 1");
}
let format = match settings.map(|s| s.default_format.as_str()) {
Some("csv") if args.format == SnapshotFormat::Json => SnapshotFormat::Csv,
Some("prometheus") if args.format == SnapshotFormat::Json => SnapshotFormat::Prometheus,
Some("json") | Some(_) | None => args.format,
};
let pretty = args.pretty.or_else(|| settings.map(|s| s.default_pretty));
Ok(Self {
format,
pretty,
includes,
query: args.query.iter().map(|s| s.trim().to_string()).collect(),
samples: args.samples,
interval: Duration::from_secs(args.interval),
timeout_per_reader: Duration::from_millis(args.timeout_ms),
output: args.output.clone(),
})
}
pub fn effective_pretty(&self, stdout_is_tty: bool) -> bool {
match self.pretty {
Some(b) => b,
None => stdout_is_tty,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotError {
pub section: String,
pub kind: String,
pub message: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub schema: u32,
pub timestamp: String,
pub hostname: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpus: Option<Vec<GpuInfo>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpus: Option<Vec<CpuInfo>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<Vec<MemoryInfo>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chassis: Option<Vec<ChassisInfo>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub processes: Option<Vec<ProcessInfo>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<Vec<StorageInfo>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<SnapshotError>,
}
impl Snapshot {
pub(crate) fn new(hostname: String) -> Self {
Self {
schema: SNAPSHOT_SCHEMA_VERSION,
timestamp: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
hostname,
gpus: None,
cpus: None,
memory: None,
chassis: None,
processes: None,
storage: None,
errors: Vec::new(),
}
}
pub fn device_count(&self) -> usize {
self.gpus.as_ref().map_or(0, Vec::len)
+ self.cpus.as_ref().map_or(0, Vec::len)
+ self.memory.as_ref().map_or(0, Vec::len)
+ self.chassis.as_ref().map_or(0, Vec::len)
+ self.processes.as_ref().map_or(0, Vec::len)
+ self.storage.as_ref().map_or(0, Vec::len)
}
}
#[derive(Debug)]
pub struct SnapshotHardFailure;
impl std::fmt::Display for SnapshotHardFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"no devices were collected from any reader — snapshot is empty"
)
}
}
impl std::error::Error for SnapshotHardFailure {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_options_defaults_to_gpu_cpu_memory_chassis() {
let opts = SnapshotOptions::default();
assert!(opts.includes.gpu);
assert!(opts.includes.cpu);
assert!(opts.includes.memory);
assert!(opts.includes.chassis);
assert!(!opts.includes.process);
assert!(!opts.includes.storage);
}
#[test]
fn effective_pretty_resolves_auto_tty() {
let mut opts = SnapshotOptions::default();
assert!(opts.effective_pretty(true));
assert!(!opts.effective_pretty(false));
opts.pretty = Some(false);
assert!(!opts.effective_pretty(true));
opts.pretty = Some(true);
assert!(opts.effective_pretty(false));
}
#[test]
fn snapshot_options_from_settings_uses_config_format() {
use crate::cli::SnapshotArgs;
use crate::common::config_file::SnapshotSettings;
let args = SnapshotArgs {
format: SnapshotFormat::Json,
pretty: None,
include: vec!["gpu".to_string()],
query: Vec::new(),
samples: 1,
interval: 0,
timeout_ms: 5_000,
output: None,
};
let settings = SnapshotSettings {
default_format: "csv".to_string(),
default_pretty: false,
};
let opts = SnapshotOptions::from_args_with_settings(&args, Some(&settings)).unwrap();
assert_eq!(opts.format, SnapshotFormat::Csv);
assert_eq!(opts.pretty, Some(false));
}
#[test]
fn snapshot_options_cli_pretty_overrides_config() {
use crate::cli::SnapshotArgs;
use crate::common::config_file::SnapshotSettings;
let args = SnapshotArgs {
format: SnapshotFormat::Json,
pretty: Some(true),
include: vec!["gpu".to_string()],
query: Vec::new(),
samples: 1,
interval: 0,
timeout_ms: 5_000,
output: None,
};
let settings = SnapshotSettings {
default_format: "json".to_string(),
default_pretty: false,
};
let opts = SnapshotOptions::from_args_with_settings(&args, Some(&settings)).unwrap();
assert_eq!(opts.pretty, Some(true), "CLI must win over config");
}
#[test]
fn snapshot_device_count_adds_across_sections() {
let mut snap = Snapshot::new("host".to_string());
assert_eq!(snap.device_count(), 0);
snap.cpus = Some(vec![]);
assert_eq!(snap.device_count(), 0);
snap.storage = Some(vec![StorageInfo {
mount_point: "/".to_string(),
total_bytes: 1,
available_bytes: 1,
host_id: "h".to_string(),
hostname: "h".to_string(),
index: 0,
}]);
assert_eq!(snap.device_count(), 1);
}
}