Skip to main content

teamcraft/domain/
attributes.rs

1//! Custom attributes and classification.
2
3use serde::{Deserialize, Serialize};
4
5use super::*;
6use crate::error::AppError;
7
8/// Make, buy or standard part – master data of an item.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum Classification {
12    /// Designed and made in-house (the default).
13    #[default]
14    Make,
15    /// Bought from a supplier (e.g. a motor, a bearing).
16    Purchased,
17    /// Standard or library part (screws, washers, Content Center, Toolbox).
18    Standard,
19}
20
21impl Classification {
22    pub const ALL: [Classification; 3] = [
23        Classification::Make,
24        Classification::Purchased,
25        Classification::Standard,
26    ];
27
28    pub fn as_str(self) -> &'static str {
29        match self {
30            Classification::Make => "make",
31            Classification::Purchased => "purchased",
32            Classification::Standard => "standard",
33        }
34    }
35
36    pub fn label(self) -> &'static str {
37        match self {
38            Classification::Make => "In-house",
39            Classification::Purchased => "Purchased",
40            Classification::Standard => "Standard part",
41        }
42    }
43
44    pub fn parse(s: &str) -> Option<Classification> {
45        match s.trim().to_ascii_lowercase().as_str() {
46            "make" | "in-house" | "inhouse" | "normal" => Some(Classification::Make),
47            "purchased" | "buy" | "bought" => Some(Classification::Purchased),
48            "standard" | "standard part" | "library" => Some(Classification::Standard),
49            _ => None,
50        }
51    }
52
53    /// The classification a BOM export states in its *BOM structure* or
54    /// procurement column (Inventor: Normal, Purchased, Phantom, Inseparable;
55    /// German exports: Kaufteil, Normteil, Eigenfertigung …). `None` for an
56    /// empty or unknown value.
57    pub fn from_bom_structure(raw: &str) -> Option<Classification> {
58        let s: String = raw
59            .chars()
60            .filter(|c| c.is_alphanumeric())
61            .flat_map(char::to_lowercase)
62            .collect();
63        match s.as_str() {
64            "normal" | "make" | "inhouse" | "eigenfertigung" | "eigenteil" | "fertigungsteil"
65            | "fertigung" | "phantom" | "inseparable" | "untrennbar" => Some(Classification::Make),
66            "purchased" | "purchase" | "buy" | "bought" | "kaufteil" | "zukaufteil" | "zukauf"
67            | "gekauft" | "einkauf" | "einkaufsteil" => Some(Classification::Purchased),
68            "standard" | "standardpart" | "normteil" | "norm" | "normalien" | "library"
69            | "librarypart" | "contentcenter" | "toolbox" | "katalogteil" => {
70                Some(Classification::Standard)
71            }
72            _ => None,
73        }
74    }
75
76    /// Parses an optional form or API value; empty means "unchanged/default".
77    pub fn parse_field(raw: Option<&str>) -> Result<Option<Classification>, AppError> {
78        match raw.map(str::trim).filter(|s| !s.is_empty()) {
79            None => Ok(None),
80            Some(s) => Classification::parse(s).map(Some).ok_or_else(|| {
81                AppError::BadRequest(format!(
82                    "Unknown classification '{s}': use make, purchased or standard."
83                ))
84            }),
85        }
86    }
87}
88
89/// Data type of a custom attribute.
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum AttributeType {
93    Text,
94    Number,
95    Boolean,
96    /// One of a fixed list of values.
97    Choice,
98}
99
100impl AttributeType {
101    pub const ALL: [AttributeType; 4] = [
102        AttributeType::Text,
103        AttributeType::Number,
104        AttributeType::Boolean,
105        AttributeType::Choice,
106    ];
107
108    pub fn as_str(self) -> &'static str {
109        match self {
110            AttributeType::Text => "text",
111            AttributeType::Number => "number",
112            AttributeType::Boolean => "boolean",
113            AttributeType::Choice => "choice",
114        }
115    }
116
117    pub fn label(self) -> &'static str {
118        match self {
119            AttributeType::Text => "Text",
120            AttributeType::Number => "Number",
121            AttributeType::Boolean => "Yes/no",
122            AttributeType::Choice => "Choice list",
123        }
124    }
125
126    pub fn parse(s: &str) -> Option<AttributeType> {
127        match s.trim().to_ascii_lowercase().as_str() {
128            "text" => Some(AttributeType::Text),
129            "number" => Some(AttributeType::Number),
130            "boolean" | "bool" | "yes/no" => Some(AttributeType::Boolean),
131            "choice" | "list" => Some(AttributeType::Choice),
132            _ => None,
133        }
134    }
135}
136
137/// Longest text value of an attribute.
138pub const MAX_ATTRIBUTE_TEXT: usize = 500;
139
140/// Attribute keys: `a-z 0-9 _`, starting with a letter, at most 40
141/// characters (`vendor`, `length_mm`). Upper case and spaces/dashes are
142/// folded (`Order No.` → `order_no`).
143pub fn normalize_attribute_key(raw: &str) -> Result<String, AppError> {
144    let mut key = String::new();
145    for c in raw.trim().chars() {
146        match c {
147            'a'..='z' | '0'..='9' => key.push(c),
148            'A'..='Z' => key.push(c.to_ascii_lowercase()),
149            ' ' | '-' | '_' | '.' | '/' if !key.is_empty() && !key.ends_with('_') => key.push('_'),
150            ' ' | '-' | '_' | '.' | '/' => {}
151            _ => {
152                return Err(AppError::BadRequest(format!(
153                    "Invalid attribute key '{raw}': use letters, digits and '_'."
154                )))
155            }
156        }
157    }
158    let key = key.trim_end_matches('_').to_string();
159    if key.is_empty() || key.len() > 40 || !key.starts_with(|c: char| c.is_ascii_lowercase()) {
160        return Err(AppError::BadRequest(format!(
161            "Invalid attribute key '{raw}': 1–40 characters a–z, 0–9 and '_', starting with a letter."
162        )));
163    }
164    Ok(key)
165}
166
167/// The choices of a choice attribute from free text (one per line or
168/// separated by `;`), trimmed, without duplicates.
169pub fn parse_attribute_choices(raw: &str) -> Result<Vec<String>, AppError> {
170    let mut out: Vec<String> = Vec::new();
171    for c in raw
172        .split(['\n', ';'])
173        .map(str::trim)
174        .filter(|c| !c.is_empty())
175    {
176        if c.chars().count() > 100 {
177            return Err(AppError::BadRequest(format!(
178                "Choice '{c}' is longer than 100 characters."
179            )));
180        }
181        if !out.iter().any(|o| o.eq_ignore_ascii_case(c)) {
182            out.push(c.to_string());
183        }
184    }
185    if out.len() > 200 {
186        return Err(AppError::BadRequest("At most 200 choices.".into()));
187    }
188    Ok(out)
189}
190
191fn parse_yes_no(raw: &str) -> Option<bool> {
192    match raw.trim().to_ascii_lowercase().as_str() {
193        "yes" | "y" | "true" | "1" | "on" | "ja" | "x" => Some(true),
194        "no" | "n" | "false" | "0" | "off" | "nein" => Some(false),
195        _ => None,
196    }
197}
198
199/// Validates one attribute value given as text (form field, or an API value
200/// converted to text) and returns it as JSON; empty input removes the value.
201pub fn parse_attribute_value(
202    label: &str,
203    data_type: AttributeType,
204    choices: &[String],
205    raw: &str,
206) -> Result<Option<serde_json::Value>, AppError> {
207    let raw = raw.trim();
208    if raw.is_empty() {
209        return Ok(None);
210    }
211    let value = match data_type {
212        AttributeType::Text => {
213            serde_json::Value::String(limit_text(label, raw, MAX_ATTRIBUTE_TEXT)?)
214        }
215        AttributeType::Number => {
216            let n = parse_decimal(label, Some(raw))?.unwrap_or_default();
217            if !n.is_finite() {
218                return Err(AppError::BadRequest(format!(
219                    "{label}: '{raw}' is not a number."
220                )));
221            }
222            serde_json::Number::from_f64(n)
223                .map(serde_json::Value::Number)
224                .ok_or_else(|| AppError::BadRequest(format!("{label}: '{raw}' is not a number.")))?
225        }
226        AttributeType::Boolean => {
227            serde_json::Value::Bool(parse_yes_no(raw).ok_or_else(|| {
228                AppError::BadRequest(format!("{label}: '{raw}' is not yes or no."))
229            })?)
230        }
231        AttributeType::Choice => {
232            let choice = choices
233                .iter()
234                .find(|c| c.eq_ignore_ascii_case(raw))
235                .ok_or_else(|| {
236                    AppError::BadRequest(format!(
237                        "{label}: '{raw}' is not one of {}.",
238                        choices.join(", ")
239                    ))
240                })?;
241            serde_json::Value::String(choice.clone())
242        }
243    };
244    Ok(Some(value))
245}
246
247/// An API value as the text [`parse_attribute_value`] reads (`null` → empty).
248pub fn attribute_input_text(value: &serde_json::Value) -> Result<String, AppError> {
249    match value {
250        serde_json::Value::Null => Ok(String::new()),
251        serde_json::Value::String(s) => Ok(s.clone()),
252        serde_json::Value::Number(n) => Ok(n.to_string()),
253        serde_json::Value::Bool(b) => Ok(b.to_string()),
254        _ => Err(AppError::BadRequest(
255            "Attribute values are text, numbers, true/false or null.".into(),
256        )),
257    }
258}
259
260/// A search condition on one attribute.
261#[derive(Clone, Debug, PartialEq)]
262pub enum AttributeCondition {
263    /// The attribute has any value.
264    Present,
265    /// Text or choice containing the text (case-insensitive).
266    Contains(String),
267    Bool(bool),
268    /// Number in `[min, max]`; either bound may be open.
269    Range(Option<f64>, Option<f64>),
270}
271
272/// Parses a search value for an attribute of `data_type`: empty = has a
273/// value; text and choices match as substring; yes/no; numbers accept `12`,
274/// `>=10`, `<=20` and `10..20` (no `10-20`, which would clash with negative
275/// numbers).
276pub fn parse_attribute_condition(
277    label: &str,
278    data_type: AttributeType,
279    raw: &str,
280) -> Result<AttributeCondition, AppError> {
281    let raw = raw.trim();
282    if raw.is_empty() {
283        return Ok(AttributeCondition::Present);
284    }
285    let num = |s: &str| -> Result<Option<f64>, AppError> {
286        parse_decimal(label, Some(s)).and_then(|n| match n {
287            Some(v) if !v.is_finite() => Err(AppError::BadRequest(format!(
288                "{label}: '{s}' is not a number."
289            ))),
290            other => Ok(other),
291        })
292    };
293    Ok(match data_type {
294        AttributeType::Text | AttributeType::Choice => AttributeCondition::Contains(raw.into()),
295        AttributeType::Boolean => AttributeCondition::Bool(
296            parse_yes_no(raw)
297                .ok_or_else(|| AppError::BadRequest(format!("{label}: search for yes or no.")))?,
298        ),
299        AttributeType::Number => {
300            if let Some((lo, hi)) = raw.split_once("..") {
301                AttributeCondition::Range(num(lo)?, num(hi)?)
302            } else if let Some(v) = raw.strip_prefix(">=") {
303                AttributeCondition::Range(num(v)?, None)
304            } else if let Some(v) = raw.strip_prefix("<=") {
305                AttributeCondition::Range(None, num(v)?)
306            } else {
307                let v = num(raw)?;
308                AttributeCondition::Range(v, v)
309            }
310        }
311    })
312}