use crate::errors::{Error, Result};
use crate::geometry::{LabelMm, SafeArea};
pub const DEFAULT_LABEL: &str = "50x30";
pub const MAX_SCAN_SECS: u64 = 300;
use crate::protocol::Model;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Hash, clap::ValueEnum, Serialize, Deserialize,
)]
#[value(rename_all = "lower")]
#[serde(rename_all = "lowercase")]
pub enum ConnPref {
#[default]
Ble,
#[serde(alias = "serial")]
Usb,
}
impl ConnPref {
pub fn as_str(self) -> &'static str {
match self {
Self::Ble => "ble",
Self::Usb => "usb",
}
}
}
impl std::fmt::Display for ConnPref {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.pad(self.as_str())
}
}
impl std::str::FromStr for ConnPref {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"ble" => Ok(Self::Ble),
"usb" | "serial" => Ok(Self::Usb),
_ => Err(Error::msg(format!(
"unknown connection '{value}' (try ble or usb)"
))),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub addr: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection: Option<ConnPref>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<Model>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scan_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub safe_area: Option<SafeArea>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
impl Config {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.addr.is_none()
&& self.connection.is_none()
&& self.model.is_none()
&& self.scan_secs.is_none()
&& self.safe_area.is_none()
&& self.label.is_none()
}
pub fn config_dir() -> Result<PathBuf> {
let dirs = directories::ProjectDirs::from("", "", "thermark")
.ok_or_else(|| Error::msg("could not determine config directory for thermark"))?;
Ok(dirs.config_dir().to_path_buf())
}
pub fn default_path() -> Result<PathBuf> {
let env = match std::env::var("THERMARK_CONFIG") {
Ok(path) => Some(path),
Err(std::env::VarError::NotPresent) => None,
Err(std::env::VarError::NotUnicode(_)) => {
return Err(Error::msg("THERMARK_CONFIG must be valid Unicode"));
}
};
Self::default_path_with(env.as_deref())
}
pub fn default_path_with(env: Option<&str>) -> Result<PathBuf> {
if let Some(p) = Self::nonempty(env) {
return Ok(PathBuf::from(p));
}
Ok(Self::config_dir()?.join("config.json"))
}
pub fn load() -> Result<Self> {
Self::load_from(&Self::default_path()?)
}
pub fn load_from(path: &Path) -> Result<Self> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Self::default()),
Err(error) => {
return Err(Error::msg(format!(
"read config {}: {error}",
path.display()
)));
}
};
Self::parse_json(&text)
.map_err(|e| Error::msg(format!("parse config {}: {e}", path.display())))
}
pub fn parse_json(text: &str) -> Result<Self> {
let config: Self =
serde_json::from_str(text).map_err(|e| Error::msg(format!("invalid JSON: {e}")))?;
config.validate()?;
Ok(config)
}
pub fn to_json_pretty(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(|e| Error::msg(format!("serialize JSON: {e}")))
}
pub fn save(&self) -> Result<PathBuf> {
let path = Self::default_path()?;
self.save_to(&path)?;
Ok(path)
}
pub fn save_to(&self, path: &Path) -> Result<()> {
self.validate()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| Error::msg(format!("create config dir {}: {e}", parent.display())))?;
}
let body = format!("{}\n", self.to_json_pretty()?);
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
Error::msg(format!(
"create temporary config in {}: {e}",
parent.display()
))
})?;
tmp.write_all(body.as_bytes())
.map_err(|e| Error::msg(format!("write temporary config: {e}")))?;
tmp.as_file()
.sync_all()
.map_err(|e| Error::msg(format!("flush temporary config: {e}")))?;
tmp.persist(path)
.map_err(|e| Error::msg(format!("replace config {}: {}", path.display(), e.error)))?;
Ok(())
}
pub fn clear() -> Result<bool> {
Self::clear_at(&Self::default_path()?)
}
pub fn clear_at(path: &Path) -> Result<bool> {
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
Err(error) => Err(Error::msg(format!(
"remove config {}: {error}",
path.display()
))),
}
}
pub fn apply_update(
&mut self,
addr: Option<&str>,
connection: Option<ConnPref>,
model: Option<Model>,
scan_secs: Option<u64>,
label: Option<&str>,
) -> Result<()> {
let addr = match addr {
Some(addr) => {
let addr = addr.trim();
if addr.is_empty() {
return Err(Error::msg("printer address cannot be blank"));
}
Some(addr.to_string())
}
None => None,
};
let label = match label {
Some(label) => {
let label = label.trim();
LabelMm::parse(label)?;
Some(label.to_string())
}
None => None,
};
if let Some(seconds) = scan_secs
&& !(1..=MAX_SCAN_SECS).contains(&seconds)
{
return Err(Error::msg(format!(
"scan time must be 1..={MAX_SCAN_SECS} seconds, got {seconds}"
)));
}
let mut updated = self.clone();
if let Some(addr) = addr {
updated.addr = Some(addr);
}
if let Some(connection) = connection {
updated.connection = Some(connection);
}
if let Some(model) = model {
updated.model = Some(model);
}
if let Some(scan_secs) = scan_secs {
updated.scan_secs = Some(scan_secs);
}
if let Some(label) = label {
updated.label = Some(label);
}
updated.validate()?;
*self = updated;
Ok(())
}
pub fn validate(&self) -> Result<()> {
if self
.addr
.as_deref()
.is_some_and(|addr| addr.trim().is_empty())
{
return Err(Error::msg("configured printer address cannot be blank"));
}
if let Some(seconds) = self.scan_secs
&& !(1..=MAX_SCAN_SECS).contains(&seconds)
{
return Err(Error::msg(format!(
"configured scan time must be 1..={MAX_SCAN_SECS} seconds, got {seconds}"
)));
}
if let Some(label) = self.label.as_deref() {
LabelMm::parse(label)
.map_err(|e| Error::msg(format!("configured label is invalid: {e}")))?;
}
if let Some(safe_area) = self.safe_area {
let label = LabelMm::parse(&self.resolve_label(None))?;
let profile = crate::profile::profile_for_model(self.resolve_model(None));
let pixels = label.to_pixels(profile.max_width_px, profile.pixels_per_mm());
if safe_area.content(pixels).is_none() {
return Err(Error::msg(
"configured safe-area insets consume the entire label",
));
}
}
Ok(())
}
fn nonempty(s: Option<&str>) -> Option<&str> {
s.map(str::trim).filter(|s| !s.is_empty())
}
pub fn resolve_addr_with(&self, cli: Option<&str>, env: Option<&str>) -> Result<String> {
if let Some(a) = Self::nonempty(cli) {
return Ok(a.to_string());
}
if let Some(a) = Self::nonempty(env) {
return Ok(a.to_string());
}
if let Some(a) = Self::nonempty(self.addr.as_deref()) {
return Ok(a.to_string());
}
Err(Error::msg(
"no printer address: pass -a \"B1-YourPrinter\" (full name), set THERMARK_ADDR, \
or: thermark scan --save / thermark config set -a \"B1-YourPrinter\"",
))
}
pub fn resolve_addr(&self, cli_addr: Option<&str>) -> Result<String> {
let env = std::env::var("THERMARK_ADDR").ok();
self.resolve_addr_with(cli_addr, env.as_deref())
}
pub fn resolve_connection(&self, cli: Option<ConnPref>) -> ConnPref {
cli.or(self.connection).unwrap_or_default()
}
pub fn resolve_model(&self, cli: Option<Model>) -> Model {
cli.or(self.model).unwrap_or_default()
}
pub fn resolve_label(&self, cli: Option<&str>) -> String {
Self::nonempty(cli)
.map(str::to_string)
.or_else(|| Self::nonempty(self.label.as_deref()).map(str::to_string))
.unwrap_or_else(|| DEFAULT_LABEL.to_string())
}
pub fn resolve_safe_area(&self, pixels_per_mm: f64) -> SafeArea {
self.safe_area
.unwrap_or_else(|| SafeArea::registration(pixels_per_mm))
}
pub fn resolve_scan_secs(&self, cli: Option<u64>) -> u64 {
cli.or(self.scan_secs).unwrap_or(4).clamp(1, MAX_SCAN_SECS)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connection_from_str_is_typed_and_rejects_unknown_values() {
assert_eq!(" BLE ".parse::<ConnPref>().unwrap(), ConnPref::Ble);
assert_eq!("usb".parse::<ConnPref>().unwrap(), ConnPref::Usb);
assert_eq!("serial".parse::<ConnPref>().unwrap(), ConnPref::Usb);
assert!("bluetooth".parse::<ConnPref>().is_err());
}
#[test]
fn roundtrip_json_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
let cfg = Config {
addr: Some("B1-YourPrinter".into()),
connection: Some(ConnPref::Ble),
model: Some(Model::B1),
scan_secs: Some(6),
safe_area: None,
label: None,
};
cfg.save_to(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.trim_start().starts_with('{'));
assert!(text.contains("B1-YourPrinter"));
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded, cfg);
}
#[test]
fn missing_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nope.json");
let cfg = Config::load_from(&path).unwrap();
assert!(cfg.is_empty());
}
#[test]
fn concurrent_saves_use_distinct_atomic_temp_files() {
let dir = tempfile::tempdir().unwrap();
let path = std::sync::Arc::new(dir.path().join("config.json"));
let threads: Vec<_> = (0..8)
.map(|n| {
let path = path.clone();
std::thread::spawn(move || {
let cfg = Config {
addr: Some(format!("B1-{n}")),
..Config::default()
};
cfg.save_to(&path)
})
})
.collect();
for thread in threads {
thread.join().unwrap().unwrap();
}
let loaded = Config::load_from(&path).unwrap();
assert!(loaded.addr.unwrap().starts_with("B1-"));
}
#[test]
fn invalid_json_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "{not json").unwrap();
let err = Config::load_from(&path).unwrap_err().to_string();
assert!(err.contains("parse") || err.contains("JSON"), "{err}");
}
#[test]
fn rejects_unknown_or_invalid_config_values() {
for body in [
r#"{"adress":"B1-Typo"}"#,
r#"{"addr":" "}"#,
r#"{"scan_secs":0}"#,
r#"{"scan_secs":301}"#,
r#"{"label":"not-a-label"}"#,
r#"{"safe_area":{"top":4294967295,"bottom":1,"left":0,"right":0}}"#,
] {
assert!(Config::parse_json(body).is_err(), "accepted {body}");
}
}
#[test]
fn apply_update_rejects_invalid_address_and_scan_time() {
let original = Config::default();
let mut cfg = original.clone();
assert!(
cfg.apply_update(Some(" "), Some(ConnPref::Ble), None, None, None)
.is_err()
);
assert_eq!(cfg, original);
assert!(
cfg.apply_update(Some("B1-Test"), Some(ConnPref::Ble), None, Some(0), None)
.is_err()
);
assert_eq!(cfg, original);
}
#[test]
fn apply_update_replaces_connection_preserving_unspecified() {
let mut cfg = Config {
addr: Some("old".into()),
connection: Some(ConnPref::Usb),
model: Some(Model::B1Pro),
scan_secs: Some(9),
safe_area: None,
label: None,
};
cfg.apply_update(Some("B1-New"), Some(ConnPref::Ble), None, None, None)
.unwrap();
assert_eq!(cfg.addr.as_deref(), Some("B1-New"));
assert_eq!(cfg.connection, Some(ConnPref::Ble));
assert_eq!(cfg.model, Some(Model::B1Pro));
assert_eq!(cfg.scan_secs, Some(9));
}
#[test]
fn apply_update_changes_only_supplied_fields() {
let mut cfg = Config {
addr: Some("B1-Old".into()),
connection: Some(ConnPref::Usb),
model: Some(Model::B1Pro),
scan_secs: Some(9),
safe_area: None,
label: Some("50x30".into()),
};
cfg.apply_update(Some(" B1-New "), None, None, None, Some("40x20"))
.unwrap();
assert_eq!(cfg.addr.as_deref(), Some("B1-New"));
assert_eq!(cfg.connection, Some(ConnPref::Usb));
assert_eq!(cfg.model, Some(Model::B1Pro));
assert_eq!(cfg.scan_secs, Some(9));
assert_eq!(cfg.label.as_deref(), Some("40x20"));
}
#[test]
fn apply_update_validates_before_mutating() {
let original = Config {
addr: Some("B1-Old".into()),
connection: Some(ConnPref::Usb),
model: None,
scan_secs: None,
safe_area: None,
label: Some("50x30".into()),
};
let mut cfg = original.clone();
assert!(
cfg.apply_update(Some("B1-New"), None, None, None, Some("invalid"))
.is_err()
);
assert_eq!(cfg, original);
let mut with_safe_area = Config {
safe_area: Some(SafeArea::B1),
..original
};
let unchanged = with_safe_area.clone();
assert!(
with_safe_area
.apply_update(None, None, None, None, Some("50x1"))
.is_err()
);
assert_eq!(with_safe_area, unchanged);
}
#[test]
fn apply_update_trims_addr() {
let mut cfg = Config::default();
cfg.apply_update(Some(" B1-Spaced "), Some(ConnPref::Ble), None, None, None)
.unwrap();
assert_eq!(cfg.addr.as_deref(), Some("B1-Spaced"));
}
#[test]
fn resolve_addr_priority() {
let cfg = Config {
addr: Some("from-config".into()),
..Default::default()
};
assert_eq!(
cfg.resolve_addr_with(Some("from-cli"), Some("from-env"))
.unwrap(),
"from-cli"
);
assert_eq!(
cfg.resolve_addr_with(None, Some("from-env")).unwrap(),
"from-env"
);
assert_eq!(cfg.resolve_addr_with(None, None).unwrap(), "from-config");
assert_eq!(
cfg.resolve_addr_with(Some(" "), Some(" ")).unwrap(),
"from-config"
);
}
#[test]
fn resolve_addr_missing_errors() {
let err = Config::default()
.resolve_addr_with(None, None)
.unwrap_err()
.to_string();
assert!(err.contains("config set"), "{err}");
}
#[test]
fn resolve_label_prefers_cli_then_config_then_default() {
let mut cfg = Config::default();
assert_eq!(cfg.resolve_label(None), DEFAULT_LABEL);
assert_eq!(cfg.resolve_label(Some("40x20")), "40x20");
cfg.label = Some("30x15".into());
assert_eq!(cfg.resolve_label(None), "30x15");
assert_eq!(cfg.resolve_label(Some("40x20")), "40x20");
assert_eq!(cfg.resolve_label(Some(" ")), "30x15");
}
#[test]
fn resolve_connection_and_scan() {
let cfg = Config {
connection: Some(ConnPref::Usb),
scan_secs: Some(8),
model: Some(Model::B1Pro),
..Default::default()
};
assert_eq!(cfg.resolve_connection(None), ConnPref::Usb);
assert_eq!(cfg.resolve_connection(Some(ConnPref::Ble)), ConnPref::Ble);
assert_eq!(cfg.resolve_model(None), Model::B1Pro);
assert_eq!(cfg.resolve_model(Some(Model::B1)), Model::B1);
assert_eq!(cfg.resolve_scan_secs(None), 8);
assert_eq!(cfg.resolve_scan_secs(Some(0)), 1);
}
#[test]
fn clear_at_removes_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
Config {
addr: Some("x".into()),
..Default::default()
}
.save_to(&path)
.unwrap();
assert!(Config::clear_at(&path).unwrap());
assert!(!path.exists());
assert!(!Config::clear_at(&path).unwrap());
}
#[test]
fn default_path_honors_thermark_config_env() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("custom.json");
assert_eq!(Config::default_path_with(path.to_str()).unwrap(), path);
assert!(
Config::default_path_with(Some(" "))
.unwrap()
.ends_with("config.json")
);
assert!(
Config::default_path_with(None)
.unwrap()
.ends_with("config.json")
);
}
#[test]
fn parse_json_object() {
let cfg = Config::parse_json(r#"{"addr":"B1-Y","connection":"usb","model":"b1"}"#).unwrap();
assert_eq!(cfg.addr.as_deref(), Some("B1-Y"));
assert_eq!(cfg.connection, Some(ConnPref::Usb));
assert_eq!(cfg.model, Some(Model::B1));
let cfg = Config::parse_json(r#"{"connection":"serial"}"#).unwrap();
assert_eq!(cfg.connection, Some(ConnPref::Usb));
}
#[test]
fn to_json_pretty_roundtrip() {
let cfg = Config {
addr: Some("B1-X".into()),
..Default::default()
};
let s = cfg.to_json_pretty().unwrap();
let back = Config::parse_json(&s).unwrap();
assert_eq!(back, cfg);
}
}