use std::path::{Path, PathBuf};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::domain::Section;
use crate::error::Result;
use crate::provider::Connection;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct PullRequestBinding {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub connection_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct WorkItemBinding {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub connection_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_id: Option<String>,
}
fn merged_ids(ids: &[String], legacy: &Option<String>) -> Vec<String> {
let mut out = ids.to_vec();
if let Some(c) = legacy {
if !out.contains(c) {
out.push(c.clone());
}
}
out
}
impl PullRequestBinding {
pub fn ids(&self) -> Vec<String> {
merged_ids(&self.connection_ids, &self.connection_id)
}
}
impl WorkItemBinding {
pub fn ids(&self) -> Vec<String> {
merged_ids(&self.connection_ids, &self.connection_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PipelineSubscription {
pub connection_id: String,
#[serde(default)]
pub definition_ids: Vec<String>,
#[serde(default)]
pub auto_discover_all: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct PipelineBinding {
#[serde(default)]
pub subscriptions: Vec<PipelineSubscription>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UiState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default)]
pub active_section: Section,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hidden_sections: Vec<Section>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview_hidden: Option<Vec<Section>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hidden_work_item_states: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dismissed_launchpad_items: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dismissed_notifications: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pr_sort: Option<SortPref>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub work_item_sort: Option<SortPref>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pipeline_sort: Option<SortPref>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pipeline_group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub work_item_group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hidden_columns: Option<[Vec<String>; 3]>,
#[serde(default)]
pub notifications: NotificationPrefs,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pr_views: Vec<SavedView>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub work_item_views: Vec<SavedView>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pipeline_views: Vec<SavedView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_sla_hours: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mouse: Option<bool>,
}
pub const DEFAULT_REVIEW_SLA_HOURS: u32 = 24;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedView {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sort: Option<SortPref>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hidden_states: Vec<String>,
}
fn yes() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct NotificationPrefs {
#[serde(default = "yes")]
pub pipeline_failed: bool,
#[serde(default = "yes")]
pub review_requested: bool,
#[serde(default = "yes")]
pub pr_approved: bool,
#[serde(default = "yes")]
pub pr_changes_requested: bool,
#[serde(default = "yes")]
pub pipeline_approval_needed: bool,
}
impl Default for NotificationPrefs {
fn default() -> Self {
Self {
pipeline_failed: true,
review_requested: true,
pr_approved: true,
pr_changes_requested: true,
pipeline_approval_needed: true,
}
}
}
impl NotificationPrefs {
pub fn any(&self) -> bool {
self.pipeline_failed
|| self.review_requested
|| self.pr_approved
|| self.pr_changes_requested
|| self.pipeline_approval_needed
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SortPref {
pub key: String,
pub desc: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ForgetopConfig {
#[serde(default)]
pub connections: Vec<Connection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_requests: Option<PullRequestBinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub work_items: Option<WorkItemBinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pipelines: Option<PipelineBinding>,
#[serde(default)]
pub ui: UiState,
#[serde(default)]
pub pipelines_opt_in: bool,
}
impl ForgetopConfig {
pub fn find_connection(&self, id: &str) -> Option<&Connection> {
self.connections.iter().find(|c| c.id == id)
}
}
#[async_trait]
pub trait ConfigStore: Send + Sync {
async fn load(&self) -> Result<ForgetopConfig>;
async fn save(&self, config: &ForgetopConfig) -> Result<()>;
}
pub fn default_config_path() -> PathBuf {
let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
base.join("forgetop").join("config.json")
}
pub struct JsonConfigStore {
path: PathBuf,
}
impl JsonConfigStore {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn at_default_path() -> Self {
Self::new(default_config_path())
}
}
#[async_trait]
impl ConfigStore for JsonConfigStore {
async fn load(&self) -> Result<ForgetopConfig> {
if !self.path.exists() {
return Ok(ForgetopConfig::default());
}
let bytes = tokio::fs::read(&self.path).await?;
Ok(serde_json::from_slice(&bytes)?)
}
async fn save(&self, config: &ForgetopConfig) -> Result<()> {
if let Some(dir) = self.path.parent() {
tokio::fs::create_dir_all(dir).await?;
}
let json = serde_json::to_vec_pretty(config)?;
let tmp = with_extension(&self.path, "tmp");
tokio::fs::write(&tmp, &json).await?;
tokio::fs::rename(&tmp, &self.path).await?;
Ok(())
}
}
fn with_extension(path: &Path, ext: &str) -> PathBuf {
let mut p = path.to_path_buf();
p.set_extension(ext);
p
}
pub struct InMemoryConfigStore {
config: std::sync::Mutex<ForgetopConfig>,
}
impl InMemoryConfigStore {
pub fn new(seed: ForgetopConfig) -> Self {
Self { config: std::sync::Mutex::new(seed) }
}
}
impl Default for InMemoryConfigStore {
fn default() -> Self {
Self::new(ForgetopConfig::default())
}
}
#[async_trait]
impl ConfigStore for InMemoryConfigStore {
async fn load(&self) -> Result<ForgetopConfig> {
Ok(self.config.lock().unwrap().clone())
}
async fn save(&self, config: &ForgetopConfig) -> Result<()> {
*self.config.lock().unwrap() = config.clone();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_emptied_repository_scope_survives_json_as_a_different_value_from_never_set() {
use crate::domain::ProviderType;
use crate::provider::Connection;
let conn = |repo_scope: Option<Vec<String>>| Connection {
id: "gh".into(),
provider_type: ProviderType::GitHub,
display_name: "GitHub".into(),
base_url: None,
organization: None,
project: None,
repository: Some("acme/pay".into()),
username: None,
credential_ref: None,
repo_scope,
};
let roundtrip = |c: &Connection| -> Connection { serde_json::from_str(&serde_json::to_string(c).unwrap()).unwrap() };
let never = conn(None);
assert!(!serde_json::to_string(&never).unwrap().contains("repo_scope"));
assert_eq!(roundtrip(&never).repo_scope, None);
assert_eq!(never.resolve_repo_scope(|| never.repository.clone()), vec!["acme/pay".to_string()]);
let emptied = conn(Some(vec![]));
assert!(serde_json::to_string(&emptied).unwrap().contains(r#""repo_scope":[]"#));
let read = roundtrip(&emptied);
assert_eq!(read.repo_scope, Some(vec![]));
assert!(read.resolve_repo_scope(|| read.repository.clone()).is_empty(), "an emptied scope must not refill");
let chosen = conn(Some(vec!["acme/ledger".into()]));
assert_eq!(roundtrip(&chosen).repo_scope, Some(vec!["acme/ledger".to_string()]));
}
#[test]
fn binding_ids_merge_legacy_and_dedup() {
let legacy: PullRequestBinding = serde_json::from_str(r#"{"connection_id":"gh-1"}"#).unwrap();
assert_eq!(legacy.ids(), vec!["gh-1".to_string()]);
let b = PullRequestBinding { connection_ids: vec!["a".into(), "b".into()], connection_id: Some("a".into()) };
assert_eq!(b.ids(), vec!["a".to_string(), "b".to_string()]);
let json = serde_json::to_string(&PullRequestBinding { connection_ids: vec!["x".into()], connection_id: None }).unwrap();
assert_eq!(json, r#"{"connection_ids":["x"]}"#);
}
}