use anyhow::{Context, Result};
use dialoguer::{Confirm, Input, Select};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
fn default_base_dir() -> String {
".parsec/workspaces".to_string()
}
fn default_branch_prefix() -> String {
"feature/".to_string()
}
fn default_provider() -> TrackerProvider {
TrackerProvider::None
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum TrackerProvider {
Jira,
Github,
Gitlab,
#[default]
None,
}
impl std::fmt::Display for TrackerProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TrackerProvider::Jira => write!(f, "jira"),
TrackerProvider::Github => write!(f, "github"),
TrackerProvider::Gitlab => write!(f, "gitlab"),
TrackerProvider::None => write!(f, "none"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum WorktreeLayout {
#[default]
Sibling, Internal, }
fn default_layout() -> WorktreeLayout {
WorktreeLayout::Sibling
}
impl std::fmt::Display for WorktreeLayout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WorktreeLayout::Sibling => write!(f, "sibling"),
WorktreeLayout::Internal => write!(f, "internal"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceConfig {
#[serde(default = "default_layout")]
pub layout: WorktreeLayout,
#[serde(default = "default_base_dir")]
pub base_dir: String, #[serde(default = "default_branch_prefix")]
pub branch_prefix: String,
#[serde(default)]
pub default_base: Option<String>,
}
impl Default for WorkspaceConfig {
fn default() -> Self {
Self {
layout: default_layout(),
base_dir: default_base_dir(),
branch_prefix: default_branch_prefix(),
default_base: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraConfig {
pub base_url: String,
pub email: Option<String>,
pub project: Option<String>,
pub board_id: Option<u64>,
pub assignee: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitlabConfig {
pub base_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackerConfig {
#[serde(default = "default_provider")]
pub provider: TrackerProvider,
#[serde(default)]
pub jira: Option<JiraConfig>,
#[serde(default)]
pub gitlab: Option<GitlabConfig>,
#[serde(default)]
pub auto_transition: Option<AutoTransitionConfig>,
#[serde(default)]
pub comment_on_ship: bool,
}
impl Default for TrackerConfig {
fn default() -> Self {
Self {
provider: default_provider(),
jira: None,
gitlab: None,
auto_transition: None,
comment_on_ship: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShipConfig {
#[serde(default = "default_true")]
pub auto_pr: bool,
#[serde(default = "default_true")]
pub auto_cleanup: bool,
#[serde(default)]
pub draft: bool,
#[serde(default)]
pub default_base: Option<String>,
}
impl Default for ShipConfig {
fn default() -> Self {
Self {
auto_pr: true,
auto_cleanup: true,
draft: false,
default_base: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HooksConfig {
#[serde(default)]
pub post_create: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AutoTransitionConfig {
#[serde(default)]
pub on_start: Option<String>,
#[serde(default)]
pub on_ship: Option<String>,
#[serde(default)]
pub on_merge: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GithubHostConfig {
pub token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RepoTrackerOverride {
pub provider: Option<TrackerProvider>,
#[serde(default)]
pub jira: Option<JiraConfig>,
#[serde(default)]
pub gitlab: Option<GitlabConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RepoOverrideConfig {
#[serde(default)]
pub tracker: Option<RepoTrackerOverride>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ParsecConfig {
#[serde(default)]
pub workspace: WorkspaceConfig,
#[serde(default)]
pub tracker: TrackerConfig,
#[serde(default)]
pub ship: ShipConfig,
#[serde(default)]
pub hooks: HooksConfig,
#[serde(default)]
pub github: HashMap<String, GithubHostConfig>,
#[serde(default)]
pub repos: HashMap<String, RepoOverrideConfig>,
}
impl ParsecConfig {
pub fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
})
.join("parsec")
.join("config.toml")
}
pub fn load() -> Result<Self> {
let path = Self::config_path();
if !path.exists() {
return Ok(Self::default());
}
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let config: Self = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
let contents =
toml::to_string_pretty(self).context("Failed to serialize config to TOML")?;
std::fs::write(&path, contents)
.with_context(|| format!("Failed to write config file: {}", path.display()))?;
Ok(())
}
pub fn resolve_for_repo(&mut self, repo_root: &Path) {
let remote_url = match std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(repo_root)
.output()
{
Ok(out) if out.status.success() => String::from_utf8(out.stdout).unwrap_or_default(),
_ => return,
};
let remote_url = remote_url.trim();
let parsed = crate::github::parse_github_remote(remote_url);
let remote = match parsed {
Some(r) => r,
None => return,
};
let key = format!("{}/{}", remote.owner, remote.repo);
let repo_cfg = match self.repos.get(&key) {
Some(r) => r.clone(),
None => return,
};
let tracker_override = match repo_cfg.tracker {
Some(t) => t,
None => return,
};
if let Some(provider) = tracker_override.provider {
self.tracker.provider = provider;
}
if let Some(jira) = tracker_override.jira {
self.tracker.jira = Some(jira);
}
if let Some(gitlab) = tracker_override.gitlab {
self.tracker.gitlab = Some(gitlab);
}
}
pub fn init_interactive() -> Result<Self> {
let mut config = Self::default();
let provider_options = &["None", "Jira", "GitHub", "GitLab"];
let provider_idx = Select::new()
.with_prompt("Issue tracker provider")
.items(provider_options)
.default(0)
.interact()
.context("Failed to read tracker provider selection")?;
config.tracker.provider = match provider_idx {
1 => TrackerProvider::Jira,
2 => TrackerProvider::Github,
3 => TrackerProvider::Gitlab,
_ => TrackerProvider::None,
};
if config.tracker.provider == TrackerProvider::Jira {
let base_url: String = Input::new()
.with_prompt("Jira base URL (e.g. https://yourorg.atlassian.net)")
.interact_text()
.context("Failed to read Jira base URL")?;
let email_input: String = Input::new()
.with_prompt("Jira email (leave blank to skip)")
.allow_empty(true)
.interact_text()
.context("Failed to read Jira email")?;
config.tracker.jira = Some(JiraConfig {
base_url,
email: if email_input.is_empty() {
None
} else {
Some(email_input)
},
project: None,
board_id: None,
assignee: None,
});
}
if config.tracker.provider == TrackerProvider::Gitlab {
let base_url: String = Input::new()
.with_prompt("GitLab base URL (e.g. https://gitlab.com)")
.default("https://gitlab.com".to_string())
.interact_text()
.context("Failed to read GitLab base URL")?;
config.tracker.gitlab = Some(GitlabConfig { base_url });
}
let layout_options = &[
"Sibling (recommended - worktrees next to repo)",
"Internal (worktrees inside .parsec/)",
];
let layout_idx = Select::new()
.with_prompt("Worktree layout")
.items(layout_options)
.default(0)
.interact()
.context("Failed to read layout selection")?;
config.workspace.layout = match layout_idx {
1 => WorktreeLayout::Internal,
_ => WorktreeLayout::Sibling,
};
let branch_prefix: String = Input::new()
.with_prompt("Branch prefix for new worktrees")
.default("feature/".to_string())
.interact_text()
.context("Failed to read branch prefix")?;
config.workspace.branch_prefix = branch_prefix;
config.ship.auto_pr = Confirm::new()
.with_prompt("Automatically open a PR when shipping?")
.default(true)
.interact()
.context("Failed to read auto PR preference")?;
config.ship.draft = Confirm::new()
.with_prompt("Create PRs as drafts by default?")
.default(false)
.interact()
.context("Failed to read draft PR preference")?;
Ok(config)
}
}