Skip to main content

stormchaser_cli/commands/
lint.rs

1use 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/// CLI command to lint a workflow file against the JSON schema.
10#[derive(clap::Parser)]
11pub struct LintCommand {
12    /// Path to the .storm workflow file to lint
13    pub file: Option<PathBuf>,
14
15    /// Additional step schemas to include in validation.
16    /// Format: TYPE=PATH
17    /// Where PATH can be a local file (e.g. MyStep=schema.json)
18    /// Or a local git repository path using git-local scheme
19    /// (e.g. MyStep=git-local:///path/to/repo?ref=main&file=schema.json)
20    #[arg(long, value_parser = parse_step_schema)]
21    pub step_schema: Vec<(String, String)>,
22
23    /// Fetch the base schema from the remote server instead of using the local schema
24    #[arg(long)]
25    pub remote: bool,
26
27    /// Download and store the server schemas locally for offline validation
28    #[arg(long)]
29    pub prepare: bool,
30}
31
32/// Parses the `TYPE=PATH` argument into a tuple for `step_schema`.
33fn 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
41/// Handles the `lint` command logic.
42pub 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    // First, parse the HCL to ensure it's syntactically valid and maps to Workflow
72    let parser = StormchaserParser::new();
73    let workflow = parser
74        .parse(&content)
75        .with_context(|| format!("Failed to parse HCL in {:?}", file_path))?;
76
77    // Serialize to JSON Value for schema validation
78    let instance = serde_json::to_value(&workflow)?;
79
80    // Generate base schema
81    let mut root_schema = resolve_base_schema(&command, url, http_client).await?;
82
83    let mut spec_schemas = HashMap::new();
84
85    // Load additional schemas from arguments
86    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 extensibility logic
94    apply_step_extensibility(&mut root_schema, &spec_schemas);
95
96    // Compile and validate
97    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
146/// Loads a JSON schema from a local file or directly from a local git repository without checkout.
147fn 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}