libprintcalc 0.2.0

Library for calculating 3D print job costs (material, print time, printer wear, electricity, labor, hardware) from sliced-file output.
Documentation
//! The `key = value` metadata block shared by plain-text `.gcode` and
//! binary `.bgcode`, and the [`SliceSummary`] built from it.
//!
//! Both formats carry the same PrusaSlicer-lineage settings dump — as
//! `;`-prefixed comments in one, as compressed blocks in the other — so
//! both hand their decoded lines here and get identical results.
//!
//! Per-slot values arrive as positional lists, and the two separators
//! differ: text lists (`filament_settings_id`, `filament_vendor`) are
//! `;`-separated, numeric lists (`filament_cost`, `filament used [g]`)
//! are `,`-separated. Slot N reads index N of each. A single
//! `filament_vendor` with several slots applies to all of them.
//!
//! Nothing here is required. This is a comment convention rather than a
//! specification, so a slicer that omits a key simply yields less
//! metadata — it never fails the parse.

use std::collections::HashMap;

use super::ParseError;
use super::slice_summary::{FilamentUsage, PlateSummary, SliceMeta, SliceSummary, SourceFormat};

/// Parses `key = value` lines (already stripped of any comment marker)
/// into a map. Blank lines and lines without `=` are skipped, with one
/// exception: the banner every PrusaSlicer-lineage slicer opens with
/// (`generated by OrcaSlicer 2.4.2 on 2026-07-18 at 20:26:48`) is the
/// only place the slicer's own name and version appear, so it's captured
/// under `generated_by` with the timestamp trimmed off.
pub fn parse_lines<'a>(lines: impl Iterator<Item = &'a str>) -> HashMap<String, String> {
    let mut map = HashMap::new();
    for line in lines {
        let line = line.trim();
        if let Some(rest) = line.strip_prefix("generated by ") {
            let name = rest.split(" on ").next().unwrap_or(rest).trim();
            if !name.is_empty() {
                map.entry("generated_by".to_string())
                    .or_insert_with(|| name.to_string());
            }
            continue;
        }
        let Some((key, value)) = line.split_once('=') else {
            continue;
        };
        let key = key.trim();
        if key.is_empty() {
            continue;
        }
        map.insert(key.to_string(), value.trim().to_string());
    }
    map
}

