use std::collections::HashMap;
use super::ParseError;
use super::slice_summary::{FilamentUsage, PlateSummary, SliceMeta, SliceSummary, SourceFormat};
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
}
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]"));
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; }
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),
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(),
}],
})
}
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()
}
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()
}
fn pick(list: &[String], i: usize) -> Option<String> {
list.get(i).filter(|s| !s.is_empty()).cloned()
}
fn pick_num(list: &[f64], i: usize) -> Option<f64> {
list.get(i).copied().filter(|v| v.is_finite())
}
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)"
))
));
}
#[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); 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);
assert_eq!(usage[0].vendor.as_deref(), Some("Generic"));
assert_eq!(usage[1].vendor.as_deref(), Some("Generic"));
}
}