use std::collections::HashMap;
use std::path::PathBuf;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::filament::MaterialProfile;
use super::printer::PrinterProfile;
use crate::formats::slice_summary::SliceSummary;
use crate::{Error, parse_material, parse_printer, resolve_materials, resolve_printer};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ProfileRef<T> {
Linked {
#[serde(rename = "ref")]
name: String,
},
Embedded {
profile: Box<T>,
},
}
impl<T> ProfileRef<T> {
pub fn embedded(&self) -> Option<&T> {
match self {
ProfileRef::Embedded { profile } => Some(profile),
ProfileRef::Linked { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum JobSource {
Path(PathBuf),
Embedded(SliceSummary),
}
impl From<String> for JobSource {
fn from(path: String) -> Self {
JobSource::Path(PathBuf::from(path))
}
}
impl From<PathBuf> for JobSource {
fn from(path: PathBuf) -> Self {
JobSource::Path(path)
}
}
impl From<SliceSummary> for JobSource {
fn from(summary: SliceSummary) -> Self {
JobSource::Embedded(summary)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LaborCost {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_per_hour: Option<f64>,
pub minutes: f64,
}
impl LaborCost {
pub fn new(minutes: f64) -> Self {
LaborCost {
rate_per_hour: None,
minutes,
}
}
pub fn with_rate_per_hour(mut self, rate: f64) -> Self {
self.rate_per_hour = Some(rate);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct HardwareItem {
pub name: String,
pub cost: f64,
#[serde(default = "one")]
pub quantity: u32,
}
impl HardwareItem {
pub fn new(name: impl Into<String>, cost: f64) -> Self {
HardwareItem {
name: name.into(),
cost,
quantity: 1,
}
}
pub fn with_quantity(mut self, quantity: u32) -> Self {
self.quantity = quantity;
self
}
}
pub const SINGLE_MATERIAL_SLOT: &str = "1";
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Job {
pub name: String,
pub source: JobSource,
pub plate_selection: Option<u32>,
pub printer: ProfileRef<PrinterProfile>,
pub material: HashMap<String, ProfileRef<MaterialProfile>>,
pub default_material: Option<ProfileRef<MaterialProfile>>,
pub quantity: u32,
pub material_efficiency_factor_override: Option<f64>,
pub electricity_cost_per_kwh_override: Option<f64>,
pub labor: LaborCost,
pub hardware: Vec<HardwareItem>,
pub margin_percentages_override: Option<Vec<f64>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum JobTier {
#[default]
Slim,
Full,
Bundled,
}
impl Job {
pub fn new(
name: impl Into<String>,
source: impl Into<JobSource>,
printer: ProfileRef<PrinterProfile>,
) -> Self {
Job {
name: name.into(),
source: source.into(),
plate_selection: None,
printer,
material: HashMap::new(),
default_material: None,
quantity: 1,
material_efficiency_factor_override: None,
electricity_cost_per_kwh_override: None,
labor: LaborCost::new(0.0),
hardware: Vec::new(),
margin_percentages_override: None,
}
}
pub fn with_plate_selection(mut self, index: Option<u32>) -> Self {
self.plate_selection = index;
self
}
pub fn with_materials(
mut self,
materials: HashMap<String, ProfileRef<MaterialProfile>>,
) -> Self {
self.material = materials;
self
}
pub fn with_default_material(mut self, material: Option<ProfileRef<MaterialProfile>>) -> Self {
self.default_material = material;
self
}
pub fn with_quantity(mut self, quantity: u32) -> Self {
self.quantity = quantity;
self
}
pub fn with_labor(mut self, labor: LaborCost) -> Self {
self.labor = labor;
self
}
pub fn with_hardware(mut self, hardware: Vec<HardwareItem>) -> Self {
self.hardware.clear();
for item in hardware {
self.add_hardware(item);
}
self
}
pub fn with_material_efficiency_factor_override(mut self, factor: Option<f64>) -> Self {
self.material_efficiency_factor_override = factor;
self
}
pub fn with_electricity_cost_per_kwh_override(mut self, cost: Option<f64>) -> Self {
self.electricity_cost_per_kwh_override = cost;
self
}
pub fn with_margin_percentages_override(mut self, margins: Option<Vec<f64>>) -> Self {
self.margin_percentages_override = margins;
self
}
pub fn add_material(&mut self, slot: impl Into<String>, material: ProfileRef<MaterialProfile>) {
self.material.insert(slot.into(), material);
}
pub fn add_hardware(&mut self, item: HardwareItem) {
match self.hardware.iter_mut().find(|h| h.name == item.name) {
Some(existing) => *existing = item,
None => self.hardware.push(item),
}
}
pub fn to_toml(&self, tier: JobTier) -> Result<String, Error> {
let job = match tier {
JobTier::Slim => return toml::to_string(self).map_err(|e| Error::Toml(e.to_string())),
JobTier::Full | JobTier::Bundled => {
let mut job = self.clone();
job.printer = ProfileRef::Embedded {
profile: Box::new(resolve_printer(&job.printer)?),
};
job.material = resolve_materials(&job.material)?
.into_iter()
.map(|(slot, profile)| {
(
slot,
ProfileRef::Embedded {
profile: Box::new(profile),
},
)
})
.collect();
job.default_material = job
.default_material
.as_ref()
.map(|r| {
Ok::<_, Error>(ProfileRef::Embedded {
profile: Box::new(crate::resolve_material(r)?),
})
})
.transpose()?;
if tier == JobTier::Bundled {
job.source = JobSource::Embedded(self.slice_summary()?);
}
job
}
};
toml::to_string(&job).map_err(|e| Error::Toml(e.to_string()))
}
pub fn embed_printer_toml(&mut self, toml_text: &str) -> Result<(), Error> {
self.printer = ProfileRef::Embedded {
profile: Box::new(parse_printer(toml_text)?),
};
Ok(())
}
pub fn embed_material_toml(
&mut self,
slot: impl Into<String>,
toml_text: &str,
) -> Result<(), Error> {
self.material.insert(
slot.into(),
ProfileRef::Embedded {
profile: Box::new(parse_material(toml_text)?),
},
);
Ok(())
}
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum PlateWire {
All(String),
Index(u32),
}
#[derive(Serialize, Deserialize)]
struct SourceWire {
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
summary: Option<SliceSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
plate: Option<PlateWire>,
}
#[derive(Serialize, Deserialize)]
struct JobWire {
name: String,
source: SourceWire,
printer: ProfileRef<PrinterProfile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
material: Option<ProfileRef<MaterialProfile>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
materials: Option<HashMap<String, ProfileRef<MaterialProfile>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_material: Option<ProfileRef<MaterialProfile>>,
#[serde(default = "one", skip_serializing_if = "is_one")]
quantity: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
material_efficiency_factor_override: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
electricity_cost_per_kwh_override: Option<f64>,
labor: LaborCost,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
hardware: Vec<HardwareItem>,
#[serde(
default,
alias = "markup_percentages_override",
skip_serializing_if = "Option::is_none"
)]
margin_percentages_override: Option<Vec<f64>>,
}
fn one() -> u32 {
1
}
fn is_one(quantity: &u32) -> bool {
*quantity == 1
}
impl<'de> Deserialize<'de> for Job {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = JobWire::deserialize(deserializer)?;
let source = match (wire.source.path, wire.source.summary) {
(Some(path), None) => JobSource::Path(path),
(None, Some(summary)) => JobSource::Embedded(summary),
(None, None) => {
return Err(D::Error::custom(
"[source] needs either `path` or `summary`",
));
}
(Some(_), Some(_)) => {
return Err(D::Error::custom(
"[source] can't have both `path` and `summary`",
));
}
};
let plate_selection = match wire.source.plate {
None => None,
Some(PlateWire::Index(i)) => Some(i),
Some(PlateWire::All(s)) if s == "all" => None,
Some(PlateWire::All(s)) => {
return Err(D::Error::custom(format!(
"invalid `[source] plate` value: {s:?} (expected \"all\" or a plate number)"
)));
}
};
let material = match (wire.material, wire.materials) {
(Some(single), None) => HashMap::from([(SINGLE_MATERIAL_SLOT.to_string(), single)]),
(None, Some(map)) => map,
(None, None) if wire.default_material.is_some() => HashMap::new(),
(None, None) => {
return Err(D::Error::custom(
"job needs `[material]`, `[materials]`, or `[default_material]`",
));
}
(Some(_), Some(_)) => {
return Err(D::Error::custom(
"job can't have both `[material]` and `[materials]`",
));
}
};
Ok(Job {
name: wire.name,
source,
plate_selection,
printer: wire.printer,
material,
default_material: wire.default_material,
quantity: wire.quantity,
material_efficiency_factor_override: wire.material_efficiency_factor_override,
electricity_cost_per_kwh_override: wire.electricity_cost_per_kwh_override,
labor: wire.labor,
hardware: wire.hardware,
margin_percentages_override: wire.margin_percentages_override,
})
}
}
impl Serialize for Job {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let (path, summary) = match &self.source {
JobSource::Path(path) => (Some(path.clone()), None),
JobSource::Embedded(summary) => (None, Some(summary.clone())),
};
let source = SourceWire {
path,
summary,
plate: self.plate_selection.map(PlateWire::Index),
};
let (material, materials) =
if self.material.len() == 1 && self.material.contains_key(SINGLE_MATERIAL_SLOT) {
(self.material.get(SINGLE_MATERIAL_SLOT).cloned(), None)
} else {
(None, Some(self.material.clone()))
};
let wire = JobWire {
name: self.name.clone(),
source,
printer: self.printer.clone(),
material,
materials,
default_material: self.default_material.clone(),
quantity: self.quantity,
material_efficiency_factor_override: self.material_efficiency_factor_override,
electricity_cost_per_kwh_override: self.electricity_cost_per_kwh_override,
labor: self.labor,
hardware: self.hardware.clone(),
margin_percentages_override: self.margin_percentages_override.clone(),
};
wire.serialize(serializer)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::filament::MaterialKind;
use crate::model::printer::PrinterKind;
#[test]
fn default_tier_single_material_round_trips() {
let toml_str = r#"
name = "widget-v2"
[source]
path = "prints/widget.gcode.3mf"
[printer]
ref = "bambu-a1"
[material]
ref = "pla-black"
[labor]
minutes = 10.0
[[hardware]]
name = "brass insert M3x5"
cost = 0.12
quantity = 4
"#;
let job: Job = toml::from_str(toml_str).unwrap();
assert_eq!(job.name, "widget-v2");
assert_eq!(
job.source,
JobSource::Path("prints/widget.gcode.3mf".into())
);
assert_eq!(job.plate_selection, None);
assert_eq!(
job.printer,
ProfileRef::Linked {
name: "bambu-a1".into()
}
);
assert_eq!(job.material.len(), 1);
assert_eq!(
job.material.get(SINGLE_MATERIAL_SLOT),
Some(&ProfileRef::Linked {
name: "pla-black".into()
})
);
assert_eq!(job.labor.minutes, 10.0);
assert_eq!(job.hardware.len(), 1);
let round_tripped: Job = toml::from_str(&toml::to_string(&job).unwrap()).unwrap();
assert_eq!(round_tripped, job);
}
#[test]
fn multi_material_map_round_trips() {
let toml_str = r#"
name = "multicolor-vase"
[source]
path = "prints/vase_ams.gcode.3mf"
[printer]
ref = "bambu-a1"
[materials]
"1" = { ref = "pla-black" }
"2" = { ref = "pla-red" }
"4" = { ref = "petg-clear" }
[labor]
rate_per_hour = 15.0
minutes = 10.0
"#;
let job: Job = toml::from_str(toml_str).unwrap();
assert_eq!(job.material.len(), 3);
assert!(job.material.contains_key("4"));
let serialized = toml::to_string(&job).unwrap();
assert!(serialized.contains("[materials."));
assert!(!serialized.contains("[material]"));
let round_tripped: Job = toml::from_str(&serialized).unwrap();
assert_eq!(round_tripped, job);
}
#[test]
fn bundled_tier_embeds_summary_and_profiles() {
let toml_str = r#"
name = "widget-v2"
[source]
[source.summary]
source_format = "bambu-3mf"
[[source.summary.plates]]
plate_index = 1
print_time_seconds = 1037.0
[[source.summary.plates.filament_usage]]
slot_id = "1"
material_type = "PLA"
grams = 6.66
[printer.profile]
name = "bambu-a1"
kind = "fdm"
purchase_price = 800.0
additional_upfront_cost = 0.0
estimated_life_years = 5.0
estimated_uptime_percent = 0.5
annual_maintenance_cost = 80.0
power_consumption_watts = 160.0
cost_buffer_factor = 1.3
[material.profile]
name = "pla-black"
kind = "filament"
price = 18.99
efficiency_factor = 1.1
[labor]
rate_per_hour = 15.0
minutes = 10.0
"#;
let job: Job = toml::from_str(toml_str).unwrap();
let JobSource::Embedded(summary) = &job.source else {
panic!("expected an embedded summary");
};
assert_eq!(summary.plates.len(), 1);
let printer = job.printer.embedded().unwrap();
assert_eq!(printer.name, "bambu-a1");
assert_eq!(printer.kind, PrinterKind::Fdm);
let material = job.material[SINGLE_MATERIAL_SLOT].embedded().unwrap();
assert_eq!(material.kind, MaterialKind::Filament);
let round_tripped: Job = toml::from_str(&toml::to_string(&job).unwrap()).unwrap();
assert_eq!(round_tripped, job);
}
#[test]
fn job_helpers_build_and_round_trip() {
let mut job: Job = toml::from_str(
r#"
name = "widget"
[source]
path = "prints/widget.gcode.3mf"
[printer]
ref = "bambu-a1"
[material]
ref = "pla-black"
[labor]
minutes = 10.0
"#,
)
.unwrap();
job.add_material(
"2",
ProfileRef::Linked {
name: "pla-red".into(),
},
);
assert_eq!(
job.material.get("2"),
Some(&ProfileRef::Linked {
name: "pla-red".into()
})
);
job.add_hardware(HardwareItem {
name: "brass insert M3x5".into(),
cost: 0.12,
quantity: 4,
});
assert_eq!(job.hardware.len(), 1);
let round_tripped: Job = toml::from_str(&job.to_toml(JobTier::Slim).unwrap()).unwrap();
assert_eq!(round_tripped, job);
}
#[test]
fn job_source_from_impls_cover_path_and_summary() {
assert_eq!(
JobSource::from("prints/widget.gcode".to_string()),
JobSource::Path("prints/widget.gcode".into())
);
assert_eq!(
JobSource::from(PathBuf::from("prints/widget.gcode")),
JobSource::Path("prints/widget.gcode".into())
);
let summary = SliceSummary {
source_format: crate::formats::slice_summary::SourceFormat::Bambu3mf,
meta: Default::default(),
plates: vec![],
};
assert_eq!(
JobSource::from(summary.clone()),
JobSource::Embedded(summary)
);
}
#[test]
fn add_hardware_replaces_an_entry_with_the_same_name() {
let mut job = Job::new(
"upsert",
PathBuf::from("x.gcode"),
ProfileRef::Linked {
name: "p".to_string(),
},
)
.with_hardware(vec![
HardwareItem::new("magnets", 0.10).with_quantity(4),
HardwareItem::new("box", 0.60),
HardwareItem::new("magnets", 0.12).with_quantity(6),
]);
assert_eq!(job.hardware.len(), 2);
assert_eq!(job.hardware[0].name, "magnets");
assert_eq!(job.hardware[0].quantity, 6);
job.add_hardware(HardwareItem::new("magnets", 0.08).with_quantity(5));
job.add_hardware(HardwareItem::new("sticker", 0.05));
let names: Vec<&str> = job.hardware.iter().map(|h| h.name.as_str()).collect();
assert_eq!(names, ["magnets", "box", "sticker"]);
assert_eq!(job.hardware[0].quantity, 5);
assert!((job.hardware[0].cost - 0.08).abs() < f64::EPSILON);
job.add_hardware(HardwareItem::new("Magnets", 1.0));
assert_eq!(job.hardware.len(), 4);
}
#[test]
fn missing_material_and_materials_is_an_error() {
let toml_str = r#"
name = "broken"
[source]
path = "prints/widget.gcode.3mf"
[printer]
ref = "bambu-a1"
[labor]
minutes = 10.0
"#;
let err = toml::from_str::<Job>(toml_str).unwrap_err();
assert!(
err.to_string().contains("material"),
"error should name the missing table, got: {err}"
);
}
}