use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::ConfigError;
use super::commands::CommandConfig;
use super::is_default;
use super::{CopyIgnoredConfig, HooksConfig, StepConfig};
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, JsonSchema)]
pub struct ProjectListConfig {
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, JsonSchema)]
pub struct ProjectCiConfig {
#[serde(default)]
pub platform: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, JsonSchema)]
pub struct ProjectCommitConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation: Option<ProjectCommitGenerationConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, JsonSchema)]
pub struct ProjectCommitGenerationConfig {
#[serde(default, rename = "template-append")]
pub template_append: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, JsonSchema)]
pub struct ProjectForgeConfig {
#[serde(default)]
pub platform: Option<String>,
#[serde(default)]
pub hostname: Option<String>,
}
impl ProjectListConfig {
pub fn is_configured(&self) -> bool {
self.url.is_some()
}
}
impl ProjectConfig {
pub fn ci_platform(&self) -> Option<&str> {
self.ci.platform.as_deref()
}
pub fn forge_platform(&self) -> Option<&str> {
self.forge
.platform
.as_deref()
.or_else(|| self.ci_platform())
}
pub fn forge_hostname(&self) -> Option<&str> {
self.forge.hostname.as_deref()
}
pub fn copy_ignored(&self) -> Option<&CopyIgnoredConfig> {
self.step.copy_ignored.as_ref()
}
pub fn commit_template_append(&self) -> Option<&str> {
self.commit
.generation
.as_ref()
.and_then(|g| g.template_append.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, JsonSchema)]
pub struct ProjectConfig {
#[serde(flatten, default)]
pub hooks: HooksConfig,
#[serde(default, skip_serializing_if = "is_default")]
pub list: ProjectListConfig,
#[serde(default, skip_serializing_if = "is_default")]
pub ci: ProjectCiConfig,
#[serde(default, skip_serializing_if = "is_default")]
pub forge: ProjectForgeConfig,
#[serde(default, skip_serializing_if = "is_default")]
pub commit: ProjectCommitConfig,
#[serde(default, skip_serializing_if = "is_default")]
pub step: StepConfig,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub aliases: BTreeMap<String, CommandConfig>,
}
impl ProjectConfig {
pub fn load(
repo: &crate::git::Repository,
write_hints: bool,
) -> Result<Option<Self>, ConfigError> {
let config_path = match repo
.project_config_path()
.map_err(|e| ConfigError(format!("Failed to get config path: {}", e)))?
{
Some(path) if path.exists() => path,
_ => return Ok(None),
};
let contents = std::fs::read_to_string(&config_path)
.map_err(|e| ConfigError(format!("Failed to read config file: {}", e)))?;
let is_main_worktree = !repo.current_worktree().is_linked().unwrap_or(true);
let repo_for_hints = if write_hints { Some(repo) } else { None };
let migrated = super::deprecation::check_and_migrate(
&config_path,
&contents,
is_main_worktree,
"Project config",
repo_for_hints,
true, )
.map_err(|e| ConfigError(e.to_string()))?
.migrated_content;
if is_main_worktree {
super::deprecation::warn_unknown_fields::<ProjectConfig>(
&contents,
&config_path,
"Project config",
);
}
let config: ProjectConfig = toml::from_str(&migrated).map_err(|e| {
ConfigError(format!(
"Project config at {} failed to parse:\n{e}",
crate::path::format_path_for_display(&config_path),
))
})?;
Ok(Some(config))
}
}
pub fn valid_project_config_keys() -> Vec<String> {
crate::config::schema_top_level_keys::<ProjectConfig>()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_all_hooks() {
let contents = r#"
pre-switch = "echo switching"
post-switch = "rename-tab"
pre-start = "npm install"
post-start = "npm run watch"
pre-commit = "cargo fmt --check"
post-commit = "echo committed"
pre-merge = "cargo test"
post-merge = "git push"
pre-remove = "echo bye"
post-remove = "echo removed"
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert!(config.hooks.pre_switch.is_some());
assert!(config.hooks.post_switch.is_some());
assert!(config.hooks.pre_create.is_some());
assert!(config.hooks.post_create.is_some());
assert!(config.hooks.pre_commit.is_some());
assert!(config.hooks.post_commit.is_some());
assert!(config.hooks.pre_merge.is_some());
assert!(config.hooks.post_merge.is_some());
assert!(config.hooks.pre_remove.is_some());
assert!(config.hooks.post_remove.is_some());
}
#[test]
fn test_deserialize_list_url() {
let contents = r#"
[list]
url = "http://localhost:{{ branch | hash_port }}"
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert_eq!(
config.list.url.as_deref(),
Some("http://localhost:{{ branch | hash_port }}")
);
assert!(config.list.is_configured());
}
#[test]
fn test_deserialize_list_empty() {
let contents = r#"
[list]
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert!(config.list.url.is_none());
assert!(!config.list.is_configured());
}
#[test]
fn test_deserialize_step_copy_ignored() {
let contents = r#"
[step.copy-ignored]
exclude = [".conductor/", ".entire/"]
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert_eq!(
config.copy_ignored().unwrap().exclude,
vec![".conductor/".to_string(), ".entire/".to_string()]
);
}
#[test]
fn test_deserialize_ci_platform_github() {
let contents = r#"
[ci]
platform = "github"
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert_eq!(config.ci.platform.as_deref(), Some("github"));
}
#[test]
fn test_deserialize_ci_platform_gitlab() {
let contents = r#"
[ci]
platform = "gitlab"
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert_eq!(config.ci.platform.as_deref(), Some("gitlab"));
}
#[test]
fn test_deserialize_ci_empty() {
let contents = r#"
[ci]
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert!(config.ci.platform.is_none());
}
#[test]
fn test_ci_config_default() {
let config = ProjectCiConfig::default();
assert!(config.platform.is_none());
}
#[test]
fn test_deserialize_forge_platform() {
let contents = r#"
[forge]
platform = "github"
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert_eq!(config.forge_platform(), Some("github"));
assert!(config.forge_hostname().is_none());
}
#[test]
fn test_deserialize_forge_hostname() {
let contents = r#"
[forge]
platform = "github"
hostname = "github.example.com"
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert_eq!(config.forge_platform(), Some("github"));
assert_eq!(config.forge_hostname(), Some("github.example.com"));
}
#[test]
fn test_forge_platform_falls_back_to_ci() {
let contents = r#"
[ci]
platform = "gitlab"
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert_eq!(config.forge_platform(), Some("gitlab"));
}
#[test]
fn test_forge_platform_takes_precedence_over_ci() {
let contents = r#"
[ci]
platform = "gitlab"
[forge]
platform = "github"
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert_eq!(config.forge_platform(), Some("github"));
assert_eq!(config.ci_platform(), Some("gitlab"));
}
#[test]
fn test_forge_config_default() {
let config = ProjectForgeConfig::default();
assert!(config.platform.is_none());
assert!(config.hostname.is_none());
}
#[test]
fn test_serialize_empty_config() {
let config = ProjectConfig::default();
let serialized = toml::to_string(&config).unwrap();
assert!(serialized.is_empty() || serialized.trim().is_empty());
}
#[test]
fn test_config_equality() {
let config1 = ProjectConfig::default();
let config2 = ProjectConfig::default();
assert_eq!(config1, config2);
}
#[test]
fn test_config_clone() {
let contents = r#"pre-merge = "cargo test""#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
let cloned = config.clone();
assert_eq!(config, cloned);
}
#[test]
fn test_commit_template_append_parses() {
let toml = r#"
[commit.generation]
template-append = "Use conventional commits"
"#;
let config: ProjectConfig = toml::from_str(toml).unwrap();
assert_eq!(
config.commit_template_append(),
Some("Use conventional commits")
);
}
#[test]
fn test_commit_template_append_blank_treated_as_unset() {
let toml = r#"
[commit.generation]
template-append = " \n\t "
"#;
let config: ProjectConfig = toml::from_str(toml).unwrap();
assert_eq!(config.commit_template_append(), None);
let toml = r#"
[commit.generation]
template-append = " - a\n - b "
"#;
let config: ProjectConfig = toml::from_str(toml).unwrap();
assert_eq!(config.commit_template_append(), Some("- a\n - b"));
}
#[test]
fn test_commit_template_append_missing_returns_none() {
let config = ProjectConfig::default();
assert_eq!(config.commit_template_append(), None);
let toml = r#"
[commit.generation]
"#;
let config: ProjectConfig = toml::from_str(toml).unwrap();
assert_eq!(config.commit_template_append(), None);
}
}