use crate::column_tree::{CellKind, ColumnLayout, ColumnSpec};
use std::collections::HashSet;
pub const PART_PREFIX: &str = "part";
pub const OCCURRENCE_PREFIX: &str = "occurrence";
pub const ITEM_KEY: &str = "item";
pub const ACTIONS_KEY: &str = "actions";
pub const VISIBLE_KEY: &str = "visible";
pub const FLAGS_KEY: &str = "flags";
pub const FREEZE_MARKER: &str = "-";
pub const QUANTITY_KEY: &str = "occurrence.Quantity";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Part,
Occurrence,
}
impl Scope {
pub fn prefix(self) -> &'static str {
match self {
Scope::Part => PART_PREFIX,
Scope::Occurrence => OCCURRENCE_PREFIX,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BomColumn {
pub scope: Scope,
pub field: String,
pub shown: bool,
}
impl BomColumn {
pub fn key(&self) -> String {
format!("{}.{}", self.scope.prefix(), self.field)
}
pub fn label(&self) -> String {
self.field.replace('_', " ")
}
pub fn kind(&self) -> CellKind {
if self.key() == QUANTITY_KEY {
return CellKind::ReadOnly;
}
catalogue()
.iter()
.find(|entry| entry.scope == self.scope && entry.field == self.field)
.map(|entry| entry.kind.clone())
.unwrap_or(CellKind::Text)
}
}
pub struct CatalogueEntry {
pub scope: Scope,
pub field: &'static str,
pub kind: CellKind,
}
pub fn catalogue() -> Vec<CatalogueEntry> {
let choice = |options: &[&str]| CellKind::Choice {
options: options.iter().map(|option| option.to_string()).collect(),
};
let part = |field, kind| CatalogueEntry {
scope: Scope::Part,
field,
kind,
};
let occurrence = |field, kind| CatalogueEntry {
scope: Scope::Occurrence,
field,
kind,
};
vec![
part("Part_Number", CellKind::Text),
part("Revision", CellKind::Text),
part("Description", CellKind::Text),
part(
"Part_Type",
choice(&["Manufactured", "Purchased", "Assembly", "Phantom", "Reference"]),
),
part("Make_Buy", choice(&["Make", "Buy"])),
part(
"Unit_of_Measure",
choice(&["EA", "MM", "M", "IN", "FT", "KG", "G", "LB", "L", "ML"]),
),
part("Material", CellKind::Text),
part("Finish", CellKind::Text),
part("Mass", CellKind::Numeric { step: 0.01 }),
part("Manufacturer", CellKind::Text),
part("Manufacturer_Part_Number", CellKind::Text),
part("Supplier", CellKind::Text),
part("Supplier_Part_Number", CellKind::Text),
part(
"Lifecycle_State",
choice(&["In Work", "In Review", "Released", "Obsolete"]),
),
occurrence("Item_Number", CellKind::Text),
occurrence("Quantity", CellKind::ReadOnly),
occurrence("Reference_Designator", CellKind::Text),
occurrence("Find_Number", CellKind::Text),
occurrence("Effectivity", CellKind::Text),
occurrence("Occurrence_Name", CellKind::Text),
occurrence("Position", CellKind::Text),
occurrence("Notes", CellKind::Text),
occurrence(
"BOM_Structure",
choice(&["Normal", "Phantom", "Reference", "Inseparable"]),
),
occurrence("Alternate_Substitute", CellKind::Text),
]
}
pub fn default_text() -> String {
let shown: HashSet<&str> = [
"occurrence.Item_Number",
"occurrence.Quantity",
"part.Part_Number",
"part.Revision",
"part.Description",
"part.Material",
"occurrence.Reference_Designator",
"occurrence.Notes",
]
.into_iter()
.collect();
let order = [
"occurrence.Item_Number",
"occurrence.Quantity",
"part.Part_Number",
"part.Revision",
"part.Description",
"part.Part_Type",
"part.Make_Buy",
"part.Unit_of_Measure",
"part.Material",
"part.Finish",
"part.Mass",
"part.Lifecycle_State",
"occurrence.Reference_Designator",
"occurrence.Find_Number",
"occurrence.Occurrence_Name",
"occurrence.Position",
"occurrence.Effectivity",
"occurrence.BOM_Structure",
"occurrence.Alternate_Substitute",
"occurrence.Notes",
"part.Manufacturer",
"part.Manufacturer_Part_Number",
"part.Supplier",
"part.Supplier_Part_Number",
];
let mut out = String::from(
"# One BOM column per line, in display order. A leading * shows it.\n\
# part.<Field> is stored on the part; occurrence.<Field> on one placement.\n\
# A name not listed here is a custom text field, not an error.\n\
# A line that is just - freezes the columns above it; the rest scroll.\n",
);
for key in order {
if shown.contains(key) {
out.push('*');
}
out.push_str(key);
out.push('\n');
}
out
}
pub fn effective_text(stored: &str) -> String {
if stored.trim().is_empty() {
default_text()
} else {
stored.to_string()
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ParsedColumns {
pub columns: Vec<BomColumn>,
pub problems: Vec<String>,
pub preserved: Vec<String>,
pub frozen: Option<usize>,
}
pub fn parse(text: &str) -> ParsedColumns {
let mut out = ParsedColumns::default();
let mut seen: HashSet<String> = HashSet::new();
for (index, raw) in text.lines().enumerate() {
let number = index + 1;
let line = raw.trim();
if line.is_empty() {
continue;
}
if line.starts_with('#') {
out.preserved.push(line.to_string());
continue;
}
if line == FREEZE_MARKER {
if out.frozen.is_some() {
out.problems.push(format!(
"line {number}: a second '{FREEZE_MARKER}' freeze marker — the first one decides"
));
} else {
out.frozen = Some(out.columns.len());
}
continue;
}
let (shown, rest) = match line.strip_prefix('*') {
Some(rest) => (true, rest.trim()),
None => (false, line),
};
let Some((prefix, field)) = rest.split_once('.') else {
out.problems.push(format!(
"line {number}: '{rest}' has no '.' — write {PART_PREFIX}.Field or {OCCURRENCE_PREFIX}.Field"
));
out.preserved.push(raw.trim_end().to_string());
continue;
};
let scope = match prefix.trim() {
PART_PREFIX => Scope::Part,
OCCURRENCE_PREFIX => Scope::Occurrence,
other => {
out.problems.push(format!(
"line {number}: unknown prefix '{other}' — only '{PART_PREFIX}.' and '{OCCURRENCE_PREFIX}.' exist"
));
out.preserved.push(raw.trim_end().to_string());
continue;
}
};
let field = field.trim();
if field.is_empty() {
out.problems
.push(format!("line {number}: '{rest}' names no field"));
out.preserved.push(raw.trim_end().to_string());
continue;
}
let column = BomColumn {
scope,
field: field.to_string(),
shown,
};
let key = column.key();
if !seen.insert(key.clone()) {
out.problems
.push(format!("line {number}: '{key}' is already configured above"));
continue;
}
out.columns.push(column);
}
out
}
pub fn serialize(columns: &[BomColumn], preserved: &[String], frozen: Option<usize>) -> String {
let mut out = String::new();
for line in preserved {
out.push_str(line);
out.push('\n');
}
for (index, column) in columns.iter().enumerate() {
if frozen == Some(index) {
out.push_str(FREEZE_MARKER);
out.push('\n');
}
if column.shown {
out.push('*');
}
out.push_str(&column.key());
out.push('\n');
}
if frozen.is_some_and(|at| at >= columns.len()) {
out.push_str(FREEZE_MARKER);
out.push('\n');
}
out
}
pub fn column_specs(parsed: &ParsedColumns) -> Vec<ColumnSpec> {
let mut specs = vec![
ColumnSpec::new(ITEM_KEY, "Item", CellKind::ReadOnly).width(190.0),
ColumnSpec::new(VISIBLE_KEY, "", CellKind::Toggle).width(26.0),
ColumnSpec::new(FLAGS_KEY, "", CellKind::Badges).width(52.0),
];
for column in &parsed.columns {
let width = match column.kind() {
CellKind::ReadOnly => 60.0,
CellKind::Numeric { .. } => 70.0,
_ => 120.0,
};
specs.push(ColumnSpec::new(column.key(), column.label(), column.kind()).width(width));
}
specs.push(
ColumnSpec::new(
ACTIONS_KEY,
"",
CellKind::Actions {
label: "\u{22EF}".to_string(),
},
)
.width(34.0),
);
specs
}
pub fn layout_from(parsed: &ParsedColumns, keep: &ColumnLayout) -> ColumnLayout {
let mut order = vec![
ITEM_KEY.to_string(),
VISIBLE_KEY.to_string(),
FLAGS_KEY.to_string(),
];
let mut hidden = HashSet::new();
for column in &parsed.columns {
let key = column.key();
if !column.shown {
hidden.insert(key.clone());
}
order.push(key);
}
order.push(ACTIONS_KEY.to_string());
ColumnLayout {
order,
hidden,
widths: keep.widths.clone(),
sort: keep.sort.clone(),
frozen: parsed.frozen.map_or(STRUCTURAL_LEADING, |above| above + STRUCTURAL_LEADING),
}
}
pub const STRUCTURAL_LEADING: usize = 3;
pub fn frozen_from_layout(layout: &ColumnLayout) -> Option<usize> {
if layout.frozen <= STRUCTURAL_LEADING {
return None;
}
Some(
layout
.order
.iter()
.take(layout.frozen)
.filter(|key| {
!matches!(
key.as_str(),
ITEM_KEY | VISIBLE_KEY | FLAGS_KEY | ACTIONS_KEY
)
})
.count(),
)
}
pub fn columns_from_layout(parsed: &ParsedColumns, layout: &ColumnLayout) -> Vec<BomColumn> {
let mut out: Vec<BomColumn> = Vec::new();
for key in &layout.order {
if matches!(
key.as_str(),
ITEM_KEY | VISIBLE_KEY | FLAGS_KEY | ACTIONS_KEY
) {
continue;
}
if let Some(column) = parsed.columns.iter().find(|column| &column.key() == key) {
out.push(BomColumn {
shown: !layout.hidden.contains(key),
..column.clone()
});
}
}
for column in &parsed.columns {
if !out.iter().any(|kept| kept.key() == column.key()) {
out.push(BomColumn {
shown: !layout.hidden.contains(&column.key()),
..column.clone()
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_star_shows_and_a_bare_line_hides() {
let parsed = parse("part.Mass\n*part.Part_Number\n*occurrence.Notes\n");
assert!(parsed.problems.is_empty(), "{:?}", parsed.problems);
assert_eq!(
parsed.columns,
vec![
BomColumn { scope: Scope::Part, field: "Mass".into(), shown: false },
BomColumn { scope: Scope::Part, field: "Part_Number".into(), shown: true },
BomColumn { scope: Scope::Occurrence, field: "Notes".into(), shown: true },
]
);
let layout = layout_from(&parsed, &ColumnLayout::default());
assert_eq!(
layout.order,
vec![
"item",
VISIBLE_KEY,
FLAGS_KEY,
"part.Mass",
"part.Part_Number",
"occurrence.Notes",
"actions"
]
);
assert!(layout.hidden.contains("part.Mass"));
assert!(!layout.hidden.contains("part.Part_Number"));
}
#[test]
fn blank_lines_and_comments_are_neither_columns_nor_problems() {
let parsed = parse("# the BOM\n\n \n*part.Revision\n");
assert_eq!(parsed.columns.len(), 1);
assert!(parsed.problems.is_empty());
assert_eq!(parsed.preserved, vec!["# the BOM"]);
}
#[test]
fn a_malformed_line_costs_one_column_and_says_which() {
let parsed = parse("*part.Part_Number\nnonsense\n*widget.Colour\n*part.\n*part.Mass\n");
assert_eq!(
parsed
.columns
.iter()
.map(BomColumn::key)
.collect::<Vec<_>>(),
vec!["part.Part_Number", "part.Mass"],
"the good lines are untouched"
);
assert_eq!(parsed.problems.len(), 3, "{:?}", parsed.problems);
assert!(parsed.problems[0].starts_with("line 2:"), "{:?}", parsed.problems);
assert!(parsed.problems[0].contains("no '.'"));
assert!(parsed.problems[1].starts_with("line 3:"));
assert!(parsed.problems[1].contains("unknown prefix 'widget'"));
assert!(parsed.problems[2].starts_with("line 4:"));
assert!(parsed.problems[2].contains("names no field"));
assert_eq!(parsed.preserved, vec!["nonsense", "*widget.Colour", "*part."]);
}
#[test]
fn a_duplicate_keeps_the_first_and_reports_the_rest() {
let parsed = parse("*part.Mass\npart.Mass\n");
assert_eq!(parsed.columns.len(), 1);
assert!(parsed.columns[0].shown, "the FIRST rule stands");
assert_eq!(parsed.problems.len(), 1);
assert!(parsed.problems[0].contains("already configured above"));
}
#[test]
fn an_unknown_field_is_a_custom_text_column_not_an_error() {
let parsed = parse("*occurrence.Torque_Spec\n*part.Cure_Time\n");
assert!(parsed.problems.is_empty(), "{:?}", parsed.problems);
assert_eq!(parsed.columns[0].kind(), CellKind::Text);
assert_eq!(parsed.columns[1].kind(), CellKind::Text);
assert_eq!(parsed.columns[0].label(), "Torque Spec", "underscores open out");
}
#[test]
fn catalogue_kinds_reach_the_columns_and_quantity_is_read_only() {
let parsed = parse("*part.Make_Buy\n*part.Mass\n*occurrence.Quantity\n*part.Description\n");
assert_eq!(
parsed.columns[0].kind(),
CellKind::Choice { options: vec!["Make".into(), "Buy".into()] }
);
assert_eq!(parsed.columns[1].kind(), CellKind::Numeric { step: 0.01 });
assert_eq!(
parsed.columns[2].kind(),
CellKind::ReadOnly,
"quantity is derived from the occurrence count, never typed"
);
assert_eq!(parsed.columns[3].kind(), CellKind::Text);
}
#[test]
fn the_default_text_parses_clean_and_covers_the_catalogue() {
let parsed = parse(&default_text());
assert!(parsed.problems.is_empty(), "{:?}", parsed.problems);
let keys: HashSet<String> = parsed.columns.iter().map(BomColumn::key).collect();
assert_eq!(keys.len(), parsed.columns.len(), "no duplicates");
for entry in catalogue() {
let key = format!("{}.{}", entry.scope.prefix(), entry.field);
assert!(keys.contains(&key), "{key} is offered");
}
assert_eq!(catalogue().len(), 24, "14 part + 10 occurrence fields");
let shown = parsed.columns.iter().filter(|column| column.shown).count();
assert!(
(4..=12).contains(&shown),
"a readable default, not all 24: {shown}"
);
}
#[test]
fn a_layout_change_round_trips_through_the_text_keeping_stray_lines() {
let text = "# mine\n*part.Part_Number\npart.Mass\nnonsense\n*occurrence.Notes\n";
let parsed = parse(text);
let mut layout = layout_from(&parsed, &ColumnLayout::default());
layout.order = vec![
"item".into(),
VISIBLE_KEY.into(),
FLAGS_KEY.into(),
"occurrence.Notes".into(),
"part.Mass".into(),
"part.Part_Number".into(),
"actions".into(),
];
layout.hidden.remove("part.Mass");
let rewritten = serialize(
&columns_from_layout(&parsed, &layout),
&parsed.preserved,
frozen_from_layout(&layout),
);
assert_eq!(
rewritten,
"# mine\nnonsense\n*occurrence.Notes\n*part.Mass\n*part.Part_Number\n"
);
let again = parse(&rewritten);
let relaid = layout_from(&again, &ColumnLayout::default());
assert_eq!(relaid.order, layout.order);
assert!(relaid.hidden.is_empty(), "everything is shown now");
}
#[test]
fn widths_and_sort_survive_a_re_read_of_the_text() {
let parsed = parse("*part.Mass\n");
let mut keep = ColumnLayout::default();
keep.widths.insert("part.Mass".into(), 240.0);
keep.sort = Some(("part.Mass".into(), true));
let layout = layout_from(&parsed, &keep);
assert_eq!(layout.widths["part.Mass"], 240.0);
assert_eq!(layout.sort, Some(("part.Mass".into(), true)));
}
#[test]
fn the_structural_columns_are_never_written_to_the_text() {
let parsed = parse("*part.Mass\n");
let specs = column_specs(&parsed);
assert_eq!(specs[0].key, ITEM_KEY, "the tree column leads");
assert_eq!(specs[1].key, VISIBLE_KEY, "then the visibility toggle");
assert_eq!(specs[2].key, FLAGS_KEY, "then the status badges");
assert_eq!(specs[4].key, ACTIONS_KEY, "the action column trails");
assert_eq!(specs.len(), STRUCTURAL_LEADING + 2);
assert!(
matches!(specs[4].kind, CellKind::Actions { .. }),
"the action column hosts the row MENU trigger, which the widget \
opens and the panel acts on"
);
let layout = layout_from(&parsed, &ColumnLayout::default());
assert_eq!(
layout.order,
vec![ITEM_KEY, VISIBLE_KEY, FLAGS_KEY, "part.Mass", ACTIONS_KEY]
);
let text = serialize(
&columns_from_layout(&parsed, &layout),
&parsed.preserved,
frozen_from_layout(&layout),
);
assert_eq!(text, "*part.Mass\n", "no lines for the structural columns");
}
#[test]
fn the_dash_marker_freezes_the_columns_above_it_and_round_trips() {
let text = "*part.Part_Number\n-\n*part.Description\n*occurrence.Notes\n";
let parsed = parse(&text);
assert_eq!(parsed.frozen, Some(1), "one configured column above the dash");
assert!(parsed.problems.is_empty(), "{:?}", parsed.problems);
assert!(
!parsed.preserved.iter().any(|line| line == FREEZE_MARKER),
"the marker is not a preserved line — it would be re-emitted above \
the columns and would then read as 'freeze nothing'"
);
let layout = layout_from(&parsed, &ColumnLayout::default());
assert_eq!(
layout.frozen,
STRUCTURAL_LEADING + 1,
"the structural columns + Part Number"
);
assert_eq!(frozen_from_layout(&layout), Some(1), "back to text terms");
assert_eq!(
serialize(
&columns_from_layout(&parsed, &layout),
&parsed.preserved,
frozen_from_layout(&layout)
),
text,
"the marker survives a write-back in its own place"
);
}
#[test]
fn no_dash_still_holds_the_structural_columns_still() {
let parsed = parse("*part.Mass\n*part.Finish\n");
assert_eq!(parsed.frozen, None, "the TEXT names no frozen column");
let layout = layout_from(&parsed, &ColumnLayout::default());
assert_eq!(
layout.frozen, STRUCTURAL_LEADING,
"but Item / visibility / badges never scroll: they are the row's \
identity, and scrolling them away leaves values with nothing \
saying which part each line is about"
);
assert_eq!(
frozen_from_layout(&layout),
None,
"the implicit freeze is NOT a marker — round-tripping it would \
invent a `-` in the user's text"
);
assert_eq!(
serialize(
&columns_from_layout(&parsed, &layout),
&parsed.preserved,
frozen_from_layout(&layout)
),
"*part.Mass\n*part.Finish\n",
"and no marker is invented"
);
}
#[test]
fn dash_edges_and_a_second_dash() {
let top = parse("-\n*part.Mass\n");
assert_eq!(top.frozen, Some(0), "nothing above it but the Item column");
assert_eq!(
layout_from(&top, &ColumnLayout::default()).frozen,
STRUCTURAL_LEADING
);
let bottom = parse("*part.Mass\n-\n");
assert_eq!(bottom.frozen, Some(1), "every column is above it");
let layout = layout_from(&bottom, &ColumnLayout::default());
assert_eq!(layout.frozen, STRUCTURAL_LEADING + 1);
assert_eq!(
serialize(
&columns_from_layout(&bottom, &layout),
&bottom.preserved,
frozen_from_layout(&layout)
),
"*part.Mass\n-\n",
"a trailing marker survives"
);
let twice = parse("*part.Mass\n-\n*part.Finish\n-\n*part.Revision\n");
assert_eq!(twice.frozen, Some(1), "the FIRST marker decides");
assert_eq!(twice.problems.len(), 1, "{:?}", twice.problems);
assert!(twice.problems[0].starts_with("line 4:"), "{:?}", twice.problems);
assert_eq!(twice.columns.len(), 3, "and it costs no column");
}
}