Skip to content

Commit 82b65e2

Browse files
(MAINT) Refactor type name parsing for reuse
Prior to this change, the `parse()` method for both `FullyQualifiedTypeName` and `WildcardTypeName` contained common logic for parsing the input string into segments and validating them. This change adds the `FullyQualifiedTypeName::parse_segments()` method that encapsulates the common parsing logic for both types. The new `WildcardTypeName::parse_segments()` method is a thin wrapper around the `FullyQualifiedTypeName` helper method to pass the correct regex and convert the errors into the appropriate `WildcardTypeNameError` variants. The helper methods only validate owner and namespaces - validating the owner segment is left to the calling `parse()` method, since the rules for the name segment vary between the types. This change also updates the tests to include a case for an empty namespace segment at the end of the namespaces, which wasn't previously covered in the integration tests for either type.
1 parent 71fe32a commit 82b65e2

5 files changed

Lines changed: 174 additions & 83 deletions

File tree

lib/dsc-lib/locales/en-us.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,7 @@ invalidWildcardTypeNameFilter = "invalid wildcard type name filter: %{err}"
875875
emptyNamespaceSegment = "namespace segment %{index} is empty"
876876
emptyOwnerSegment = "owner segment is empty"
877877
emptyTypeName = "wildcard type name cannot be an empty string"
878+
invalidErrorConversion = "cannot convert `FullyQualifiedTypeName` parsing error into a `WildcardTypeName` parsing error: %{err}"
878879
invalidNameSegment = "name segment '%{name}' contains invalid characters"
879880
invalidNamespaceSegment = "namespace segment '%{namespace}' contains invalid characters"
880881
invalidOwnerSegment = "owner segment '%{owner}' contains invalid characters"

