use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde::de::DeserializeOwned;
use libprintcalc::model::cost::CostDefaults;
use libprintcalc::model::filament::MaterialProfile;
use libprintcalc::model::printer::PrinterProfile;
#[derive(Debug)]
#[non_exhaustive]
pub enum LibraryError {
Io(std::io::Error),
Toml(String),
NotFound {
kind: &'static str,
name: String,
},
}
impl fmt::Display for LibraryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LibraryError::Io(e) => write!(f, "io error: {e}"),
LibraryError::Toml(e) => write!(f, "toml error: {e}"),
LibraryError::NotFound { kind, name } => write!(f, "{kind} {name:?} not found"),
}
}
}
impl std::error::Error for LibraryError {}
impl From<std::io::Error> for LibraryError {
fn from(e: std::io::Error) -> Self {
LibraryError::Io(e)
}
}
pub fn config_dir() -> PathBuf {
if let Ok(dir) = std::env::var("PRINTCALC_CONFIG_DIR") {
return PathBuf::from(dir);
}
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("printcalc")
}
pub fn printers_dir() -> PathBuf {
config_dir().join("printers")
}
pub fn filaments_dir() -> PathBuf {
config_dir().join("filaments")
}
pub fn load_config() -> Result<CostDefaults, LibraryError> {
let path = config_dir().join("config.toml");
match fs::read_to_string(&path) {
Ok(text) => {
let (config, unknown) =
libprintcalc::parse_toml(&text).map_err(|e| LibraryError::Toml(e.to_string()))?;
warn_unknown_keys(&path, &unknown);
Ok(config)
}
Err(_) => Ok(CostDefaults::default()),
}
}
pub fn save_config(config: &CostDefaults) -> Result<(), LibraryError> {
let dir = config_dir();
fs::create_dir_all(&dir)?;
let text = toml::to_string(config).map_err(|e| LibraryError::Toml(e.to_string()))?;
fs::write(dir.join("config.toml"), text)?;
Ok(())
}
pub fn warn_unknown_keys(path: &Path, keys: &[String]) {
if keys.is_empty() {
return;
}
let noun = if keys.len() == 1 { "key" } else { "keys" };
eprintln!(
"warning: unrecognized {noun} in {}: {} (ignored)",
path.display(),
keys.join(", ")
);
}
fn save_profile<T: Serialize>(dir: &Path, name: &str, profile: &T) -> Result<(), LibraryError> {
fs::create_dir_all(dir)?;
let text = toml::to_string(profile).map_err(|e| LibraryError::Toml(e.to_string()))?;
fs::write(dir.join(format!("{name}.toml")), text)?;
Ok(())
}
fn load_profile<T: DeserializeOwned>(
dir: &Path,
kind: &'static str,
name: &str,
) -> Result<T, LibraryError> {
let path = dir.join(format!("{name}.toml"));
let text = fs::read_to_string(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
LibraryError::NotFound {
kind,
name: name.to_string(),
}
} else {
LibraryError::Io(e)
}
})?;
let (profile, unknown) =
libprintcalc::parse_toml(&text).map_err(|e| LibraryError::Toml(e.to_string()))?;
warn_unknown_keys(&path, &unknown);
Ok(profile)
}
fn remove_profile(dir: &Path, kind: &'static str, name: &str) -> Result<(), LibraryError> {
fs::remove_file(dir.join(format!("{name}.toml"))).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
LibraryError::NotFound {
kind,
name: name.to_string(),
}
} else {
LibraryError::Io(e)
}
})
}
fn list_profiles(dir: &Path) -> Result<Vec<String>, LibraryError> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|ext| ext.to_str()) == Some("toml"))
.filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(String::from))
.collect();
names.sort();
Ok(names)
}
pub fn save_printer(profile: &PrinterProfile) -> Result<(), LibraryError> {
save_profile(&printers_dir(), &profile.name, profile)
}
pub fn load_printer(name: &str) -> Result<PrinterProfile, LibraryError> {
load_profile(&printers_dir(), "printer", name)
}
pub fn list_printers() -> Result<Vec<String>, LibraryError> {
list_profiles(&printers_dir())
}
pub fn remove_printer(name: &str) -> Result<(), LibraryError> {
remove_profile(&printers_dir(), "printer", name)
}
pub fn save_filament(profile: &MaterialProfile) -> Result<(), LibraryError> {
save_profile(&filaments_dir(), &profile.name, profile)
}
pub fn load_filament(name: &str) -> Result<MaterialProfile, LibraryError> {
load_profile(&filaments_dir(), "filament", name)
}
pub fn list_filaments() -> Result<Vec<String>, LibraryError> {
list_profiles(&filaments_dir())
}
pub fn remove_filament(name: &str) -> Result<(), LibraryError> {
remove_profile(&filaments_dir(), "filament", name)
}
#[cfg(test)]
mod tests {
use super::*;
use libprintcalc::model::filament::MaterialKind;
fn remove_dir_if_present(path: &Path) {
match fs::remove_dir_all(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => panic!("couldn't remove {}: {e}", path.display()),
}
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("printcalc-test-{name}"));
remove_dir_if_present(&dir);
dir
}
#[test]
fn save_load_and_list_round_trip() {
let dir = temp_dir("save-load-list");
let profile = MaterialProfile::new("pla-black", MaterialKind::Filament)
.with_price(18.99)
.with_efficiency_factor(1.1)
.with_color("black");
save_profile(&dir, &profile.name, &profile).unwrap();
let loaded: MaterialProfile = load_profile(&dir, "filament", "pla-black").unwrap();
assert_eq!(loaded, profile);
assert_eq!(list_profiles(&dir).unwrap(), vec!["pla-black".to_string()]);
remove_profile(&dir, "filament", "pla-black").unwrap();
assert!(list_profiles(&dir).unwrap().is_empty());
assert!(matches!(
remove_profile(&dir, "filament", "pla-black"),
Err(LibraryError::NotFound { .. })
));
remove_dir_if_present(&dir);
}
#[test]
fn load_missing_profile_is_not_found() {
let dir = temp_dir("missing");
let err = load_profile::<MaterialProfile>(&dir, "filament", "nope").unwrap_err();
assert!(matches!(
err,
LibraryError::NotFound { kind: "filament", name } if name == "nope"
));
}
#[test]
fn list_of_missing_dir_is_empty() {
let dir = temp_dir("list-missing-dir");
assert_eq!(list_profiles(&dir).unwrap(), Vec::<String>::new());
}
}