stormchaser_cli/commands/
lint.rs1use anyhow::{Context, Result};
2use schemars::schema::Schema;
3use serde_json::Value;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use stormchaser_dsl::StormchaserParser;
7use stormchaser_model::schema_gen::{apply_step_extensibility, generate_dsl_schema};
8
9#[derive(clap::Parser)]
11pub struct LintCommand {
12 pub file: Option<PathBuf>,
14
15 #[arg(long, value_parser = parse_step_schema)]
21 pub step_schema: Vec<(String, String)>,
22
23 #[arg(long)]
25 pub remote: bool,
26
27 #[arg(long)]
29 pub prepare: bool,
30}
31
32fn parse_step_schema(s: &str) -> Result<(String, String)> {
34 let parts: Vec<&str> = s.splitn(2, '=').collect();
35 if parts.len() != 2 {
36 anyhow::bail!("Invalid step-schema format. Expected TYPE=PATH");
37 }
38 Ok((parts[0].to_string(), parts[1].to_string()))
39}
40
41pub async fn handle(
43 url: &str,
44 http_client: &reqwest_middleware::ClientWithMiddleware,
45 command: LintCommand,
46) -> Result<()> {
47 if command.prepare && command.file.is_none() {
48 let req_url = format!("{}/api/v1/schema", url);
49 let resp = http_client
50 .get(&req_url)
51 .send()
52 .await?
53 .error_for_status()
54 .with_context(|| format!("Failed to fetch remote schema from {}", req_url))?;
55 let json: Value = resp.json().await?;
56 std::fs::write(
57 ".stormchaser-schema.json",
58 serde_json::to_string_pretty(&json)?,
59 )?;
60 println!("✓ Downloaded and saved remote schema to .stormchaser-schema.json");
61 return Ok(());
62 }
63
64 let file_path = command
65 .file
66 .clone()
67 .context("No workflow file provided to lint")?;
68 let content = std::fs::read_to_string(&file_path)
69 .with_context(|| format!("Failed to read file: {:?}", file_path))?;
70
71 let parser = StormchaserParser::new();
73 let workflow = parser
74 .parse(&content)
75 .with_context(|| format!("Failed to parse HCL in {:?}", file_path))?;
76
77 let instance = serde_json::to_value(&workflow)?;
79
80 let mut root_schema = resolve_base_schema(&command, url, http_client).await?;
82
83 let mut spec_schemas = HashMap::new();
84
85 for (type_name, path) in command.step_schema {
87 let schema_json = load_schema(&path)?;
88 let schema_obj: Schema = serde_json::from_value(schema_json)
89 .with_context(|| format!("Failed to parse schema for type {}", type_name))?;
90 spec_schemas.insert(type_name, schema_obj);
91 }
92
93 apply_step_extensibility(&mut root_schema, &spec_schemas);
95
96 let schema_value = serde_json::to_value(&root_schema)?;
98 let validator = jsonschema::Validator::new(&schema_value)
99 .map_err(|e| anyhow::anyhow!("Failed to compile JSON schema: {}", e))?;
100
101 if !validator.is_valid(&instance) {
102 println!("Validation failed for {:?}", file_path);
103 for error in validator.iter_errors(&instance) {
104 println!("- {}: {}", error.instance_path(), error);
105 }
106 anyhow::bail!("Workflow failed schema validation");
107 }
108
109 println!("✓ Workflow successfully validated against schema.");
110 Ok(())
111}
112
113async fn resolve_base_schema(
114 command: &LintCommand,
115 url: &str,
116 http_client: &reqwest_middleware::ClientWithMiddleware,
117) -> Result<schemars::schema::RootSchema> {
118 if command.remote || command.prepare {
119 let req_url = format!("{}/api/v1/schema", url);
120 let resp = http_client
121 .get(&req_url)
122 .send()
123 .await?
124 .error_for_status()
125 .with_context(|| format!("Failed to fetch remote schema from {}", req_url))?;
126 let json: Value = resp.json().await?;
127
128 if command.prepare {
129 std::fs::write(
130 ".stormchaser-schema.json",
131 serde_json::to_string_pretty(&json)?,
132 )?;
133 println!("✓ Downloaded and saved remote schema to .stormchaser-schema.json");
134 }
135
136 serde_json::from_value(json).context("Failed to parse remote schema")
137 } else if Path::new(".stormchaser-schema.json").exists() {
138 let content = std::fs::read_to_string(".stormchaser-schema.json")
139 .context("Failed to read .stormchaser-schema.json")?;
140 serde_json::from_str(&content).context("Failed to parse .stormchaser-schema.json")
141 } else {
142 Ok(generate_dsl_schema())
143 }
144}
145
146fn load_schema(path: &str) -> Result<Value> {
148 if path.starts_with("git-local://") {
149 let url = url::Url::parse(path)?;
150 let repo_path = url.path();
151
152 let mut ref_name = "HEAD".to_string();
153 let mut file_path = String::new();
154
155 for (k, v) in url.query_pairs() {
156 if k == "ref" {
157 ref_name = v.into_owned();
158 } else if k == "file" {
159 file_path = v.into_owned();
160 }
161 }
162
163 if file_path.is_empty() {
164 anyhow::bail!("Missing 'file' query parameter in git-local URL");
165 }
166
167 let repo = git2::Repository::open(repo_path)
168 .with_context(|| format!("Failed to open git repository at {}", repo_path))?;
169
170 let obj = repo
171 .revparse_single(&ref_name)
172 .with_context(|| format!("Failed to find ref {} in {}", ref_name, repo_path))?;
173
174 let tree = obj
175 .peel_to_tree()
176 .with_context(|| format!("Failed to peel ref {} to a tree", ref_name))?;
177
178 let entry = tree
179 .get_path(std::path::Path::new(&file_path))
180 .with_context(|| format!("Failed to find file {} at ref {}", file_path, ref_name))?;
181
182 let blob = entry.to_object(&repo)?.peel_to_blob()?;
183 let content = blob.content();
184
185 let json: Value = serde_json::from_slice(content)
186 .with_context(|| format!("Failed to parse JSON from {} in git repo", file_path))?;
187
188 Ok(json)
189 } else {
190 let content = std::fs::read_to_string(path)
191 .with_context(|| format!("Failed to read schema file: {}", path))?;
192 let json: Value = serde_json::from_str(&content)
193 .with_context(|| format!("Failed to parse JSON from file: {}", path))?;
194 Ok(json)
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use reqwest_middleware::ClientBuilder;
202 use std::io::Write;
203 use tempfile::NamedTempFile;
204
205 static LINT_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
206
207 #[test]
208 fn test_parse_step_schema_valid() {
209 let (t, p) = parse_step_schema("CustomType=local.json").unwrap();
210 assert_eq!(t, "CustomType");
211 assert_eq!(p, "local.json");
212
213 let (t2, p2) =
214 parse_step_schema("GitType=git-local:///foo?ref=main&file=schema.json").unwrap();
215 assert_eq!(t2, "GitType");
216 assert_eq!(p2, "git-local:///foo?ref=main&file=schema.json");
217 }
218
219 #[test]
220 fn test_parse_step_schema_invalid() {
221 assert!(parse_step_schema("InvalidFormatWithoutEquals").is_err());
222 }
223
224 #[tokio::test]
225 async fn test_lint_handle_valid_workflow() -> Result<()> {
226 let _guard = LINT_MUTEX.lock().await;
227 let mut file = NamedTempFile::new()?;
228 writeln!(
229 file,
230 r#"
231stormchaser_dsl_version = "0.1"
232workflow "test_workflow" {{
233 description = "A valid workflow"
234 steps {{
235 step "test_step" "RunContainer" {{
236 image = "alpine"
237 }}
238 }}
239}}
240"#
241 )?;
242
243 let cmd = LintCommand {
244 file: Some(file.path().to_path_buf()),
245 step_schema: vec![],
246 remote: false,
247 prepare: false,
248 };
249
250 let http_client = ClientBuilder::new(reqwest::Client::new()).build();
251 handle("http://localhost", &http_client, cmd).await?;
252 Ok(())
253 }
254
255 #[tokio::test]
256 async fn test_lint_handle_invalid_spec() -> Result<()> {
257 let _guard = LINT_MUTEX.lock().await;
258 let _ = std::fs::remove_file(".stormchaser-schema.json");
259 let mut file = NamedTempFile::new()?;
260 writeln!(
261 file,
262 r#"
263stormchaser_dsl_version = "0.1"
264workflow "test_workflow" {{
265 description = "A workflow with an invalid spec type"
266 steps {{
267 step "test_step" "RunContainer" {{
268 image = 123
269 }}
270 }}
271}}
272"#
273 )?;
274
275 let cmd = LintCommand {
276 file: Some(file.path().to_path_buf()),
277 step_schema: vec![],
278 remote: false,
279 prepare: false,
280 };
281
282 let http_client = ClientBuilder::new(reqwest::Client::new()).build();
283 let result = handle("http://localhost", &http_client, cmd).await;
284 assert!(
285 result.is_err(),
286 "Expected invalid schema to fail validation"
287 );
288 assert!(
289 result
290 .unwrap_err()
291 .to_string()
292 .contains("schema validation"),
293 "Error should indicate schema validation failure"
294 );
295 Ok(())
296 }
297
298 #[test]
299 fn test_load_schema_local_file() -> Result<()> {
300 let mut file = NamedTempFile::new()?;
301 writeln!(file, r#"{{"type":"object"}}"#)?;
302 let val = load_schema(file.path().to_str().unwrap())?;
303 assert_eq!(val["type"], "object");
304 Ok(())
305 }
306
307 use wiremock::matchers::{method, path};
308 use wiremock::{Mock, MockServer, ResponseTemplate};
309
310 #[tokio::test]
311 async fn test_lint_handle_prepare() -> Result<()> {
312 let _guard = LINT_MUTEX.lock().await;
313 let _ = std::fs::remove_file(".stormchaser-schema.json");
314 let server = MockServer::start().await;
315 Mock::given(method("GET"))
316 .and(path("/api/v1/schema"))
317 .respond_with(
318 ResponseTemplate::new(200).set_body_json(serde_json::json!({"type": "object"})),
319 )
320 .mount(&server)
321 .await;
322
323 let cmd = LintCommand {
324 file: None,
325 step_schema: vec![],
326 remote: false,
327 prepare: true,
328 };
329
330 let http_client = ClientBuilder::new(reqwest::Client::new()).build();
331 handle(&server.uri(), &http_client, cmd).await?;
332
333 let saved = std::fs::read_to_string(".stormchaser-schema.json")?;
334 assert!(saved.contains("\"type\": \"object\""));
335 std::fs::remove_file(".stormchaser-schema.json")?;
336
337 Ok(())
338 }
339}