use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
pub struct Calibration {
#[serde(default)]
pub yaw_offset_deg: f32,
}
pub fn state_file_path() -> Option<PathBuf> {
let base = if let Some(xdg) = std::env::var_os("XDG_STATE_HOME").filter(|s| !s.is_empty()) {
PathBuf::from(xdg)
} else {
let home = std::env::var_os("HOME").filter(|s| !s.is_empty())?;
PathBuf::from(home).join(".local/state")
};
Some(base.join("turret").join("calibration.toml"))
}
impl Calibration {
pub fn load() -> Self {
let Some(path) = state_file_path() else {
warn!("No HOME or XDG_STATE_HOME set; calibration cannot be loaded, using defaults");
return Self::default();
};
let text = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
debug!(
"No calibration file at {} — using defaults (this is expected on first run)",
path.display()
);
return Self::default();
}
Err(e) => {
warn!(
"Failed to read calibration from {}: {} — using defaults",
path.display(),
e
);
return Self::default();
}
};
match toml::from_str::<Self>(&text) {
Ok(cal) => {
if !cal.yaw_offset_deg.is_finite() {
warn!(
"Calibration file {} contained non-finite yaw_offset_deg ({}); \
using defaults",
path.display(),
cal.yaw_offset_deg
);
return Self::default();
}
info!(
"Loaded calibration from {}: yaw_offset_deg={:.3}",
path.display(),
cal.yaw_offset_deg
);
cal
}
Err(e) => {
warn!(
"Calibration file {} is malformed: {} — using defaults",
path.display(),
e
);
Self::default()
}
}
}
pub fn save(&self) -> std::io::Result<()> {
let path = state_file_path().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no $HOME or $XDG_STATE_HOME — cannot pick a state-file path",
)
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let payload = toml::to_string_pretty(self).map_err(std::io::Error::other)?;
std::fs::write(&path, payload)?;
debug!(
"Persisted calibration to {}: yaw_offset_deg={:.3}",
path.display(),
self.yaw_offset_deg
);
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
struct EnvFixture {
_guard: std::sync::MutexGuard<'static, ()>,
tmp: std::path::PathBuf,
prev_xdg: Option<std::ffi::OsString>,
prev_home: Option<std::ffi::OsString>,
}
impl EnvFixture {
fn new() -> Self {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let guard = LOCK.lock().unwrap_or_else(|p| p.into_inner());
let pid = std::process::id();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = std::env::temp_dir().join(format!("turret-cal-test-{pid}-{n}"));
std::fs::create_dir_all(&tmp).unwrap();
let prev_xdg = std::env::var_os("XDG_STATE_HOME");
let prev_home = std::env::var_os("HOME");
std::env::set_var("XDG_STATE_HOME", &tmp);
std::env::remove_var("HOME");
Self {
_guard: guard,
tmp,
prev_xdg,
prev_home,
}
}
}
impl Drop for EnvFixture {
fn drop(&mut self) {
match self.prev_xdg.take() {
Some(v) => std::env::set_var("XDG_STATE_HOME", v),
None => std::env::remove_var("XDG_STATE_HOME"),
}
match self.prev_home.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
if std::fs::remove_dir_all(&self.tmp).is_err() {
}
}
}
#[test]
fn load_returns_default_when_file_missing() {
let fx = EnvFixture::new();
let cal = Calibration::load();
assert_eq!(cal, Calibration::default());
let path = state_file_path().unwrap();
assert!(path.starts_with(&fx.tmp));
assert!(!path.exists());
}
#[test]
fn save_then_load_round_trips() {
let _fx = EnvFixture::new();
let cal = Calibration {
yaw_offset_deg: -4.5,
};
cal.save().unwrap();
let loaded = Calibration::load();
assert!((loaded.yaw_offset_deg - -4.5).abs() < 1e-6);
}
#[test]
fn load_rejects_non_finite_yaw_offset() {
let _fx = EnvFixture::new();
let path = state_file_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"yaw_offset_deg = nan\n").unwrap();
assert_eq!(Calibration::load(), Calibration::default());
std::fs::write(&path, b"yaw_offset_deg = inf\n").unwrap();
assert_eq!(Calibration::load(), Calibration::default());
std::fs::write(&path, b"yaw_offset_deg = -inf\n").unwrap();
assert_eq!(Calibration::load(), Calibration::default());
}
#[test]
fn load_returns_default_on_malformed_toml() {
let _fx = EnvFixture::new();
let path = state_file_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"this is = not = valid = toml").unwrap();
assert_eq!(Calibration::load(), Calibration::default());
}
#[test]
fn missing_yaw_offset_field_uses_default() {
let _fx = EnvFixture::new();
let path = state_file_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"").unwrap();
assert_eq!(Calibration::load().yaw_offset_deg, 0.0);
}
}