use std::collections::HashSet;
use std::fs::{self, OpenOptions};
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use serde_json::{Map, Value};
use crate::cli::{SnapshotFormat, SnapshotIncludes};
pub mod collector;
pub mod options;
pub mod query;
pub mod serializers;
pub use collector::{DefaultSnapshotCollector, SnapshotCollector, collect_once};
#[allow(unused_imports)]
pub use options::{
SNAPSHOT_SCHEMA_VERSION, Snapshot, SnapshotError, SnapshotHardFailure, SnapshotOptions,
};
pub async fn run(opts: SnapshotOptions) -> Result<()> {
let collector = Arc::new(DefaultSnapshotCollector::new());
run_with_collector(opts, collector).await
}
pub async fn run_with_collector<C: SnapshotCollector + 'static>(
opts: SnapshotOptions,
collector: Arc<C>,
) -> Result<()> {
let writer_is_stdout = opts.output.as_deref().is_none_or(|p| p == "-");
let stdout_is_tty = io::stdout().is_terminal();
let sample_count = match opts.format {
SnapshotFormat::Prometheus => {
if opts.samples > 1 {
eprintln!(
"warning: --samples > 1 is ignored for --format prometheus (single scrape)"
);
}
1
}
_ => opts.samples.max(1),
};
let mut snapshots: Vec<Snapshot> = Vec::with_capacity(sample_count as usize);
for i in 0..sample_count {
if i > 0 && !opts.interval.is_zero() {
tokio::time::sleep(opts.interval).await;
}
let snap = collect_once(collector.clone(), &opts.includes, opts.timeout_per_reader).await;
snapshots.push(snap);
}
let hard_failure = snapshots.iter().all(|s| s.device_count() == 0);
if hard_failure {
return Err(anyhow::Error::new(SnapshotHardFailure));
}
let rendered = render(&opts, &snapshots, stdout_is_tty)?;
if writer_is_stdout {
let stdout = io::stdout();
let mut handle = stdout.lock();
handle
.write_all(rendered.as_bytes())
.context("failed to write snapshot to stdout")?;
handle.flush().ok();
} else {
let path = opts.output.as_deref().unwrap();
write_output_atomic(Path::new(path), &rendered)?;
}
Ok(())
}
fn write_output_atomic(final_path: &Path, contents: &str) -> Result<()> {
let tmp_path = pick_tmp_path(final_path);
let file_result = {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.custom_flags(libc::O_NOFOLLOW)
.mode(0o600)
.open(&tmp_path)
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.share_mode(0)
.open(&tmp_path)
}
#[cfg(not(any(unix, windows)))]
{
OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)
}
};
let mut file = file_result
.with_context(|| format!("failed to create snapshot temp file {}", tmp_path.display()))?;
if let Err(e) = file.write_all(contents.as_bytes()) {
let _ = fs::remove_file(&tmp_path);
return Err(e)
.with_context(|| format!("failed to write snapshot data to {}", tmp_path.display()));
}
if let Err(e) = file.sync_all() {
let _ = fs::remove_file(&tmp_path);
return Err(e)
.with_context(|| format!("failed to fsync snapshot temp file {}", tmp_path.display()));
}
drop(file);
if let Err(e) = fs::rename(&tmp_path, final_path) {
let _ = fs::remove_file(&tmp_path);
return Err(e).with_context(|| {
format!(
"failed to rename snapshot temp file {} -> {}",
tmp_path.display(),
final_path.display()
)
});
}
Ok(())
}
fn pick_tmp_path(final_path: &Path) -> PathBuf {
let base = {
let mut p = final_path.as_os_str().to_os_string();
p.push(".tmp");
PathBuf::from(p)
};
if !base.exists() {
return base;
}
for i in 1..=64 {
let mut p = final_path.as_os_str().to_os_string();
p.push(format!(".tmp.{i}"));
let candidate = PathBuf::from(p);
if !candidate.exists() {
return candidate;
}
}
base
}
fn render(opts: &SnapshotOptions, snapshots: &[Snapshot], stdout_is_tty: bool) -> Result<String> {
match opts.format {
SnapshotFormat::Json => {
let pretty = opts.effective_pretty(stdout_is_tty);
serializers::json::render(snapshots, pretty)
}
SnapshotFormat::Csv => serializers::csv::render(opts, snapshots),
SnapshotFormat::Prometheus => serializers::prometheus::render(snapshots),
}
}
pub fn sanitize_json_floats(v: &mut Value) {
match v {
Value::Number(n) => {
if let Some(f) = n.as_f64()
&& !f.is_finite()
{
*v = Value::Null;
}
}
Value::Array(arr) => {
for item in arr.iter_mut() {
sanitize_json_floats(item);
}
}
Value::Object(obj) => {
for item in obj.values_mut() {
sanitize_json_floats(item);
}
}
_ => {}
}
}
pub fn augment_device_json(section: &str, index: usize, mut value: Value) -> Value {
if let Value::Object(ref mut map) = value {
map.entry("index")
.or_insert(Value::Number(serde_json::Number::from(index)));
map.entry("section")
.or_insert(Value::String(section.to_string()));
} else {
let mut obj = Map::new();
obj.insert("index".to_string(), Value::Number(index.into()));
obj.insert("section".to_string(), Value::String(section.to_string()));
obj.insert("value".to_string(), value);
return Value::Object(obj);
}
value
}
fn bucket<T: serde::Serialize>(section: &'static str, items: &[T]) -> Vec<Value> {
let mut out = Vec::with_capacity(items.len());
for (i, item) in items.iter().enumerate() {
match serde_json::to_value(item) {
Ok(mut v) => {
sanitize_json_floats(&mut v);
out.push(augment_device_json(section, i, v));
}
Err(e) => {
eprintln!(
"snapshot: skipping {section} device index {i}: serialization failed: {e}"
);
}
}
}
out
}
pub fn buckets_for_csv(
snap: &Snapshot,
includes: &SnapshotIncludes,
) -> Vec<(&'static str, Vec<Value>)> {
let mut buckets: Vec<(&'static str, Vec<Value>)> = Vec::new();
if includes.gpu
&& let Some(gpus) = snap.gpus.as_ref()
{
buckets.push(("gpu", bucket("gpu", gpus)));
}
if includes.cpu
&& let Some(cpus) = snap.cpus.as_ref()
{
buckets.push(("cpu", bucket("cpu", cpus)));
}
if includes.memory
&& let Some(mems) = snap.memory.as_ref()
{
buckets.push(("memory", bucket("memory", mems)));
}
if includes.chassis
&& let Some(ch) = snap.chassis.as_ref()
{
buckets.push(("chassis", bucket("chassis", ch)));
}
if includes.process
&& let Some(procs) = snap.processes.as_ref()
{
buckets.push(("process", bucket("process", procs)));
}
if includes.storage
&& let Some(sto) = snap.storage.as_ref()
{
buckets.push(("storage", bucket("storage", sto)));
}
buckets
}
pub fn default_csv_columns() -> Vec<&'static str> {
vec![
"section",
"index",
"hostname",
"name",
"uuid",
"utilization",
"used_memory",
"total_memory",
"temperature",
"power_consumption",
]
}
pub fn effective_csv_columns(opts: &SnapshotOptions) -> Vec<String> {
if opts.query.is_empty() {
default_csv_columns()
.into_iter()
.map(String::from)
.collect()
} else {
let mut seen: HashSet<String> = HashSet::new();
let mut out = Vec::with_capacity(opts.query.len());
for c in &opts.query {
let trimmed = c.trim();
if trimmed.is_empty() {
continue;
}
if seen.insert(trimmed.to_string()) {
out.push(trimmed.to_string());
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn augment_injects_index_and_section() {
let raw = serde_json::json!({ "name": "gpu0" });
let out = augment_device_json("gpu", 3, raw);
assert_eq!(out["index"], serde_json::json!(3));
assert_eq!(out["section"], serde_json::json!("gpu"));
assert_eq!(out["name"], serde_json::json!("gpu0"));
}
#[test]
fn augment_preserves_existing_index() {
let raw = serde_json::json!({ "name": "gpu0", "index": 99 });
let out = augment_device_json("gpu", 0, raw);
assert_eq!(out["index"], serde_json::json!(99));
}
#[test]
fn augment_wraps_primitive_in_object() {
let raw = serde_json::json!(42);
let out = augment_device_json("cpu", 1, raw);
assert!(out.is_object());
assert_eq!(out["index"], serde_json::json!(1));
assert_eq!(out["section"], serde_json::json!("cpu"));
assert_eq!(out["value"], serde_json::json!(42));
}
#[test]
fn default_csv_columns_is_non_empty_and_unique() {
let cols = default_csv_columns();
assert!(!cols.is_empty());
let as_set: HashSet<&&'static str> = cols.iter().collect();
assert_eq!(as_set.len(), cols.len(), "columns must be unique");
}
#[test]
fn effective_csv_columns_dedups_user_query() {
let opts = SnapshotOptions {
query: vec![
"name".to_string(),
"utilization".to_string(),
"name".to_string(),
" ".to_string(),
],
..Default::default()
};
let cols = effective_csv_columns(&opts);
assert_eq!(cols, vec!["name".to_string(), "utilization".to_string()]);
}
#[test]
fn sanitize_json_floats_replaces_nan_and_infinities() {
let mut v = serde_json::json!({
"a": 1.0,
"b": serde_json::Value::Null,
"nested": {
"arr": [1.0, 2.0]
}
});
if let Value::Object(ref mut map) = v {
map.insert("nan_field".to_string(), serde_json::json!(0.0));
}
let mut tree = serde_json::json!({
"finite": 2.5,
"null_val": null,
"str_val": "hello",
"arr": [1.0, 2.0]
});
sanitize_json_floats(&mut tree);
assert_eq!(tree["finite"], serde_json::json!(2.5));
assert!(tree["null_val"].is_null());
assert_eq!(tree["str_val"], serde_json::json!("hello"));
assert_eq!(tree["arr"][0], serde_json::json!(1.0));
}
#[test]
fn sanitize_json_floats_handles_nested_arrays_and_objects() {
let mut v = serde_json::json!({
"outer": [
{ "inner": 1.0 },
{ "inner": 2.0 }
]
});
sanitize_json_floats(&mut v);
assert_eq!(v["outer"][0]["inner"], serde_json::json!(1.0));
assert_eq!(v["outer"][1]["inner"], serde_json::json!(2.0));
}
#[test]
fn pick_tmp_path_returns_base_when_not_existing() {
let pid = std::process::id();
let base = std::path::PathBuf::from(format!("/tmp/all-smi-pick-test-{pid}.json"));
let _ = std::fs::remove_file(&base);
let tmp = {
let mut p = base.as_os_str().to_os_string();
p.push(".tmp");
std::path::PathBuf::from(p)
};
let _ = std::fs::remove_file(&tmp);
let result = pick_tmp_path(&base);
assert_eq!(result, tmp);
}
#[test]
fn write_output_atomic_creates_file_and_contents_match() {
let pid = std::process::id();
let path = std::path::PathBuf::from(format!("/tmp/all-smi-atomic-test-{pid}.json"));
let _ = std::fs::remove_file(&path);
let contents = r#"{"test":true}"#;
write_output_atomic(&path, contents).expect("atomic write should succeed");
let read_back = std::fs::read_to_string(&path).expect("file must exist after atomic write");
assert_eq!(read_back, contents);
let _ = std::fs::remove_file(&path);
}
}