miyabi_modes/
validator.rs

1use crate::error::{ModeError, ModeResult};
2use crate::mode::MiyabiMode;
3
4/// Validator for mode definitions
5pub struct ModeValidator;
6
7impl ModeValidator {
8    /// Validate a mode definition
9    pub fn validate(mode: &MiyabiMode) -> ModeResult<()> {
10        Self::validate_slug(&mode.slug)?;
11        Self::validate_name(&mode.name)?;
12        Self::validate_character(&mode.character)?;
13        Self::validate_role_definition(&mode.role_definition)?;
14        Self::validate_groups(&mode.groups)?;
15        Self::validate_source(&mode.source)?;
16
17        if let Some(ref regex) = mode.file_regex {
18            Self::validate_regex(regex)?;
19        }
20
21        Ok(())
22    }
23
24    fn validate_slug(slug: &str) -> ModeResult<()> {
25        if slug.is_empty() {
26            return Err(ModeError::MissingField("slug".into()));
27        }
28
29        // Slug must be URL-safe: lowercase alphanumeric with hyphens
30        if !slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
31            return Err(ModeError::ValidationFailed(
32                "Slug must contain only lowercase letters, numbers, and hyphens".into(),
33            ));
34        }
35
36        if slug.len() > 50 {
37            return Err(ModeError::ValidationFailed("Slug must be 50 characters or less".into()));
38        }
39
40        Ok(())
41    }
42
43    fn validate_name(name: &str) -> ModeResult<()> {
44        if name.is_empty() {
45            return Err(ModeError::MissingField("name".into()));
46        }
47
48        if name.len() > 100 {
49            return Err(ModeError::ValidationFailed("Name must be 100 characters or less".into()));
50        }
51
52        Ok(())
53    }
54
55    fn validate_character(character: &str) -> ModeResult<()> {
56        if character.is_empty() {
57            return Err(ModeError::MissingField("character".into()));
58        }
59
60        if character.len() > 50 {
61            return Err(ModeError::ValidationFailed(
62                "Character name must be 50 characters or less".into(),
63            ));
64        }
65
66        Ok(())
67    }
68
69    fn validate_role_definition(role_def: &str) -> ModeResult<()> {
70        if role_def.is_empty() {
71            return Err(ModeError::MissingField("roleDefinition".into()));
72        }
73
74        if role_def.len() < 10 {
75            return Err(ModeError::ValidationFailed(
76                "Role definition too short (minimum 10 characters)".into(),
77            ));
78        }
79
80        Ok(())
81    }
82
83    fn validate_groups(groups: &[crate::mode::ToolGroup]) -> ModeResult<()> {
84        if groups.is_empty() {
85            return Err(ModeError::ValidationFailed("At least one tool group is required".into()));
86        }
87
88        Ok(())
89    }
90
91    fn validate_source(source: &str) -> ModeResult<()> {
92        if source != "miyabi-core" && source != "user" {
93            return Err(ModeError::ValidationFailed(
94                "Source must be 'miyabi-core' or 'user'".into(),
95            ));
96        }
97
98        Ok(())
99    }
100
101    fn validate_regex(pattern: &str) -> ModeResult<()> {
102        regex::Regex::new(pattern)?;
103        Ok(())
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::mode::ToolGroup;
111
112    fn create_valid_mode() -> MiyabiMode {
113        MiyabiMode {
114            slug: "test-mode".into(),
115            name: "Test Mode".into(),
116            character: "てすとん".into(),
117            role_definition: "This is a test mode for validation".into(),
118            when_to_use: "Use for testing".into(),
119            groups: vec![ToolGroup::Read],
120            custom_instructions: "Test instructions".into(),
121            source: "user".into(),
122            file_regex: None,
123            description: None,
124            system_prompt_args: None,
125            tools: vec![],
126        }
127    }
128
129    #[test]
130    fn test_valid_mode() {
131        let mode = create_valid_mode();
132        assert!(ModeValidator::validate(&mode).is_ok());
133    }
134
135    #[test]
136    fn test_invalid_slug_uppercase() {
137        let mut mode = create_valid_mode();
138        mode.slug = "TestMode".into();
139        assert!(ModeValidator::validate(&mode).is_err());
140    }
141
142    #[test]
143    fn test_invalid_slug_special_chars() {
144        let mut mode = create_valid_mode();
145        mode.slug = "test_mode".into();
146        assert!(ModeValidator::validate(&mode).is_err());
147    }
148
149    #[test]
150    fn test_empty_slug() {
151        let mut mode = create_valid_mode();
152        mode.slug = "".into();
153        assert!(ModeValidator::validate(&mode).is_err());
154    }
155
156    #[test]
157    fn test_short_role_definition() {
158        let mut mode = create_valid_mode();
159        mode.role_definition = "Short".into();
160        assert!(ModeValidator::validate(&mode).is_err());
161    }
162
163    #[test]
164    fn test_empty_groups() {
165        let mut mode = create_valid_mode();
166        mode.groups = vec![];
167        assert!(ModeValidator::validate(&mode).is_err());
168    }
169
170    #[test]
171    fn test_invalid_source() {
172        let mut mode = create_valid_mode();
173        mode.source = "invalid-source".into();
174        assert!(ModeValidator::validate(&mode).is_err());
175    }
176
177    #[test]
178    fn test_invalid_regex() {
179        let mut mode = create_valid_mode();
180        mode.file_regex = Some("[invalid".into());
181        assert!(ModeValidator::validate(&mode).is_err());
182    }
183
184    #[test]
185    fn test_valid_regex() {
186        let mut mode = create_valid_mode();
187        mode.file_regex = Some(r".*\.rs$".into());
188        assert!(ModeValidator::validate(&mode).is_ok());
189    }
190}