/// Builds a single-plate `SliceSummary` from a parsed metadata map.
///
/// `filament used [g]` is either a plain scalar (single-extruder print —
/// one `FilamentUsage`, `slot_id` normalized to `"1"` to line up with
/// Bambu's single-tray convention and the `--filament`/`[filament]` sugar)
/// or a comma-separated list, one value per extruder slot
/// on a multi-extruder printer (XL, Core One IDEX) — one `FilamentUsage`
/// per nonzero entry, `slot_id` = its 0-based position in the list.
///
/// Every other per-filament key follows the same positional shape, so the
/// slicer's own description of each slot (profile name, vendor, price,
/// density, color) is read off at the same index. PrusaSlicer separates
/// those lists inconsistently — text with `;`, numbers with `,` — which
/// [`split_list`] and [`split_num_list`] handle respectively.
pub fn build_slice_summary(
    metadata: &HashMap<String, String>,
    source_format: SourceFormat,
) -> Result<SliceSummary, ParseError> {
    const TIME_KEY: &str = "estimated printing time (normal mode)";
    const GRAMS_KEY: &str = "filament used [g]";

    let print_time_seconds = metadata
        .get(TIME_KEY)
        .ok_or(ParseError::MissingField(TIME_KEY))
        .and_then(|s| {
            parse_duration(s).ok_or_else(|| ParseError::InvalidField(TIME_KEY, s.clone()))
        })?;

    let grams_raw = metadata
        .get(GRAMS_KEY)
        .ok_or(ParseError::MissingField(GRAMS_KEY))?;

    let types = split_list(metadata.get("filament_type"));
    let colours = split_list(metadata.get("filament_colour"));
    let profiles = split_list(metadata.get("filament_settings_id"));
    let vendors = split_list(metadata.get("filament_vendor"));
    let prices = split_num_list(metadata.get("filament_cost"));
    let densities = split_num_list(metadata.get("filament_density"));
    let millimetres = split_num_list(metadata.get("filament used [mm]"));

    // `filament_vendor` is often a single value even on a multi-slot
    // print (all slots from one maker) — reuse it for every slot then.
    let vendor_at = |i: usize| -> Option<String> {
        let single = match vendors.as_slice() {
            [only] => Some(only.clone()),
            _ => None,
        };
        pick(&vendors, i).or(single)
    };

    let filament_usage = if grams_raw.contains(',') {
        let mut usage = Vec::new();
        for (i, raw) in grams_raw.split(',').enumerate() {
            let grams: f64 = raw
                .trim()
                .parse()
                .map_err(|_parse_err| ParseError::InvalidField(GRAMS_KEY, grams_raw.clone()))?;
            if grams == 0.0 {
                continue; // extruder slot not used on this print
            }
            usage.push(FilamentUsage {
                slot_id: i.to_string(),
                material_type: pick(&types, i),
                color: pick(&colours, i),
                profile_name: pick(&profiles, i),
                vendor: vendor_at(i),
                price: pick_num(&prices, i),
                density: pick_num(&densities, i),
                meters: pick_num(&millimetres, i).map(|mm| mm / 1000.0),
                grams: Some(grams),
                ml: None,
            });
        }
        usage
    } else {
        let grams: f64 = grams_raw
            .parse()
            .map_err(|_parse_err| ParseError::InvalidField(GRAMS_KEY, grams_raw.clone()))?;
        vec![FilamentUsage {
            slot_id: "1".to_string(),
            material_type: pick(&types, 0),
            // The scalar case is a single-extruder printer, so index 0 of
            // every other list describes the one filament in play.
            color: pick(&colours, 0),
            profile_name: pick(&profiles, 0),
            vendor: vendor_at(0),
            price: pick_num(&prices, 0),
            density: pick_num(&densities, 0),
            meters: pick_num(&millimetres, 0).map(|mm| mm / 1000.0),
            grams: Some(grams),
            ml: None,
        }]
    };

    Ok(SliceSummary {
        source_format,
        meta: SliceMeta {
            printer_profile: metadata.get("printer_settings_id").cloned(),
            print_profile: metadata.get("print_settings_id").cloned(),
            printer_variant: metadata.get("printer_variant").cloned(),
            nozzle_diameter: split_num_list(metadata.get("nozzle_diameter"))
                .first()
                .copied(),
            slicer: metadata.get("generated_by").cloned(),
        },
        plates: vec![PlateSummary {
            plate_index: 1,
            print_time_seconds,
            filament_usage,
            printer_model: metadata.get("printer_model").cloned(),
            layer_height: metadata
                .get("layer_height")
                .and_then(|s| s.parse::<f64>().ok()),
            extra: HashMap::new(),
        }],
    })
}

/// `"PLA;PLA;PETG"` -> `["PLA", "PLA", "PETG"]`, trimmed and unquoted,
/// positions kept intact (an empty entry stays as `""` rather than
/// shifting later positions) so `.get(i)` lines up with
/// `filament used [g]`'s `i`. `None` input (key absent) is just an empty
/// list — every `.get(i)` then yields `None`.
fn split_list(value: Option<&String>) -> Vec<String> {
    value
        .map(|s| {
            s.split(';')
                .map(|part| part.trim().trim_matches('"').trim().to_string())
                .collect()
        })
        .unwrap_or_default()
}

/// Same as [`split_list`] for PrusaSlicer's *numeric* per-filament lists,
/// which it comma-separates instead (`filament_cost = 25.4,25.4,...`). A
/// scalar with no separator is a one-entry list.
fn split_num_list(value: Option<&String>) -> Vec<f64> {
    value
        .map(|s| {
            s.split(',')
                .map(|part| part.trim().parse::<f64>().unwrap_or(f64::NAN))
                .collect()
        })
        .unwrap_or_default()
}

/// `list[i]`, dropping blanks — an absent or empty entry is `None`.
fn pick(list: &[String], i: usize) -> Option<String> {
    list.get(i).filter(|s| !s.is_empty()).cloned()
}

