diff --git a/qlty-check/src/planner/invocation_directory.rs b/qlty-check/src/planner/invocation_directory.rs index 2202ce20a..0423373f2 100644 --- a/qlty-check/src/planner/invocation_directory.rs +++ b/qlty-check/src/planner/invocation_directory.rs @@ -114,7 +114,7 @@ impl InvocationDirectoryPlanner { #[cfg(test)] mod test { use super::*; - use crate::{planner::target::Target, tool::null_tool::NullTool}; + use crate::{planner::target::Target, tool::shell_tool::ShellTool}; use qlty_analysis::{utils::fs::path_to_string, WorkspaceEntryKind}; use qlty_config::config::InvocationDirectoryDef; use qlty_test_utilities::git::sample_repo; @@ -144,13 +144,13 @@ mod test { config_files: vec!["config_file.json".into()], ..Default::default() }, - tool: Box::new(NullTool { + tool: Box::new(ShellTool { parent_directory: temp_dir .to_path_buf() .join(".qlty") .join("cache") .join("tools") - .join("null_tool"), + .join("shell_tool"), plugin_name: "mock_plugin".to_string(), plugin: Default::default(), }), @@ -304,13 +304,13 @@ mod test { config_files: vec!["config_file.json".into()], ..Default::default() }, - tool: Box::new(NullTool { + tool: Box::new(ShellTool { parent_directory: temp_dir_path .to_path_buf() .join(".qlty") .join("cache") .join("tools") - .join("null_tool"), + .join("shell_tool"), plugin_name: "mock_plugin".to_string(), plugin: Default::default(), }), diff --git a/qlty-check/src/planner/target_batcher.rs b/qlty-check/src/planner/target_batcher.rs index 54aacd105..b47fe7b35 100644 --- a/qlty-check/src/planner/target_batcher.rs +++ b/qlty-check/src/planner/target_batcher.rs @@ -243,7 +243,7 @@ fn normalize_config_path( #[cfg(test)] mod test { use super::*; - use crate::tool::null_tool::NullTool; + use crate::tool::shell_tool::ShellTool; use qlty_analysis::WorkspaceEntryKind; use qlty_config::config::{InvocationDirectoryDef, InvocationDirectoryType, PluginDef}; use qlty_test_utilities::git::sample_repo; @@ -288,7 +288,7 @@ mod test { config_files: vec!["config1".into(), "config2".into()], ..Default::default() }, - tool: Box::new(NullTool { + tool: Box::new(ShellTool { plugin_name: "mock_plugin".to_string(), plugin: Default::default(), ..Default::default() @@ -520,7 +520,7 @@ mod test { config_files: vec!["config_file.json".into()], ..Default::default() }, - tool: Box::new(NullTool { + tool: Box::new(ShellTool { plugin_name: "mock_plugin".to_string(), plugin: Default::default(), ..Default::default() diff --git a/qlty-check/src/tool.rs b/qlty-check/src/tool.rs index 3f8dbdbea..b543c62ed 100644 --- a/qlty-check/src/tool.rs +++ b/qlty-check/src/tool.rs @@ -5,13 +5,13 @@ pub mod go; mod installations; pub mod java; pub mod node; -pub mod null_tool; pub mod php; pub mod python; pub mod ruby; mod ruby_source; mod runnable_archive; pub mod rust; +pub mod shell_tool; pub mod tool_builder; use crate::tool::download::Download; @@ -120,7 +120,7 @@ pub enum ToolType { Download, RuntimePackage, GitHubRelease, - NullTool, + ShellTool, } pub trait Tool: Debug + Sync + Send { diff --git a/qlty-check/src/tool/null_tool.rs b/qlty-check/src/tool/null_tool.rs deleted file mode 100644 index 23c5db5de..000000000 --- a/qlty-check/src/tool/null_tool.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::path::PathBuf; - -use super::{global_tools_root, ToolType}; -use crate::{ui::ProgressTask, Tool}; -use anyhow::Result; -use qlty_analysis::utils::fs::path_to_string; -use qlty_config::config::PluginDef; - -#[derive(Debug, Clone)] -pub struct NullTool { - pub parent_directory: PathBuf, - pub plugin_name: String, - pub plugin: PluginDef, -} - -impl Default for NullTool { - fn default() -> Self { - Self { - parent_directory: PathBuf::from(global_tools_root()), - plugin_name: "NullTool".to_string(), - plugin: Default::default(), - } - } -} - -impl Tool for NullTool { - fn parent_directory(&self) -> String { - path_to_string(self.parent_directory.join(self.name())) - } - - fn plugin(&self) -> Option { - Some(self.plugin.clone()) - } - - fn name(&self) -> String { - self.plugin_name.clone() - } - - fn tool_type(&self) -> ToolType { - ToolType::NullTool - } - - fn version(&self) -> Option { - self.plugin.version.clone() - } - - fn version_command(&self) -> Option { - self.plugin.version_command.clone() - } - - fn version_regex(&self) -> String { - self.plugin.version_regex.clone() - } - - fn package_install(&self, _: &ProgressTask, _: &str, _: &str) -> Result<()> { - Ok(()) - } - - fn clone_box(&self) -> Box { - Box::new(self.clone()) - } - - fn install_and_validate(&self, _: &ProgressTask) -> Result<()> { - Ok(()) - } - - fn is_installed(&self) -> bool { - true - } -} diff --git a/qlty-check/src/tool/shell_tool.rs b/qlty-check/src/tool/shell_tool.rs new file mode 100644 index 000000000..847361da2 --- /dev/null +++ b/qlty-check/src/tool/shell_tool.rs @@ -0,0 +1,178 @@ +use std::path::PathBuf; + +use super::{global_tools_root, ToolType}; +use crate::{ui::ProgressTask, Tool}; +use anyhow::Result; +use qlty_analysis::utils::fs::path_to_string; +use qlty_config::config::PluginDef; + +#[derive(Debug, Clone)] +pub struct ShellTool { + pub parent_directory: PathBuf, + pub plugin_name: String, + pub plugin: PluginDef, +} + +impl Default for ShellTool { + fn default() -> Self { + Self { + parent_directory: PathBuf::from(global_tools_root()), + plugin_name: "ShellTool".to_string(), + plugin: Default::default(), + } + } +} + +impl Tool for ShellTool { + fn parent_directory(&self) -> String { + path_to_string(self.parent_directory.join(self.name())) + } + + fn plugin(&self) -> Option { + Some(self.plugin.clone()) + } + + fn name(&self) -> String { + self.plugin_name.clone() + } + + fn tool_type(&self) -> ToolType { + ToolType::ShellTool + } + + fn version(&self) -> Option { + self.plugin.version.clone() + } + + fn version_command(&self) -> Option { + self.plugin.version_command.clone() + } + + fn version_regex(&self) -> String { + self.plugin.version_regex.clone() + } + + fn package_install(&self, _: &ProgressTask, _: &str, _: &str) -> Result<()> { + Ok(()) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn install_and_validate(&self, task: &ProgressTask) -> Result<()> { + if self.plugin.install_script.is_some() { + self.internal_pre_install(task)?; + self.install(task)?; + } + Ok(()) + } + + fn install(&self, _task: &ProgressTask) -> Result<()> { + if let Some(ref script_path) = self.plugin.install_script { + let script_path = + std::fs::canonicalize(script_path).unwrap_or_else(|_| PathBuf::from(script_path)); + self.run_command(duct::cmd!("sh", script_path))?; + } + Ok(()) + } + + fn is_installed(&self) -> bool { + if self.plugin.install_script.is_some() { + self.donefile_path().exists() && self.exists() + } else { + true + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::Progress; + use std::path::PathBuf; + use tempfile::tempdir; + + #[test] + fn test_setup_runs_install_script() { + let temp_dir = tempdir().unwrap(); + let script_path = temp_dir.path().join("install.sh"); + std::fs::write(&script_path, "touch marker.txt").unwrap(); + + let tool = ShellTool { + parent_directory: temp_dir.path().join("tools"), + plugin_name: "test_plugin".to_string(), + plugin: PluginDef { + version: Some("1.0.0".to_string()), + install_script: Some(script_path.to_string_lossy().to_string()), + ..Default::default() + }, + }; + + let task = Progress::new(false, 1).task("TEST", "installing"); + tool.setup(&task).unwrap(); + + assert!(tool.is_installed()); + assert!(PathBuf::from(tool.directory()).join("marker.txt").exists()); + } + + #[test] + fn test_setup_without_install_script_is_noop() { + let tool = ShellTool { + plugin_name: "noop_plugin".to_string(), + plugin: PluginDef { + version: Some("1.0.0".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + assert!(tool.is_installed()); + } + + #[test] + fn test_setup_failing_install_script() { + let temp_dir = tempdir().unwrap(); + let script_path = temp_dir.path().join("bad_install.sh"); + std::fs::write(&script_path, "exit 1").unwrap(); + + let tool = ShellTool { + parent_directory: temp_dir.path().join("tools"), + plugin_name: "failing_plugin".to_string(), + plugin: PluginDef { + version: Some("1.0.0".to_string()), + install_script: Some(script_path.to_string_lossy().to_string()), + ..Default::default() + }, + }; + + let task = Progress::new(false, 1).task("TEST", "installing"); + let result = tool.setup(&task); + + assert!(result.is_err()); + assert!(!tool.is_installed()); + } + + #[test] + fn test_setup_is_idempotent() { + let temp_dir = tempdir().unwrap(); + let script_path = temp_dir.path().join("install.sh"); + std::fs::write(&script_path, "touch marker.txt").unwrap(); + + let tool = ShellTool { + parent_directory: temp_dir.path().join("tools"), + plugin_name: "test_plugin".to_string(), + plugin: PluginDef { + version: Some("1.0.0".to_string()), + install_script: Some(script_path.to_string_lossy().to_string()), + ..Default::default() + }, + }; + + let task = Progress::new(false, 1).task("TEST", "installing"); + tool.setup(&task).unwrap(); + tool.setup(&task).unwrap(); + + assert!(tool.is_installed()); + } +} diff --git a/qlty-check/src/tool/tool_builder.rs b/qlty-check/src/tool/tool_builder.rs index 0626134e2..5be842449 100644 --- a/qlty-check/src/tool/tool_builder.rs +++ b/qlty-check/src/tool/tool_builder.rs @@ -9,9 +9,9 @@ use crate::Tool; use super::{ download::{Download, DownloadTool}, github::{GitHubRelease, GitHubReleaseTool}, - go, java, node, - null_tool::NullTool, - php, python, ruby, rust, RuntimeTool, + go, java, node, php, python, ruby, rust, + shell_tool::ShellTool, + RuntimeTool, }; #[derive(Debug, Clone, Copy)] @@ -135,7 +135,7 @@ impl ToolBuilder<'_> { } else if let Some(download_name) = self.plugin.downloads.first() { self.build_download_tool(download_name, &plugin_version) } else { - Ok(Box::new(NullTool { + Ok(Box::new(ShellTool { plugin_name: self.plugin_name.to_string(), plugin: self.plugin.clone(), ..Default::default() diff --git a/qlty-cli/tests/cmd/check/install_script.in/.gitignore b/qlty-cli/tests/cmd/check/install_script.in/.gitignore new file mode 100644 index 000000000..abbd1c70e --- /dev/null +++ b/qlty-cli/tests/cmd/check/install_script.in/.gitignore @@ -0,0 +1,4 @@ +.qlty/results +.qlty/logs +.qlty/out +.qlty/sources diff --git a/qlty-cli/tests/cmd/check/install_script.in/.qlty/qlty.toml b/qlty-cli/tests/cmd/check/install_script.in/.qlty/qlty.toml new file mode 100644 index 000000000..17e4c3c6a --- /dev/null +++ b/qlty-cli/tests/cmd/check/install_script.in/.qlty/qlty.toml @@ -0,0 +1,14 @@ +config_version = "0" + +[plugins.definitions.greeter] +file_types = ["shell"] +install_script = "install.sh" + +[plugins.definitions.greeter.drivers.lint] +script = "sh ${linter}/greet.sh ${target}" +success_codes = [0] +output = "pass_fail" + +[[plugin]] +name = "greeter" +version = "1.0.0" diff --git a/qlty-cli/tests/cmd/check/install_script.in/install.sh b/qlty-cli/tests/cmd/check/install_script.in/install.sh new file mode 100644 index 000000000..edbef0301 --- /dev/null +++ b/qlty-cli/tests/cmd/check/install_script.in/install.sh @@ -0,0 +1,4 @@ +#!/bin/sh +echo '#!/bin/sh' > greet.sh +echo 'grep -q "hello" "$1" && exit 0 || exit 1' >> greet.sh +chmod +x greet.sh diff --git a/qlty-cli/tests/cmd/check/install_script.in/sample.sh b/qlty-cli/tests/cmd/check/install_script.in/sample.sh new file mode 100644 index 000000000..4a6e351d3 --- /dev/null +++ b/qlty-cli/tests/cmd/check/install_script.in/sample.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo "hello world" diff --git a/qlty-cli/tests/cmd/check/install_script.stderr b/qlty-cli/tests/cmd/check/install_script.stderr new file mode 100644 index 000000000..46df8a6c8 --- /dev/null +++ b/qlty-cli/tests/cmd/check/install_script.stderr @@ -0,0 +1,3 @@ + [0/1] [..]Planning... [..]s + [1/1] [..]Analyzing all targets... +✔ No issues diff --git a/qlty-cli/tests/cmd/check/install_script.stdout b/qlty-cli/tests/cmd/check/install_script.stdout new file mode 100644 index 000000000..45fb73476 --- /dev/null +++ b/qlty-cli/tests/cmd/check/install_script.stdout @@ -0,0 +1,7 @@ + JOBS: 2 + +Plugin Result Targets Time Debug File +greeter Success 1 target [..]s .qlty/out/invoke-[..].yaml +greeter Success 1 target [..]s .qlty/out/invoke-[..].yaml + +Checked 2 files diff --git a/qlty-cli/tests/cmd/check/install_script.toml b/qlty-cli/tests/cmd/check/install_script.toml new file mode 100644 index 000000000..1669837ac --- /dev/null +++ b/qlty-cli/tests/cmd/check/install_script.toml @@ -0,0 +1,2 @@ +bin.name = "qlty" +args = ["check", "--all", "--verbose", "--no-cache"] diff --git a/qlty-config/src/config/plugin.rs b/qlty-config/src/config/plugin.rs index cf04f97bd..1a68690b4 100644 --- a/qlty-config/src/config/plugin.rs +++ b/qlty-config/src/config/plugin.rs @@ -411,6 +411,9 @@ pub struct PluginDef { #[serde(default = "default_tab_column_width")] pub tab_column_width: usize, + + #[serde(default)] + pub install_script: Option, } fn default_idempotent() -> bool { diff --git a/qlty-config/src/sources/source.rs b/qlty-config/src/sources/source.rs index ef65a581c..44ab23d7d 100644 --- a/qlty-config/src/sources/source.rs +++ b/qlty-config/src/sources/source.rs @@ -154,6 +154,7 @@ pub trait Source: SourceFetch { .parse::() .with_context(|| format!("Could not parse {}", source_file.path.display()))?; self.add_context_to_exported_config_paths(&mut contents_toml, source_file); + self.add_context_to_install_script(&mut contents_toml, source_file); Builder::validate_toml(&source_file.path, contents_toml.clone()) .with_context(|| SOURCE_PARSE_ERROR)?; @@ -195,6 +196,33 @@ pub trait Source: SourceFetch { Some(()) } + fn add_context_to_install_script( + &self, + toml: &mut toml::Value, + source_file: &SourceFile, + ) -> Option<()> { + for (_, plugin) in toml + .as_table_mut()? + .get_mut("plugins")? + .as_table_mut()? + .get_mut("definitions")? + .as_table_mut()? + .iter_mut() + { + if let Some(value) = plugin.get_mut("install_script") { + if let Some(value_str) = value.as_str() { + if let Some(parent) = source_file.path.parent() { + let resolved = parent.join(value_str); + let absolute = std::fs::canonicalize(&resolved).unwrap_or(resolved); + *value = Value::String(absolute.to_string_lossy().to_string()); + } + } + } + } + + Some(()) + } + fn build_config(&self) -> Result { let toml_string = toml::to_string(&self.toml()?).unwrap(); let file = File::from_str(&toml_string, config::FileFormat::Toml);