use crate::fmt_time;
use serde::{Deserialize, Serialize};
use std::{
env, fs,
io::Read,
path::{Path, PathBuf},
time::{Duration, SystemTime},
};
use tracing::{debug, info, warn};
pub const CURRENT_CAL_SCHEMA_VERSION: u16 = 1;
const CAL_FILE_NAME: &str = "cal.json";
const DEFAULT_RECORD_STORE_RELATIVE_PATH: &str = ".deimos/records";
const DEIMOS_CONTROLS_RECORD_BASE_URL: &str = "https://deimoscontrols.com/records";
const MAX_CAL_JSON_BYTES: u64 = 5 * 1024 * 1024;
const CAL_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct CalRecordCore {
pub schema_version: u16,
pub peripheral_kind: String,
pub model_number: u64,
pub serial_number: u64,
pub procedure: String,
pub procedure_version: u16,
pub generated_at_utc: String,
pub calibrators: Vec<String>,
}
impl CalRecordCore {
pub fn new(
peripheral_kind: impl Into<String>,
model_number: u64,
serial_number: u64,
procedure: impl Into<String>,
procedure_version: u16,
calibrators: Vec<String>,
) -> Self {
Self {
schema_version: CURRENT_CAL_SCHEMA_VERSION,
peripheral_kind: peripheral_kind.into(),
model_number,
serial_number,
procedure: procedure.into(),
procedure_version,
generated_at_utc: fmt_time(SystemTime::now()),
calibrators,
}
}
}
pub fn query_cals<P: AsRef<Path>>(
slug: &str,
local_sources: &[P],
offline_only: bool,
) -> Result<Option<String>, String> {
let slug_segments = validate_cal_slug(slug)?;
debug!(slug, "Querying calibration record.");
if let Some(default_store) = default_cal_store() {
if let Some(cals) = try_read_local_cal(&default_store, &slug_segments)? {
info!(
slug,
source = %default_store.display(),
"Discovered calibration record in default local store."
);
return Ok(Some(cals));
}
} else {
debug!(
slug,
"No home directory available for default calibration store."
);
}
for local_source in local_sources {
if let Some(cals) = try_read_local_cal(local_source.as_ref(), &slug_segments)? {
info!(
slug,
source = %local_source.as_ref().display(),
"Discovered calibration record in local store."
);
return Ok(Some(cals));
}
}
if offline_only {
info!(
slug,
"Calibration record was not found in local stores; offline_only is set."
);
return Ok(None);
}
let remote_result = fetch_deimos_controls_cal(&slug_segments);
let cals = match remote_result {
Ok(Some(cals)) => cals,
Ok(None) => {
info!(
slug,
"Calibration record was not found at deimoscontrols.com."
);
return Ok(None);
}
Err(err) if err.is_transport_failure => {
warn!(
slug,
error = %err.message,
"Calibration record could not be queried from deimoscontrols.com; treating remote lookup as missing."
);
return Ok(None);
}
Err(err) => return Err(err.message),
};
info!(slug, "Fetched calibration record from deimoscontrols.com.");
let default_store = default_cal_store()
.ok_or_else(|| "Unable to determine home directory for calibration cache".to_owned())?;
write_cached_cal(&default_store, &slug_segments, &cals)?;
info!(
slug,
cache = %default_store.display(),
"Cached calibration record in default local store."
);
Ok(Some(cals))
}
fn validate_cal_slug(slug: &str) -> Result<Vec<&str>, String> {
let segments = slug.split('/').collect::<Vec<_>>();
if segments.is_empty() {
return Err("Calibration slug must not be empty".to_owned());
}
for segment in &segments {
if segment.is_empty() || *segment == "." || *segment == ".." {
return Err(format!(
"Calibration slug contains invalid segment `{segment}`"
));
}
if !segment
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(format!(
"Calibration slug segment `{segment}` contains unsupported characters"
));
}
}
Ok(segments)
}
fn default_cal_store() -> Option<PathBuf> {
env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(DEFAULT_RECORD_STORE_RELATIVE_PATH))
}
fn cal_path(root: &Path, slug_segments: &[&str]) -> PathBuf {
let mut path = root.to_path_buf();
for segment in slug_segments {
path.push(segment);
}
path.push(CAL_FILE_NAME);
path
}
fn try_read_local_cal(root: &Path, slug_segments: &[&str]) -> Result<Option<String>, String> {
let path = cal_path(root, slug_segments);
match fs::read_to_string(&path) {
Ok(cals) => Ok(Some(cals)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
debug!(path = %path.display(), "Calibration record not found in local store.");
Ok(None)
}
Err(err) => Err(format!("Failed to read {}: {err}", path.display())),
}
}
fn fetch_deimos_controls_cal(slug_segments: &[&str]) -> Result<Option<String>, RemoteCalError> {
let url = format!(
"{}/{}/{}",
DEIMOS_CONTROLS_RECORD_BASE_URL,
slug_segments.join("/"),
CAL_FILE_NAME
);
let client = reqwest::blocking::Client::builder()
.timeout(CAL_QUERY_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|err| {
RemoteCalError::unexpected(format!("Failed to build calibration HTTP client: {err}"))
})?;
let response = client.get(&url).send().map_err(|err| {
RemoteCalError::transport(format!(
"Failed to fetch calibration record from {url}: {err}"
))
})?;
debug!(url, status = %response.status(), "Received calibration query response.");
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
return Err(RemoteCalError::unexpected(format!(
"Failed to fetch calibration record from {url}: HTTP {}",
response.status()
)));
}
if let Some(content_length) = response.content_length()
&& content_length > MAX_CAL_JSON_BYTES
{
return Err(RemoteCalError::unexpected(format!(
"Calibration record from {url} is too large: {content_length} bytes"
)));
}
let mut body = Vec::new();
let mut reader = response.take(MAX_CAL_JSON_BYTES + 1);
reader.read_to_end(&mut body).map_err(|err| {
RemoteCalError::transport(format!(
"Failed to read calibration record from {url}: {err}"
))
})?;
if body.len() as u64 > MAX_CAL_JSON_BYTES {
return Err(RemoteCalError::unexpected(format!(
"Calibration record from {url} exceeds {} bytes",
MAX_CAL_JSON_BYTES
)));
}
String::from_utf8(body).map(Some).map_err(|err| {
RemoteCalError::unexpected(format!(
"Calibration record from {url} was not valid UTF-8: {err}"
))
})
}
struct RemoteCalError {
message: String,
is_transport_failure: bool,
}
impl RemoteCalError {
fn transport(message: String) -> Self {
Self {
message,
is_transport_failure: true,
}
}
fn unexpected(message: String) -> Self {
Self {
message,
is_transport_failure: false,
}
}
}
fn write_cached_cal(root: &Path, slug_segments: &[&str], cals: &str) -> Result<(), String> {
let path = cal_path(root, slug_segments);
let parent = path.parent().ok_or_else(|| {
format!(
"Unable to determine parent directory for {}",
path.display()
)
})?;
fs::create_dir_all(parent).map_err(|err| {
warn!(
path = %parent.display(),
"Failed to create calibration cache directory: {err}"
);
format!("Failed to create {}: {err}", parent.display())
})?;
fs::write(&path, cals).map_err(|err| {
warn!(
path = %path.display(),
"Failed to write calibration cache file: {err}"
);
format!("Failed to write {}: {err}", path.display())
})
}