/// `list[i]` for a numeric list, dropping the NaN [`split_num_list`] uses
/// for an unparseable entry.
fn pick_num(list: &[f64], i: usize) -> Option<f64> {
    list.get(i).copied().filter(|v| v.is_finite())
}

/// "20m 7s" / "1h 5m 30s" style duration, as emitted after the
/// `estimated printing time` keys.
fn parse_duration(s: &str) -> Option<f64> {
    let mut total = 0.0;
    let mut matched = false;
    for token in s.split_whitespace() {
        let unit_start = token.find(|c: char| c.is_ascii_alphabetic())?;
        let (num, unit) = token.split_at(unit_start);
        let num: f64 = num.parse().ok()?;
        total += match unit {
            "h" => num * 3600.0,
            "m" => num * 60.0,
            "s" => num,
            _ => return None,
        };
        matched = true;
    }
    matched.then_some(total)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_lines_builds_a_map_and_skips_junk() {
        let lines = [
            "filament used [g] = 3.10",
            "printer_model = Prusa MINI",
            "",
            "no equals here",
            "generated by OrcaSlicer 2.4.2 on 2026-07-18 at 20:26:48",
        ];
        let map = parse_lines(lines.into_iter());
        assert_eq!(
            map.get("filament used [g]").map(String::as_str),
            Some("3.10")
        );
        assert_eq!(
            map.get("printer_model").map(String::as_str),
            Some("Prusa MINI")
        );
        assert_eq!(
            map.get("generated_by").map(String::as_str),
            Some("OrcaSlicer 2.4.2")
        );
        assert_eq!(map.len(), 3);
    }

    #[test]
    fn parse_duration_handles_minutes_seconds_and_hours() {
        assert_eq!(parse_duration("20m 7s"), Some(1207.0));
        assert_eq!(parse_duration("1h 5m 30s"), Some(3930.0));
        assert_eq!(parse_duration("45s"), Some(45.0));
        assert_eq!(parse_duration(""), None);
        assert_eq!(parse_duration("garbage"), None);
    }

    #[test]
    fn build_slice_summary_requires_time_and_grams() {
        let mut metadata = HashMap::new();
        metadata.insert("filament used [g]".to_string(), "3.10".to_string());
        assert!(matches!(
            build_slice_summary(&metadata, SourceFormat::Gcode),
            Err(ParseError::MissingField(
                "estimated printing time (normal mode)"
            ))
        ));
    }

    /// PrusaSlicer quotes `filament_settings_id` and separates its text
    /// lists with `;` but its numeric ones with `,` — both shapes have to
    /// line up on the same slot index.
    #[test]
    fn per_filament_lists_are_read_at_the_matching_index() {
        let metadata: HashMap<String, String> = [
            ("estimated printing time (normal mode)", "19m 4s"),
            ("filament used [g]", "1.38, 1.47, 0.00"),
            ("filament used [mm]", "463.5, 493.2, 0.0"),
            ("filament_type", "PLA;PETG;PLA"),
            ("filament_colour", "#FF8000;#1F77B4;#000000"),
            (
                "filament_settings_id",
                "\"Generic PLA\";\"Generic PETG\";\"x\"",
            ),
            ("filament_cost", "25.4,29.9,25.4"),
            ("filament_density", "1.24,1.27,1.24"),
            ("filament_vendor", "Generic"),
        ]
        .into_iter()
        .map(|(k, v)| (k.to_string(), v.to_string()))
        .collect();

        let summary = build_slice_summary(&metadata, SourceFormat::Gcode).unwrap();
        let usage = &summary.plates[0].filament_usage;
        assert_eq!(usage.len(), 2); // the 0.00 slot is skipped
        assert_eq!(usage[1].slot_id, "1");
        assert_eq!(usage[1].material_type.as_deref(), Some("PETG"));
        assert_eq!(usage[1].profile_name.as_deref(), Some("Generic PETG"));
        assert_eq!(usage[1].price, Some(29.9));
        assert_eq!(usage[1].density, Some(1.27));
        assert!((usage[1].meters.unwrap() - 0.4932).abs() < 1e-9);
        // A single `filament_vendor` covers every slot.
        assert_eq!(usage[0].vendor.as_deref(), Some("Generic"));
        assert_eq!(usage[1].vendor.as_deref(), Some("Generic"));
    }
}