///|
const STOCK_CSV_BYTES : Int32 = 2_097_152
///|
fn stock_csv_cell(raw : StringView, row : Int32) -> String raise PlanError {
let len = raw.length()
if len > 0 && raw[0] == '"' {
if len < 2 || raw[len - 1] != '"' {
raise Invalid("stock CSV row \{row} contains an unterminated quoted cell")
}
raw[1:len - 1].to_owned()
} else {
raw.to_owned()
}
}
///|
fn stock_csv_cells(
line : StringView,
row : Int32,
) -> (String, String) raise PlanError {
match line.split_once(",") {
Some((left, right)) =>
if right.contains(",") {
raise Invalid("stock CSV row \{row} must have exactly two cells")
} else {
(stock_csv_cell(left, row), stock_csv_cell(right, row))
}
None => raise Invalid("stock CSV row \{row} must have exactly two cells")
}
}
///|
fn stock_csv_quantity(digits : String, row : Int32) -> Int32 raise PlanError {
if digits.length() == 0 {
raise Invalid("stock CSV row \{row} has an empty stock count")
}
let mut value = 0
for c in digits {
if c < '0' || c > '9' {
raise Invalid(
"stock CSV row \{row} must use an unsigned decimal stock count",
)
}
// value has already passed the previous bound check, so this step
// cannot overflow even if the cell has an excessive number of digits.
value = value * 10 + c.to_int() - '0'.to_int()
if value > max_quantity {
raise Invalid("stock CSV row \{row} exceeds \{max_quantity} units")
}
}
value
}
///|
/// Replace listed stock counts from a two-column `part,stock` CSV.
/// Accepts LF or CRLF, an optional UTF-8 BOM, and optionally quoted cells.
/// IDs and counts use a single-line ASCII alphabet; embedded quotes,
/// delimiters, and multiline cells are rejected. Returns independent arrays.
pub fn refresh_stock(
manifest : Manifest,
csv : String,
) -> Manifest raise PlanError {
let bom = csv.length() > 0 && csv[0] == '\u{FEFF}'
// The accepted grammar is ASCII except for the optional three-byte BOM.
if csv.length() > STOCK_CSV_BYTES - (if bom { 2 } else { 0 }) {
raise Invalid("stock CSV exceeds 2 MiB")
}
let part_indices : Map[String, Int32] = Map([])
for index, part in manifest.parts {
if !valid_id(part.id) || part_indices.contains(part.id) {
raise Invalid("invalid or duplicate manifest part id: " + part.id)
}
part_indices[part.id] = index
}
let replacements : Array[Int32?] = Array::make(manifest.parts.length(), None)
let mut start = if bom { 1 } else { 0 }
let mut row = 0
for index in start..<(csv.length() + 1) {
let at_end = index == csv.length()
if !at_end && csv[index] != '\n' {
continue
}
if at_end && start == index {
break
}
let end = if !at_end && index > start && csv[index - 1] == '\r' {
index - 1
} else {
index
}
row += 1
if row > 257 {
raise Invalid("stock CSV supports at most 256 updates")
}
let (id, count) = stock_csv_cells(csv[start:end], row)
start = index + 1
if row == 1 {
if id != "part" || count != "stock" {
raise Invalid("stock CSV header must be part,stock")
}
continue
}
if !valid_id(id) {
raise Invalid("stock CSV row \{row} has an invalid part ID")
}
let part_index = match part_indices.get(id) {
Some(part_index) => part_index
None =>
raise Invalid("stock CSV row \{row} references unknown part " + id)
}
if replacements[part_index] is Some(_) {
raise Invalid("stock CSV row \{row} repeats part " + id)
}
replacements[part_index] = Some(stock_csv_quantity(count, row))
}
if row < 2 {
raise Invalid("stock CSV requires a header and at least one update")
}
let parts = []
for index, part in manifest.parts {
parts.push({
id: part.id,
stock: replacements[index].unwrap_or(part.stock),
packs: part.packs.copy(),
})
}
{
parts,
kits: manifest.kits.map(kit => { id: kit.id, items: kit.items.copy(), }),
orders: manifest.orders.copy(),
}
}
///|
fn manifest_number(value : Int32) -> Json {
Json::number(value.to_double(), repr=value.to_string())
}
///|
/// Encode a validated snapshot using the same schema read by `parse_manifest`.
pub fn encode_manifest(manifest : Manifest) -> String {
let parts = manifest.parts.map(part => {
Json::object(
Map([
("id", Json::string(part.id)),
("stock", manifest_number(part.stock)),
(
"packs",
Json::array(
part.packs.map(pack => {
Json::object(
Map([
("id", Json::string(pack.id)),
("quantity", manifest_number(pack.quantity)),
("cost_cents", manifest_number(pack.cost_cents)),
]),
)
}),
),
),
]),
)
})
let kits = manifest.kits.map(kit => {
Json::object(
Map([
("id", Json::string(kit.id)),
(
"items",
Json::array(
kit.items.map(item => {
Json::object(
Map([
("item", Json::string(item.item)),
("quantity", manifest_number(item.quantity)),
]),
)
}),
),
),
]),
)
})
let orders = manifest.orders.map(order => {
Json::object(
Map([
("id", Json::string(order.id)),
("kit", Json::string(order.kit)),
("quantity", manifest_number(order.quantity)),
]),
)
})
Json::object(
Map([
("schema", Json::string("benchkitmap/v1")),
("parts", Json::array(parts)),
("kits", Json::array(kits)),
("orders", Json::array(orders)),
]),
).stringify()
}