Skip to main content

greentic_component/scaffold/
validate.rs

1#![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}