greentic_component/scaffold/
validate.rs1#![cfg(feature = "cli")]
2
3use std::env;
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7
8use miette::Diagnostic;
9use once_cell::sync::Lazy;
10use regex::Regex;
11use semver::Version;
12use thiserror::Error;
13
14static NAME_RE: Lazy<Regex> =
15 Lazy::new(|| Regex::new(r"^[a-z0-9]+([_-][a-z0-9]+)*$").expect("valid name regex"));
16static OPERATION_RE: Lazy<Regex> =
17 Lazy::new(|| Regex::new(r"^[a-z][a-z0-9_.:-]*$").expect("valid operation regex"));
18static ORG_RE: Lazy<Regex> = Lazy::new(|| {
19 Regex::new(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$")
20 .expect("valid org regex")
21});
22
23#[derive(Debug, Error, Diagnostic)]
24pub enum ValidationError {
25 #[error("component name may not be empty")]
26 #[diagnostic(
27 code = "greentic.cli.name_empty",
28 help = "Provide a kebab- or snake-case name, e.g. `demo-component`."
29 )]
30 EmptyName,
31 #[error("component name must be lowercase kebab-or-snake case (got `{0}`)")]
32 #[diagnostic(
33 code = "greentic.cli.name_invalid",
34 help = "Use lowercase letters, digits, '-' or '_' separators."
35 )]
36 InvalidName(String),
37 #[error("organization must be reverse-DNS style (got `{0}`)")]
38 #[diagnostic(
39 code = "greentic.cli.org_invalid",
40 help = "Use values like `ai.greentic` or `dev.example.tools`."
41 )]
42 InvalidOrg(String),
43 #[error("invalid semantic version `{input}`: {source}")]
44 #[diagnostic(
45 code = "greentic.cli.version_invalid",
46 help = "Use standard semver such as 0.1.0 or 1.2.3-alpha.1."
47 )]
48 InvalidSemver {
49 input: String,
50 #[source]
51 source: semver::Error,
52 },
53 #[error("operation name must match the canonical manifest pattern (got `{0}`)")]
54 #[diagnostic(
55 code = "greentic.cli.operation_invalid",
56 help = "Use lowercase names starting with a letter; allowed characters are letters, digits, '.', '_', ':', and '-'."
57 )]
58 InvalidOperationName(String),
59 #[error("operation `{0}` was declared more than once")]
60 #[diagnostic(
61 code = "greentic.cli.operation_duplicate",
62 help = "Pass each operation only once."
63 )]
64 DuplicateOperationName(String),
65 #[error("default_operation `{0}` must match one of the declared operations")]
66 #[diagnostic(
67 code = "greentic.cli.default_operation_unknown",
68 help = "Choose a default operation from the operations declared for this component."
69 )]
70 UnknownDefaultOperation(String),
71 #[error("filesystem mode must be one of none, read_only, sandbox (got `{0}`)")]
72 #[diagnostic(
73 code = "greentic.cli.filesystem_mode_invalid",
74 help = "Use one of `none`, `read_only`, or `sandbox`."
75 )]
76 InvalidFilesystemMode(String),
77 #[error("filesystem mount must be `name:host_class:guest_path` (got `{0}`)")]
78 #[diagnostic(
79 code = "greentic.cli.filesystem_mount_invalid",
80 help = "Pass mounts as `name:host_class:guest_path`, for example `assets:assets:/assets`."
81 )]
82 InvalidFilesystemMount(String),
83 #[error("telemetry scope must be one of tenant, pack, node (got `{0}`)")]
84 #[diagnostic(
85 code = "greentic.cli.telemetry_scope_invalid",
86 help = "Use one of `tenant`, `pack`, or `node`."
87 )]
88 InvalidTelemetryScope(String),
89 #[error("secret format must be one of bytes, text, json (got `{0}`)")]
90 #[diagnostic(
91 code = "greentic.cli.secret_format_invalid",
92 help = "Use one of `bytes`, `text`, or `json`."
93 )]
94 InvalidSecretFormat(String),
95 #[error("telemetry attribute must be `key=value` (got `{0}`)")]
96 #[diagnostic(
97 code = "greentic.cli.telemetry_attribute_invalid",
98 help = "Pass telemetry attributes as `key=value`."
99 )]
100 InvalidTelemetryAttribute(String),
101 #[error("config field must be `name:type[:required|optional]` (got `{0}`)")]
102 #[diagnostic(
103 code = "greentic.cli.config_field_invalid",
104 help = "Pass config fields as `enabled:bool:required` or `api_key:string`."
105 )]
106 InvalidConfigField(String),
107 #[error("config field name must be lowercase snake_case (got `{0}`)")]
108 #[diagnostic(
109 code = "greentic.cli.config_field_name_invalid",
110 help = "Use lowercase field names like `enabled` or `api_key`."
111 )]
112 InvalidConfigFieldName(String),
113 #[error("config field type must be one of string, bool, integer, number (got `{0}`)")]
114 #[diagnostic(
115 code = "greentic.cli.config_field_type_invalid",
116 help = "Use `string`, `bool`, `integer`, or `number`."
117 )]
118 InvalidConfigFieldType(String),
119 #[error("unable to determine working directory: {0}")]
120 #[diagnostic(code = "greentic.cli.cwd_unavailable")]
121 WorkingDir(#[source] io::Error),
122 #[error("target path points to an existing file: {0}")]
123 #[diagnostic(
124 code = "greentic.cli.path_is_file",
125 help = "Pick a different --path or remove the file."
126 )]
127 TargetIsFile(PathBuf),
128 #[error("target directory {0} already exists and is not empty")]
129 #[diagnostic(
130 code = "greentic.cli.path_not_empty",
131 help = "Provide an empty directory or omit --path to create a new one."
132 )]
133 TargetDirNotEmpty(PathBuf),
134 #[error("failed to inspect path {0}: {1}")]
135 #[diagnostic(code = "greentic.cli.path_io")]
136 Io(PathBuf, #[source] io::Error),
137}
138
139impl ValidationError {
140 pub fn code(&self) -> &'static str {
141 match self {
142 ValidationError::EmptyName => "greentic.cli.name_empty",
143 ValidationError::InvalidName(_) => "greentic.cli.name_invalid",
144 ValidationError::InvalidOrg(_) => "greentic.cli.org_invalid",
145 ValidationError::InvalidSemver { .. } => "greentic.cli.version_invalid",
146 ValidationError::InvalidOperationName(_) => "greentic.cli.operation_invalid",
147 ValidationError::DuplicateOperationName(_) => "greentic.cli.operation_duplicate",
148 ValidationError::UnknownDefaultOperation(_) => "greentic.cli.default_operation_unknown",
149 ValidationError::InvalidFilesystemMode(_) => "greentic.cli.filesystem_mode_invalid",
150 ValidationError::InvalidFilesystemMount(_) => "greentic.cli.filesystem_mount_invalid",
151 ValidationError::InvalidTelemetryScope(_) => "greentic.cli.telemetry_scope_invalid",
152 ValidationError::InvalidSecretFormat(_) => "greentic.cli.secret_format_invalid",
153 ValidationError::InvalidTelemetryAttribute(_) => {
154 "greentic.cli.telemetry_attribute_invalid"
155 }
156 ValidationError::InvalidConfigField(_) => "greentic.cli.config_field_invalid",
157 ValidationError::InvalidConfigFieldName(_) => "greentic.cli.config_field_name_invalid",
158 ValidationError::InvalidConfigFieldType(_) => "greentic.cli.config_field_type_invalid",
159 ValidationError::WorkingDir(_) => "greentic.cli.cwd_unavailable",
160 ValidationError::TargetIsFile(_) => "greentic.cli.path_is_file",
161 ValidationError::TargetDirNotEmpty(_) => "greentic.cli.path_not_empty",
162 ValidationError::Io(_, _) => "greentic.cli.path_io",
163 }
164 }
165}
166
167pub type ValidationResult<T> = std::result::Result<T, ValidationError>;
168
169#[derive(Debug, Clone, Eq, PartialEq)]
170pub struct ComponentName(String);
171
172impl ComponentName {
173 pub fn parse(value: &str) -> Result<Self, ValidationError> {
174 let trimmed = value.trim();
175 if trimmed.is_empty() {
176 return Err(ValidationError::EmptyName);
177 }
178 if !NAME_RE.is_match(trimmed) {
179 return Err(ValidationError::InvalidName(trimmed.to_owned()));
180 }
181 Ok(Self(trimmed.to_owned()))
182 }
183
184 pub fn as_str(&self) -> &str {
185 &self.0
186 }
187
188 pub fn into_string(self) -> String {
189 self.0
190 }
191}
192
193pub fn is_valid_name(value: &str) -> bool {
194 ComponentName::parse(value).is_ok()
195}
196
197pub fn normalize_operation_name(value: &str) -> ValidationResult<String> {
198 let trimmed = value.trim();
199 if OPERATION_RE.is_match(trimmed) {
200 Ok(trimmed.to_string())
201 } else {
202 Err(ValidationError::InvalidOperationName(trimmed.to_string()))
203 }
204}
205
206#[derive(Debug, Clone, Eq, PartialEq)]
207pub struct OrgNamespace(String);
208
209impl OrgNamespace {
210 pub fn parse(value: &str) -> Result<Self, ValidationError> {
211 let trimmed = value.trim();
212 if ORG_RE.is_match(trimmed) {
213 Ok(Self(trimmed.to_owned()))
214 } else {
215 Err(ValidationError::InvalidOrg(trimmed.to_owned()))
216 }
217 }
218
219 pub fn as_str(&self) -> &str {
220 &self.0
221 }
222
223 pub fn into_string(self) -> String {
224 self.0
225 }
226}
227
228pub fn normalize_version(value: &str) -> ValidationResult<String> {
229 Version::parse(value)
230 .map(|v| v.to_string())
231 .map_err(|source| ValidationError::InvalidSemver {
232 input: value.to_string(),
233 source,
234 })
235}
236
237pub fn resolve_target_path(
238 name: &ComponentName,
239 provided: Option<&Path>,
240) -> Result<PathBuf, ValidationError> {
241 let relative = provided
242 .map(PathBuf::from)
243 .unwrap_or_else(|| PathBuf::from(name.as_str()));
244 if relative.is_absolute() {
245 return Ok(relative);
246 }
247 let cwd = env::current_dir().map_err(ValidationError::WorkingDir)?;
248 Ok(cwd.join(relative))
249}
250
251pub fn ensure_path_available(path: &Path) -> Result<(), ValidationError> {
252 match fs::metadata(path) {
253 Ok(metadata) => {
254 if metadata.is_file() {
255 return Err(ValidationError::TargetIsFile(path.to_path_buf()));
256 }
257 let mut entries =
258 fs::read_dir(path).map_err(|err| ValidationError::Io(path.to_path_buf(), err))?;
259 if entries.next().is_some() {
260 return Err(ValidationError::TargetDirNotEmpty(path.to_path_buf()));
261 }
262 }
263 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
264 Err(err) => return Err(ValidationError::Io(path.to_path_buf(), err)),
265 }
266 Ok(())
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use assert_fs::TempDir;
273
274 #[test]
275 fn rejects_invalid_names() {
276 let err = ComponentName::parse("HelloWorld").unwrap_err();
277 assert!(matches!(err, ValidationError::InvalidName(_)));
278 }
279
280 #[test]
281 fn resolves_default_path_relative_to_cwd() {
282 let name = ComponentName::parse("demo-component").unwrap();
283 let path = resolve_target_path(&name, None).unwrap();
284 assert!(path.ends_with("demo-component"));
285 }
286
287 #[test]
288 fn detects_non_empty_directories() {
289 let temp = TempDir::new().unwrap();
290 let target = temp.path().join("demo");
291 fs::create_dir_all(&target).unwrap();
292 fs::write(target.join("file.txt"), "data").unwrap();
293 let err = ensure_path_available(&target).unwrap_err();
294 assert!(matches!(err, ValidationError::TargetDirNotEmpty(_)));
295 }
296
297 #[test]
298 fn rejects_invalid_orgs() {
299 let err = OrgNamespace::parse("NoDots").unwrap_err();
300 assert!(matches!(err, ValidationError::InvalidOrg(_)));
301 }
302
303 #[test]
304 fn accepts_valid_orgs() {
305 let org = OrgNamespace::parse("ai.greentic").unwrap();
306 assert_eq!(org.as_str(), "ai.greentic");
307 }
308
309 #[test]
310 fn detects_invalid_semver() {
311 assert!(matches!(
312 normalize_version("01.0.0").unwrap_err(),
313 ValidationError::InvalidSemver { .. }
314 ));
315 }
316
317 #[test]
318 fn normalizes_semver() {
319 let normalized = normalize_version("1.2.3-alpha.1").unwrap();
320 assert_eq!(normalized, "1.2.3-alpha.1");
321 }
322}