use crate::config::AppConfig;
use crate::models::{MrStatus, SavedMr, SavedState, TrackedMr};
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::PathBuf;
use zeroize::Zeroizing;
pub async fn save_config_async(config: &AppConfig) {
if let Some(config_dir) = get_save_dir() {
let path = config_dir.join("config.json");
if let Ok(json) = serde_json::to_string_pretty(config) {
if let Err(e) = tokio::fs::create_dir_all(&config_dir).await {
tracing::error!(error = %e, path = ?config_dir, "Failed to create config directory");
return;
}
if let Err(e) = tokio::fs::write(&path, json).await {
tracing::error!(error = %e, path = ?path, "Failed to write config.json");
}
}
}
}
fn prompt_required(label: &str) -> String {
loop {
print!("{}: ", label);
let _ = std::io::stdout().flush();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_ok() {
let value = input.trim().to_string();
if !value.is_empty() {
return value;
}
}
println!(" ⚠️ This field is required, please enter a value.");
}
}
pub async fn ensure_gitlab_config(config: &mut crate::config::AppConfig) {
let mut changed = false;
let url_from_env = std::env::var("GITLAB_URL")
.ok()
.filter(|v| !v.trim().is_empty());
let url_in_config = config.gitlab_url.as_deref().unwrap_or("").trim().is_empty();
if url_from_env.is_none() && url_in_config {
println!("🌐 No GitLab URL found in config or environment.");
println!(" Leave empty to use the default (https://gitlab.com)");
print!("GitLab URL [https://gitlab.com]: ");
let _ = std::io::stdout().flush();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_ok() {
let value = input.trim().to_string();
config.gitlab_url = if value.is_empty() {
Some("https://gitlab.com".to_string())
} else {
Some(value)
};
changed = true;
}
}
let id_from_env = std::env::var("GITLAB_PROJECT_ID")
.ok()
.filter(|v| !v.trim().is_empty());
let id_in_config = config.project_id.as_deref().unwrap_or("").trim().is_empty();
if id_from_env.is_none() && id_in_config {
println!("🔢 No GitLab Project ID found in config or environment.");
let value = prompt_required("Please enter your GitLab Project ID");
config.project_id = Some(value);
changed = true;
}
if changed {
save_config_async(config).await;
println!("✅ Config saved to config.json!\n");
}
}
const KEYRING_SERVICE: &str = "gitlab-tracker";
const KEYRING_ACCOUNT: &str = "gitlab_token";
const KEYRING_SERVICE_LEGACY: &str = "gitlab_tracker";
pub fn migrate_legacy_keyring_entry() {
if let Ok(canonical) = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT) {
if let Ok(existing) = canonical.get_password() {
if !existing.trim().is_empty() {
tracing::debug!(
"Canonical keyring entry already populated — skipping legacy migration"
);
return;
}
}
}
let legacy_token = match keyring::Entry::new(KEYRING_SERVICE_LEGACY, KEYRING_ACCOUNT) {
Ok(entry) => match entry.get_password() {
Ok(pwd) if !pwd.trim().is_empty() => Zeroizing::new(pwd.trim().to_string()),
_ => return,
},
Err(_) => return,
};
tracing::info!(
from = KEYRING_SERVICE_LEGACY,
to = KEYRING_SERVICE,
"Migrating token from legacy keyring entry to canonical entry"
);
match keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT) {
Ok(entry) => {
if let Err(e) = entry.set_password(&legacy_token) {
tracing::error!(error = %e, "Failed to write token to canonical keyring entry during migration");
return;
}
}
Err(e) => {
tracing::error!(error = %e, "Failed to open canonical keyring entry during migration");
return;
}
}
match keyring::Entry::new(KEYRING_SERVICE_LEGACY, KEYRING_ACCOUNT) {
Ok(entry) => {
if let Err(e) = entry.delete_password() {
tracing::warn!(error = %e, "Token migrated but failed to delete legacy keyring entry");
} else {
tracing::info!("Legacy keyring entry deleted successfully");
}
}
Err(e) => {
tracing::warn!(error = %e, "Token migrated but could not open legacy keyring entry for deletion");
}
}
}
pub fn get_or_prompt_token() -> Zeroizing<String> {
if let Ok(tok) = std::env::var("GITLAB_TOKEN") {
let tok = Zeroizing::new(tok);
if !tok.trim().is_empty() {
tracing::info!("GITLAB_TOKEN loaded from environment variable");
return Zeroizing::new(tok.trim().to_string());
}
}
tracing::debug!(
service = KEYRING_SERVICE,
account = KEYRING_ACCOUNT,
"Attempting to read token from OS keyring"
);
match keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT) {
Ok(entry) => match entry.get_password() {
Ok(password) => {
let password = Zeroizing::new(password);
if !password.trim().is_empty() {
tracing::info!("GITLAB_TOKEN loaded from OS keyring");
return Zeroizing::new(password.trim().to_string());
}
tracing::warn!("Keyring entry found but token is empty — falling back to prompt");
}
Err(e) => {
tracing::warn!(error = %e, "Failed to read token from OS keyring — falling back to prompt");
}
},
Err(e) => {
tracing::warn!(error = %e, "Failed to open keyring entry — falling back to prompt");
}
}
println!("🔑 No GITLAB_TOKEN found in environment or system Keyring.");
match rpassword::prompt_password("Please enter your GitLab Personal Access Token: ") {
Ok(raw) => {
let token = Zeroizing::new(raw);
if !token.trim().is_empty() {
let token = Zeroizing::new(token.trim().to_string());
tracing::debug!(
service = KEYRING_SERVICE,
account = KEYRING_ACCOUNT,
"Saving token to OS keyring"
);
match keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT) {
Ok(entry) => match entry.set_password(&token) {
Ok(_) => {
tracing::info!("Token successfully saved to OS keyring");
println!("✅ Token securely saved to OS Keyring!\n");
}
Err(e) => {
tracing::error!(error = %e, "Failed to save token to OS keyring");
}
},
Err(e) => {
tracing::error!(error = %e, "Failed to open keyring entry for writing");
}
}
return token;
}
}
Err(e) => {
tracing::error!(error = %e, "Failed to read token from prompt");
}
}
panic!("Error: Personal Access Token is required to run gitlab_tracker.");
}
pub fn get_save_dir() -> Option<PathBuf> {
let project_dirs = directories::ProjectDirs::from("com", "gitlab-tracker", "gitlab-tracker")?;
Some(project_dirs.config_dir().to_path_buf())
}
pub async fn load_or_create_config_async() -> AppConfig {
if let Some(config_dir) = get_save_dir() {
let config_path = config_dir.join("config.json");
if let Ok(content) = tokio::fs::read_to_string(&config_path).await {
if let Ok(mut config) = serde_json::from_str::<AppConfig>(&content) {
if let Ok(env_branches) = std::env::var("DEFAULT_BRANCHES") {
config.default_branches = env_branches
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
if let Ok(env_prefixes) = std::env::var("TABLE_LABEL_PREFIXES") {
config.table_label_prefixes = env_prefixes
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
if let Ok(val) = std::env::var("ACTIVITY_RECENT_DAYS") {
if let Ok(days) = val.trim().parse::<u64>() {
config.activity_recent_days = days;
}
}
if let Ok(val) = std::env::var("ACTIVITY_STALE_DAYS") {
if let Ok(days) = val.trim().parse::<u64>() {
config.activity_stale_days = days;
}
}
return config;
}
}
let default_config = AppConfig::default();
let _ = tokio::fs::create_dir_all(&config_dir).await;
if let Ok(json) = serde_json::to_string_pretty(&default_config) {
let _ = tokio::fs::write(config_path, json).await;
}
return default_config;
}
AppConfig::default()
}
pub async fn load_state_async() -> (Vec<SavedMr>, Vec<String>, HashMap<String, HashSet<String>>) {
if let Some(config_dir) = get_save_dir() {
let path = config_dir.join("tracker_state.json");
if let Ok(content) = tokio::fs::read_to_string(path).await {
if let Ok(state) = serde_json::from_str::<SavedState>(&content) {
return (state.mrs, state.branches, state.last_known_branches);
}
}
}
(vec![], vec![], HashMap::new())
}
pub async fn save_state_async(
mrs: &[TrackedMr],
branches: &[String],
last_known_branches: &HashMap<String, HashSet<String>>,
) {
let state = SavedState {
mrs: mrs
.iter()
.map(|m| SavedMr {
id: m.id.clone(),
title: m.title.clone(),
sha: m.sha.clone(),
found_branches: match &m.status {
MrStatus::MergedIn(set) => set.clone(),
_ => HashSet::new(),
},
description: Some(m.description.clone()),
author: Some(m.author.clone()),
assignee: Some(m.assignee.clone()),
reviewers: m.reviewers.clone(),
milestone: Some(m.milestone.clone()),
milestone_due_date: m.milestone_due_date.clone(),
web_url: Some(m.web_url.clone()),
labels: Some(m.labels.clone()),
updated_at: m.updated_at.clone(),
source_branch: Some(m.source_branch.clone()),
target_branch: Some(m.target_branch.clone()),
state: m.state.clone(),
merged_by: m.merged_by.clone(),
merged_at: m.merged_at.clone(),
pipelines: m.pipelines.clone(),
user_notes_count: m.user_notes_count,
flagged: m.flagged,
})
.collect(),
branches: branches.to_vec(),
last_known_branches: last_known_branches.clone(),
};
if let Ok(json) = serde_json::to_string_pretty(&state) {
if let Some(config_dir) = get_save_dir() {
let path = config_dir.join("tracker_state.json");
let _ = tokio::fs::create_dir_all(&config_dir).await;
let _ = tokio::fs::write(path, json).await;
}
}
}