diff --git a/docs/src/content/docs/reference/Config File/Steps/command.md b/docs/src/content/docs/reference/Config File/Steps/command.md index c297a026..8c7203af 100644 --- a/docs/src/content/docs/reference/Config File/Steps/command.md +++ b/docs/src/content/docs/reference/Config File/Steps/command.md @@ -27,3 +27,9 @@ and the value is one of the [available variables](/reference/config-file/variabl **Take care when selecting a key to replace** as Knope will replace _any_ matching string that it finds. Replacements occur in the order they're declared in the config, so Knope may replace earlier substitutions with later ones. + +## Working directory + +By default, the command will be run from the current working directory. +If you want to run the command from the directory of the first config file in the ancestry of the current working directory, +you can set the `use_working_directory` attribute to `false`. diff --git a/src/config/mod.rs b/src/config/mod.rs index ae000bad..288e7ffd 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,4 @@ -use std::fs; +use std::{fs, path::PathBuf}; use ::toml::{from_str, to_string, Spanned}; use indexmap::IndexMap; @@ -40,12 +40,37 @@ pub(crate) struct Config { impl Config { const CONFIG_PATH: &'static str = "knope.toml"; + /// Get the path to the config file + pub(crate) fn config_path() -> Option { + let mut config_path = std::env::current_dir().ok()?; + + // Recursively search for the config file in all parent directories + loop { + let path = config_path.join(Self::CONFIG_PATH); + log::debug!("Attempting to load config from {path:?}"); + if path.exists() { + return Some(path); + } + config_path.pop(); + let parent = config_path.parent(); + if parent.is_none() { + log::debug!("No `knope.toml` found"); + return None; + } + } + } + /// Create a Config from a TOML file or load the default config via `generate` /// /// ## Errors /// 1. Cannot parse file contents into a Config pub(crate) fn load() -> Result { - let Ok(source_code) = fs::read_to_string(Self::CONFIG_PATH) else { + let Some(config_path) = Self::config_path() else { + log::debug!("No `knope.toml` found, using default config"); + return Ok(ConfigSource::Default(generate()?)); + }; + + let Ok(source_code) = fs::read_to_string(config_path) else { log::debug!("No `knope.toml` found, using default config"); return Ok(ConfigSource::Default(generate()?)); }; @@ -263,52 +288,6 @@ pub(crate) enum Error { Package(#[from] package::Error), } -#[cfg(test)] -mod test_errors { - - use super::Config; - - #[test] - fn conflicting_format() { - let toml_string = r#" - package = {} - [packages.something] - [[workflows]] - name = "default" - [[workflows.steps]] - type = "Command" - command = "echo this is nothing, really" - "# - .to_string(); - let config: super::toml::ConfigLoader = toml::from_str(&toml_string).unwrap(); - let config = Config::try_from((config, toml_string)); - assert!(config.is_err(), "Expected an error, got {config:?}"); - } - - #[test] - fn gitea_asset_error() { - let toml_string = r#" - [packages.something] - [[packages.something.assets]] - name = "something" - path = "something" - [[workflows]] - name = "default" - [[workflows.steps]] - type = "Command" - command = "echo this is nothing, really" - [gitea] - host = "https://gitea.example.com" - owner = "knope" - repo = "knope" - "# - .to_string(); - let config: super::toml::ConfigLoader = toml::from_str(&toml_string).unwrap(); - let config = Config::try_from((config, toml_string)); - assert!(config.is_err(), "Expected an error, got {config:?}"); - } -} - /// Generate a brand new Config for the project in the current directory. pub(crate) fn generate() -> Result { let packages = find_packages()?; @@ -369,10 +348,12 @@ fn generate_workflows(has_forge: bool, packages: &[Package]) -> Vec { Step::Command { command: format!("git commit -m \"{commit_message}\"",), variables, + use_working_directory: None, }, Step::Command { command: String::from("git push"), variables: None, + use_working_directory: None, }, Step::Release, ] @@ -381,15 +362,18 @@ fn generate_workflows(has_forge: bool, packages: &[Package]) -> Vec { Step::Command { command: format!("git commit -m \"{commit_message}\""), variables, + use_working_directory: None, }, Step::Release, Step::Command { command: String::from("git push"), variables: None, + use_working_directory: None, }, Step::Command { command: String::from("git push --tags"), variables: None, + use_working_directory: None, }, ] }; @@ -405,3 +389,49 @@ fn generate_workflows(has_forge: bool, packages: &[Package]) -> Vec { }, ] } + +#[cfg(test)] +mod test_errors { + + use super::Config; + + #[test] + fn conflicting_format() { + let toml_string = r#" + package = {} + [packages.something] + [[workflows]] + name = "default" + [[workflows.steps]] + type = "Command" + command = "echo this is nothing, really" + "# + .to_string(); + let config: super::toml::ConfigLoader = toml::from_str(&toml_string).unwrap(); + let config = Config::try_from((config, toml_string)); + assert!(config.is_err(), "Expected an error, got {config:?}"); + } + + #[test] + fn gitea_asset_error() { + let toml_string = r#" + [packages.something] + [[packages.something.assets]] + name = "something" + path = "something" + [[workflows]] + name = "default" + [[workflows.steps]] + type = "Command" + command = "echo this is nothing, really" + [gitea] + host = "https://gitea.example.com" + owner = "knope" + repo = "knope" + "# + .to_string(); + let config: super::toml::ConfigLoader = toml::from_str(&toml_string).unwrap(); + let config = Config::try_from((config, toml_string)); + assert!(config.is_err(), "Expected an error, got {config:?}"); + } +} diff --git a/src/step/command.rs b/src/step/command.rs index 43330304..70664dd5 100644 --- a/src/step/command.rs +++ b/src/step/command.rs @@ -1,18 +1,40 @@ +use std::path::PathBuf; + use indexmap::IndexMap; use miette::Diagnostic; use crate::{ - variables, - variables::{replace_variables, Template, Variable}, + config::Config, + variables::{self, replace_variables, Template, Variable}, RunType, }; +/// Gets the path to use for the command, defaulting to the current working directory if `use_working_directory` isn't set. +/// If `use_working_directory` is set to `true`, the current working directory is used. +/// If `use_working_directory` is set to `false`, the directory of the first config file in the ancestry of the current working directory is used. +/// If there is no config file in the ancestry of the current working directory, the current working directory is used. Although this situation should be impossible, +/// as the user will need to configure the command explicitly to set `use_working_directory` to `false`. +fn get_directory_for_command(use_working_directory: Option) -> Option { + let use_working_directory_thing = use_working_directory.unwrap_or(true); + if use_working_directory_thing { + return None; + } + let config_path = Config::config_path(); + let config_directory = match &config_path { + Some(path) => path.parent(), + None => None, + }; + + config_directory.as_ref().map(|path| path.to_path_buf()) +} + /// Run the command string `command` in the current shell after replacing the keys of `variables` /// with the values that the [`Variable`]s represent. pub(crate) fn run_command( mut run_type: RunType, mut command: String, variables: Option>, + use_working_directory: Option, ) -> Result { let (state, dry_run_stdout) = match &mut run_type { RunType::DryRun { state, stdout } => (state, Some(stdout)), @@ -31,7 +53,16 @@ pub(crate) fn run_command( writeln!(stdout, "Would run {command}")?; return Ok(run_type); } - let status = execute::command(command).status()?; + let mut cmd = execute::command(command); + + let directory = get_directory_for_command(use_working_directory); + + println!("Directory: {directory:?}"); + if let Some(directory) = directory { + cmd.current_dir(directory); + } + + let status = cmd.status()?; if status.success() { return Ok(run_type); } @@ -67,6 +98,7 @@ mod test_run_command { RunType::Real(State::new(None, None, None, Vec::new(), Verbose::No)), command.to_string(), None, + None, ); assert!(result.is_ok()); @@ -75,7 +107,37 @@ mod test_run_command { RunType::Real(State::new(None, None, None, Vec::new(), Verbose::No)), String::from("exit 1"), None, + None, ); assert!(result.is_err()); } } + +#[cfg(test)] +mod test_get_directory_for_command { + use super::get_directory_for_command; + + #[test] + fn test_get_directory_for_command_with_use_working_directory_true_uses_working_directory() { + let result = get_directory_for_command(Some(true)); + assert!(result.is_none()); + } + + #[test] + fn test_get_directory_for_command_with_use_working_directory_false_uses_config_directory() { + let result = get_directory_for_command(Some(false)); + assert!(result.is_some()); + } + + #[test] + fn test_get_directory_for_command_without_use_working_directory_uses_current_directory() { + let result = get_directory_for_command(None); + assert!(result.is_none()); + } + + #[test] + fn test_get_directory_for_command_with_no_config_file_in_ancestry_uses_current_directory() { + let result = get_directory_for_command(None); + assert!(result.is_none()); + } +} diff --git a/src/step/mod.rs b/src/step/mod.rs index c1f96ffa..9df7ea2e 100644 --- a/src/step/mod.rs +++ b/src/step/mod.rs @@ -71,6 +71,9 @@ pub(crate) enum Step { /// A map of value-to-replace to [Variable][`crate::command::Variable`] to replace /// it with. variables: Option>, + /// Whether to run the command in the current working directory or the directory of the config file. + /// If not set, the command will be run in the current working directory. + use_working_directory: Option, }, /// This will look through all commits since the last tag and parse any /// [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) it finds. It will @@ -110,9 +113,11 @@ impl Step { Step::SwitchBranches => git::switch_branches(run_type)?, Step::RebaseBranch { to } => git::rebase_branch(&to, run_type)?, Step::BumpVersion(rule) => releases::bump_version(run_type, &rule)?, - Step::Command { command, variables } => { - command::run_command(run_type, command, variables)? - } + Step::Command { + command, + variables, + use_working_directory, + } => command::run_command(run_type, command, variables, use_working_directory)?, Step::PrepareRelease(prepare_release) => { releases::prepare_release(run_type, &prepare_release)? }