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()
}
}
pub fn part_fields(config_text: &str, stored: &serde_json::Value) -> Vec<BomColumn> {
let mut out: Vec<BomColumn> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut push = |out: &mut Vec<BomColumn>, seen: &mut HashSet<String>, column: BomColumn| {
if seen.insert(column.field.clone()) {
out.push(column);
}
};
for column in parse(config_text).columns {
if column.scope == Scope::Part {
push(&mut out, &mut seen, column);
}
}
for entry in catalogue() {
if entry.scope == Scope::Part {
push(
&mut out,
&mut seen,
BomColumn { scope: Scope::Part, field: entry.field.to_string(), shown: false },
);
}
}
if let Some(record) = stored.as_object() {
let mut orphans: Vec<&String> = record.keys().collect();
orphans.sort();
for key in orphans {
push(
&mut out,
&mut seen,
BomColumn { scope: Scope::Part, field: key.clone(), shown: false },
);
}
}
out
}
#[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
}