Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement a way to load configuration recursively from parent #951

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
**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
Loading