Skip to main content

tightbeam/utils/urn/builders/
spec.rs

1//! UrnSpecBuilder for programmatic URN specification construction
2
3#[cfg(not(feature = "std"))]
4extern crate alloc;
5
6#[cfg(not(feature = "std"))]
7use alloc::{borrow::Cow, string::String, vec::Vec};
8
9#[cfg(feature = "std")]
10use std::borrow::Cow;
11
12use crate::utils::urn::{UrnComponents, UrnValidationError};
13
14/// Validation pattern for field values
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Pattern {
17	/// Alphabetic characters only: `[a-zA-Z]+`
18	Alpha,
19	/// Numeric characters only: `[0-9]+`
20	Numeric,
21	/// Alphanumeric characters: `[a-zA-Z0-9]+`
22	AlphaNumeric,
23	/// Alphanumeric characters with hyphens: `[a-z0-9-]+`
24	AlphaNumericHyphen,
25}
26
27impl Pattern {
28	/// Check if a value matches this pattern
29	pub fn matches(&self, value: &str) -> bool {
30		match self {
31			Pattern::Alpha => value.chars().all(|c| c.is_ascii_alphabetic()),
32			Pattern::Numeric => value.chars().all(|c| c.is_ascii_digit()),
33			Pattern::AlphaNumeric => value.chars().all(|c| c.is_ascii_alphanumeric()),
34			Pattern::AlphaNumericHyphen => value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
35		}
36	}
37
38	/// Get the regex pattern string for error messages
39	pub fn pattern_str(&self) -> &'static str {
40		match self {
41			Pattern::Alpha => "^[a-zA-Z]+$",
42			Pattern::Numeric => "^[0-9]+$",
43			Pattern::AlphaNumeric => "^[a-zA-Z0-9]+$",
44			Pattern::AlphaNumericHyphen => "^[a-z0-9-]+$",
45		}
46	}
47}
48
49/// Validation constraint for a field
50#[derive(Debug, Clone)]
51pub enum Constraint {
52	/// Field must equal a constant value
53	Const(&'static str),
54	/// Field must be one of the given values
55	OneOf(Cow<'static, [&'static str]>),
56}
57
58impl Constraint {
59	/// Check if a value matches this constraint
60	pub fn matches(&self, value: &str) -> bool {
61		match self {
62			Constraint::Const(expected) => value == *expected,
63			Constraint::OneOf(options) => options.contains(&value),
64		}
65	}
66
67	/// Get the constraint pattern string for error messages
68	pub fn pattern_str(&self) -> &'static str {
69		match self {
70			Constraint::Const(_) => "const(\"value\")",
71			Constraint::OneOf(_) => "oneof(...)",
72		}
73	}
74}
75
76/// Configuration for a single field in a URN specification
77#[derive(Debug, Clone)]
78pub struct FieldConfig {
79	/// Field name
80	pub name: &'static str,
81	/// Whether the field is required
82	pub required: bool,
83	/// Validation constraints
84	pub constraints: Vec<Constraint>,
85	/// Validation pattern (separate from constraints)
86	pub pattern: Option<Pattern>,
87	/// NSS separator before this field (e.g., ":" or "/")
88	pub nss_separator: Option<&'static str>,
89}
90
91impl FieldConfig {
92	fn new(name: &'static str, required: bool) -> Self {
93		Self { name, required, constraints: Vec::new(), pattern: None, nss_separator: None }
94	}
95}
96
97/// NSS formatting configuration
98#[derive(Debug, Clone)]
99pub enum NssFormat {
100	/// Simple join with separator
101	Join(&'static str),
102	/// Custom format string with {} placeholders in field order
103	Custom(String),
104}
105
106/// Builder for constructing URN specifications programmatically
107#[derive(Debug, Clone)]
108pub struct UrnSpecBuilder {
109	/// Namespace Identifier
110	nid: &'static str,
111	/// Field configurations in order
112	fields: Vec<FieldConfig>,
113	/// NSS format configuration
114	nss_format: NssFormat,
115}
116
117impl UrnSpecBuilder {
118	/// Get or create a field configuration, returning its index
119	fn get_or_create_field_index(&mut self, name: &'static str, required: bool) -> usize {
120		if let Some(index) = self.fields.iter().position(|f| f.name == name) {
121			self.fields[index].required = required;
122			index
123		} else {
124			self.fields.push(FieldConfig::new(name, required));
125			self.fields.len() - 1
126		}
127	}
128
129	/// Add a required field
130	pub fn field_required(mut self, name: &'static str) -> Self {
131		self.get_or_create_field_index(name, true);
132		self
133	}
134
135	/// Add an optional field
136	pub fn field_optional(mut self, name: &'static str) -> Self {
137		self.get_or_create_field_index(name, false);
138		self
139	}
140
141	/// Add a const constraint to the specified field
142	pub fn field_const(mut self, name: &'static str, value: &'static str) -> Self {
143		let index = self.get_or_create_field_index(name, true);
144		self.fields[index].constraints.push(Constraint::Const(value));
145		self
146	}
147
148	/// Add a oneof constraint to the specified field
149	pub fn field_oneof(mut self, name: &'static str, options: &'static [&'static str]) -> Self {
150		let index = self.get_or_create_field_index(name, true);
151		self.fields[index].constraints.push(Constraint::OneOf(Cow::Borrowed(options)));
152		self
153	}
154
155	/// Set pattern for the specified field
156	pub fn field_pattern(mut self, name: &'static str, pattern: Pattern) -> Self {
157		let index = self.get_or_create_field_index(name, true);
158		self.fields[index].pattern = Some(pattern);
159		self
160	}
161
162	/// Set NSS separator for the specified field
163	pub fn field_nss_separator(mut self, name: &'static str, separator: &'static str) -> Self {
164		if let Some(field) = self.fields.iter_mut().find(|f| f.name == name) {
165			field.nss_separator = Some(separator);
166		}
167
168		self
169	}
170
171	/// Set custom NSS format string (e.g., "{}:{}/{}")
172	pub fn nss_format(mut self, format: &str) -> Self {
173		self.nss_format = NssFormat::Custom(format.to_string());
174		self
175	}
176
177	/// Validate components using this spec's configuration
178	pub fn validate<'a>(&self, components: &dyn UrnComponents<'a>) -> Result<(), UrnValidationError> {
179		for field in &self.fields {
180			// Check required
181			if field.required && components.get_component(field.name).is_none() {
182				return Err(UrnValidationError::RequiredFieldMissing(field.name));
183			}
184
185			// Check constraints and pattern if value exists
186			if let Some(value) = components.get_component(field.name) {
187				let value_str = value.as_ref();
188
189				// Check constraints
190				for constraint in &field.constraints {
191					if !constraint.matches(value_str) {
192						return Err(UrnValidationError::InvalidFormat { field: field.name, pattern: None });
193					}
194				}
195
196				// Check pattern
197				if let Some(pattern) = field.pattern {
198					if !pattern.matches(value_str) {
199						return Err(UrnValidationError::InvalidFormat { field: field.name, pattern: Some(pattern) });
200					}
201				}
202			}
203		}
204
205		Ok(())
206	}
207
208	/// Build NSS from components using this spec's configuration
209	pub fn build_nss<'a>(&self, components: &dyn UrnComponents<'a>) -> Result<String, UrnValidationError> {
210		match &self.nss_format {
211			NssFormat::Join(default_separator) => {
212				let mut nss_parts = Vec::new();
213				for field in &self.fields {
214					if let Some(value) = components.get_component(field.name) {
215						// Add separator before value (except for first field)
216						if !nss_parts.is_empty() {
217							// Use field-specific separator if set, otherwise default
218							let separator = field.nss_separator.unwrap_or(default_separator);
219							nss_parts.push(separator);
220						}
221
222						nss_parts.push(value.as_ref());
223					}
224				}
225
226				if nss_parts.is_empty() {
227					return Err(UrnValidationError::RequiredFieldMissing("nss components"));
228				}
229
230				Ok(nss_parts.join(""))
231			}
232			NssFormat::Custom(format_str) => {
233				// Extract field values in order for required fields
234				let mut field_values = Vec::new();
235				for field in &self.fields {
236					if field.required {
237						let value = components
238							.get_component(field.name)
239							.ok_or(UrnValidationError::RequiredFieldMissing(field.name))?;
240						field_values.push(value.as_ref());
241					} else if let Some(value) = components.get_component(field.name) {
242						field_values.push(value.as_ref());
243					}
244				}
245
246				// Count placeholders in format string
247				let placeholder_count = format_str.matches("{}").count();
248				if placeholder_count != field_values.len() {
249					return Err(UrnValidationError::RequiredFieldMissing("nss components"));
250				}
251
252				// Build NSS using format string - replace placeholders one at a time
253				let mut result = format_str.to_string();
254				for value in field_values {
255					if let Some(pos) = result.find("{}") {
256						result.replace_range(pos..pos + 2, value);
257					}
258				}
259
260				let nss = result;
261
262				Ok(nss)
263			}
264		}
265	}
266
267	/// Get the NID for this spec
268	pub fn nid(&self) -> &'static str {
269		self.nid
270	}
271
272	/// Get field configurations
273	pub fn fields(&self) -> &[FieldConfig] {
274		&self.fields
275	}
276}
277
278impl From<&'static str> for UrnSpecBuilder {
279	fn from(nid: &'static str) -> Self {
280		Self { nid, fields: Vec::new(), nss_format: NssFormat::Join(":") }
281	}
282}
283
284#[cfg(test)]
285mod tests {
286	use crate::utils::urn::UrnBuilder;
287
288	use super::*;
289
290	#[test]
291	fn test_pattern_matches() {
292		let test_cases: &[(Pattern, &[&str], &[&str])] = &[
293			(
294				Pattern::Alpha,
295				&["abc", "XYZ", "Hello", "WORLD"],
296				&["abc123", "123", "abc-xyz", "a1b", "space here"],
297			),
298			(Pattern::Numeric, &["123", "0", "999", "42"], &["abc", "12a", "1-2", "12.5"]),
299			(
300				Pattern::AlphaNumeric,
301				&["abc123", "XYZ999", "Hello42", "WORLD0", "abc", "123"],
302				&["abc-xyz", "space here", "12.5", "a_b"],
303			),
304			(
305				Pattern::AlphaNumericHyphen,
306				&["abc-123", "xyz-y", "test-case", "kebab-case-id", "abc", "123"],
307				&["abc_xyz", "space here", "12.5"],
308			),
309		];
310
311		for (pattern, valid_values, invalid_values) in test_cases {
312			for value in *valid_values {
313				assert!(pattern.matches(value), "Pattern {pattern:?} should match '{value}'");
314			}
315			for value in *invalid_values {
316				assert!(!pattern.matches(value), "Pattern {pattern:?} should not match '{value}'");
317			}
318		}
319	}
320
321	#[test]
322	fn test_pattern_str() {
323		// Data-driven: test all Pattern variants
324		let test_cases: &[(Pattern, &str)] = &[
325			(Pattern::Alpha, "^[a-zA-Z]+$"),
326			(Pattern::Numeric, "^[0-9]+$"),
327			(Pattern::AlphaNumeric, "^[a-zA-Z0-9]+$"),
328			(Pattern::AlphaNumericHyphen, "^[a-z0-9-]+$"),
329		];
330
331		for (pattern, expected) in test_cases {
332			assert_eq!(
333				pattern.pattern_str(),
334				*expected,
335				"Pattern {pattern:?} should have pattern_str '{expected}'"
336			);
337		}
338	}
339
340	#[test]
341	fn test_constraint_matches() {
342		let const_test_cases: &[(&str, &[&str], &[&str])] =
343			&[("expected", &["expected"], &["unexpected", "Expected", "EXPECTED", ""])];
344
345		for (const_value, valid_values, invalid_values) in const_test_cases {
346			let constraint = Constraint::Const(const_value);
347			for value in *valid_values {
348				assert!(constraint.matches(value), "Const({const_value:?}) should match '{value}'");
349			}
350			for value in *invalid_values {
351				assert!(!constraint.matches(value), "Const({const_value:?}) should not match '{value}'");
352			}
353		}
354
355		// OneOf constraint test cases: (options, valid_values, invalid_values)
356		let oneof_test_cases: &[(&[&str], &[&str], &[&str])] = &[
357			(
358				&["option1", "option2", "option3"],
359				&["option1", "option2", "option3"],
360				&["option4", "Option1", "OPTION1", ""],
361			),
362			(&["a", "b", "c"], &["a", "b", "c"], &["d", "A", "ab", ""]),
363		];
364
365		for (options, valid_values, invalid_values) in oneof_test_cases {
366			let constraint = Constraint::OneOf(Cow::Borrowed(*options));
367			for value in *valid_values {
368				assert!(constraint.matches(value), "OneOf({options:?}) should match '{value}'");
369			}
370			for value in *invalid_values {
371				assert!(!constraint.matches(value), "OneOf({options:?}) should not match '{value}'");
372			}
373		}
374
375		// Verify we test all Constraint variants
376		let tested_const = !const_test_cases.is_empty();
377		let tested_oneof = !oneof_test_cases.is_empty();
378		assert!(tested_const, "Const variant must be tested");
379		assert!(tested_oneof, "OneOf variant must be tested");
380	}
381
382	#[test]
383	fn test_constraint_pattern_str() {
384		assert_eq!(Constraint::Const("value").pattern_str(), "const(\"value\")");
385		assert_eq!(Constraint::OneOf(Cow::Borrowed(&["a", "b"])).pattern_str(), "oneof(...)");
386	}
387
388	#[test]
389	fn test_urn_spec_builder_validation() {
390		// Macro to reduce repetition in validation tests
391		macro_rules! validate_pass {
392			(spec: [$($spec_op:ident($($arg:expr),*)),+], values: [$($k:literal = $v:literal),*]) => {{
393				let spec = UrnSpecBuilder::from("test")$(.$spec_op($($arg),*))+;
394				let builder = UrnBuilder::default()$(.set($k, $v))*;
395				assert!(spec.validate(&builder).is_ok());
396			}};
397		}
398
399		macro_rules! validate_fail_missing {
400			(spec: [$($spec_op:ident($($arg:expr),*)),+], values: [$($k:literal = $v:literal),*], field: $expected:literal) => {{
401				let spec = UrnSpecBuilder::from("test")$(.$spec_op($($arg),*))+;
402				let builder = UrnBuilder::default()$(.set($k, $v))*;
403				assert!(matches!(
404					spec.validate(&builder),
405					Err(UrnValidationError::RequiredFieldMissing(field)) if field == $expected
406				));
407			}};
408		}
409
410		macro_rules! validate_fail_format {
411			(spec: [$($spec_op:ident($($arg:expr),*)),+], values: [$($k:literal = $v:literal),*], field: $expected:literal) => {{
412				let spec = UrnSpecBuilder::from("test")$(.$spec_op($($arg),*))+;
413				let builder = UrnBuilder::default()$(.set($k, $v))*;
414				assert!(matches!(
415					spec.validate(&builder),
416					Err(UrnValidationError::InvalidFormat { field, .. }) if field == $expected
417				));
418			}};
419		}
420
421		// Required field missing
422		validate_fail_missing!(
423			spec: [field_required("field1"), field_pattern("field1", Pattern::Alpha)],
424			values: [],
425			field: "field1"
426		);
427
428		// Optional field passes when missing
429		validate_pass!(spec: [field_optional("field1")], values: []);
430
431		// Pattern validation - valid
432		validate_pass!(
433			spec: [field_required("field1"), field_pattern("field1", Pattern::Alpha)],
434			values: ["field1" = "abc"]
435		);
436
437		// Pattern validation - invalid
438		validate_fail_format!(
439			spec: [field_required("field1"), field_pattern("field1", Pattern::Alpha)],
440			values: ["field1" = "abc123"],
441			field: "field1"
442		);
443
444		// Const constraint - valid
445		validate_pass!(
446			spec: [field_required("field1"), field_const("field1", "expected")],
447			values: ["field1" = "expected"]
448		);
449
450		// Const constraint - invalid
451		validate_fail_format!(
452			spec: [field_required("field1"), field_const("field1", "expected")],
453			values: ["field1" = "unexpected"],
454			field: "field1"
455		);
456
457		// OneOf constraint - valid
458		validate_pass!(
459			spec: [field_required("field1"), field_oneof("field1", &["a", "b", "c"])],
460			values: ["field1" = "b"]
461		);
462
463		// OneOf constraint - invalid
464		validate_fail_format!(
465			spec: [field_required("field1"), field_oneof("field1", &["a", "b", "c"])],
466			values: ["field1" = "d"],
467			field: "field1"
468		);
469	}
470
471	#[test]
472	fn test_urn_spec_builder_build_nss() -> Result<(), UrnValidationError> {
473		// Macro to reduce repetition in NSS building tests
474		macro_rules! build_nss_pass {
475			(spec: [$($spec_op:ident($($arg:expr),*)),+], values: [$($k:literal = $v:literal),+], expected: $exp:literal) => {{
476				let spec = UrnSpecBuilder::from("test")$(.$spec_op($($arg),*))+;
477				let builder = UrnBuilder::default()$(.set($k, $v))+;
478				assert_eq!(spec.build_nss(&builder)?, $exp);
479			}};
480		}
481
482		macro_rules! build_nss_fail {
483			(spec: [$($spec_op:ident($($arg:expr),*)),*], values: [$($k:literal = $v:literal),*], error: $err_msg:literal) => {{
484				#![allow(unused_mut)]
485				let mut spec = UrnSpecBuilder::from("test");
486				$(spec = spec.$spec_op($($arg),*);)*
487				let mut builder = UrnBuilder::default();
488				$(builder = builder.set($k, $v);)*
489				assert!(matches!(
490					spec.build_nss(&builder),
491					Err(UrnValidationError::RequiredFieldMissing(msg)) if msg == $err_msg
492				));
493			}};
494		}
495
496		// Join format (default)
497		build_nss_pass!(
498			spec: [
499				field_required("field1"),
500				field_required("field2"),
501				field_nss_separator("field2", "/"),
502				field_optional("field3")
503			],
504			values: ["field1" = "value1", "field2" = "value2"],
505			expected: "value1/value2"
506		);
507
508		// Custom format
509		build_nss_pass!(
510			spec: [
511				field_required("field1"),
512				field_required("field2"),
513				field_optional("field3"),
514				nss_format("{}:{}/{}")
515			],
516			values: ["field1" = "value1", "field2" = "value2", "field3" = "value3"],
517			expected: "value1:value2/value3"
518		);
519
520		// Empty NSS
521		build_nss_fail!(
522			spec: [],
523			values: [],
524			error: "nss components"
525		);
526
527		// Format mismatch
528		build_nss_fail!(
529			spec: [field_required("field1"), field_required("field2"), nss_format("{}")],
530			values: ["field1" = "value1", "field2" = "value2"],
531			error: "nss components"
532		);
533
534		Ok(())
535	}
536
537	#[test]
538	fn test_urn_spec_builder_field_operations() {
539		let spec = UrnSpecBuilder::from("test")
540			.field_required("field1")
541			.field_optional("field2")
542			.field_const("field1", "const_value")
543			.field_oneof("field2", &["opt1", "opt2"])
544			.field_pattern("field1", Pattern::Alpha)
545			.field_nss_separator("field1", ":")
546			.field_nss_separator("field2", "/");
547
548		assert_eq!(spec.nid(), "test");
549		assert_eq!(spec.fields().len(), 2);
550		assert_eq!(spec.fields()[0].name, "field1");
551		assert!(spec.fields()[0].required);
552		assert_eq!(spec.fields()[0].constraints.len(), 1);
553		assert_eq!(spec.fields()[0].pattern, Some(Pattern::Alpha));
554		assert_eq!(spec.fields()[0].nss_separator, Some(":"));
555		assert_eq!(spec.fields()[1].name, "field2");
556		// field_oneof() sets field to required
557		assert!(spec.fields()[1].required);
558		assert_eq!(spec.fields()[1].constraints.len(), 1);
559		assert_eq!(spec.fields()[1].nss_separator, Some("/"));
560	}
561}