1use std::path::{Path, PathBuf};
5
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8
9use crate::domain::Section;
10use crate::error::Result;
11use crate::provider::Connection;
12
13#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
14pub struct PullRequestBinding {
15 #[serde(default, skip_serializing_if = "Vec::is_empty")]
17 pub connection_ids: Vec<String>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub connection_id: Option<String>,
21}
22
23#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
24pub struct WorkItemBinding {
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub connection_ids: Vec<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub connection_id: Option<String>,
29}
30
31fn merged_ids(ids: &[String], legacy: &Option<String>) -> Vec<String> {
33 let mut out = ids.to_vec();
34 if let Some(c) = legacy {
35 if !out.contains(c) {
36 out.push(c.clone());
37 }
38 }
39 out
40}
41
42impl PullRequestBinding {
43 pub fn ids(&self) -> Vec<String> {
44 merged_ids(&self.connection_ids, &self.connection_id)
45 }
46}
47
48impl WorkItemBinding {
49 pub fn ids(&self) -> Vec<String> {
50 merged_ids(&self.connection_ids, &self.connection_id)
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct PipelineSubscription {
57 pub connection_id: String,
58 #[serde(default)]
59 pub definition_ids: Vec<String>,
60 #[serde(default)]
61 pub auto_discover_all: bool,
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
65pub struct PipelineBinding {
66 #[serde(default)]
67 pub subscriptions: Vec<PipelineSubscription>,
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71pub struct UiState {
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub theme: Option<String>,
74 #[serde(default)]
75 pub active_section: Section,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 pub hidden_sections: Vec<Section>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub preview_hidden: Option<Vec<Section>>,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub hidden_work_item_states: Vec<String>,
88 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub dismissed_launchpad_items: Vec<String>,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub dismissed_notifications: Vec<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub pr_sort: Option<SortPref>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub work_item_sort: Option<SortPref>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub pipeline_sort: Option<SortPref>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub pipeline_group: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub hidden_columns: Option<[Vec<String>; 3]>,
112 #[serde(default)]
114 pub notifications: NotificationPrefs,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub pr_views: Vec<SavedView>,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub work_item_views: Vec<SavedView>,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub pipeline_views: Vec<SavedView>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub review_sla_hours: Option<u32>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub mouse: Option<bool>,
130}
131
132pub const DEFAULT_REVIEW_SLA_HOURS: u32 = 24;
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SavedView {
138 pub name: String,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub filter: Option<String>,
142 #[serde(default, skip_serializing_if = "String::is_empty")]
144 pub query: String,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub sort: Option<SortPref>,
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub hidden_states: Vec<String>,
150}
151
152fn yes() -> bool {
153 true
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158pub struct NotificationPrefs {
159 #[serde(default = "yes")]
160 pub pipeline_failed: bool,
161 #[serde(default = "yes")]
162 pub review_requested: bool,
163 #[serde(default = "yes")]
164 pub pr_approved: bool,
165 #[serde(default = "yes")]
166 pub pr_changes_requested: bool,
167 #[serde(default = "yes")]
168 pub pipeline_approval_needed: bool,
169}
170
171impl Default for NotificationPrefs {
172 fn default() -> Self {
173 Self {
174 pipeline_failed: true,
175 review_requested: true,
176 pr_approved: true,
177 pr_changes_requested: true,
178 pipeline_approval_needed: true,
179 }
180 }
181}
182
183impl NotificationPrefs {
184 pub fn any(&self) -> bool {
186 self.pipeline_failed
187 || self.review_requested
188 || self.pr_approved
189 || self.pr_changes_requested
190 || self.pipeline_approval_needed
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct SortPref {
197 pub key: String,
198 pub desc: bool,
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
203pub struct ForgetopConfig {
204 #[serde(default)]
205 pub connections: Vec<Connection>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub pull_requests: Option<PullRequestBinding>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub work_items: Option<WorkItemBinding>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub pipelines: Option<PipelineBinding>,
212 #[serde(default)]
213 pub ui: UiState,
214 #[serde(default)]
218 pub pipelines_opt_in: bool,
219}
220
221impl ForgetopConfig {
222 pub fn find_connection(&self, id: &str) -> Option<&Connection> {
223 self.connections.iter().find(|c| c.id == id)
224 }
225}
226
227#[async_trait]
229pub trait ConfigStore: Send + Sync {
230 async fn load(&self) -> Result<ForgetopConfig>;
231 async fn save(&self, config: &ForgetopConfig) -> Result<()>;
232}
233
234pub fn default_config_path() -> PathBuf {
237 let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
238 base.join("forgetop").join("config.json")
239}
240
241pub struct JsonConfigStore {
243 path: PathBuf,
244}
245
246impl JsonConfigStore {
247 pub fn new(path: impl Into<PathBuf>) -> Self {
248 Self { path: path.into() }
249 }
250
251 pub fn at_default_path() -> Self {
252 Self::new(default_config_path())
253 }
254}
255
256#[async_trait]
257impl ConfigStore for JsonConfigStore {
258 async fn load(&self) -> Result<ForgetopConfig> {
259 if !self.path.exists() {
260 return Ok(ForgetopConfig::default());
261 }
262 let bytes = tokio::fs::read(&self.path).await?;
263 Ok(serde_json::from_slice(&bytes)?)
264 }
265
266 async fn save(&self, config: &ForgetopConfig) -> Result<()> {
267 if let Some(dir) = self.path.parent() {
268 tokio::fs::create_dir_all(dir).await?;
269 }
270 let json = serde_json::to_vec_pretty(config)?;
271 let tmp = with_extension(&self.path, "tmp");
273 tokio::fs::write(&tmp, &json).await?;
274 tokio::fs::rename(&tmp, &self.path).await?;
275 Ok(())
276 }
277}
278
279fn with_extension(path: &Path, ext: &str) -> PathBuf {
280 let mut p = path.to_path_buf();
281 p.set_extension(ext);
282 p
283}
284
285pub struct InMemoryConfigStore {
287 config: std::sync::Mutex<ForgetopConfig>,
288}
289
290impl InMemoryConfigStore {
291 pub fn new(seed: ForgetopConfig) -> Self {
292 Self { config: std::sync::Mutex::new(seed) }
293 }
294}
295
296impl Default for InMemoryConfigStore {
297 fn default() -> Self {
298 Self::new(ForgetopConfig::default())
299 }
300}
301
302#[async_trait]
303impl ConfigStore for InMemoryConfigStore {
304 async fn load(&self) -> Result<ForgetopConfig> {
305 Ok(self.config.lock().unwrap().clone())
306 }
307 async fn save(&self, config: &ForgetopConfig) -> Result<()> {
308 *self.config.lock().unwrap() = config.clone();
309 Ok(())
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
323 fn an_emptied_repository_scope_survives_json_as_a_different_value_from_never_set() {
324 use crate::domain::ProviderType;
325 use crate::provider::Connection;
326
327 let conn = |repo_scope: Option<Vec<String>>| Connection {
328 id: "gh".into(),
329 provider_type: ProviderType::GitHub,
330 display_name: "GitHub".into(),
331 base_url: None,
332 organization: None,
333 project: None,
334 repository: Some("acme/pay".into()),
335 username: None,
336 credential_ref: None,
337 repo_scope,
338 };
339 let roundtrip = |c: &Connection| -> Connection { serde_json::from_str(&serde_json::to_string(c).unwrap()).unwrap() };
340
341 let never = conn(None);
343 assert!(!serde_json::to_string(&never).unwrap().contains("repo_scope"));
344 assert_eq!(roundtrip(&never).repo_scope, None);
345 assert_eq!(never.resolve_repo_scope(|| never.repository.clone()), vec!["acme/pay".to_string()]);
346
347 let emptied = conn(Some(vec![]));
349 assert!(serde_json::to_string(&emptied).unwrap().contains(r#""repo_scope":[]"#));
350 let read = roundtrip(&emptied);
351 assert_eq!(read.repo_scope, Some(vec![]));
352 assert!(read.resolve_repo_scope(|| read.repository.clone()).is_empty(), "an emptied scope must not refill");
353
354 let chosen = conn(Some(vec!["acme/ledger".into()]));
356 assert_eq!(roundtrip(&chosen).repo_scope, Some(vec!["acme/ledger".to_string()]));
357 }
358
359 #[test]
360 fn binding_ids_merge_legacy_and_dedup() {
361 let legacy: PullRequestBinding = serde_json::from_str(r#"{"connection_id":"gh-1"}"#).unwrap();
363 assert_eq!(legacy.ids(), vec!["gh-1".to_string()]);
364
365 let b = PullRequestBinding { connection_ids: vec!["a".into(), "b".into()], connection_id: Some("a".into()) };
367 assert_eq!(b.ids(), vec!["a".to_string(), "b".to_string()]);
368
369 let json = serde_json::to_string(&PullRequestBinding { connection_ids: vec!["x".into()], connection_id: None }).unwrap();
371 assert_eq!(json, r#"{"connection_ids":["x"]}"#);
372 }
373}