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 ORG_RE: Lazy<Regex> = Lazy::new(|| {
17 Regex::new(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$")
18 .expect("valid org regex")
19});
20
21#[derive(Debug, Error, Diagnostic)]
22pub enum ValidationError {
23 #[error("component name may not be empty")]
24 #[diagnostic(
25 code = "greentic.cli.name_empty",
26 help = "Provide a kebab- or snake-case name, e.g. `demo-component`."
27 )]
28 EmptyName,
29 #[error("component name must be lowercase kebab-or-snake case (got `{0}`)")]
30 #[diagnostic(
31 code = "greentic.cli.name_invalid",
32 help = "Use lowercase letters, digits, '-' or '_' separators."
33 )]
34 InvalidName(String),
35 #[error("organization must be reverse-DNS style (got `{0}`)")]
36 #[diagnostic(
37 code = "greentic.cli.org_invalid",
38 help = "Use values like `ai.greentic` or `dev.example.tools`."
39 )]
40 InvalidOrg(String),
41 #[error("invalid semantic version `{input}`: {source}")]
42 #[diagnostic(
43 code = "greentic.cli.version_invalid",
44 help = "Use standard semver such as 0.1.0 or 1.2.3-alpha.1."
45 )]
46 InvalidSemver {
47 input: String,
48 #[source]
49 source: semver::Error,
50 },
51 #[error("unable to determine working directory: {0}")]
52 #[diagnostic(code = "greentic.cli.cwd_unavailable")]
53 WorkingDir(#[source] io::Error),
54 #[error("target path points to an existing file: {0}")]
55 #[diagnostic(
56 code = "greentic.cli.path_is_file",
57 help = "Pick a different --path or remove the file."
58 )]
59 TargetIsFile(PathBuf),
60 #[error("target directory {0} already exists and is not empty")]
61 #[diagnostic(
62 code = "greentic.cli.path_not_empty",
63 help = "Provide an empty directory or omit --path to create a new one."
64 )]
65 TargetDirNotEmpty(PathBuf),
66 #[error("failed to inspect path {0}: {1}")]
67 #[diagnostic(code = "greentic.cli.path_io")]
68 Io(PathBuf, #[source] io::Error),
69}
70
71impl ValidationError {
72 pub fn code(&self) -> &'static str {
73 match self {
74 ValidationError::EmptyName => "greentic.cli.name_empty",
75 ValidationError::InvalidName(_) => "greentic.cli.name_invalid",
76 ValidationError::InvalidOrg(_) => "greentic.cli.org_invalid",
77 ValidationError::InvalidSemver { .. } => "greentic.cli.version_invalid",
78 ValidationError::WorkingDir(_) => "greentic.cli.cwd_unavailable",
79 ValidationError::TargetIsFile(_) => "greentic.cli.path_is_file",
80 ValidationError::TargetDirNotEmpty(_) => "greentic.cli.path_not_empty",
81 ValidationError::Io(_, _) => "greentic.cli.path_io",
82 }
83 }
84}
85
86pub type ValidationResult<T> = std::result::Result<T, ValidationError>;
87
88#[derive(Debug, Clone, Eq, PartialEq)]
89pub struct ComponentName(String);
90
91impl ComponentName {
92 pub fn parse(value: &str) -> Result<Self, ValidationError> {
93 let trimmed = value.trim();
94 if trimmed.is_empty() {
95 return Err(ValidationError::EmptyName);
96 }
97 if !NAME_RE.is_match(trimmed) {
98 return Err(ValidationError::InvalidName(trimmed.to_owned()));
99 }
100 Ok(Self(trimmed.to_owned()))
101 }
102
103 pub fn as_str(&self) -> &str {
104 &self.0
105 }
106
107 pub fn into_string(self) -> String {
108 self.0
109 }
110}
111
112pub fn is_valid_name(value: &str) -> bool {
113 ComponentName::parse(value).is_ok()
114}
115
116#[derive(Debug, Clone, Eq, PartialEq)]
117pub struct OrgNamespace(String);
118
119impl OrgNamespace {
120 pub fn parse(value: &str) -> Result<Self, ValidationError> {
121 let trimmed = value.trim();
122 if ORG_RE.is_match(trimmed) {
123 Ok(Self(trimmed.to_owned()))
124 } else {
125 Err(ValidationError::InvalidOrg(trimmed.to_owned()))
126 }
127 }
128
129 pub fn as_str(&self) -> &str {
130 &self.0
131 }
132
133 pub fn into_string(self) -> String {
134 self.0
135 }
136}
137
138pub fn normalize_version(value: &str) -> ValidationResult<String> {
139 Version::parse(value)
140 .map(|v| v.to_string())
141 .map_err(|source| ValidationError::InvalidSemver {
142 input: value.to_string(),
143 source,
144 })
145}
146
147pub fn resolve_target_path(
148 name: &ComponentName,
149 provided: Option<&Path>,
150) -> Result<PathBuf, ValidationError> {
151 let relative = provided
152 .map(PathBuf::from)
153 .unwrap_or_else(|| PathBuf::from(name.as_str()));
154 if relative.is_absolute() {
155 return Ok(relative);
156 }
157 let cwd = env::current_dir().map_err(ValidationError::WorkingDir)?;
158 Ok(cwd.join(relative))
159}
160
161pub fn ensure_path_available(path: &Path) -> Result<(), ValidationError> {
162 match fs::metadata(path) {
163 Ok(metadata) => {
164 if metadata.is_file() {
165 return Err(ValidationError::TargetIsFile(path.to_path_buf()));
166 }
167 let mut entries =
168 fs::read_dir(path).map_err(|err| ValidationError::Io(path.to_path_buf(), err))?;
169 if entries.next().is_some() {
170 return Err(ValidationError::TargetDirNotEmpty(path.to_path_buf()));
171 }
172 }
173 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
174 Err(err) => return Err(ValidationError::Io(path.to_path_buf(), err)),
175 }
176 Ok(())
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use assert_fs::TempDir;
183
184 #[test]
185 fn rejects_invalid_names() {
186 let err = ComponentName::parse("HelloWorld").unwrap_err();
187 assert!(matches!(err, ValidationError::InvalidName(_)));
188 }
189
190 #[test]
191 fn resolves_default_path_relative_to_cwd() {
192 let name = ComponentName::parse("demo-component").unwrap();
193 let path = resolve_target_path(&name, None).unwrap();
194 assert!(path.ends_with("demo-component"));
195 }
196
197 #[test]
198 fn detects_non_empty_directories() {
199 let temp = TempDir::new().unwrap();
200 let target = temp.path().join("demo");
201 fs::create_dir_all(&target).unwrap();
202 fs::write(target.join("file.txt"), "data").unwrap();
203 let err = ensure_path_available(&target).unwrap_err();
204 assert!(matches!(err, ValidationError::TargetDirNotEmpty(_)));
205 }
206
207 #[test]
208 fn rejects_invalid_orgs() {
209 let err = OrgNamespace::parse("NoDots").unwrap_err();
210 assert!(matches!(err, ValidationError::InvalidOrg(_)));
211 }
212
213 #[test]
214 fn accepts_valid_orgs() {
215 let org = OrgNamespace::parse("ai.greentic").unwrap();
216 assert_eq!(org.as_str(), "ai.greentic");
217 }
218
219 #[test]
220 fn detects_invalid_semver() {
221 assert!(matches!(
222 normalize_version("01.0.0").unwrap_err(),
223 ValidationError::InvalidSemver { .. }
224 ));
225 }
226
227 #[test]
228 fn normalizes_semver() {
229 let normalized = normalize_version("1.2.3-alpha.1").unwrap();
230 assert_eq!(normalized, "1.2.3-alpha.1");
231 }
232}