Skip to main content

teamcraft/domain/
validation.rs

1//! Validation helpers and the working-folder rules.
2
3use crate::error::AppError;
4
5/// The first `max` characters of `s`, with "…" if it was cut.
6pub fn truncate_chars(s: &str, max: usize) -> String {
7    if s.chars().count() <= max {
8        return s.to_string();
9    }
10    let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
11    out.push('…');
12    out
13}
14
15/// Item numbers: 1–64 characters `A-Z 0-9 . _ -`, starting with a letter or
16/// digit. They are normalised to upper case.
17pub fn normalize_item_number(raw: &str) -> Result<String, AppError> {
18    let s = raw.trim().to_ascii_uppercase();
19    let valid = !s.is_empty()
20        && s.len() <= 64
21        && s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
22        && s.chars()
23            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
24    if valid {
25        Ok(s)
26    } else {
27        Err(AppError::BadRequest(format!(
28            "Invalid item number '{raw}': use 1–64 characters A–Z, 0–9, '.', '_' or '-', starting with a letter or digit."
29        )))
30    }
31}
32
33/// Usernames: 2–64 characters `a-z 0-9 . _ -`, normalised to lower case.
34pub fn normalize_username(raw: &str) -> Result<String, AppError> {
35    let s = raw.trim().to_ascii_lowercase();
36    let valid = (2..=64).contains(&s.len())
37        && s.chars()
38            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
39    if valid {
40        Ok(s)
41    } else {
42        Err(AppError::BadRequest(format!(
43            "Invalid username '{raw}': use 2–64 characters a–z, 0–9, '.', '_' or '-'."
44        )))
45    }
46}
47
48/// Project codes: 1–32 characters `A-Z 0-9 _ -`, normalised to upper case.
49pub fn normalize_project_code(raw: &str) -> Result<String, AppError> {
50    let s = raw.trim().to_ascii_uppercase();
51    let valid = (1..=32).contains(&s.len())
52        && s.chars()
53            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'));
54    if valid {
55        Ok(s)
56    } else {
57        Err(AppError::BadRequest(format!(
58            "Invalid project code '{raw}': use 1–32 characters A–Z, 0–9, '_' or '-'."
59        )))
60    }
61}
62
63pub const MIN_PASSWORD_LEN: usize = 10;
64
65pub fn validate_password(password: &str) -> Result<(), AppError> {
66    if password.chars().count() < MIN_PASSWORD_LEN {
67        return Err(AppError::BadRequest(format!(
68            "Passwords must be at least {MIN_PASSWORD_LEN} characters long."
69        )));
70    }
71    if password.len() > 1024 {
72        return Err(AppError::BadRequest("Password is too long.".into()));
73    }
74    Ok(())
75}
76
77/// Requires a non-empty, trimmed text of at most `max` characters.
78pub fn require_text(field: &str, value: &str, max: usize) -> Result<String, AppError> {
79    let v = value.trim();
80    if v.is_empty() {
81        return Err(AppError::BadRequest(format!("{field} must not be empty.")));
82    }
83    limit_text(field, v, max)
84}
85
86// ---------------------------------------------------------------------------
87// Working folder: where a file sits inside its project group
88// ---------------------------------------------------------------------------
89
90/// Longest folder path of a file (characters).
91pub const MAX_FOLDER_LEN: usize = 400;
92/// Deepest folder nesting of a file.
93pub const MAX_FOLDER_DEPTH: usize = 32;
94/// Longest single folder name.
95pub const MAX_FOLDER_SEGMENT: usize = 120;
96
97/// Normalises the folder of a file inside its project group – the place it
98/// gets in the desktop working folder `<workspace>/<GROUP>/<folder>/<file>`.
99///
100/// Backslashes become `/`, empty and `.` segments as well as leading and
101/// trailing slashes are dropped (`""` is the top of the group). Refused:
102/// `..`, names that are not valid on Windows, macOS and Linux (`<>:"|?*`,
103/// control characters, trailing dots or spaces, `CON`, `NUL`, `COM1`, …) and
104/// paths longer than [`MAX_FOLDER_LEN`] or deeper than [`MAX_FOLDER_DEPTH`].
105pub fn normalize_folder(raw: &str) -> Result<String, AppError> {
106    let bad = |why: String| AppError::BadRequest(format!("Folder '{}': {why}", raw.trim()));
107    let mut segments = Vec::new();
108    for segment in raw.trim().split(['/', '\\']) {
109        let segment = segment.trim_start();
110        if segment.is_empty() || segment == "." {
111            continue;
112        }
113        if segment == ".." {
114            return Err(bad(
115                "'..' is not allowed – folders are relative to the project group.".into(),
116            ));
117        }
118        if let Some(c) = segment
119            .chars()
120            .find(|c| c.is_control() || matches!(c, '<' | '>' | ':' | '"' | '|' | '?' | '*'))
121        {
122            return Err(bad(format!(
123                "the character {c:?} is not allowed in folder names."
124            )));
125        }
126        if segment.ends_with(['.', ' ']) {
127            return Err(bad(format!(
128                "'{segment}' ends with a dot or a space (not valid on Windows)."
129            )));
130        }
131        let stem = segment
132            .split('.')
133            .next()
134            .unwrap_or(segment)
135            .to_ascii_uppercase();
136        let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
137            || (stem.len() == 4
138                && (stem.starts_with("COM") || stem.starts_with("LPT"))
139                && stem.as_bytes()[3].is_ascii_digit());
140        if reserved {
141            return Err(bad(format!("'{segment}' is a reserved name on Windows.")));
142        }
143        if segment.chars().count() > MAX_FOLDER_SEGMENT {
144            return Err(bad(format!(
145                "folder names must be at most {MAX_FOLDER_SEGMENT} characters."
146            )));
147        }
148        segments.push(segment);
149    }
150    if segments.len() > MAX_FOLDER_DEPTH {
151        return Err(bad(format!(
152            "at most {MAX_FOLDER_DEPTH} folder levels are allowed."
153        )));
154    }
155    let folder = segments.join("/");
156    if folder.chars().count() > MAX_FOLDER_LEN {
157        return Err(bad(format!(
158            "the path must be at most {MAX_FOLDER_LEN} characters."
159        )));
160    }
161    Ok(folder)
162}
163
164/// Trims a text and checks it is at most `max` characters.
165pub fn limit_text(field: &str, value: &str, max: usize) -> Result<String, AppError> {
166    let v = value.trim();
167    if v.chars().count() > max {
168        return Err(AppError::BadRequest(format!(
169            "{field} must be at most {max} characters."
170        )));
171    }
172    Ok(v.to_string())
173}
174
175/// Validates an optional non-negative number such as a mass or a cost.
176pub fn validate_non_negative(field: &str, value: Option<f64>) -> Result<Option<f64>, AppError> {
177    match value {
178        Some(v) if !v.is_finite() || v < 0.0 => Err(AppError::BadRequest(format!(
179            "{field} must be a non-negative number."
180        ))),
181        other => Ok(other),
182    }
183}
184
185/// Validates a BOM quantity.
186pub fn validate_quantity(value: f64) -> Result<f64, AppError> {
187    if value.is_finite() && value > 0.0 && value < 1e9 {
188        Ok(value)
189    } else {
190        Err(AppError::BadRequest(
191            "Quantity must be a positive number.".into(),
192        ))
193    }
194}
195
196/// Parses user supplied decimal numbers, accepting both `1.5` and `1,5`.
197/// Empty input yields `None`.
198pub fn parse_decimal(field: &str, raw: Option<&str>) -> Result<Option<f64>, AppError> {
199    let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
200        return Ok(None);
201    };
202    raw.replace(',', ".")
203        .parse::<f64>()
204        .map(Some)
205        .map_err(|_| AppError::BadRequest(format!("{field}: '{raw}' is not a number.")))
206}
207
208/// Escapes `%`, `_` and `\` so user input can be used inside a LIKE pattern.
209pub fn like_pattern(query: &str) -> String {
210    let mut out = String::with_capacity(query.len() + 2);
211    out.push('%');
212    for c in query.trim().chars() {
213        if matches!(c, '%' | '_' | '\\') {
214            out.push('\\');
215        }
216        out.push(c);
217    }
218    out.push('%');
219    out
220}