use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use crate::path;
const DEFAULT_SEARCH_FOLDER: &str = "~/";
const DEFAULT_GLOBAL_BRANCHES: &[&str] = &["main", "master"];
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
#[serde(rename = "search-folders", default = "default_search_folders")]
pub search_folders: Vec<String>,
#[serde(default)]
pub pins: Pins,
#[serde(default)]
pub aliases: HashMap<String, String>,
#[serde(skip)]
file_path: Option<PathBuf>,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Pins {
#[serde(default)]
pub repositories: Vec<String>,
#[serde(default)]
pub branches: PinnedBranches,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PinnedBranches {
#[serde(default = "default_global_branches")]
pub global: Vec<String>,
#[serde(default, deserialize_with = "deserialize_repo_branches")]
pub repositories: HashMap<String, Vec<String>>,
}
fn deserialize_repo_branches<'de, D>(
deserializer: D,
) -> std::result::Result<HashMap<String, Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_yaml::Value::deserialize(deserializer)?;
match value {
serde_yaml::Value::Mapping(map) => Ok(flatten_repo_map(&map, "")),
_ => Ok(HashMap::new()),
}
}
fn flatten_repo_map(map: &serde_yaml::Mapping, prefix: &str) -> HashMap<String, Vec<String>> {
let mut result = HashMap::new();
for (k, v) in map {
let key_part = k.as_str().unwrap_or("");
let full_key = if prefix.is_empty() {
key_part.to_string()
} else {
format!("{prefix}.{key_part}")
};
match v {
serde_yaml::Value::Sequence(seq) => {
let branches: Vec<String> = seq
.iter()
.filter_map(|item| item.as_str().map(|s| s.to_string()))
.collect();
result.insert(full_key, branches);
}
serde_yaml::Value::Mapping(nested) => {
result.extend(flatten_repo_map(nested, &full_key));
}
_ => {}
}
}
result
}
impl Default for PinnedBranches {
fn default() -> Self {
Self {
global: default_global_branches(),
repositories: HashMap::new(),
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
search_folders: default_search_folders(),
pins: Pins::default(),
aliases: HashMap::new(),
file_path: None,
}
}
}
fn default_search_folders() -> Vec<String> {
vec![DEFAULT_SEARCH_FOLDER.to_string()]
}
fn default_global_branches() -> Vec<String> {
DEFAULT_GLOBAL_BRANCHES
.iter()
.map(|s| s.to_string())
.collect()
}
pub fn load() -> Config {
let config_path = find_config_file();
let Some(path) = config_path else {
return Config::default();
};
let contents = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("ERROR Error reading gimme configuration: {e}");
return Config::default();
}
};
match serde_yaml::from_str::<Config>(&contents) {
Ok(mut config) => {
config.file_path = Some(path);
config
}
Err(e) => {
eprintln!("ERROR Error reading gimme configuration: {e}");
Config {
file_path: Some(path),
..Default::default()
}
}
}
}
pub fn find_config_file() -> Option<PathBuf> {
for name in [".gimme.config.yaml", ".gimme.config.yml"] {
let local = PathBuf::from(name);
if local.exists() {
return Some(local);
}
}
if let Some(gimme_dir) = crate::xdg::gimme_dir() {
for name in ["config.yaml", "config.yml"] {
let path = gimme_dir.join(name);
if path.exists() {
return Some(path);
}
}
}
let home = dirs::home_dir()?;
for name in [".gimme.config.yaml", ".gimme.config.yml"] {
let path = home.join(name);
if path.exists() {
return Some(path);
}
}
None
}
fn save(config: &Config) -> Result<()> {
let path = match &config.file_path {
Some(p) => p.clone(),
None => {
let gimme_dir = crate::xdg::gimme_dir().context("Could not determine config directory")?;
fs::create_dir_all(&gimme_dir)?;
gimme_dir.join("config.yaml")
}
};
let yaml = serde_yaml::to_string(config)?;
fs::write(&path, yaml)?;
Ok(())
}
fn is_default_search_folders(folders: &[String]) -> bool {
folders.len() == 1 && folders[0] == DEFAULT_SEARCH_FOLDER
}
pub fn get_search_folders(config: &Config) -> Vec<String> {
config
.search_folders
.iter()
.map(|raw| {
path::normalize(raw).unwrap_or_else(|e| {
eprintln!("ERROR Error parsing search folder \"{raw}\": {e}");
raw.clone()
})
})
.collect()
}
pub fn add_group(config: &mut Config, group_path: &str) {
let normalized = match path::normalize(group_path) {
Ok(n) => n,
Err(e) => {
eprintln!("ERROR Error parsing search folder \"{group_path}\": {e}");
return;
}
};
let already_exists = config
.search_folders
.iter()
.any(|g| path::normalize(g).unwrap_or_default() == normalized);
if already_exists {
eprintln!("Group already exists: \"{group_path}\".");
return;
}
if is_default_search_folders(&config.search_folders) {
config.search_folders = vec![group_path.to_string()];
} else {
config.search_folders.push(group_path.to_string());
}
if let Err(e) = save(config) {
eprintln!("ERROR Error saving config: {e}");
return;
}
eprintln!("Added search group \"{group_path}\".");
}
pub fn delete_group(config: &mut Config, group_path: &str) {
let normalized = path::normalize(group_path).unwrap_or_default();
let original_len = config.search_folders.len();
config
.search_folders
.retain(|g| path::normalize(g).unwrap_or_default() != normalized);
if config.search_folders.len() == original_len {
eprintln!("Group not found: \"{group_path}\".");
return;
}
if let Err(e) = save(config) {
eprintln!("ERROR Error saving config: {e}");
return;
}
eprintln!("Deleted search group \"{group_path}\".");
}
pub fn delete_group_by_index(config: &mut Config, index: usize) {
if index >= config.search_folders.len() {
eprintln!(
"Index out of range: {index} (have {} groups).",
config.search_folders.len()
);
return;
}
let removed = config.search_folders.remove(index);
if let Err(e) = save(config) {
eprintln!("ERROR Error saving config: {e}");
return;
}
eprintln!("Deleted search group \"{removed}\".");
}
pub fn get_pinned_repos(config: &Config) -> Vec<String> {
config
.pins
.repositories
.iter()
.map(|raw| {
path::normalize(raw).unwrap_or_else(|e| {
eprintln!("ERROR Error parsing pinned path \"{raw}\": {e}");
raw.clone()
})
})
.collect()
}
pub fn add_pinned_repo(config: &mut Config, repo_path: &str) {
let normalized = match path::normalize(repo_path) {
Ok(n) => n,
Err(e) => {
eprintln!("ERROR Error parsing pinned path \"{repo_path}\": {e}");
return;
}
};
let already_exists = config
.pins
.repositories
.iter()
.any(|r| path::normalize(r).unwrap_or_default() == normalized);
if already_exists {
eprintln!("Pinned repo already exists: \"{repo_path}\".");
return;
}
config.pins.repositories.push(repo_path.to_string());
if let Err(e) = save(config) {
eprintln!("ERROR Error saving config: {e}");
return;
}
eprintln!("Added pinned repository \"{repo_path}\".");
}
pub fn delete_pinned_repo(config: &mut Config, repo_path: &str) {
let normalized = path::normalize(repo_path).unwrap_or_default();
let original_len = config.pins.repositories.len();
config
.pins
.repositories
.retain(|r| path::normalize(r).unwrap_or_default() != normalized);
if config.pins.repositories.len() == original_len {
eprintln!("Pinned repo not found: \"{repo_path}\".");
return;
}
if let Err(e) = save(config) {
eprintln!("ERROR Error saving config: {e}");
return;
}
eprintln!("Deleted pinned repository \"{repo_path}\".");
}
pub fn is_branch_globally_pinned(config: &Config, branch: &str) -> bool {
config.pins.branches.global.contains(&branch.to_string())
}
pub fn add_global_pinned_branch(config: &mut Config, branch: &str) {
if config.pins.branches.global.contains(&branch.to_string()) {
eprintln!("Branch \"{branch}\" is already protected.");
return;
}
config.pins.branches.global.push(branch.to_string());
if let Err(e) = save(config) {
eprintln!("ERROR Error saving config: {e}");
return;
}
eprintln!("Added protected branch \"{branch}\".");
}
pub fn delete_global_pinned_branch(config: &mut Config, branch: &str) {
let original_len = config.pins.branches.global.len();
config.pins.branches.global.retain(|b| b != branch);
if config.pins.branches.global.len() == original_len {
eprintln!("Branch \"{branch}\" is not protected.");
return;
}
if let Err(e) = save(config) {
eprintln!("ERROR Error saving config: {e}");
return;
}
eprintln!("Removed protected branch \"{branch}\".");
}
pub fn is_branch_pinned_for_repo(config: &Config, repo_id: &str, branch: &str) -> bool {
config
.pins
.branches
.repositories
.get(repo_id)
.map(|branches| branches.contains(&branch.to_string()))
.unwrap_or(false)
}
pub fn add_repo_pinned_branch(config: &mut Config, repo_id: &str, branch: &str) {
let branches = config
.pins
.branches
.repositories
.entry(repo_id.to_string())
.or_default();
if branches.contains(&branch.to_string()) {
eprintln!("Branch \"{branch}\" already pinned for repo \"{repo_id}\".");
return;
}
branches.push(branch.to_string());
if let Err(e) = save(config) {
eprintln!("ERROR Error saving config: {e}");
return;
}
eprintln!("Added pinned branch \"{branch}\" for repo \"{repo_id}\".");
}
pub fn delete_repo_pinned_branch(config: &mut Config, repo_id: &str, branch: &str) {
let branches = match config.pins.branches.repositories.get_mut(repo_id) {
Some(b) => b,
None => {
eprintln!("No pinned branches found for repo \"{repo_id}\".");
return;
}
};
let original_len = branches.len();
branches.retain(|b| b != branch);
if branches.len() == original_len {
eprintln!("Branch \"{branch}\" not pinned for repo \"{repo_id}\".");
return;
}
if branches.is_empty() {
config.pins.branches.repositories.remove(repo_id);
}
if let Err(e) = save(config) {
eprintln!("ERROR Error saving config: {e}");
return;
}
eprintln!("Deleted pinned branch \"{branch}\" for repo \"{repo_id}\".");
}
pub fn add_alias(config: &mut Config, short: &str, expanded: &str) {
config
.aliases
.insert(short.to_string(), expanded.to_string());
if let Err(e) = save(config) {
eprintln!("ERROR Error saving config: {e}");
return;
}
eprintln!("Added alias \"{short}\" -> \"{expanded}\".");
}
pub fn delete_alias(config: &mut Config, short: &str) {
if config.aliases.remove(short).is_none() {
eprintln!("Alias not found: \"{short}\".");
return;
}
if let Err(e) = save(config) {
eprintln!("ERROR Error saving config: {e}");
return;
}
eprintln!("Deleted alias \"{short}\".");
}