lib/dsc-lib/src/types/fully_qualified_type_name.rs

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -402,17 +402,78 @@ impl FullyQualifiedTypeName {
402402
/// );
403403
/// ```
404404
pub fn parse(text: &str) -> Result<Self, FullyQualifiedTypeNameError> {
405+
let errors = &mut Vec::<FullyQualifiedTypeNameError>::new();
406+
let validating_segment_regex = Self::init_validating_segment_regex();
407+
let (_owner, _namespaces, name) = Self::parse_segments(
408+
text,
409+
&validating_segment_regex,
410+
errors
411+
);
412+
if errors.len() == 1 && errors[0] == FullyQualifiedTypeNameError::EmptyTypeName {
413+
// If the only error encountered was that the input text is empty, return that error directly
414+
// rather than wrapping it in an InvalidTypeName error, since an empty string is a common
415+
// and specific case that can be handled directly without needing to check the list of
416+
// errors for that condition.
417+
return Err(errors[0].clone())
418+
}
419+
420+
if name.is_empty() {
421+
errors.push(FullyQualifiedTypeNameError::MissingNameSegment);
422+
} else if !validating_segment_regex.is_match(&name) {
423+
errors.push(FullyQualifiedTypeNameError::InvalidNameSegment {
424+
segment_text: name.clone()
425+
});
426+
}
427+
428+
if errors.is_empty() {
429+
Ok(Self(text.to_string()))
430+
} else {
431+
Err(FullyQualifiedTypeNameError::InvalidTypeName {
432+
text: text.to_string(),
433+
errors: errors.clone(),
434+
})
435+
}
436+
}
437+
438+
/// Private helper to parse the input into segments and collect validation errors for owner
439+
/// and namespace segments.
440+
///
441+
/// This is used by the public `parse()` method to handle the common parsing and validation
442+
/// logic that is shared by [`FullyQualifiedTypeName`] and [`WildcardTypeName`] since they
443+
/// share the same overall structure.
444+
///
445+
/// # Arguments
446+
///
447+
/// - `text`: The input string to parse into type name segments.
448+
/// - `validating_segment_regex`: The regex to use for validating each segment of the type name.
449+
/// - `errors`: A mutable reference to a vector for collecting any validation errors encountered
450+
/// while parsing the segments. This allows the method to accumulate multiple errors for
451+
/// different segments of the type name.
452+
///
453+
/// # Returns
454+
///
455+
/// The method returns the parsed segments (`owner`, `namespaces`, and `name`) as a tuple. Any
456+
/// validation errors encountered during parsing are added to the provided `errors` vector for
457+
/// the caller to handle.
458+
pub(crate) fn parse_segments(
459+
text: &str,
460+
validating_segment_regex: &Regex,
461+
errors: &mut Vec<FullyQualifiedTypeNameError>
462+
) -> (String, Vec<String>, String) {
405463
// If the input text is empty, return an error immediately to avoid unnecessary processing.
406464
if text.is_empty() {
407-
return Err(FullyQualifiedTypeNameError::EmptyTypeName);
465+
errors.push(FullyQualifiedTypeNameError::EmptyTypeName);
466+
return (String::new(), Vec::new(), String::new());
408467
}
409468

410-
let errors = &mut Vec::<FullyQualifiedTypeNameError>::new();
469+
// Initialize the variables to hold the parsed segments
411470
let owner: String;
412471
let namespaces: Vec<String>;
413472
let name: String;
414-
let validating_segment_regex = Self::init_validating_segment_regex();
415473

474+
// Split the input text into segments based on the presence of the '/' character, which
475+
// separates the name segment from the owner and namespace segments. Then split the owner
476+
// and namespace portion based on '.' characters to separate the owner from the namespaces.
416477
if let Some((owner_and_namespaces, name_segment)) = text.rsplit_once('/') {
417478
name = name_segment.to_string();
418479
let mut segments = owner_and_namespaces
@@ -435,6 +496,7 @@ impl FullyQualifiedTypeName {
435496
name = String::new();
436497
}
437498

499+
// Validate the owner segment, which *must* be defined.
438500
if owner.is_empty() {
439501
errors.push(FullyQualifiedTypeNameError::EmptyOwnerSegment);
440502
} else if !validating_segment_regex.is_match(&owner) {
@@ -443,7 +505,8 @@ impl FullyQualifiedTypeName {
443505
});
444506
}
445507

446-
for (index, namespace) in namespaces.into_iter().enumerate() {
508+
// Validate each namespace segment, if any are defined.
509+
for (index, namespace) in namespaces.clone().into_iter().enumerate() {
447510
if namespace.is_empty() {
448511
errors.push(FullyQualifiedTypeNameError::EmptyNamespaceSegment {
449512
// Insert the index as 1-based for more user-friendly error messages
@@ -456,22 +519,7 @@ impl FullyQualifiedTypeName {
456519
}
457520
}
458521

459-
if name.is_empty() {
460-
errors.push(FullyQualifiedTypeNameError::MissingNameSegment);
461-
} else if !validating_segment_regex.is_match(&name) {
462-
errors.push(FullyQualifiedTypeNameError::InvalidNameSegment {
463-
segment_text: name.clone()
464-
});
465-
}
466-
467-
if errors.is_empty() {
468-
Ok(Self(text.to_string()))
469-
} else {
470-
Err(FullyQualifiedTypeNameError::InvalidTypeName {
471-
text: text.to_string(),
472-
errors: errors.clone(),
473-
})
474-
}
522+
(owner, namespaces, name)
475523
}
476524

477525
/// Defines the regular expression for validating a string as a fully qualified type name.

lib/dsc-lib/src/types/wildcard_type_name.rs

Lines changed: 85 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use rust_i18n::t;
1212
use serde::{Deserialize, Serialize};
1313
use thiserror::Error;
1414

15-
use crate::types::FullyQualifiedTypeName;
15+
use crate::types::{FullyQualifiedTypeName, FullyQualifiedTypeNameError};
1616
use crate::util::convert_wildcard_to_regex;
1717

1818
/// Defines the wildcard type name for filtering DSC resources or extensions by name.
@@ -224,6 +224,16 @@ pub enum WildcardTypeNameError {
224224
#[source]
225225
err: regex::Error,
226226
},
227+
#[error("{t}", t = t!(
228+
"types.wildcard_type_name.invalidErrorConversion",
229+
"err" => err
230+
))]
231+
InvalidErrorConversion {
232+
/// The specific error that was encountered when attempting to convert the wildcard type
233+
/// name into a regex pattern for matching, but which is not a regex::Error and thus
234+
/// can't be included in the `InvalidRegex` error variant.
235+
err: FullyQualifiedTypeNameError,
236+
}
227237
}
228238

229239
/// This static lazily defines the validating regex for [`WildcardTypeName`]. It enables the
@@ -409,73 +419,17 @@ impl WildcardTypeName {
409419
});
410420
}
411421

