use std::path::PathBuf;
use bon::Builder;
use serde::Deserialize;
use crate::{
error::{ConfigSnafu, Error, Result},
jj::Jujutsu,
};
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ForgeType {
GitLab,
GitHub,
Forgejo,
}
impl std::fmt::Display for ForgeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
ForgeType::GitLab => "gitlab",
ForgeType::GitHub => "github",
ForgeType::Forgejo => "forgejo",
}
)
}
}
impl std::str::FromStr for ForgeType {
type Err = Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"gitlab" => Ok(Self::GitLab),
"github" => Ok(Self::GitHub),
"forgejo" => Ok(Self::Forgejo),
_ => Err(ConfigSnafu {
message: format!("Invalid forge type: {}", s),
}
.build()),
}
}
}
impl ForgeType {
pub fn detect_from_host(host: &str) -> Option<Self> {
if host.contains("gitlab") {
Some(Self::GitLab)
} else if host.contains("github") {
Some(Self::GitHub)
} else if host.contains("forgejo") || host.contains("gitea") || host.contains("codeberg") {
Some(Self::Forgejo)
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum DescriptionFormat {
None,
#[default]
Linear,
Tree,
}
fn default_remote_name() -> String {
"origin".to_string()
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Builder)]
#[serde(rename_all = "camelCase")]
pub struct Config {
pub forge: ForgeType,
#[serde(default)]
pub default_base_branch: Option<String>,
#[serde(default = "default_remote_name")]
#[builder(default = default_remote_name())]
pub remote_name: String,
#[serde(default)]
pub ca_bundle: Option<String>,
#[serde(default)]
#[builder(default)]
pub tls_accept_non_compliant_certs: bool,
#[serde(default)]
#[builder(default)]
pub description: DescriptionConfig,
#[serde(default = "default_true")]
#[builder(default)]
pub delete_source_branch: bool,
#[serde(default)]
#[builder(default)]
pub squash_commits: bool,
#[serde(default)]
#[builder(default)]
pub assign_to_self: bool,
#[serde(default)]
#[builder(default)]
pub default_reviewers: Vec<String>,
#[serde(default)]
#[builder(default)]
pub open_as_draft: bool,
#[serde(default)]
#[builder(default)]
pub gitlab: GitLabConfig,
#[serde(default)]
#[builder(default)]
pub github: GitHubConfig,
#[serde(default)]
#[builder(default)]
pub forgejo: ForgejoConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct GitLabConfig {
#[serde(default)]
pub host: String,
#[serde(default)]
pub project: String,
#[serde(default)]
pub target_project: String,
#[serde(default)]
pub token: String,
}
impl GitLabConfig {
pub fn target_project(&self) -> &str {
if self.target_project.is_empty() {
&self.project
} else {
&self.target_project
}
}
pub fn source_project(&self) -> &str {
&self.project
}
pub fn is_fork_workflow(&self) -> bool {
self.target_project() != self.project
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct GitHubConfig {
#[serde(default)]
pub host: String,
#[serde(default)]
pub project: String,
#[serde(default)]
pub target_project: String,
#[serde(default)]
pub token: String,
}
impl GitHubConfig {
pub fn target_project(&self) -> &str {
if self.target_project.is_empty() {
&self.project
} else {
&self.target_project
}
}
pub fn source_project(&self) -> &str {
&self.project
}
pub fn is_fork_workflow(&self) -> bool {
self.target_project() != self.project
}
}
fn default_wip_prefix() -> String {
"WIP: ".to_string()
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ForgejoConfig {
#[serde(default)]
pub host: String,
#[serde(default)]
pub project: String,
#[serde(default)]
pub target_project: String,
#[serde(default)]
pub token: String,
#[serde(default = "default_wip_prefix")]
pub wip_prefix: String,
}
impl ForgejoConfig {
pub fn target_project(&self) -> &str {
if self.target_project.is_empty() {
&self.project
} else {
&self.target_project
}
}
pub fn source_project(&self) -> &str {
&self.project
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DescriptionConfig {
pub enabled: bool,
#[serde(default)]
pub format: DescriptionFormatsConfig,
}
impl Default for DescriptionConfig {
fn default() -> Self {
Self {
enabled: true,
format: Default::default(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DescriptionFormatsConfig {
pub single: DescriptionFormat,
pub linear: DescriptionFormat,
pub tree: DescriptionFormat,
pub complex: DescriptionFormat,
}
impl Default for DescriptionFormatsConfig {
fn default() -> Self {
Self {
single: DescriptionFormat::None,
linear: DescriptionFormat::Linear,
tree: DescriptionFormat::Linear,
complex: DescriptionFormat::Linear,
}
}
}
impl Config {
pub fn load(repo_path: impl Into<PathBuf>) -> Result<Self> {
let jj = Jujutsu::new(repo_path)?;
let output = jj.exec(["config", "list"])?;
let toml_value: toml::Value = toml::from_str(&output.stdout).map_err(|e| {
ConfigSnafu {
message: format!("Failed to parse config as TOML: {}", e),
}
.build()
})?;
let jj_vine_value = toml_value.get("jj-vine").ok_or_else(|| {
ConfigSnafu {
message: "Missing required config section: jj-vine".to_string(),
}
.build()
})?;
let config: Config = jj_vine_value.clone().try_into().map_err(|e| {
ConfigSnafu {
message: format!("Failed to parse jj-vine config: {}", e),
}
.build()
})?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
match self.forge {
ForgeType::GitLab => {
if self.gitlab.host.is_empty() {
return Err(ConfigSnafu {
message: "gitlab.host is required when forge is gitlab".to_string(),
}
.build());
}
if self.gitlab.project.is_empty() {
return Err(ConfigSnafu {
message: "gitlab.project is required when forge is gitlab".to_string(),
}
.build());
}
if self.gitlab.token.is_empty() {
return Err(ConfigSnafu {
message: "gitlab.token is required when forge is gitlab".to_string(),
}
.build());
}
}
ForgeType::GitHub => {
if self.github.project.is_empty() {
return Err(ConfigSnafu {
message: "github.project is required when forge is github".to_string(),
}
.build());
}
if self.github.token.is_empty() {
return Err(ConfigSnafu {
message: "github.token is required when forge is github".to_string(),
}
.build());
}
}
ForgeType::Forgejo => {
if self.forgejo.host.is_empty() {
return Err(ConfigSnafu {
message: "forgejo.host is required when forge is forgejo".to_string(),
}
.build());
}
if self.forgejo.project.is_empty() {
return Err(ConfigSnafu {
message: "forgejo.project is required when forge is forgejo".to_string(),
}
.build());
}
if self.forgejo.token.is_empty() {
return Err(ConfigSnafu {
message: "forgejo.token is required when forge is forgejo".to_string(),
}
.build());
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
fn create_test_repo() -> (TempDir, PathBuf) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = temp_dir.path().to_path_buf();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec(["git", "init", "--colocate"])
.expect("Failed to init jj repo");
(temp_dir, repo_path)
}
#[test]
fn test_config_load_missing_required() {
let (_temp, repo_path) = create_test_repo();
let result = Config::load(&repo_path);
assert!(result.is_err());
if let Err(Error::Config { message, .. }) = result {
assert!(
message.contains("missing field")
|| message.contains("gitlab")
|| message.contains("jj-vine"),
"Error should mention missing field, got: {}",
message
);
} else {
panic!("Expected Config error for missing required field");
}
}
#[test]
fn test_config_load_complete() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.host",
"https://gitlab.example.com",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.project",
"my-group/my-project",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.token",
"glpat-test123",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.forge", "gitlab"])
.expect("Failed to set config");
let config = Config::load(&repo_path).expect("Failed to load config");
assert_eq!(config.gitlab.host, "https://gitlab.example.com".to_string());
assert_eq!(config.gitlab.project, "my-group/my-project".to_string());
assert_eq!(config.gitlab.token, "glpat-test123".to_string());
assert_eq!(config.remote_name, "origin");
}
#[test]
fn test_config_with_optional_fields() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.host",
"https://gitlab.example.com",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.project",
"my-group/my-project",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.token",
"glpat-test123",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.branchPrefix", "mrs/"])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.remoteName", "upstream"])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.defaultBranch", "master"])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.forge", "gitlab"])
.expect("Failed to set config");
let config = Config::load(&repo_path).expect("Failed to load config");
assert_eq!(config.gitlab.host, "https://gitlab.example.com".to_string());
assert_eq!(config.gitlab.project, "my-group/my-project".to_string());
assert_eq!(config.gitlab.token, "glpat-test123".to_string());
assert_eq!(config.remote_name, "upstream");
}
#[test]
fn test_config_default_stack_visualization() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.host",
"https://gitlab.com",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.project",
"test/proj",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.gitlab.token", "token"])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.forge", "gitlab"])
.expect("Failed to set config");
let config = Config::load(&repo_path).expect("Failed to load config");
dbg!(&config);
assert!(config.description.enabled);
assert!(matches!(
config.description.format.single,
DescriptionFormat::None
));
assert!(matches!(
config.description.format.linear,
DescriptionFormat::Linear
));
assert!(matches!(
config.description.format.tree,
DescriptionFormat::Linear
));
assert!(matches!(
config.description.format.complex,
DescriptionFormat::Linear
));
}
#[test]
fn test_config_explicit_stack_visualization() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.host",
"https://gitlab.com",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.project",
"test/proj",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.gitlab.token", "token"])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.description.enabled",
"false",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.forge", "gitlab"])
.expect("Failed to set config");
let config = Config::load(&repo_path).expect("Failed to load config");
assert!(!config.description.enabled);
assert!(matches!(
config.description.format.single,
DescriptionFormat::None
));
assert!(matches!(
config.description.format.linear,
DescriptionFormat::Linear
));
assert!(matches!(
config.description.format.tree,
DescriptionFormat::Linear
));
assert!(matches!(
config.description.format.complex,
DescriptionFormat::Linear
));
}
#[test]
fn test_config_default_mr_settings() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.host",
"https://gitlab.com",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.project",
"test/proj",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.gitlab.token", "token"])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.forge", "gitlab"])
.expect("Failed to set config");
let config = Config::load(&repo_path).expect("Failed to load config");
assert!(config.delete_source_branch);
assert!(!config.squash_commits);
}
#[test]
fn test_config_explicit_mr_settings() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.host",
"https://gitlab.com",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.project",
"test/proj",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.gitlab.token", "token"])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.deleteSourceBranch",
"false",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.squashCommits", "true"])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.forge", "gitlab"])
.expect("Failed to set config");
let config = Config::load(&repo_path).expect("Failed to load config");
assert!(!config.delete_source_branch);
assert!(config.squash_commits);
}
#[test]
fn test_config_default_assign_to_self() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.host",
"https://gitlab.com",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.project",
"test/proj",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.gitlab.token", "token"])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.forge", "gitlab"])
.expect("Failed to set config");
let config = Config::load(&repo_path).expect("Failed to load config");
assert!(!config.assign_to_self);
}
#[test]
fn test_config_explicit_assign_to_self() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.host",
"https://gitlab.com",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.project",
"test/proj",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.gitlab.token", "token"])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.assignToSelf", "true"])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.forge", "gitlab"])
.expect("Failed to set config");
let config = Config::load(&repo_path).expect("Failed to load config");
assert!(config.assign_to_self);
}
#[test]
fn test_config_default_reviewers_empty() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.host",
"https://gitlab.com",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.project",
"test/proj",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.gitlab.token", "token"])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.forge", "gitlab"])
.expect("Failed to set config");
let config = Config::load(&repo_path).expect("Failed to load config");
assert!(config.default_reviewers.is_empty());
}
#[test]
fn test_config_default_reviewers_single() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.host",
"https://gitlab.com",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.project",
"test/proj",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.gitlab.token", "token"])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.defaultReviewers",
r#"["reviewer1"]"#,
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.forge", "gitlab"])
.expect("Failed to set config");
let config = Config::load(&repo_path).expect("Failed to load config");
assert_eq!(config.default_reviewers, vec!["reviewer1"]);
}
#[test]
fn test_config_default_reviewers_multiple() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(&repo_path).expect("Failed to create Jujutsu instance");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.host",
"https://gitlab.com",
])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.gitlab.project",
"test/proj",
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.gitlab.token", "token"])
.expect("Failed to set config");
jj.exec([
"config",
"set",
"--repo",
"jj-vine.defaultReviewers",
r#"["reviewer1", "reviewer2", "reviewer3"]"#,
])
.expect("Failed to set config");
jj.exec(["config", "set", "--repo", "jj-vine.forge", "gitlab"])
.expect("Failed to set config");
let config = Config::load(&repo_path).expect("Failed to load config");
assert_eq!(
config.default_reviewers,
vec!["reviewer1", "reviewer2", "reviewer3"]
);
}
#[test]
fn test_gitlab_direct_mode_without_target() {
let config = GitLabConfig {
host: "https://gitlab.com".to_string(),
project: "myuser/myrepo".to_string(),
target_project: "".to_string(),
token: "token".to_string(),
};
assert_eq!(config.target_project(), "myuser/myrepo");
assert_eq!(config.source_project(), "myuser/myrepo");
assert!(!config.is_fork_workflow());
}
#[test]
fn test_gitlab_fork_mode_with_different_target() {
let config = GitLabConfig {
host: "https://gitlab.com".to_string(),
project: "myuser/fork".to_string(),
target_project: "upstream/repo".to_string(),
token: "token".to_string(),
};
assert_eq!(config.target_project(), "upstream/repo");
assert_eq!(config.source_project(), "myuser/fork");
assert!(config.is_fork_workflow());
}
#[test]
fn test_gitlab_fork_mode_with_same_target() {
let config = GitLabConfig {
host: "https://gitlab.com".to_string(),
project: "myuser/repo".to_string(),
target_project: "myuser/repo".to_string(),
token: "token".to_string(),
};
assert_eq!(config.target_project(), "myuser/repo");
assert_eq!(config.source_project(), "myuser/repo");
assert!(!config.is_fork_workflow());
}
#[test]
fn test_github_direct_mode_without_target() {
let config = GitHubConfig {
host: "https://api.github.com".to_string(),
project: "myuser/myrepo".to_string(),
target_project: "".to_string(),
token: "token".to_string(),
};
assert_eq!(config.target_project(), "myuser/myrepo");
assert_eq!(config.source_project(), "myuser/myrepo");
assert!(!config.is_fork_workflow());
}
}