use std::collections::HashMap;
use std::error::Error;
use std::io::IsTerminal;
use std::path::PathBuf;
use clap::ValueEnum;
use libprintcalc::formats::slice_summary::{SliceSummary, SlotUsage};
use libprintcalc::model::cost::CostBreakdown;
use libprintcalc::model::filament::MaterialProfile;
use libprintcalc::model::job::{HardwareItem, Job};
use libprintcalc::model::printer::PrinterProfile;
use crate::interactive;
use crate::ops::{self, JobTier, NewJobParams, ProfileChoice};
#[derive(Clone, Copy, ValueEnum)]
pub enum OutputFormat {
Table,
Json,
}
#[derive(Clone, Copy, ValueEnum)]
pub enum JobType {
Default,
Full,
Bundled,
}
impl From<JobType> for JobTier {
fn from(t: JobType) -> Self {
match t {
JobType::Default => JobTier::Default,
JobType::Full => JobTier::Full,
JobType::Bundled => JobTier::Bundled,
}
}
}
pub struct JobInvocation {
pub file: PathBuf,
pub name: Option<String>,
pub printer: Option<String>,
pub filament: Option<String>,
pub filament_map: Option<String>,
pub default_filament: Option<String>,
pub output: Option<PathBuf>,
pub job_type: JobType,
pub plate: Option<String>,
pub labor_minutes: Option<f64>,
pub labor_rate: Option<f64>,
pub quantity: Option<u32>,
pub electricity_cost: Option<f64>,
pub margin: Option<String>,
pub efficiency_factor: Option<f64>,
pub hardware: Vec<String>,
pub no_detect: bool,
pub format: OutputFormat,
pub color: bool,
pub interactive: bool,
pub retier: Option<JobType>,
}
struct Overrides {
plate: ops::PlateOverride,
hardware: Vec<HardwareItem>,
quantity: Option<u32>,
electricity_cost: Option<f64>,
margins: Option<Vec<f64>>,
efficiency_factor: Option<f64>,
}
impl Overrides {
fn parse(inv: &JobInvocation) -> Result<Self, Box<dyn Error>> {
Ok(Overrides {
plate: parse_plate(inv.plate.as_deref())?,
hardware: parse_hardware(&inv.hardware)?,
quantity: inv.quantity,
electricity_cost: inv.electricity_cost,
margins: inv
.margin
.as_deref()
.map(super::parse_margins)
.transpose()?,
efficiency_factor: inv.efficiency_factor,
})
}
fn apply(self, job: &mut Job) {
self.plate.apply(job);
for item in self.hardware {
job.add_hardware(item);
}
if let Some(q) = self.quantity {
job.quantity = q;
}
if self.electricity_cost.is_some() {
job.electricity_cost_per_kwh_override = self.electricity_cost;
}
if self.margins.is_some() {
job.margin_percentages_override = self.margins;
}
if self.efficiency_factor.is_some() {
job.material_efficiency_factor_override = self.efficiency_factor;
}
}
}
fn currency_symbol() -> String {
ops::show_config()
.map(|c| c.currency_symbol)
.unwrap_or_else(|_| "$".to_string())
}
pub fn run(inv: JobInvocation) -> Result<(), Box<dyn Error>> {
if inv.file.extension().and_then(|e| e.to_str()) == Some("toml") {
if let Some(job_type) = inv.retier {
let output = inv
.output
.ok_or("--retier needs -o/--output to save the re-tiered job to")?;
let retiered = ops::retier_job(&inv.file, job_type.into())?;
ops::save_job(&retiered, &output)?;
eprintln!("wrote {}", output.display());
return Ok(());
}
let overrides = Overrides::parse(&inv)?;
let mut job = ops::load_job(&inv.file)?;
overrides.apply(&mut job);
if let Some(output) = &inv.output {
ops::save_job(&job, output)?;
eprintln!("wrote {}", output.display());
}
let breakdown = ops::calc_job_at(&job, &inv.file)?;
print_result(&breakdown, inv.format, inv.color, ¤cy_symbol());
return Ok(());
}
new_job_flow(inv)
}
fn new_job_flow(inv: JobInvocation) -> Result<(), Box<dyn Error>> {
let slice = ops::parse_slice(&inv.file)?;
let overrides = Overrides::parse(&inv)?;
let currency = currency_symbol();
let mapped: HashMap<String, ProfileChoice<MaterialProfile>> = match &inv.filament_map {
Some(s) => parse_filament_map(s)?,
None => HashMap::new(),
};
let detected = (!inv.no_detect).then(|| ops::detect_profiles(&slice));
if let Some(detected) = &detected {
let covered = |slot: &str| inv.filament.is_some() || mapped.contains_key(slot);
print_detected(
&inv.file,
&slice,
detected,
inv.printer.is_none(),
&covered,
¤cy,
);
}
let printer = resolve_printer_choice(inv.printer, detected.as_ref(), inv.interactive)?;
let filaments = resolve_filaments(
&slice,
detected.as_ref(),
inv.filament,
mapped,
inv.default_filament.is_some(),
inv.interactive,
)?;
let labor_minutes = inv.labor_minutes.unwrap_or(0.0);
let labor_rate = interactive::optional_f64(
inv.labor_rate,
"Labor rate per hour (blank = use config default)",
inv.interactive,
)?;
let name = inv.name.clone().unwrap_or_else(|| {
inv.output
.as_ref()
.and_then(|p| p.file_stem())
.or_else(|| inv.file.file_stem())
.and_then(|s| s.to_str())
.unwrap_or("job")
.to_string()
});
let outcome = ops::new_job(
NewJobParams {
name,
slice_path: inv.file.clone(),
printer,
filaments,
default_filament: inv.default_filament.map(ProfileChoice::Named),
quantity: overrides.quantity.unwrap_or(1),
hardware: overrides.hardware,
plate_selection: overrides.plate.selection(),
labor_minutes,
labor_rate_per_hour: labor_rate,
electricity_cost_per_kwh_override: overrides.electricity_cost,
margin_percentages_override: overrides.margins,
material_efficiency_factor_override: overrides.efficiency_factor,
tier: inv.job_type.into(),
},
slice,
)?;
if outcome.plate_selection_ignored {
eprintln!("warning: --plate is ignored for single-plate sliced files");
}
if let Some(path) = &inv.output {
ops::save_job(&outcome.job, path)?;
eprintln!("wrote {}", path.display());
}
let breakdown = ops::calc_job_data(&outcome.job, &outcome.slice)?;
print_result(&breakdown, inv.format, inv.color, ¤cy);
Ok(())
}
fn print_detected(
path: &std::path::Path,
slice: &SliceSummary,
detected: &ops::Detected,
want_printer: bool,
want_slot: &dyn Fn(&str) -> bool,
currency: &str,
) {
let mut lines: Vec<String> = Vec::new();
if let Some(slicer) = &slice.meta.slicer {
lines.push(format!(" slicer: {slicer}"));
}
if want_printer {
let model = slice
.meta
.printer_profile
.as_deref()
.or_else(|| slice.printer_model());
match (&detected.printer, model) {
(Some(m), _) => lines.push(format!(
" printer: {} -> {} ({})",
m.matched_on,
m.profile.name,
confidence_label(m.confidence)
)),
(None, Some(model)) => {
lines.push(format!(" printer: {model} -> no matching profile"))
}
(None, None) => {}
}
}
for slot in &detected.slots {
if want_slot(&slot.usage.slot_id) {
continue;
}
let described = slot.usage.label();
lines.push(match (&slot.matched, &slot.from_file) {
(Some(m), _) => format!(
" {described} -> {} ({})",
m.profile.name,
confidence_label(m.confidence)
),
(None, Some(profile)) => format!(
" {described} -> priced from the file at {currency}{:.2}/{}",
profile.price,
if slot.usage.ml > 0.0 { "L" } else { "kg" }
),
(None, None) => format!(" {described} -> no matching profile"),
});
}
if lines.is_empty() {
return;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("sliced file");
eprintln!("detected from {name}");
for line in lines {
eprintln!("{line}");
}
}
fn confidence_label(confidence: f64) -> &'static str {
if confidence >= 0.999 {
"exact"
} else {
"guess"
}
}
fn resolve_printer_choice(
flag: Option<String>,
detected: Option<&ops::Detected>,
interactive_mode: bool,
) -> Result<ProfileChoice<PrinterProfile>, Box<dyn Error>> {
if let Some(name) = flag {
return Ok(ProfileChoice::Named(name));
}
if let Some(matched) = detected.and_then(|d| d.printer.as_ref()) {
return Ok(ProfileChoice::Named(matched.profile.name.clone()));
}
let name = interactive::require_choice(
None,
"printer",
"Printer profile name",
interactive_mode,
&ops::list_printers().unwrap_or_default(),
)?;
Ok(ProfileChoice::Named(name))
}
fn resolve_filaments(
slice: &SliceSummary,
detected: Option<&ops::Detected>,
filament: Option<String>,
mut map: HashMap<String, ProfileChoice<MaterialProfile>>,
has_default: bool,
interactive_mode: bool,
) -> Result<HashMap<String, ProfileChoice<MaterialProfile>>, Box<dyn Error>> {
let slots = slice.slots();
let slot_ids: Vec<String> = if slots.is_empty() {
vec!["1".to_string()]
} else {
slots.iter().map(|s| s.slot_id.clone()).collect()
};
if let Some(default_name) = &filament {
for slot in &slot_ids {
map.entry(slot.clone())
.or_insert_with(|| ProfileChoice::Named(default_name.clone()));
}
}
for slot in detected.map(|d| d.slots.as_slice()).unwrap_or_default() {
if let Some(profile) = slot.profile() {
map.entry(slot.usage.slot_id.clone()).or_insert_with(|| {
match &slot.matched {
Some(m) => ProfileChoice::Named(m.profile.name.clone()),
None => ProfileChoice::Profile(Box::new(profile.clone())),
}
});
}
}
let missing: Vec<&SlotUsage> = slots
.iter()
.filter(|slot| !map.contains_key(&slot.slot_id))
.collect();
if missing.is_empty() || has_default {
if map.is_empty() && !has_default {
let name = interactive::require_choice(
None,
"filament",
"Filament profile name",
interactive_mode,
&ops::list_filaments().unwrap_or_default(),
)?;
let first_slot = slot_ids
.into_iter()
.next()
.unwrap_or_else(|| "1".to_string());
return Ok(HashMap::from([(first_slot, ProfileChoice::Named(name))]));
}
return Ok(map);
}
if !interactive_mode {
let listed: Vec<String> = missing.iter().map(|s| s.label()).collect();
return Err(format!(
"no filament for {}; pass --filament as a default, or --filament-map slot=name,... per slot",
listed.join(", ")
)
.into());
}
let choices = ops::list_filaments().unwrap_or_default();
for slot in missing {
let label = if slots.len() == 1 {
"Filament profile name".to_string()
} else {
format!("Filament profile for {}", slot.label())
};
let name = interactive::require_choice(None, "filament", &label, true, &choices)?;
map.insert(slot.slot_id.clone(), ProfileChoice::Named(name));
}
Ok(map)
}
fn parse_plate(plate: Option<&str>) -> Result<ops::PlateOverride, Box<dyn Error>> {
match plate {
None => Ok(ops::PlateOverride::Keep),
Some("all") => Ok(ops::PlateOverride::All),
Some(s) => s
.parse::<u32>()
.map(ops::PlateOverride::Only)
.map_err(|_parse_err| format!("invalid --plate value: {s:?}").into()),
}
}
fn parse_filament_map(
s: &str,
) -> Result<HashMap<String, ProfileChoice<MaterialProfile>>, Box<dyn Error>> {
s.split(',')
.map(|pair| {
let (slot, name) = pair.split_once('=').ok_or_else(|| {
format!("invalid --filament-map entry {pair:?}, expected slot=name")
})?;
Ok((
slot.trim().to_string(),
ProfileChoice::Named(name.trim().to_string()),
))
})
.collect()
}
fn parse_hardware(entries: &[String]) -> Result<Vec<HardwareItem>, Box<dyn Error>> {
entries.iter().map(|e| parse_hardware_entry(e)).collect()
}
fn parse_hardware_entry(entry: &str) -> Result<HardwareItem, Box<dyn Error>> {
let invalid = || -> Box<dyn Error> {
format!("invalid --hardware entry {entry:?}, expected name:unit-price[:units]").into()
};
let mut parts: Vec<&str> = entry.rsplitn(3, ':').map(str::trim).collect();
parts.reverse();
let (name, price, units) = match parts.as_slice() {
[name, price] => (*name, *price, "1"),
[name, price, units] => (*name, *price, *units),
_ => return Err(invalid()),
};
if name.is_empty() {
return Err(invalid());
}
let cost: f64 = price
.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
.parse()
.map_err(|_parse_err| format!("invalid --hardware unit price {price:?} in {entry:?}"))?;
if !cost.is_finite() || cost < 0.0 {
return Err(format!("invalid --hardware unit price {price:?} in {entry:?}").into());
}
let quantity: u32 = units.parse().ok().filter(|q| *q > 0).ok_or_else(|| {
format!("invalid --hardware units {units:?} in {entry:?} (whole number, at least 1)")
})?;
Ok(HardwareItem::new(name, cost).with_quantity(quantity))
}
fn print_result(breakdown: &CostBreakdown, format: OutputFormat, color: bool, currency: &str) {
match format {
OutputFormat::Table => print_receipt(breakdown, color && color_allowed(), currency),
OutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(breakdown).expect("CostBreakdown always serializes")
),
}
}
fn color_allowed() -> bool {
std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none_or(|v| v.is_empty())
}
const CYAN: &str = "\x1b[36m";
const AMBER: &str = "\x1b[33m";
const ROSE: &str = "\x1b[31m";
const BLUE: &str = "\x1b[34m";
const BOLD_GREEN: &str = "\x1b[1;32m";
const RESET: &str = "\x1b[0m";
fn print_plate_section(b: &CostBreakdown, width: usize) {
if b.plate_breakdown.len() <= 1 {
return;
}
println!("plates");
for p in &b.plate_breakdown {
let label = format!("Plate {}", p.plate_index);
let detail = format!(
"{} · {}",
format_duration(p.print_time_seconds),
format_amount(p.grams, p.ml)
);
print_dotted(&label, &detail, width);
}
let combined_seconds: f64 = b.plate_breakdown.iter().map(|p| p.print_time_seconds).sum();
let combined_grams: f64 = b.plate_breakdown.iter().map(|p| p.grams).sum();
let combined_ml: f64 = b.plate_breakdown.iter().map(|p| p.ml).sum();
println!(" {}", "─".repeat(width + 3));
let detail = format!(
"{} · {}",
format_duration(combined_seconds),
format_amount(combined_grams, combined_ml)
);
print_dotted("Combined", &detail, width);
println!();
}
fn print_material_section(b: &CostBreakdown, width: usize, color: bool, cur: &str) {
if b.material_breakdown.is_empty() {
return;
}
println!("material breakdown");
for line in &b.material_breakdown {
let price_unit = if line.unit == "g" { "kg" } else { "L" };
let detail = format!(
"{:.2}{} @ {cur}{:.2}/{}",
line.amount, line.unit, line.unit_price, price_unit
);
print_item_line(
&line.filament_name,
&detail,
line.cost,
width,
CYAN,
color,
cur,
);
}
println!();
}
fn print_hardware_section(b: &CostBreakdown, width: usize, color: bool, cur: &str) {
if b.hardware_breakdown.is_empty() {
return;
}
println!("hardware breakdown");
for line in &b.hardware_breakdown {
let each = if b.quantity > 1 {
format!(
"x{} @ {cur}{:.2} × {}",
line.quantity, line.unit_cost, b.quantity
)
} else {
format!("x{} @ {cur}{:.2}", line.quantity, line.unit_cost)
};
print_item_line(&line.name, &each, line.cost, width, BLUE, color, cur);
}
println!();
}
fn print_receipt(b: &CostBreakdown, color: bool, cur: &str) {
let batch = b.quantity > 1;
let cost_labels = [
"Material",
"Printer wear",
"Electricity",
"Labor",
"Hardware",
"TOTAL",
];
let plate_labels = ["Combined".to_string()];
let width = b
.plate_breakdown
.iter()
.map(|p| format!("Plate {}", p.plate_index).len())
.chain(plate_labels.iter().map(|s| s.len()))
.chain(b.material_breakdown.iter().map(|l| l.filament_name.len()))
.chain(b.hardware_breakdown.iter().map(|l| l.name.len()))
.chain(cost_labels.iter().map(|s| s.len()))
.max()
.unwrap_or(0)
+ 14;
println!();
print_plate_section(b, width);
print_material_section(b, width, color, cur);
print_hardware_section(b, width, color, cur);
if batch {
println!("summary ({} units)", b.quantity);
} else {
println!("summary");
}
print_cost_line("Material", b.material_cost, width, CYAN, color, cur);
print_cost_line("Printer wear", b.wear_cost, width, AMBER, color, cur);
print_cost_line("Electricity", b.electricity_cost, width, AMBER, color, cur);
print_cost_line("Labor", b.labor_cost, width, ROSE, color, cur);
print_cost_line("Hardware", b.hardware_cost, width, BLUE, color, cur);
println!(" {}", "─".repeat(width + 3));
print_cost_line("TOTAL", b.total_cost, width, BOLD_GREEN, color, cur);
if batch {
print_cost_line(" per unit", b.unit_cost, width, BOLD_GREEN, color, cur);
}
println!();
let margins: Vec<String> = b
.suggested_prices
.iter()
.map(|p| format!("{:.0}%", p.margin_percent))
.collect();
let prices: Vec<String> = b
.suggested_prices
.iter()
.map(|p| format!("{cur}{:.2}", p.price))
.collect();
let header = format!(" sale price @ {} margin:", margins.join(" / "));
println!(
"{}",
paint(
&format!("{header} {}", prices.join(" ")),
BOLD_GREEN,
color
)
);
if batch {
let per_unit: Vec<String> = b
.suggested_prices
.iter()
.map(|p| format!("{cur}{:.2}", p.unit_price))
.collect();
let label = format!("{:<width$}", " per unit:", width = header.len());
println!(
"{}",
paint(
&format!("{label} {}", per_unit.join(" ")),
BOLD_GREEN,
color
)
);
}
}
fn print_dotted(label: &str, trailing: &str, width: usize) {
let dots = ".".repeat(width.saturating_sub(label.len()));
println!(" {label}{dots} {trailing}");
}
fn print_cost_line(label: &str, value: f64, width: usize, code: &str, color: bool, cur: &str) {
let dots = ".".repeat(width.saturating_sub(label.len()));
println!(
"{}",
paint(&format!(" {label}{dots} {cur}{value:.2}"), code, color)
);
}
fn print_item_line(
label: &str,
detail: &str,
cost: f64,
width: usize,
code: &str,
color: bool,
cur: &str,
) {
let dots = ".".repeat(width.saturating_sub(label.len()));
println!(
"{}",
paint(
&format!(" {label}{dots} {detail} = {cur}{cost:.2}"),
code,
color
)
);
}
fn format_duration(seconds: f64) -> String {
let total = seconds.round() as i64;
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
if h > 0 {
format!("{h}h{m:02}m{s:02}s")
} else {
format!("{m}m{s:02}s")
}
}
fn format_amount(grams: f64, ml: f64) -> String {
match (grams > 0.0, ml > 0.0) {
(true, true) => format!("{grams:.2}g + {ml:.2}ml"),
(true, false) => format!("{grams:.2}g"),
(false, true) => format!("{ml:.2}ml"),
(false, false) => "0g".to_string(),
}
}
fn paint(text: &str, code: &str, enabled: bool) -> String {
if enabled {
format!("{code}{text}{RESET}")
} else {
text.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_name_price_units() {
let item = parse_hardware_entry("M3 brass insert:0.08:4").unwrap();
assert_eq!(item.name, "M3 brass insert");
assert_eq!(item.cost, 0.08);
assert_eq!(item.quantity, 4);
}
#[test]
fn units_default_to_one_and_currency_symbol_is_tolerated() {
let item = parse_hardware_entry("box:$0.60").unwrap();
assert_eq!(item.name, "box");
assert_eq!(item.cost, 0.60);
assert_eq!(item.quantity, 1);
assert_eq!(parse_hardware_entry("box:€0.60").unwrap().cost, 0.60);
}
#[test]
fn name_may_contain_colons() {
let item = parse_hardware_entry("magnet 6:3:0.12:2").unwrap();
assert_eq!(item.name, "magnet 6:3");
assert_eq!(item.cost, 0.12);
assert_eq!(item.quantity, 2);
}
#[test]
fn rejects_malformed_entries() {
for bad in [
"box",
":0.5",
"box:abc",
"box:-1",
"box:0.5:0",
"box:0.5:1.5",
"box:0.5:x",
] {
assert!(
parse_hardware_entry(bad).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn parses_many() {
let items = parse_hardware(&["a:1".into(), "b:2:3".into()]).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[1].quantity, 3);
}
}