Skip to content

Commit

Permalink
feat: Implement a way to load configuration recursively from parent
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-way committed Mar 19, 2024
1 parent 621c2c0 commit fa31f49
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 54 deletions.
6 changes: 6 additions & 0 deletions docs/src/content/docs/reference/Config File/Steps/command.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Check warning on line 33 in docs/src/content/docs/reference/Config File/Steps/command.md

View workflow job for this annotation

GitHub Actions / Vale

[vale] reported by reviewdog 🐶 [Microsoft.Passive] 'be run' looks like passive voice. Raw Output: {"message": "[Microsoft.Passive] 'be run' looks like passive voice.", "location": {"path": "docs/src/content/docs/reference/Config File/Steps/command.md", "range": {"start": {"line": 33, "column": 30}}}, "severity": "INFO"}
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`.
126 changes: 78 additions & 48 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::fs;
use std::{fs, path::PathBuf};

use ::toml::{from_str, to_string, Spanned};
use indexmap::IndexMap;
Expand Down Expand Up @@ -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<PathBuf> {
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<ConfigSource, Error> {
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()?));
};
Expand Down Expand Up @@ -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<Config, package::Error> {
let packages = find_packages()?;
Expand Down Expand Up @@ -369,10 +348,12 @@ fn generate_workflows(has_forge: bool, packages: &[Package]) -> Vec<Workflow> {
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,
]
Expand All @@ -381,15 +362,18 @@ fn generate_workflows(has_forge: bool, packages: &[Package]) -> Vec<Workflow> {
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,
},
]
};
Expand All @@ -405,3 +389,49 @@ fn generate_workflows(has_forge: bool, packages: &[Package]) -> Vec<Workflow> {
},
]
}

#[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:?}");
}
}
68 changes: 65 additions & 3 deletions src/step/command.rs
Original file line number Diff line number Diff line change
@@ -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<bool>) -> Option<PathBuf> {
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<IndexMap<String, Variable>>,
use_working_directory: Option<bool>,
) -> Result<RunType, Error> {
let (state, dry_run_stdout) = match &mut run_type {
RunType::DryRun { state, stdout } => (state, Some(stdout)),
Expand All @@ -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);
}
Expand Down Expand Up @@ -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());
Expand All @@ -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());
}
}
11 changes: 8 additions & 3 deletions src/step/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IndexMap<String, Variable>>,
/// 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<bool>,
},
/// 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
Expand Down Expand Up @@ -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)?
}
Expand Down

0 comments on commit fa31f49

Please sign in to comment.