412-
// We need to validate each segment of the type name separately to provide better error
413-
// messages about which specific segment(s) contain invalid characters. We also need to
414-
// allow for the presence of wildcard characters in any segment, which means we can't rely
415-
// on the same regex pattern used for fully qualified type names and instead need a simpler
416-
// pattern that just checks for valid characters within each segment.
417-
//
418-
// We also want to collect all segment errors rather than failing on the first invalid
419-
// segment, to provide more comprehensive feedback to the user.
420422
let errors = &mut Vec::<WildcardTypeNameError>::new();
421-
let owner: String;
422-
let namespaces: Vec<String>;
423-
let require_name: bool;
424-
let name: String;
425-
let regex: Regex;
426-
let validating_segment_regex = Self::init_validating_segment_regex();
427-
428-
// Split the input into owner/namespaces and name segments
429-
if let Some((owner_and_namespaces, name_segment)) = text.rsplit_once('/') {
430-
name = name_segment.to_string();
431-
let mut segments = owner_and_namespaces
432-
.split('.')
433-
.map(|s| s.to_string())
434-
.collect::<Vec<String>>();
435-
owner = segments.remove(0);
436-
namespaces = segments;
437-
} else if text.contains('.') {
438-
let mut segments = text
439-
.split('.')
440-
.map(|s| s.to_string())
441-
.collect::<Vec<String>>();
442-
owner = segments.remove(0);
443-
namespaces = segments;
444-
name = String::new();
445-
} else {
446-
owner = text.to_string();
447-
namespaces = Vec::new();
448-
name = String::new();
449-
}
450-
451-
// Validate the owner segment, which _must_ be defined.
452-
if owner.is_empty() {
453-
errors.push(WildcardTypeNameError::EmptyOwnerSegment);
454-
} else if !validating_segment_regex.is_match(&owner) {
455-
errors.push(WildcardTypeNameError::InvalidOwnerSegment {
456-
segment_text: owner.clone(),
457-
});
458-
}
459-
460-
// Validate every defined namespace segment
461-
for (index, namespace) in (&namespaces).into_iter().enumerate() {
462-
if namespace.is_empty() {
463-
errors.push(WildcardTypeNameError::EmptyNamespaceSegment {
464-
// Insert the index as 1-based for more user-friendly error messages
465-
index: index + 1
466-
});
467-
} else if !validating_segment_regex.is_match(&namespace) {
468-
errors.push(WildcardTypeNameError::InvalidNamespaceSegment {
469-
segment_text: namespace.clone(),
470-
});
471-
}
472-
}
423+
let (owner, namespaces, name) = Self::parse_segments(
424+
text,
425+
errors
426+
);
473427

