use std::collections::BTreeMap;
use config::ConfigError;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::commands::CommandConfig;
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.as_ref().and_then(|ci| ci.platform.as_deref())
}
pub fn forge_platform(&self) -> Option<&str> {
self.forge
.as_ref()
.and_then(|f| f.platform.as_deref())
.or_else(|| self.ci_platform())
}
pub fn forge_hostname(&self) -> Option<&str> {
self.forge.as_ref().and_then(|f| f.hostname.as_deref())
}
pub fn copy_ignored(&self) -> Option<&CopyIgnoredConfig> {
self.step
.as_ref()
.and_then(|step| step.copy_ignored.as_ref())
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, JsonSchema)]
pub struct ProjectConfig {
#[serde(flatten, default)]
pub hooks: HooksConfig,
#[serde(default)]
pub list: Option<ProjectListConfig>,
#[serde(default)]
pub ci: Option<ProjectCiConfig>,
#[serde(default)]
pub forge: Option<ProjectForgeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<StepConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aliases: Option<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::Message(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::Message(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>(
&config_path,
&find_unknown_keys(&contents),
"Project config",
);
}
let config: ProjectConfig = toml::from_str(&contents)
.map_err(|e| ConfigError::Message(format!("Failed to parse TOML: {}", e)))?;
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()
}
pub fn find_unknown_keys(contents: &str) -> std::collections::HashMap<String, toml::Value> {
let Ok(table) = contents.parse::<toml::Table>() else {
return std::collections::HashMap::new();
};
let valid_keys = valid_project_config_keys();
table
.into_iter()
.filter(|(key, _)| !valid_keys.contains(key))
.collect()
}
#[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!(config.list.is_some());
let list = config.list.unwrap();
assert_eq!(
list.url.as_deref(),
Some("http://localhost:{{ branch | hash_port }}")
);
assert!(list.is_configured());
}
#[test]
fn test_deserialize_list_empty() {
let contents = r#"
[list]
"#;
let config: ProjectConfig = toml::from_str(contents).unwrap();
assert!(config.list.is_some());
let list = config.list.unwrap();
assert!(list.url.is_none());
assert!(!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!(config.ci.is_some());
let ci = config.ci.unwrap();
assert_eq!(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!(config.ci.is_some());
let ci = config.ci.unwrap();
assert_eq!(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.is_some());
let ci = config.ci.unwrap();
assert!(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_find_unknown_keys_empty() {
let contents = "";
let keys = find_unknown_keys(contents);
assert!(keys.is_empty());
}
#[test]
fn test_find_unknown_keys_all_known() {
let contents = r#"
post-create = "npm install"
pre-merge = "cargo test"
[step.copy-ignored]
exclude = [".conductor/"]
"#;
let keys = find_unknown_keys(contents);
assert!(keys.is_empty());
}
#[test]
fn test_find_unknown_keys_unknown_key() {
let contents = r#"
post-create = "npm install"
unknown-key = "value"
"#;
let keys = find_unknown_keys(contents);
assert_eq!(keys.len(), 1);
assert!(keys.contains_key("unknown-key"));
}
#[test]
fn test_find_unknown_keys_multiple_unknown() {
let contents = r#"
foo = "bar"
baz = "qux"
post-create = "npm install"
"#;
let keys = find_unknown_keys(contents);
assert_eq!(keys.len(), 2);
assert!(keys.contains_key("foo"));
assert!(keys.contains_key("baz"));
}
#[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);
}
}