Skip to main content

forgetop_core/
config.rs

1//! Persisted configuration: connections + per-section bindings, and the config store.
2//! Note: config NEVER contains secrets — only a `credential_ref` key into the secret store.
3
4use 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    /// Connections whose pull requests are aggregated into the PR list.
16    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17    pub connection_ids: Vec<String>,
18    /// Legacy single-bind field; folded into `connection_ids` on read.
19    #[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
31/// Merges the multi-bind list with any legacy single id, de-duplicated.
32fn 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/// One connection feeding the Pipelines section, plus the pipelines subscribed from it.
55#[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    /// Sections the user has hidden from the tab bar.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub hidden_sections: Vec<Section>,
79    /// Sections whose list has the terminal preview pane switched off. The pane is on by
80    /// default (wide terminals only), so an empty list means "on everywhere".
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub preview_hidden: Vec<Section>,
83    /// Work-item state names the user has hidden from the Work Items list
84    /// (provider-specific strings, e.g. "Done"). Anything not listed is shown.
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub hidden_work_item_states: Vec<String>,
87    /// Launchpad items the user has explicitly dismissed from Command Center's
88    /// "Needs you" list ("connection_id:item_id" keys, see launchpad::Entry::key).
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub dismissed_launchpad_items: Vec<String>,
91    /// Notifications the user dismissed from the TUI inbox ("connection_id:id:updated_at"
92    /// keys), so a thread with new activity comes back rather than staying hidden.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub dismissed_notifications: Vec<String>,
95    /// Per-view sort column + direction. `None` = provider order.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub pr_sort: Option<SortPref>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub work_item_sort: Option<SortPref>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub pipeline_sort: Option<SortPref>,
102    /// How the Pipelines list is grouped: "pipeline" (default), "trigger", "branch",
103    /// or "off" for the ungrouped list. Unknown values fall back to the default.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub pipeline_group: Option<String>,
106    /// Which desktop notifications are enabled. Defaults to all on.
107    #[serde(default)]
108    pub notifications: NotificationPrefs,
109    /// Saved views per section (a named bundle of filter + sort + state).
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub pr_views: Vec<SavedView>,
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub work_item_views: Vec<SavedView>,
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub pipeline_views: Vec<SavedView>,
116    /// Hours a review request may wait before its age turns yellow in the Command Center's
117    /// "Needs your review" (red at three times this). `None` = [`DEFAULT_REVIEW_SLA_HOURS`].
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub review_sla_hours: Option<u32>,
120    /// Whether the terminal UI captures the mouse (click tabs and rows, wheel scroll).
121    /// `None` = on. Capturing means native text selection needs Shift (Option in iTerm).
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub mouse: Option<bool>,
124}
125
126/// Default for [`UiState::review_sla_hours`].
127pub const DEFAULT_REVIEW_SLA_HOURS: u32 = 24;
128
129/// A named, saved bundle of a section's filter + sort + visible state.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct SavedView {
132    pub name: String,
133    /// PR base filter: "all" / "mine" / "review" (Pull Requests only).
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub filter: Option<String>,
136    /// Quick-filter text applied to the list.
137    #[serde(default, skip_serializing_if = "String::is_empty")]
138    pub query: String,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub sort: Option<SortPref>,
141    /// Hidden work-item states (Work Items only).
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub hidden_states: Vec<String>,
144}
145
146fn yes() -> bool {
147    true
148}
149
150/// Per-event desktop-notification opt-ins. Every event defaults to on.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152pub struct NotificationPrefs {
153    #[serde(default = "yes")]
154    pub pipeline_failed: bool,
155    #[serde(default = "yes")]
156    pub review_requested: bool,
157    #[serde(default = "yes")]
158    pub pr_approved: bool,
159    #[serde(default = "yes")]
160    pub pr_changes_requested: bool,
161    #[serde(default = "yes")]
162    pub pipeline_approval_needed: bool,
163}
164
165impl Default for NotificationPrefs {
166    fn default() -> Self {
167        Self {
168            pipeline_failed: true,
169            review_requested: true,
170            pr_approved: true,
171            pr_changes_requested: true,
172            pipeline_approval_needed: true,
173        }
174    }
175}
176
177impl NotificationPrefs {
178    /// True if any event is enabled.
179    pub fn any(&self) -> bool {
180        self.pipeline_failed
181            || self.review_requested
182            || self.pr_approved
183            || self.pr_changes_requested
184            || self.pipeline_approval_needed
185    }
186}
187
188/// A saved sort: which column (by key) and whether descending.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct SortPref {
191    pub key: String,
192    pub desc: bool,
193}
194
195/// Root persisted configuration.
196#[derive(Debug, Clone, Default, Serialize, Deserialize)]
197pub struct ForgetopConfig {
198    #[serde(default)]
199    pub connections: Vec<Connection>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub pull_requests: Option<PullRequestBinding>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub work_items: Option<WorkItemBinding>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub pipelines: Option<PipelineBinding>,
206    #[serde(default)]
207    pub ui: UiState,
208}
209
210impl ForgetopConfig {
211    pub fn find_connection(&self, id: &str) -> Option<&Connection> {
212        self.connections.iter().find(|c| c.id == id)
213    }
214}
215
216/// Loads and saves the root configuration.
217#[async_trait]
218pub trait ConfigStore: Send + Sync {
219    async fn load(&self) -> Result<ForgetopConfig>;
220    async fn save(&self, config: &ForgetopConfig) -> Result<()>;
221}
222
223/// Resolves the on-disk config path: `$XDG_CONFIG_HOME/forgetop/config.json` (or the
224/// platform config dir).
225pub fn default_config_path() -> PathBuf {
226    let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
227    base.join("forgetop").join("config.json")
228}
229
230/// JSON-file config store with atomic (temp + rename) writes.
231pub struct JsonConfigStore {
232    path: PathBuf,
233}
234
235impl JsonConfigStore {
236    pub fn new(path: impl Into<PathBuf>) -> Self {
237        Self { path: path.into() }
238    }
239
240    pub fn at_default_path() -> Self {
241        Self::new(default_config_path())
242    }
243}
244
245#[async_trait]
246impl ConfigStore for JsonConfigStore {
247    async fn load(&self) -> Result<ForgetopConfig> {
248        if !self.path.exists() {
249            return Ok(ForgetopConfig::default());
250        }
251        let bytes = tokio::fs::read(&self.path).await?;
252        Ok(serde_json::from_slice(&bytes)?)
253    }
254
255    async fn save(&self, config: &ForgetopConfig) -> Result<()> {
256        if let Some(dir) = self.path.parent() {
257            tokio::fs::create_dir_all(dir).await?;
258        }
259        let json = serde_json::to_vec_pretty(config)?;
260        // Write to a temp file then rename so a crash mid-write can't corrupt config.
261        let tmp = with_extension(&self.path, "tmp");
262        tokio::fs::write(&tmp, &json).await?;
263        tokio::fs::rename(&tmp, &self.path).await?;
264        Ok(())
265    }
266}
267
268fn with_extension(path: &Path, ext: &str) -> PathBuf {
269    let mut p = path.to_path_buf();
270    p.set_extension(ext);
271    p
272}
273
274/// Non-persistent config store (used for `--demo` and tests).
275pub struct InMemoryConfigStore {
276    config: std::sync::Mutex<ForgetopConfig>,
277}
278
279impl InMemoryConfigStore {
280    pub fn new(seed: ForgetopConfig) -> Self {
281        Self { config: std::sync::Mutex::new(seed) }
282    }
283}
284
285impl Default for InMemoryConfigStore {
286    fn default() -> Self {
287        Self::new(ForgetopConfig::default())
288    }
289}
290
291#[async_trait]
292impl ConfigStore for InMemoryConfigStore {
293    async fn load(&self) -> Result<ForgetopConfig> {
294        Ok(self.config.lock().unwrap().clone())
295    }
296    async fn save(&self, config: &ForgetopConfig) -> Result<()> {
297        *self.config.lock().unwrap() = config.clone();
298        Ok(())
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    /// The whole `Option<Vec<String>>` choice rests on this: `Some([])` and `None` must survive a
307    /// real JSON round-trip as *different* values. If an emptied scope came back as `None`, the
308    /// factory would fall back to the legacy single repository and silently refill a scope the
309    /// user had deliberately cleared. (The in-memory store clones rather than serializes, so it
310    /// cannot catch this — only going through JSON can.)
311    #[test]
312    fn an_emptied_repository_scope_survives_json_as_a_different_value_from_never_set() {
313        use crate::domain::ProviderType;
314        use crate::provider::Connection;
315
316        let conn = |repo_scope: Option<Vec<String>>| Connection {
317            id: "gh".into(),
318            provider_type: ProviderType::GitHub,
319            display_name: "GitHub".into(),
320            base_url: None,
321            organization: None,
322            project: None,
323            repository: Some("acme/pay".into()),
324            username: None,
325            credential_ref: None,
326            repo_scope,
327        };
328        let roundtrip = |c: &Connection| -> Connection { serde_json::from_str(&serde_json::to_string(c).unwrap()).unwrap() };
329
330        // Never set → the field isn't written at all, and reads back as absent.
331        let never = conn(None);
332        assert!(!serde_json::to_string(&never).unwrap().contains("repo_scope"));
333        assert_eq!(roundtrip(&never).repo_scope, None);
334        assert_eq!(never.resolve_repo_scope(|| never.repository.clone()), vec!["acme/pay".to_string()]);
335
336        // Chose none → written as `[]`, reads back as `Some([])`, and stays empty.
337        let emptied = conn(Some(vec![]));
338        assert!(serde_json::to_string(&emptied).unwrap().contains(r#""repo_scope":[]"#));
339        let read = roundtrip(&emptied);
340        assert_eq!(read.repo_scope, Some(vec![]));
341        assert!(read.resolve_repo_scope(|| read.repository.clone()).is_empty(), "an emptied scope must not refill");
342
343        // Chose some → exactly those come back.
344        let chosen = conn(Some(vec!["acme/ledger".into()]));
345        assert_eq!(roundtrip(&chosen).repo_scope, Some(vec!["acme/ledger".to_string()]));
346    }
347
348    #[test]
349    fn binding_ids_merge_legacy_and_dedup() {
350        // Legacy single-bind config deserializes and migrates via ids().
351        let legacy: PullRequestBinding = serde_json::from_str(r#"{"connection_id":"gh-1"}"#).unwrap();
352        assert_eq!(legacy.ids(), vec!["gh-1".to_string()]);
353
354        // Multi-bind with a legacy id already present is de-duplicated.
355        let b = PullRequestBinding { connection_ids: vec!["a".into(), "b".into()], connection_id: Some("a".into()) };
356        assert_eq!(b.ids(), vec!["a".to_string(), "b".to_string()]);
357
358        // New writes only serialize the list, not the legacy field.
359        let json = serde_json::to_string(&PullRequestBinding { connection_ids: vec!["x".into()], connection_id: None }).unwrap();
360        assert_eq!(json, r#"{"connection_ids":["x"]}"#);
361    }
362}