use crate::{GitRepo, Config, Party, ConfigLoader, Error, Result};
use inquire::{MultiSelect, Select, Text, Confirm};
use tracing::{info, debug};
pub struct InteractiveSelector<'a> {
git_repo: &'a GitRepo,
config_loader: &'a ConfigLoader,
}
#[derive(Debug, Clone)]
pub struct BranchOption {
pub name: String,
pub branch_type: BranchType,
pub description: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BranchType {
Local,
Remote,
Party,
}
#[derive(Debug, Clone)]
struct MergeOrderOption {
key: &'static str,
description: &'static str,
}
impl<'a> InteractiveSelector<'a> {
pub fn new(git_repo: &'a GitRepo, config_loader: &'a ConfigLoader) -> Self {
Self {
git_repo,
config_loader,
}
}
pub fn select_branches_for_party(&self, party_name: &str) -> Result<()> {
info!("Starting interactive branch selection for party: {}", party_name);
let mut config = self.config_loader.load_config(None)?;
let branch_options = self.get_available_branches(&config)?;
if branch_options.is_empty() {
println!("No branches available for selection.");
return Ok(());
}
let current_members = config.parties.get(party_name)
.map(|p| p.members.clone())
.unwrap_or_default();
let default_selection = self.get_default_selection(&branch_options, ¤t_members);
println!("\n🎉 Branch Party - Interactive Branch Selection");
println!("Party: {}", party_name);
if !current_members.is_empty() {
println!("Current members: {}", current_members.join(", "));
}
println!("\nSelect branches to include in the party:");
println!(" • Local branches are shown as 'branch-name'");
println!(" • Remote branches are shown as 'origin/branch-name'");
println!(" • Other parties are shown as '@party-name'");
let selected_options = MultiSelect::new("Branches:", branch_options)
.with_default(&default_selection)
.with_help_message("Use ↑↓ to navigate, Space to select/deselect, Enter to confirm")
.prompt()?;
if selected_options.is_empty() {
println!("No branches selected. Party configuration unchanged.");
return Ok(());
}
let selected_branches: Vec<String> = selected_options
.into_iter()
.map(|opt| {
match opt.branch_type {
BranchType::Party => format!("@{}", opt.name),
_ => opt.name,
}
})
.collect();
println!("\n📋 Selection Summary:");
for branch in &selected_branches {
println!(" ✓ {}", branch);
}
let confirm = Confirm::new("Save this configuration?")
.with_default(true)
.prompt()?;
if !confirm {
println!("Configuration not saved.");
return Ok(());
}
self.update_party_configuration(&mut config, party_name, selected_branches)?;
println!("\n✅ Party '{}' configuration updated successfully!", party_name);
Ok(())
}
pub fn create_new_party(&self) -> Result<()> {
info!("Starting interactive party creation");
println!("\n🎉 Branch Party - Create New Party");
let party_name = Text::new("Enter party name:")
.with_help_message("Use lowercase letters, numbers, and hyphens")
.with_validator(|input: &str| {
if input.trim().is_empty() {
Ok(inquire::validator::Validation::Invalid("Party name cannot be empty".into()))
} else if !input.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
Ok(inquire::validator::Validation::Invalid("Party name can only contain letters, numbers, hyphens, and underscores".into()))
} else {
Ok(inquire::validator::Validation::Valid)
}
})
.prompt()?;
let party_name = party_name.trim().to_lowercase();
let config = self.config_loader.load_config(None)?;
if config.parties.contains_key(&party_name) {
let overwrite = Confirm::new(&format!("Party '{}' already exists. Overwrite?", party_name))
.with_default(false)
.prompt()?;
if !overwrite {
println!("Party creation cancelled.");
return Ok(());
}
}
self.select_branches_for_party(&party_name)?;
Ok(())
}
fn get_available_branches(&self, config: &Config) -> Result<Vec<BranchOption>> {
let mut options = Vec::new();
match self.git_repo.list_local_branches() {
Ok(local_branches) => {
for branch in local_branches {
if !branch.starts_with("party/") {
options.push(BranchOption {
name: branch.clone(),
branch_type: BranchType::Local,
description: format!("Local branch: {}", branch),
});
}
}
}
Err(e) => {
debug!("Could not list local branches: {}", e);
}
}
match self.git_repo.list_remote_branches() {
Ok(remote_branches) => {
for branch in remote_branches {
if branch != "HEAD" && !branch.starts_with("party/") {
options.push(BranchOption {
name: format!("origin/{}", branch),
branch_type: BranchType::Remote,
description: format!("Remote branch: origin/{}", branch),
});
}
}
}
Err(e) => {
debug!("Could not list remote branches: {}", e);
}
}
for (party_name, _party) in &config.parties {
options.push(BranchOption {
name: party_name.clone(),
branch_type: BranchType::Party,
description: format!("Party reference: @{}", party_name),
});
}
options.sort_by(|a, b| {
match (&a.branch_type, &b.branch_type) {
(BranchType::Local, BranchType::Local) => a.name.cmp(&b.name),
(BranchType::Remote, BranchType::Remote) => a.name.cmp(&b.name),
(BranchType::Party, BranchType::Party) => a.name.cmp(&b.name),
(BranchType::Local, _) => std::cmp::Ordering::Less,
(BranchType::Remote, BranchType::Party) => std::cmp::Ordering::Less,
(BranchType::Remote, BranchType::Local) => std::cmp::Ordering::Greater,
(BranchType::Party, _) => std::cmp::Ordering::Greater,
}
});
Ok(options)
}
fn get_default_selection(&self, options: &[BranchOption], current_members: &[String]) -> Vec<usize> {
let mut default_indices = Vec::new();
for (index, option) in options.iter().enumerate() {
let member_name = match option.branch_type {
BranchType::Party => format!("@{}", option.name),
_ => option.name.clone(),
};
if current_members.contains(&member_name) {
default_indices.push(index);
}
}
default_indices
}
fn update_party_configuration(&self, config: &mut Config, party_name: &str, branches: Vec<String>) -> Result<()> {
let party = Party {
members: branches,
merge_order: config.parties.get(party_name)
.map(|p| p.merge_order.clone())
.unwrap_or_default(),
conflict_policy: config.parties.get(party_name)
.map(|p| p.conflict_policy.clone())
.unwrap_or_default(),
};
config.parties.insert(party_name.to_string(), party);
let config_path = self.config_loader.repo_config_path();
let yaml = serde_yaml::to_string(config)?;
let commented_yaml = self.add_comments_to_yaml(yaml);
std::fs::write(config_path, commented_yaml)?;
Ok(())
}
fn add_comments_to_yaml(&self, yaml: String) -> String {
let mut result = String::new();
result.push_str("# Branch Party Configuration\n");
result.push_str("# Updated via interactive selection\n");
result.push_str("# See https://github.com/example/branch-party for documentation\n\n");
for line in yaml.lines() {
if line.trim_start().starts_with("base_branch:") {
result.push_str("# Base branch to merge into (usually 'main' or 'develop')\n");
} else if line.trim_start().starts_with("parties:") {
result.push_str("# Party definitions\n");
result.push_str("# Each party can contain branches or references to other parties (@party_name)\n");
} else if line.trim_start().starts_with("members:") {
result.push_str(" # Selected branches and party references\n");
} else if line.trim_start().starts_with("merge_order:") {
result.push_str(" # Options: listed, newest_first, oldest_first\n");
} else if line.trim_start().starts_with("default:") && line.contains("manual") {
result.push_str(" # Options: ours, theirs, union, manual\n");
}
result.push_str(line);
result.push('\n');
}
result
}
pub fn select_merge_order(&self, party_name: &str) -> Result<()> {
println!("\n⚙️ Configure merge order for party '{}'", party_name);
let options = vec![
MergeOrderOption { key: "listed", description: "Keep branches in the order they are listed" },
MergeOrderOption { key: "newest_first", description: "Merge branches with newest commits first" },
MergeOrderOption { key: "oldest_first", description: "Merge branches with oldest commits first" },
];
let selected = Select::new("Select merge order strategy:", options)
.prompt()?;
let mut config = self.config_loader.load_config(None)?;
if let Some(party) = config.parties.get_mut(party_name) {
party.merge_order = selected.key.parse().unwrap_or_default();
let config_path = self.config_loader.repo_config_path();
let yaml = serde_yaml::to_string(&config)?;
let commented_yaml = self.add_comments_to_yaml(yaml);
std::fs::write(config_path, commented_yaml)?;
println!("✅ Merge order updated to: {}", selected.key);
} else {
return Err(Error::party_not_found(party_name));
}
Ok(())
}
pub fn interactive_party_selection(&self) -> Result<()> {
match self.select_party_to_edit()? {
Some(party_name) => {
self.select_branches_for_party(&party_name)?
}
None => {
self.create_new_party()?
}
}
Ok(())
}
pub fn select_party_to_edit(&self) -> Result<Option<String>> {
let config = self.config_loader.load_config(None)?;
if config.parties.is_empty() {
println!("No parties configured. Use 'init --with-sample' to create sample parties.");
return Ok(None);
}
let mut party_options: Vec<String> = config.parties.keys().cloned().collect();
party_options.sort();
party_options.insert(0, "➕ Create new party".to_string());
let selected = Select::new("Select a party to configure:", party_options)
.prompt()?;
if selected == "➕ Create new party" {
Ok(None) } else {
Ok(Some(selected))
}
}
}
impl std::fmt::Display for BranchOption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let icon = match self.branch_type {
BranchType::Local => "🔧",
BranchType::Remote => "🌐",
BranchType::Party => "🎉",
};
write!(f, "{} {}", icon, self.description)
}
}
impl std::fmt::Display for MergeOrderOption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description)
}
}
impl From<inquire::InquireError> for Error {
fn from(err: inquire::InquireError) -> Self {
match err {
inquire::InquireError::OperationCanceled => Error::UserAborted,
inquire::InquireError::OperationInterrupted => Error::UserAborted,
_ => Error::config(format!("Interactive selection error: {}", err)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_branch_option_display() {
let local_option = BranchOption {
name: "feature/test".to_string(),
branch_type: BranchType::Local,
description: "Local branch: feature/test".to_string(),
};
assert!(local_option.to_string().contains("🔧"));
assert!(local_option.to_string().contains("Local branch: feature/test"));
let party_option = BranchOption {
name: "qa".to_string(),
branch_type: BranchType::Party,
description: "Party reference: @qa".to_string(),
};
assert!(party_option.to_string().contains("🎉"));
assert!(party_option.to_string().contains("Party reference: @qa"));
}
#[test]
fn test_default_selection() {
let temp_dir = TempDir::new().unwrap();
let _repo = git2::Repository::init(temp_dir.path()).unwrap();
let git_repo = GitRepo::open(temp_dir.path()).unwrap();
let config_loader = ConfigLoader::new(temp_dir.path().to_path_buf());
let selector = InteractiveSelector::new(&git_repo, &config_loader);
let options = vec![
BranchOption {
name: "main".to_string(),
branch_type: BranchType::Local,
description: "Local branch: main".to_string(),
},
BranchOption {
name: "feature/a".to_string(),
branch_type: BranchType::Local,
description: "Local branch: feature/a".to_string(),
},
];
let current_members = vec!["main".to_string()];
let selection = selector.get_default_selection(&options, ¤t_members);
assert_eq!(selection, vec![0]);
}
}