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 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}