Skip to main content

lemma/
spec_set_id.rs

1//! Parse spec set identifiers: a single spec set `name`.
2//!
3//! Effective datetime is never embedded in the id; pass it separately
4//! (e.g. CLI `--effective`, HTTP `Accept-Datetime`).
5
6use crate::error::Error;
7use crate::limits::MAX_SPEC_NAME_LENGTH;
8
9/// Validate and normalize a spec set identifier (a workspace spec name).
10///
11/// Trims whitespace and rejects empty input, embedded whitespace, and the
12/// version sigils `~` / `^` (which once denoted revisions and are now reserved).
13/// Evaluation entry points use the workspace main base only, so `from` qualifiers
14/// are not accepted here.
15pub fn parse_spec_set_id(s: &str) -> Result<String, Error> {
16    let s = s.trim();
17    if s.is_empty() {
18        return Err(Error::request(
19            "Spec set identifier cannot be empty",
20            Some("Use a spec set name"),
21        ));
22    }
23
24    if s.contains('~') {
25        return Err(Error::request(
26            "Spec set identifier cannot contain '~'",
27            Some("Use a plain spec name"),
28        ));
29    }
30
31    if s.contains('^') {
32        return Err(Error::request(
33            "Spec set identifier cannot contain '^'",
34            Some("Use a plain spec name"),
35        ));
36    }
37
38    if s.split_whitespace().count() != 1 {
39        return Err(Error::request(
40            "Spec set identifier must be a single spec name",
41            Some("Specs run from the workspace main base; do not include an extra repository qualifier"),
42        ));
43    }
44
45    if s.len() > MAX_SPEC_NAME_LENGTH {
46        return Err(Error::request(
47            format!(
48                "Spec name exceeds maximum length ({} characters)",
49                MAX_SPEC_NAME_LENGTH
50            ),
51            Some("Shorten the spec name"),
52        ));
53    }
54
55    Ok(s.to_string())
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn parse_name_only() {
64        assert_eq!(parse_spec_set_id("pricing").unwrap(), "pricing".to_string());
65        assert_eq!(
66            parse_spec_set_id("  pricing  ").unwrap(),
67            "pricing".to_string()
68        );
69    }
70
71    #[test]
72    fn repository_qualifier_rejected() {
73        assert!(parse_spec_set_id("nl/tax pricing").is_err());
74        assert!(parse_spec_set_id("@lemma/std pricing").is_err());
75    }
76
77    #[test]
78    fn tilde_rejected() {
79        assert!(parse_spec_set_id("pricing~a1b2c3d4").is_err());
80    }
81
82    #[test]
83    fn caret_rejected() {
84        assert!(parse_spec_set_id("pricing^a1b2c3d4").is_err());
85    }
86
87    #[test]
88    fn empty_err() {
89        assert!(parse_spec_set_id("").is_err());
90        assert!(parse_spec_set_id("   ").is_err());
91    }
92}