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 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()
}
}
#[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 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 _ = super::deprecation::check_and_migrate(
&config_path,
&contents,
is_main_worktree,
"Project config",
repo_for_hints,
true, );
if is_main_worktree {
super::deprecation::warn_unknown_fields::<ProjectConfig>(
&contents,
&config_path,
"Project config",
);
}
let config: ProjectConfig = toml::from_str(&contents).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> {
use schemars::SchemaGenerator;
let schema = SchemaGenerator::default().into_root_schema_for::<ProjectConfig>();
schema
.as_object()
.and_then(|obj| obj.get("properties"))
.and_then(|p| p.as_object())
.map(|props| props.keys().cloned().collect())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_all_hooks() {
let contents = r#"
post-create = "npm install"
post-start = "npm run watch"
post-switch = "rename-tab"
pre-commit = "cargo fmt --check"
pre-merge = "cargo test"
post-merge = "git push"
pre-remove = "echo bye"
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert!(config.hooks.post_create.is_some());
assert!(config.hooks.post_start.is_some());
assert!(config.hooks.post_switch.is_some());
assert!(config.hooks.pre_commit.is_some());
assert!(config.hooks.pre_merge.is_some());
assert!(config.hooks.post_merge.is_some());
assert!(config.hooks.pre_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);
}
}