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 = "Vec::is_empty")]
82 pub preview_hidden: Vec<Section>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub hidden_work_item_states: Vec<String>,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub dismissed_launchpad_items: Vec<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub pr_sort: Option<SortPref>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub work_item_sort: Option<SortPref>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub pipeline_sort: Option<SortPref>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub pipeline_group: Option<String>,
102 #[serde(default)]
104 pub notifications: NotificationPrefs,
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub pr_views: Vec<SavedView>,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub work_item_views: Vec<SavedView>,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub pipeline_views: Vec<SavedView>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub review_sla_hours: Option<u32>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub mouse: Option<bool>,
120}
121
122pub const DEFAULT_REVIEW_SLA_HOURS: u32 = 24;
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct SavedView {
128 pub name: String,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub filter: Option<String>,
132 #[serde(default, skip_serializing_if = "String::is_empty")]
134 pub query: String,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub sort: Option<SortPref>,
137 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 pub hidden_states: Vec<String>,
140}
141
142fn yes() -> bool {
143 true
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148pub struct NotificationPrefs {
149 #[serde(default = "yes")]
150 pub pipeline_failed: bool,
151 #[serde(default = "yes")]
152 pub review_requested: bool,
153 #[serde(default = "yes")]
154 pub pr_approved: bool,
155 #[serde(default = "yes")]
156 pub pr_changes_requested: bool,
157 #[serde(default = "yes")]
158 pub pipeline_approval_needed: bool,
159}
160
161impl Default for NotificationPrefs {
162 fn default() -> Self {
163 Self {
164 pipeline_failed: true,
165 review_requested: true,
166 pr_approved: true,
167 pr_changes_requested: true,
168 pipeline_approval_needed: true,
169 }
170 }
171}
172
173impl NotificationPrefs {
174 pub fn any(&self) -> bool {
176 self.pipeline_failed
177 || self.review_requested
178 || self.pr_approved
179 || self.pr_changes_requested
180 || self.pipeline_approval_needed
181 }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct SortPref {
187 pub key: String,
188 pub desc: bool,
189}
190
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
193pub struct ForgetopConfig {
194 #[serde(default)]
195 pub connections: Vec<Connection>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub pull_requests: Option<PullRequestBinding>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub work_items: Option<WorkItemBinding>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub pipelines: Option<PipelineBinding>,
202 #[serde(default)]
203 pub ui: UiState,
204}
205
206impl ForgetopConfig {
207 pub fn find_connection(&self, id: &str) -> Option<&Connection> {
208 self.connections.iter().find(|c| c.id == id)
209 }
210}
211
212#[async_trait]
214pub trait ConfigStore: Send + Sync {
215 async fn load(&self) -> Result<ForgetopConfig>;
216 async fn save(&self, config: &ForgetopConfig) -> Result<()>;
217}
218
219pub fn default_config_path() -> PathBuf {
222 let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
223 base.join("forgetop").join("config.json")
224}
225
226pub struct JsonConfigStore {
228 path: PathBuf,
229}
230
231impl JsonConfigStore {
232 pub fn new(path: impl Into<PathBuf>) -> Self {
233 Self { path: path.into() }
234 }
235
236 pub fn at_default_path() -> Self {
237 Self::new(default_config_path())
238 }
239}
240
241#[async_trait]
242impl ConfigStore for JsonConfigStore {
243 async fn load(&self) -> Result<ForgetopConfig> {
244 if !self.path.exists() {
245 return Ok(ForgetopConfig::default());
246 }
247 let bytes = tokio::fs::read(&self.path).await?;
248 Ok(serde_json::from_slice(&bytes)?)
249 }
250
251 async fn save(&self, config: &ForgetopConfig) -> Result<()> {
252 if let Some(dir) = self.path.parent() {
253 tokio::fs::create_dir_all(dir).await?;
254 }
255 let json = serde_json::to_vec_pretty(config)?;
256 let tmp = with_extension(&self.path, "tmp");
258 tokio::fs::write(&tmp, &json).await?;
259 tokio::fs::rename(&tmp, &self.path).await?;
260 Ok(())
261 }
262}
263
264fn with_extension(path: &Path, ext: &str) -> PathBuf {
265 let mut p = path.to_path_buf();
266 p.set_extension(ext);
267 p
268}
269
270pub struct InMemoryConfigStore {
272 config: std::sync::Mutex<ForgetopConfig>,
273}
274
275impl InMemoryConfigStore {
276 pub fn new(seed: ForgetopConfig) -> Self {
277 Self { config: std::sync::Mutex::new(seed) }
278 }
279}
280
281impl Default for InMemoryConfigStore {
282 fn default() -> Self {
283 Self::new(ForgetopConfig::default())
284 }
285}
286
287#[async_trait]
288impl ConfigStore for InMemoryConfigStore {
289 async fn load(&self) -> Result<ForgetopConfig> {
290 Ok(self.config.lock().unwrap().clone())
291 }
292 async fn save(&self, config: &ForgetopConfig) -> Result<()> {
293 *self.config.lock().unwrap() = config.clone();
294 Ok(())
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
308 fn an_emptied_repository_scope_survives_json_as_a_different_value_from_never_set() {
309 use crate::domain::ProviderType;
310 use crate::provider::Connection;
311
312 let conn = |repo_scope: Option<Vec<String>>| Connection {
313 id: "gh".into(),
314 provider_type: ProviderType::GitHub,
315 display_name: "GitHub".into(),
316 base_url: None,
317 organization: None,
318 project: None,
319 repository: Some("acme/pay".into()),
320 username: None,
321 credential_ref: None,
322 repo_scope,
323 };
324 let roundtrip = |c: &Connection| -> Connection { serde_json::from_str(&serde_json::to_string(c).unwrap()).unwrap() };
325
326 let never = conn(None);
328 assert!(!serde_json::to_string(&never).unwrap().contains("repo_scope"));
329 assert_eq!(roundtrip(&never).repo_scope, None);
330 assert_eq!(never.resolve_repo_scope(|| never.repository.clone()), vec!["acme/pay".to_string()]);
331
332 let emptied = conn(Some(vec![]));
334 assert!(serde_json::to_string(&emptied).unwrap().contains(r#""repo_scope":[]"#));
335 let read = roundtrip(&emptied);
336 assert_eq!(read.repo_scope, Some(vec![]));
337 assert!(read.resolve_repo_scope(|| read.repository.clone()).is_empty(), "an emptied scope must not refill");
338
339 let chosen = conn(Some(vec!["acme/ledger".into()]));
341 assert_eq!(roundtrip(&chosen).repo_scope, Some(vec!["acme/ledger".to_string()]));
342 }
343
344 #[test]
345 fn binding_ids_merge_legacy_and_dedup() {
346 let legacy: PullRequestBinding = serde_json::from_str(r#"{"connection_id":"gh-1"}"#).unwrap();
348 assert_eq!(legacy.ids(), vec!["gh-1".to_string()]);
349
350 let b = PullRequestBinding { connection_ids: vec!["a".into(), "b".into()], connection_id: Some("a".into()) };
352 assert_eq!(b.ids(), vec!["a".to_string(), "b".to_string()]);
353
354 let json = serde_json::to_string(&PullRequestBinding { connection_ids: vec!["x".into()], connection_id: None }).unwrap();
356 assert_eq!(json, r#"{"connection_ids":["x"]}"#);
357 }
358}