teamcraft/domain/
validation.rs1use crate::error::AppError;
4
5pub 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
15pub 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
33pub 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
48pub 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
77pub 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
86pub const MAX_FOLDER_LEN: usize = 400;
92pub const MAX_FOLDER_DEPTH: usize = 32;
94pub const MAX_FOLDER_SEGMENT: usize = 120;
96
97pub 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
164pub 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
175pub 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
185pub 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
196pub 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
208pub 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}