Skip to main content

nika_engine/binding/
validate.rs

1//! Task ID Validation
2//!
3//! Task IDs must follow snake_case convention:
4//! - Start with lowercase letter
5//! - Contain only lowercase letters, digits, underscores
6//! - No dashes, dots, or uppercase letters
7//!
8//! Rationale: Dots are reserved for path separator in `task.field.subfield`
9//!
10//! Performance: Manual validation without regex for O(n) single-pass check with no allocations.
11//! Regex would have higher startup cost and memory overhead for a simple pattern.
12
13use crate::error::NikaError;
14
15/// Validate a task ID without regex overhead
16///
17/// Manual implementation for optimal performance:
18/// - O(n) single-pass validation
19/// - Zero allocations
20/// - No regex compilation overhead
21///
22/// Valid task IDs:
23/// - Start with lowercase letter [a-z]
24/// - Contain only lowercase letters, digits, underscores [a-z0-9_]*
25///
26/// Invalid patterns:
27/// - Dashes: `fetch-api` (use `fetch_api`)
28/// - Uppercase: `myTask` (use `my_task`)
29/// - Dots: `weather.api` (dots reserved for paths)
30/// - Numbers first: `123task` (must start with letter)
31/// - Leading underscore: `_private` (not idiomatic)
32pub fn validate_task_id(id: &str) -> Result<(), NikaError> {
33    // Empty check
34    if id.is_empty() {
35        return Err(NikaError::InvalidTaskId {
36            id: id.to_string(),
37            reason: "cannot be empty".into(),
38        });
39    }
40
41    // First character: must be [a-z]
42    let first = id.as_bytes()[0];
43    if !first.is_ascii_lowercase() {
44        return Err(NikaError::InvalidTaskId {
45      id: id.to_string(),
46      reason: "must start with lowercase letter (a-z), then lowercase letters, digits, or underscores".into(),
47    });
48    }
49
50    // Remaining characters: must be [a-z0-9_]
51    for &byte in &id.as_bytes()[1..] {
52        if !byte.is_ascii_lowercase() && !byte.is_ascii_digit() && byte != b'_' {
53            return Err(NikaError::InvalidTaskId {
54        id: id.to_string(),
55        reason: "must start with lowercase letter (a-z), then lowercase letters, digits, or underscores".into(),
56      });
57        }
58    }
59
60    Ok(())
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    // ═══════════════════════════════════════════════════════════════
68    // Valid task IDs - boundary and common cases
69    // ═══════════════════════════════════════════════════════════════
70
71    #[test]
72    fn valid_simple() {
73        assert!(validate_task_id("weather").is_ok());
74        assert!(validate_task_id("w").is_ok());
75    }
76
77    #[test]
78    fn valid_with_underscore() {
79        assert!(validate_task_id("get_data").is_ok());
80        assert!(validate_task_id("fetch_api").is_ok());
81        assert!(validate_task_id("my_task_name").is_ok());
82        assert!(validate_task_id("a_").is_ok());
83        assert!(validate_task_id("a__b").is_ok());
84    }
85
86    #[test]
87    fn valid_with_numbers() {
88        assert!(validate_task_id("task123").is_ok());
89        assert!(validate_task_id("step2").is_ok());
90        assert!(validate_task_id("v2_parser").is_ok());
91        assert!(validate_task_id("a0").is_ok());
92        assert!(validate_task_id("a123456789").is_ok());
93    }
94
95    #[test]
96    fn valid_single_letter() {
97        assert!(validate_task_id("a").is_ok());
98        assert!(validate_task_id("x").is_ok());
99        assert!(validate_task_id("z").is_ok());
100    }
101
102    #[test]
103    fn valid_all_lowercase_boundaries() {
104        assert!(validate_task_id("abcdefghijklmnopqrstuvwxyz").is_ok());
105        assert!(validate_task_id("a0123456789").is_ok());
106    }
107
108    // ═══════════════════════════════════════════════════════════════
109    // Invalid task IDs - NIKA-055 (detailed error messages)
110    // ═══════════════════════════════════════════════════════════════
111
112    #[test]
113    fn reject_empty() {
114        let err = validate_task_id("").unwrap_err();
115        assert!(err.to_string().contains("NIKA-055"));
116        assert!(err.to_string().contains("cannot be empty"));
117    }
118
119    #[test]
120    fn reject_number_start() {
121        let err = validate_task_id("123task").unwrap_err();
122        assert!(err.to_string().contains("NIKA-055"));
123        assert!(err.to_string().contains("start with lowercase letter"));
124    }
125
126    #[test]
127    fn reject_uppercase_start() {
128        let err = validate_task_id("Task").unwrap_err();
129        assert!(err.to_string().contains("NIKA-055"));
130    }
131
132    #[test]
133    fn reject_all_uppercase() {
134        let err = validate_task_id("TASK").unwrap_err();
135        assert!(err.to_string().contains("NIKA-055"));
136    }
137
138    #[test]
139    fn reject_uppercase_middle() {
140        let err = validate_task_id("myTask").unwrap_err();
141        assert!(err.to_string().contains("NIKA-055"));
142    }
143
144    #[test]
145    fn reject_underscore_start() {
146        let err = validate_task_id("_private").unwrap_err();
147        assert!(err.to_string().contains("NIKA-055"));
148        assert!(err.to_string().contains("start with lowercase letter"));
149    }
150
151    #[test]
152    fn reject_dash() {
153        let err = validate_task_id("fetch-api").unwrap_err();
154        assert!(err.to_string().contains("NIKA-055"));
155        // Should suggest fetch_api
156    }
157
158    #[test]
159    fn reject_dot() {
160        let err = validate_task_id("weather.api").unwrap_err();
161        assert!(err.to_string().contains("NIKA-055"));
162        // Dots are reserved for path traversal
163    }
164
165    #[test]
166    fn reject_spaces() {
167        assert!(validate_task_id("my task").is_err());
168        assert!(validate_task_id(" weather").is_err());
169        assert!(validate_task_id("weather ").is_err());
170        assert!(validate_task_id("my  task").is_err());
171    }
172
173    #[test]
174    fn reject_special_chars() {
175        assert!(validate_task_id("task!").is_err());
176        assert!(validate_task_id("task@name").is_err());
177        assert!(validate_task_id("task#1").is_err());
178        assert!(validate_task_id("task$").is_err());
179        assert!(validate_task_id("task%name").is_err());
180        assert!(validate_task_id("task&more").is_err());
181        assert!(validate_task_id("task(x)").is_err());
182        assert!(validate_task_id("task=value").is_err());
183        assert!(validate_task_id("task+more").is_err());
184        assert!(validate_task_id("task[0]").is_err());
185        assert!(validate_task_id("task{x}").is_err());
186        assert!(validate_task_id("task|pipe").is_err());
187        assert!(validate_task_id("task\\slash").is_err());
188        assert!(validate_task_id("task;semicolon").is_err());
189        assert!(validate_task_id("task:colon").is_err());
190        assert!(validate_task_id("task'quote").is_err());
191        assert!(validate_task_id("task\u{00a0}nbsp").is_err()); // non-breaking space
192    }
193
194    #[test]
195    fn reject_emoji_and_unicode() {
196        assert!(validate_task_id("task😀").is_err());
197        assert!(validate_task_id("tâche").is_err()); // French accented character
198        assert!(validate_task_id("任務").is_err()); // Japanese
199    }
200
201    #[test]
202    fn error_message_contains_nika_code() {
203        let result = validate_task_id("invalid-name");
204        let err = result.unwrap_err();
205        assert!(err.to_string().contains("NIKA-055"));
206    }
207
208    #[test]
209    fn error_message_includes_invalid_id() {
210        let invalid_id = "my-invalid-task";
211        let result = validate_task_id(invalid_id);
212        let err = result.unwrap_err();
213        assert!(err.to_string().contains(invalid_id));
214    }
215}