use std::path::Path;
use ::toml::{Spanned, from_str, to_string};
use indexmap::IndexMap;
use itertools::Itertools;
use knope_config::ReleaseNotes;
use knope_versioning::package::Name;
use miette::{Diagnostic, IntoDiagnostic, Result, SourceSpan};
use serde::Serialize;
use thiserror::Error;
pub(crate) use self::package::Package;
use self::toml::ConfigLoader;
use crate::{
fs,
integrations::git,
step::{PrepareRelease, Step},
workflow::Workflow,
};
mod package;
mod toml;
pub(crate) use toml::{GitHub, Gitea, Jira};
use crate::fs::WriteType;
#[derive(Debug)]
pub(crate) struct Config {
pub(crate) release_notes: ReleaseNotes,
pub(crate) packages: Vec<Package>,
pub(crate) workflows: Vec<Workflow>,
pub(crate) jira: Option<Jira>,
pub(crate) github: Option<GitHub>,
pub(crate) gitea: Option<Gitea>,
}
impl Config {
const CONFIG_PATH: &'static str = "knope.toml";
pub(crate) fn load() -> Result<ConfigSource, Error> {
let Ok(source_code) = fs::read_to_string(Self::CONFIG_PATH) else {
tracing::debug!("No `knope.toml` found, using default config");
return Ok(ConfigSource::Default(generate()?));
};
let config_loader: ConfigLoader = from_str(&source_code)?;
let config_source = Self::try_from((config_loader, source_code)).map(ConfigSource::File)?;
config_source.fill_in_gaps()
}
pub(crate) fn set_prerelease_label(&mut self, label: &str) {
for workflow in &mut self.workflows {
workflow.set_prerelease_label(label);
}
}
pub(crate) fn write_out(mut self) -> Result<()> {
#[derive(Serialize)]
struct SimpleConfig {
#[serde(skip_serializing_if = "Option::is_none")]
package: Option<knope_config::Package>,
#[serde(skip_serializing_if = "IndexMap::is_empty")]
packages: IndexMap<String, knope_config::Package>,
workflows: Vec<Workflow>,
github: Option<GitHub>,
gitea: Option<Gitea>,
}
let (package, packages) = if self.packages.len() < 2 {
(
self.packages.pop().map(knope_config::Package::from),
IndexMap::new(),
)
} else {
(
None,
self.packages
.into_iter()
.map(|package| (package.name.to_string(), package.into()))
.collect(),
)
};
let config = SimpleConfig {
package,
packages,
workflows: self.workflows,
github: self.github,
gitea: self.gitea,
};
#[allow(clippy::unwrap_used)] let serialized = to_string(&config).unwrap();
fs::write(
WriteType::Real::<String, String>(serialized),
Path::new(Config::CONFIG_PATH),
)
.into_diagnostic()
}
}
impl TryFrom<(ConfigLoader, String)> for Config {
type Error = Error;
fn try_from(
(config, source_code): (ConfigLoader, String),
) -> std::result::Result<Self, Self::Error> {
let packages = match (config.package, config.packages) {
(Some(package), Some(packages)) => {
return if let Some(first_packages) = packages.first() {
Err(Error::ConflictingPackages {
source_code,
package_definition: package.span().into(),
packages_definition: first_packages.1.span().into(),
})
} else {
Err(Error::EmptyPackages)
};
}
(Some(package), None) => vec![Package::from_toml(
Name::Default,
package.into_inner(),
&source_code,
)?],
(None, Some(packages)) => packages
.into_iter()
.map(|(name, spanned)| {
Package::from_toml(Name::Custom(name), spanned.into_inner(), &source_code)
})
.try_collect()?,
(None, None) => Vec::new(),
};
if config.gitea.is_some() && packages.iter().any(|package| package.assets.is_some()) {
return Err(Error::GiteaAssetUploads);
}
let workflows = config
.workflows
.map(|workflows| {
workflows
.into_inner()
.into_iter()
.map(Spanned::into_inner)
.collect()
})
.unwrap_or_default();
Ok(Self {
packages,
workflows,
release_notes: config.shared.release_notes.unwrap_or_default(),
jira: config.jira.map(Spanned::into_inner),
github: config.github.map(Spanned::into_inner),
gitea: config.gitea.map(Spanned::into_inner),
})
}
}
pub(crate) enum ConfigSource {
Default(Config),
File(Config),
Hybrid(Config),
}
impl ConfigSource {
pub(crate) fn into_inner(self) -> Config {
match self {
Self::File(config) | Self::Default(config) | Self::Hybrid(config) => config,
}
}
fn fill_in_gaps(self) -> Result<Self, Error> {
let mut config = match self {
Self::Hybrid(_) | Self::Default(_) => return Ok(self),
Self::File(config) => config,
};
if config.packages.is_empty() {
config.packages = Package::find_in_working_dir()?;
}
if config.workflows.is_empty() {
config.workflows = generate_workflows(
config.github.is_some() || config.gitea.is_some(),
&config.packages,
);
}
Ok(Self::Hybrid(config))
}
}
#[derive(Debug, Diagnostic, Error)]
pub(crate) enum Error {
#[error(transparent)]
#[diagnostic(
code(config::toml),
help("Check the TOML is valid."),
url("https://knope.tech/reference/config-file/packages/")
)]
Toml(#[from] ::toml::de::Error),
#[error("You cannot define both `packages` and `package`")]
#[diagnostic(
code(config::conflicting_packages),
help("Add the `package` as a key under `packages` instead."),
url("https://knope.tech/reference/config-file/packages/")
)]
ConflictingPackages {
#[source_code]
source_code: String,
#[label("`package` defined here")]
package_definition: SourceSpan,
#[label("`packages` defined here")]
packages_definition: SourceSpan,
},
#[error("The `packages` key is defined but does not contain any packages")]
#[diagnostic(
code(config::empty_packages),
help("Add at least one package to the `packages` key."),
url("https://knope.tech/reference/config-file/packages/")
)]
EmptyPackages,
#[error("Asset uploads for Gitea are not supported")]
#[diagnostic(
code(config::gitea_asset_uploads),
help("Remove the `[[package.assets]]` key from your config."),
url("https://github.com/knope-dev/knope/issues/779")
)]
GiteaAssetUploads,
#[error(transparent)]
#[diagnostic(transparent)]
Package(#[from] package::Error),
#[error(transparent)]
#[diagnostic(transparent)]
VersionedFile(#[from] package::VersionedFileError),
}
pub(crate) fn generate() -> Result<Config, package::Error> {
let packages = Package::find_in_working_dir()?;
let first_remote = git::get_first_remote();
let github = match first_remote {
Some(ref remote) if remote.contains("github.com") => {
let parts = remote.split('/').collect::<Vec<_>>();
let owner = parts.get(parts.len() - 2).map(|owner| {
owner
.strip_prefix("git@github.com:")
.unwrap_or(owner)
.to_string()
});
let repo = parts
.last()
.map(|repo| repo.strip_suffix(".git").unwrap_or(repo).to_string());
owner
.and_then(|owner| repo.map(|repo| (owner, repo)))
.map(|(owner, repo)| GitHub { owner, repo })
}
_ => None,
};
let gitea = first_remote.as_ref().and_then(|remote| {
if Gitea::KNOWN_PUBLIC_GITEA_HOSTS
.iter()
.any(|known_host| remote.contains(known_host))
{
Gitea::try_from_remote(remote)
} else {
None
}
});
Ok(Config {
workflows: generate_workflows(github.is_some() || gitea.is_some(), &packages),
jira: None,
release_notes: ReleaseNotes::default(),
github,
gitea,
packages,
})
}
fn generate_workflows(has_forge: bool, packages: &[Package]) -> Vec<Workflow> {
let commit_message = if packages.len() < 2 {
"chore: prepare release $version"
} else {
"chore: prepare releases"
};
let mut release_steps = if has_forge {
vec![
Step::Command {
command: format!("git commit -m \"{commit_message}\"",),
variables: None,
shell: None,
},
Step::Command {
command: String::from("git push"),
variables: None,
shell: None,
},
Step::Release,
]
} else {
vec![
Step::Command {
command: format!("git commit -m \"{commit_message}\""),
variables: None,
shell: None,
},
Step::Release,
Step::Command {
command: String::from("git push"),
variables: None,
shell: None,
},
Step::Command {
command: String::from("git push --tags"),
variables: None,
shell: None,
},
]
};
release_steps.insert(0, Step::PrepareRelease(PrepareRelease::default()));
let mut workflows = vec![
Workflow {
name: String::from("release"),
help_text: None,
steps: release_steps,
},
Workflow {
name: String::from("document-change"),
help_text: None,
steps: vec![Step::CreateChangeFile],
},
];
if packages.len() == 1 {
let get_version_steps = vec![Step::Command {
command: String::from("echo \"$version\""),
variables: None,
shell: None,
}];
workflows.push(Workflow {
name: String::from("get-version"),
help_text: Some(String::from("Get the current version of the project")),
steps: get_version_steps,
});
}
workflows
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
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:?}");
}
}