use crate::config::AppConfig;
use crate::models::{MrStatus, SavedMr, SavedState, TrackedMr};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::PathBuf;
use zeroize::Zeroizing;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectEntry {
pub name: Option<String>,
pub gitlab_url: String,
pub project_id: String,
#[serde(default)]
pub active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_branches: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub table_label_prefixes: Option<Vec<String>>,
#[serde(
skip_serializing_if = "Option::is_none",
alias = "diff_difficulty_profile"
)]
pub complexity_profile: Option<crate::models::DifficultyProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tracked_branches: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_interval_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activity_stale_days: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activity_recent_days: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visible_columns: Option<crate::config::VisibleColumns>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label_colors: Option<std::collections::HashMap<String, crate::config::LabelColorConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tracker: Option<TrackerConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackerConfig {
pub provider: String,
pub url: String,
#[serde(flatten)]
pub extra: toml::Table,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectsConfig {
#[serde(rename = "project")]
pub projects: Vec<ProjectEntry>,
}
pub fn projects_toml_path() -> Option<PathBuf> {
get_save_dir().map(|d| d.join("projects.toml"))
}
pub async fn load_projects_toml() -> ProjectsConfig {
let Some(path) = projects_toml_path() else {
return ProjectsConfig::default();
};
match tokio::fs::read_to_string(&path).await {
Ok(content) => toml::from_str(&content).unwrap_or_else(|e| {
tracing::warn!(error = %e, "Failed to parse projects.toml — using empty config");
ProjectsConfig::default()
}),
Err(_) => ProjectsConfig::default(),
}
}
async fn save_projects_toml(cfg: &ProjectsConfig) {
let Some(path) = projects_toml_path() else {
return;
};
if let Some(dir) = path.parent() {
let _ = tokio::fs::create_dir_all(dir).await;
}
match toml::to_string_pretty(cfg) {
Ok(content) => {
if let Err(e) = tokio::fs::write(&path, content).await {
tracing::error!(error = %e, path = ?path, "Failed to write projects.toml");
}
}
Err(e) => tracing::error!(error = %e, "Failed to serialise projects.toml"),
}
}
async fn try_migrate_from_config_json() -> Option<ProjectEntry> {
let config_dir = get_save_dir()?;
let toml_path = config_dir.join("projects.toml");
if toml_path.exists() {
return None;
}
let json_path = config_dir.join("config.json");
let content = tokio::fs::read_to_string(&json_path).await.ok()?;
let root: serde_json::Value = serde_json::from_str(&content).ok()?;
let gitlab_url = root
.get("gitlab_url")
.and_then(|v| v.as_str())
.filter(|v| !v.trim().is_empty())
.map(|v| v.trim_end_matches('/').to_string())?;
let project_id = root
.get("project_id")
.and_then(|v| v.as_str())
.filter(|v| !v.trim().is_empty())
.map(|v| v.to_string())?;
let default_branches: Option<Vec<String>> = root
.get("default_branches")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.filter(|v: &Vec<String>| !v.is_empty());
let table_label_prefixes: Option<Vec<String>> = root
.get("table_label_prefixes")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.filter(|v: &Vec<String>| !v.is_empty());
let complexity_profile: Option<crate::models::DifficultyProfile> = root
.get("complexity_profile")
.or_else(|| root.get("diff_difficulty_profile"))
.and_then(|v| serde_json::from_value(v.clone()).ok());
let refresh_interval_secs: Option<u64> =
root.get("refresh_interval_secs").and_then(|v| v.as_u64());
let activity_stale_days: Option<u64> = root.get("activity_stale_days").and_then(|v| v.as_u64());
let activity_recent_days: Option<u64> =
root.get("activity_recent_days").and_then(|v| v.as_u64());
let visible_columns: Option<crate::config::VisibleColumns> = root
.get("visible_columns")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let label_colors: Option<std::collections::HashMap<String, crate::config::LabelColorConfig>> =
root.get("label_colors")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.filter(|m: &std::collections::HashMap<_, _>| !m.is_empty());
#[cfg(feature = "redmine")]
let tracker = try_migrate_redmine_yaml(&config_dir).await;
#[cfg(not(feature = "redmine"))]
let tracker: Option<TrackerConfig> = None;
let entry = ProjectEntry {
name: Some("Migrated from config.json".to_string()),
gitlab_url: gitlab_url.clone(),
project_id: project_id.clone(),
active: true,
default_branches,
table_label_prefixes,
complexity_profile,
tracked_branches: None, refresh_interval_secs,
activity_stale_days,
activity_recent_days,
visible_columns,
label_colors,
tracker,
};
let cfg = ProjectsConfig {
projects: vec![entry.clone()],
};
save_projects_toml(&cfg).await;
tracing::info!(
gitlab_url = %gitlab_url,
project_id = %project_id,
"Migrated project settings from config.json to projects.toml"
);
println!("✅ Project settings migrated from config.json to projects.toml\n");
Some(entry)
}
#[cfg(feature = "redmine")]
async fn try_migrate_redmine_yaml(config_dir: &std::path::Path) -> Option<TrackerConfig> {
let yaml_path = config_dir.join("redmine.yaml");
let content = tokio::fs::read_to_string(&yaml_path).await.ok()?;
let map: serde_yml::Mapping = serde_yml::from_str(&content).ok()?;
let url = map
.get("redmine_url")
.and_then(|v| v.as_str())
.filter(|v| !v.trim().is_empty())
.map(|v| v.trim_end_matches('/').to_string())?;
let mut extra = toml::Table::new();
for (k, v) in &map {
let key = match k.as_str() {
Some(s) if s != "redmine_url" => s.to_string(),
_ => continue,
};
if let Ok(json) = serde_json::to_value(v) {
if let Ok(toml_val) = toml::Value::try_from(json) {
extra.insert(key, toml_val);
}
}
}
tracing::info!(url = %url, "Migrated Redmine config from redmine.yaml to projects.toml");
println!("✅ Redmine config migrated from redmine.yaml to projects.toml");
match tokio::fs::remove_file(&yaml_path).await {
Ok(_) => println!("🗑️ redmine.yaml removed (settings are now in projects.toml)\n"),
Err(e) => {
tracing::warn!(error = %e, path = ?yaml_path, "Could not remove legacy redmine.yaml")
}
}
Some(TrackerConfig {
provider: "redmine".to_string(),
url,
extra,
})
}
async fn try_enrich_from_config_json(entry: &mut ProjectEntry) -> bool {
if entry.default_branches.is_some()
&& entry.table_label_prefixes.is_some()
&& entry.complexity_profile.is_some()
&& entry.refresh_interval_secs.is_some()
&& entry.activity_stale_days.is_some()
&& entry.activity_recent_days.is_some()
&& entry.visible_columns.is_some()
&& entry.label_colors.is_some()
{
return false;
}
let Some(config_dir) = get_save_dir() else {
return false;
};
let json_path = config_dir.join("config.json");
let Ok(content) = tokio::fs::read_to_string(&json_path).await else {
return false;
};
let Ok(root) = serde_json::from_str::<serde_json::Value>(&content) else {
return false;
};
let mut changed = false;
if entry.default_branches.is_none() {
let val: Option<Vec<String>> = root
.get("default_branches")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.filter(|v: &Vec<String>| !v.is_empty());
if val.is_some() {
entry.default_branches = val;
changed = true;
}
}
if entry.table_label_prefixes.is_none() {
let val: Option<Vec<String>> = root
.get("table_label_prefixes")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.filter(|v: &Vec<String>| !v.is_empty());
if val.is_some() {
entry.table_label_prefixes = val;
changed = true;
}
}
if entry.complexity_profile.is_none() {
let val: Option<crate::models::DifficultyProfile> = root
.get("complexity_profile")
.or_else(|| root.get("diff_difficulty_profile"))
.and_then(|v| serde_json::from_value(v.clone()).ok());
if val.is_some() {
entry.complexity_profile = val;
changed = true;
}
}
if entry.refresh_interval_secs.is_none() {
if let Some(val) = root.get("refresh_interval_secs").and_then(|v| v.as_u64()) {
entry.refresh_interval_secs = Some(val);
changed = true;
}
}
if entry.activity_stale_days.is_none() {
if let Some(val) = root.get("activity_stale_days").and_then(|v| v.as_u64()) {
entry.activity_stale_days = Some(val);
changed = true;
}
}
if entry.activity_recent_days.is_none() {
if let Some(val) = root.get("activity_recent_days").and_then(|v| v.as_u64()) {
entry.activity_recent_days = Some(val);
changed = true;
}
}
if entry.visible_columns.is_none() {
let val: Option<crate::config::VisibleColumns> = root
.get("visible_columns")
.and_then(|v| serde_json::from_value(v.clone()).ok());
if val.is_some() {
entry.visible_columns = val;
changed = true;
}
}
if entry.label_colors.is_none() {
let val: Option<std::collections::HashMap<String, crate::config::LabelColorConfig>> = root
.get("label_colors")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.filter(|m: &std::collections::HashMap<_, _>| !m.is_empty());
if val.is_some() {
entry.label_colors = val;
changed = true;
}
}
changed
}
pub async fn resolve_active_project() -> ProjectEntry {
let env_url = std::env::var("GITLAB_URL")
.ok()
.filter(|v| !v.trim().is_empty());
let env_id = std::env::var("GITLAB_PROJECT_ID")
.ok()
.filter(|v| !v.trim().is_empty());
if let (Some(url), Some(id)) = (env_url, env_id) {
return ProjectEntry {
name: None,
gitlab_url: url.trim_end_matches('/').to_string(),
project_id: id,
active: true,
default_branches: None,
table_label_prefixes: None,
complexity_profile: None,
tracked_branches: None,
refresh_interval_secs: None,
activity_stale_days: None,
activity_recent_days: None,
visible_columns: None,
label_colors: None,
tracker: None,
};
}
let mut projects_cfg = load_projects_toml().await;
if !projects_cfg.projects.is_empty() {
let idx = projects_cfg
.projects
.iter()
.position(|p| p.active)
.unwrap_or(0);
let entry = &mut projects_cfg.projects[idx];
entry.gitlab_url = entry.gitlab_url.trim_end_matches('/').to_string();
let needs_save_json = try_enrich_from_config_json(entry).await;
if needs_save_json {
tracing::info!(
"Backfilled project-scoped settings into projects.toml from config.json"
);
}
#[cfg(feature = "redmine")]
let needs_save_redmine = if entry.tracker.is_none() {
if let Some(config_dir) = get_save_dir() {
if let Some(tracker) = try_migrate_redmine_yaml(&config_dir).await {
entry.tracker = Some(tracker);
true
} else {
false
}
} else {
false
}
} else {
false
};
#[cfg(not(feature = "redmine"))]
let needs_save_redmine = false;
if needs_save_json || needs_save_redmine {
save_projects_toml(&projects_cfg).await;
}
return projects_cfg.projects[idx].clone();
}
if let Some(migrated) = try_migrate_from_config_json().await {
return migrated;
}
println!("⚙️ No project configured yet. Let's set one up.\n");
print!("GitLab URL [https://gitlab.com]: ");
let _ = std::io::stdout().flush();
let mut input = String::new();
let gitlab_url = if std::io::stdin().read_line(&mut input).is_ok() {
let v = input.trim().to_string();
if v.is_empty() {
"https://gitlab.com".to_string()
} else {
v
}
} else {
"https://gitlab.com".to_string()
};
let project_id = prompt_required("GitLab Project ID");
print!("Project name (optional label): ");
let _ = std::io::stdout().flush();
let mut name_input = String::new();
let name = if std::io::stdin().read_line(&mut name_input).is_ok() {
let v = name_input.trim().to_string();
if v.is_empty() {
None
} else {
Some(v)
}
} else {
None
};
let entry = ProjectEntry {
name,
gitlab_url: gitlab_url.trim_end_matches('/').to_string(),
project_id: project_id.clone(),
active: true,
default_branches: None,
table_label_prefixes: None,
complexity_profile: None,
tracked_branches: None,
refresh_interval_secs: None,
activity_stale_days: None,
activity_recent_days: None,
visible_columns: None,
label_colors: None,
tracker: None,
};
projects_cfg.projects.push(entry.clone());
save_projects_toml(&projects_cfg).await;
println!("✅ Project saved to projects.toml!\n");
entry
}
pub async fn save_visible_columns_async(cols: &crate::config::VisibleColumns, project_idx: usize) {
let mut cfg = load_projects_toml().await;
if let Some(entry) = cfg.projects.get_mut(project_idx) {
entry.visible_columns = Some(cols.clone());
save_projects_toml(&cfg).await;
}
}
pub async fn save_branches_async(branches: &[String], project_idx: usize) {
let mut cfg = load_projects_toml().await;
if let Some(entry) = cfg.projects.get_mut(project_idx) {
entry.tracked_branches = if branches.is_empty() {
None
} else {
Some(branches.to_vec())
};
save_projects_toml(&cfg).await;
}
}
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.");
}
}
const KEYRING_SERVICE: &str = "gitlab-tracker";
const KEYRING_ACCOUNT_LEGACY: &str = "gitlab_token";
const KEYRING_SERVICE_LEGACY: &str = "gitlab_tracker";
fn account_for(gitlab_url: &str) -> String {
format!("gitlab_token::{}", gitlab_url.trim_end_matches('/'))
}
pub fn migrate_legacy_keyring_entry(gitlab_url: &str) {
let canonical_account = account_for(gitlab_url);
let flat_token = match keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT_LEGACY) {
Ok(entry) => match entry.get_password() {
Ok(pwd) if !pwd.trim().is_empty() => Some(Zeroizing::new(pwd.trim().to_string())),
_ => None,
},
Err(_) => None,
};
if flat_token.is_none() {
if let Ok(legacy_entry) =
keyring::Entry::new(KEYRING_SERVICE_LEGACY, KEYRING_ACCOUNT_LEGACY)
{
if let Ok(pwd) = legacy_entry.get_password() {
let pwd = Zeroizing::new(pwd.trim().to_string());
if !pwd.is_empty() {
tracing::info!(
from = KEYRING_SERVICE_LEGACY,
to = KEYRING_SERVICE,
"Migrating token from legacy service name to canonical service name"
);
if let Ok(canonical_flat) =
keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT_LEGACY)
{
if let Err(e) = canonical_flat.set_password(&pwd) {
tracing::error!(error = %e, "Failed to write to canonical flat entry during step-1 migration");
} else {
if let Err(e) = legacy_entry.delete_credential() {
tracing::warn!(error = %e, "Step-1 migration succeeded but failed to delete legacy entry");
} else {
tracing::info!(
"Legacy keyring entry (step 1) deleted successfully"
);
}
}
}
}
}
}
}
if let Ok(per_instance) = keyring::Entry::new(KEYRING_SERVICE, &canonical_account) {
if let Ok(existing) = per_instance.get_password() {
if !existing.trim().is_empty() {
tracing::debug!(
account = %canonical_account,
"Per-instance keyring entry already populated — skipping step-2 migration"
);
return;
}
}
}
let flat_token = match keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT_LEGACY) {
Ok(entry) => match entry.get_password() {
Ok(pwd) if !pwd.trim().is_empty() => Zeroizing::new(pwd.trim().to_string()),
_ => return,
},
Err(_) => return,
};
tracing::info!(
url = %gitlab_url,
from = KEYRING_ACCOUNT_LEGACY,
to = %canonical_account,
"Migrating token from flat account key to per-instance account key"
);
match keyring::Entry::new(KEYRING_SERVICE, &canonical_account) {
Ok(entry) => {
if let Err(e) = entry.set_password(&flat_token) {
tracing::error!(error = %e, "Failed to write to per-instance keyring entry during step-2 migration");
return;
}
}
Err(e) => {
tracing::error!(error = %e, "Failed to open per-instance keyring entry during step-2 migration");
return;
}
}
match keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT_LEGACY) {
Ok(entry) => {
if let Err(e) = entry.delete_credential() {
tracing::warn!(error = %e, "Step-2 migration succeeded but failed to delete flat legacy entry");
} else {
tracing::info!("Flat legacy keyring entry (step 2) deleted successfully");
}
}
Err(e) => {
tracing::warn!(error = %e, "Step-2 migration succeeded but could not open flat legacy entry for deletion: {e}");
}
}
}
pub fn get_or_prompt_token(gitlab_url: &str) -> 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());
}
}
let account = account_for(gitlab_url);
tracing::debug!(
service = KEYRING_SERVICE,
account = %account,
"Attempting to read token from OS keyring"
);
match keyring::Entry::new(KEYRING_SERVICE, &account) {
Ok(entry) => match entry.get_password() {
Ok(password) => {
let password = Zeroizing::new(password);
if !password.trim().is_empty() {
tracing::info!(url = %gitlab_url, "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 = %account,
"Saving token to OS keyring"
);
match keyring::Entry::new(KEYRING_SERVICE, &account) {
Ok(entry) => match entry.set_password(&token) {
Ok(_) => {
tracing::info!(url = %gitlab_url, "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()
}
fn tracker_state_file_name(gitlab_url: &str, project_id: &str) -> String {
const FNV_OFFSET: u32 = 2_166_136_261;
const FNV_PRIME: u32 = 16_777_619;
let mut hash = FNV_OFFSET;
let url = gitlab_url.trim_end_matches('/');
for byte in url
.bytes()
.chain(b"|".iter().copied())
.chain(project_id.bytes())
{
hash ^= byte as u32;
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("tracker_{:08x}.json", hash)
}
async fn migrate_tracker_state_file(
config_dir: &std::path::Path,
gitlab_url: &str,
project_id: &str,
) {
let legacy = config_dir.join("tracker_state.json");
let target_name = tracker_state_file_name(gitlab_url, project_id);
let target = config_dir.join(&target_name);
if !legacy.exists() || target.exists() {
return;
}
match tokio::fs::rename(&legacy, &target).await {
Ok(_) => {
tracing::info!(
from = "tracker_state.json",
to = %target_name,
"Silently migrated tracker state to tenant-scoped file"
);
}
Err(e) => {
tracing::warn!(
error = %e,
"Could not rename tracker_state.json to tenant-scoped file — \
will create a fresh state file on next save"
);
}
}
}
pub async fn load_state_async(
gitlab_url: &str,
project_id: &str,
) -> (Vec<SavedMr>, Vec<String>, HashMap<String, HashSet<String>>) {
let Some(config_dir) = get_save_dir() else {
return (vec![], vec![], HashMap::new());
};
migrate_tracker_state_file(&config_dir, gitlab_url, project_id).await;
let file_name = tracker_state_file_name(gitlab_url, project_id);
let path = config_dir.join(&file_name);
if let Ok(content) = tokio::fs::read_to_string(&path).await {
if let Ok(state) = serde_json::from_str::<SavedState>(&content) {
if !state.branches.is_empty() {
let mut cfg = load_projects_toml().await;
let needs_migration = cfg
.projects
.first()
.map(|p| p.tracked_branches.is_none())
.unwrap_or(false);
if needs_migration {
if let Some(entry) = cfg.projects.first_mut() {
entry.tracked_branches = Some(state.branches.clone());
save_projects_toml(&cfg).await;
tracing::info!(
"Migrated tracked branches from {} to projects.toml",
file_name
);
}
}
}
return (state.mrs, state.branches, state.last_known_branches);
}
}
(vec![], vec![], HashMap::new())
}
pub async fn save_state_async(
mrs: &[TrackedMr],
last_known_branches: &HashMap<String, HashSet<String>>,
gitlab_url: &str,
project_id: &str,
) {
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,
linked_ticket: m.linked_ticket.clone(),
diff_stats: m.diff_stats.clone(),
})
.collect(),
branches: 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 file_name = tracker_state_file_name(gitlab_url, project_id);
let path = config_dir.join(file_name);
let _ = tokio::fs::create_dir_all(&config_dir).await;
let _ = tokio::fs::write(path, json).await;
}
}
}