474428
// The name segment is required if no wildcard is defined that can match the name
475429
// segment. For example, `Contoso.*.Example` would require a name segment because it
476430
// defines a wildcard in a prior namespace segment with a following literal namespace
477431
// segment.
478-
require_name = if namespaces.is_empty() {
432+
let require_name = if namespaces.is_empty() {
479433
!owner.contains('*')
480434
} else {
481435
!namespaces.last().unwrap().contains('*')
@@ -485,6 +439,7 @@ impl WildcardTypeName {
485439
}
486440

487441
// Validate the name segment for invalid characters if it's defined.
442+
let validating_segment_regex = Self::init_validating_segment_regex();
488443
if !name.is_empty() && !validating_segment_regex.is_match(&name) {
489444
errors.push(WildcardTypeNameError::InvalidNameSegment {
490445
segment_text: name.clone(),
@@ -493,7 +448,7 @@ impl WildcardTypeName {
493448

494449
// Construct the regular expression.
495450
let pattern = convert_wildcard_to_regex(text);
496-
regex = match RegexBuilder::new(&pattern).case_insensitive(true).build() {
451+
let regex = match RegexBuilder::new(&pattern).case_insensitive(true).build() {
497452
Ok(r) => r,
498453
Err(err) => {
499454
errors.push(WildcardTypeNameError::InvalidRegex {
@@ -521,6 +476,49 @@ impl WildcardTypeName {
521476
}
522477
}
523478

479+
/// Private helper to parse the input into segments and collect validation errors for owner
480+
/// and namespace segments.
481+
///
482+
/// This is used by the public `parse()` method to perform the initial parsing of the input
483+
/// string into its constituent segments. It reuses the implementation from
484+
/// [`FullyQualifiedTypeName::parse_segments()`], but converts the errors into the appropriate
485+
/// [`WildcardTypeNameError`] variants and collects them in the provided `errors` vector for
486+
/// bubbling up in the main error handling logic of the `parse()` method.
487+
///
488+
/// # Arguments
489+
///
490+
/// - `text` - The input string to parse into segments.
491+
/// - `errors`: A mutable reference to a vector for collecting any validation errors encountered
492+
/// while parsing the segments. This allows the method to accumulate multiple errors for
493+
/// different segments of the type name.
494+
///
495+
/// # Returns
496+
///
497+
/// The method returns the parsed segments (`owner`, `namespaces`, and `name`) as a tuple. Any
498+
/// validation errors encountered during parsing are added to the provided `errors` vector for
499+
/// the caller to handle.
500+
pub(crate) fn parse_segments(text: &str, errors: &mut Vec<WildcardTypeNameError>) -> (String, Vec<String>, String) {
501+
// Reuse the segment parsing logic from FullyQualifiedTypeName since the overall structure
502+
// is the same, but we need to provide a different validating regex that allows for
503+
// wildcard characters and the name segment may be optional depending on the presence of
504+
// wildcards in prior segments.
505+
let fqtn_errors = &mut Vec::<FullyQualifiedTypeNameError>::new();
506+
let validating_segment_regex = Self::init_validating_segment_regex();
507+
let parsed = FullyQualifiedTypeName::parse_segments(
508+
text,
509+
validating_segment_regex,
510+
fqtn_errors
511+
);
512+
// Convert the FQTN errors and add into the WCTN error collection
513+
errors.extend(fqtn_errors.into_iter().map(|e| {
514+
match TryInto::<WildcardTypeNameError>::try_into(e.clone()) {
515+
Ok(wtne) => wtne,
516+
Err(e) => e,
517+
}
518+
}));
519+
// Return the parsed segments.
520+
parsed
521+
}
524522
/// Returns `true` if the wildcard type name has a length of zero and otherwise `false`.
525523
pub fn is_empty(&self) -> bool {
526524
self.text.is_empty()
@@ -714,3 +712,27 @@ impl Hash for WildcardTypeName {
714712
self.text.to_lowercase().hash(state);
715713
}
716714
}
715+
716+
impl TryFrom<FullyQualifiedTypeNameError> for WildcardTypeNameError {
717+
type Error = WildcardTypeNameError;
718+
fn try_from(value: FullyQualifiedTypeNameError) -> Result<Self, Self::Error> {
719+
match value {
720+
FullyQualifiedTypeNameError::InvalidOwnerSegment {
721+
segment_text
722+
} => Ok(WildcardTypeNameError::InvalidOwnerSegment { segment_text }),
723+
FullyQualifiedTypeNameError::InvalidNamespaceSegment {
724+
segment_text
725+
} => Ok(WildcardTypeNameError::InvalidNamespaceSegment { segment_text }),
726+
FullyQualifiedTypeNameError::EmptyNamespaceSegment {
727+
index
728+
} => Ok(WildcardTypeNameError::EmptyNamespaceSegment { index }),
729+
FullyQualifiedTypeNameError::InvalidNameSegment {
730+
segment_text
731+
} => Ok(WildcardTypeNameError::InvalidNameSegment { segment_text }),
732+
FullyQualifiedTypeNameError::EmptyOwnerSegment => Ok(WildcardTypeNameError::EmptyOwnerSegment),
733+
FullyQualifiedTypeNameError::MissingNameSegment => Ok(WildcardTypeNameError::MissingNameSegment),
734+
// No other errors should be converted
735+
_ => Err(WildcardTypeNameError::InvalidErrorConversion { err: value })
736+
}
737+
}
738+
}

lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ mod methods {
8181
]
8282
}; "empty namespace segment"
8383
)]
84+
#[test_case("Owner.Empty.Last.Namespace./Name" =>
85+
FullyQualifiedTypeNameError::InvalidTypeName{
86+
text: "Owner.Empty.Last.Namespace./Name".to_string(),
87+
errors: vec![
88+
FullyQualifiedTypeNameError::EmptyNamespaceSegment {
89+
index: 4
90+
}
91+
]
92+
}; "empty namespace segment at end of namespaces"
93+
)]
8494
#[test_case("Invalid&Owner/Name" =>
8595
FullyQualifiedTypeNameError::InvalidTypeName{
8696
text: "Invalid&Owner/Name".to_string(),

lib/dsc-lib/tests/integration/types/wildcard_type_name.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ mod methods {
7070
]
7171
}; "empty namespace segment"
7272
)]
73+
#[test_case("Owner.Empty.Last.Namespace./*" =>
74+
WildcardTypeNameError::InvalidTypeName{
75+
text: "Owner.Empty.Last.Namespace./*".to_string(),
76+
errors: vec![
77+
WildcardTypeNameError::EmptyNamespaceSegment {
78+
index: 4
79+
}
80+
]
81+
}; "empty namespace segment at end of namespaces"
82+
)]
7383
#[test_case("Owner.*/Invalid&CharactersInName" =>
7484
WildcardTypeNameError::InvalidTypeName {
7585
text: "Owner.*/Invalid&CharactersInName".to_string(),

0 commit comments

Comments
 (0)