Skip to main content

forgetop_core/
service.rs

1//! Runtime config service + resolver/section/health services.
2
3use std::sync::{Arc, Mutex};
4
5use tokio::sync::watch;
6
7use crate::config::*;
8use crate::domain::Section;
9use crate::error::{Error, Result};
10use crate::provider::*;
11use crate::secret::SecretStore;
12
13/// Holds the live config and applies mutations at runtime: each change is validated
14/// against provider capabilities and persisted. Secrets go to the secret store; the
15/// config only ever references them by key.
16pub struct ConfigService {
17    store: Arc<dyn ConfigStore>,
18    secrets: Arc<dyn SecretStore>,
19    registry: Arc<ProviderRegistry>,
20    config: Mutex<ForgetopConfig>,
21    /// Bumped whenever a persisted change alters what should be fetched — connections
22    /// or section bindings, never UI preferences. The dashboard and the TUI share one
23    /// `ConfigService`, so this is how a connection added in the browser reaches the
24    /// terminal without waiting for the refresh tick.
25    data_changed: watch::Sender<u64>,
26}
27
28impl ConfigService {
29    pub fn new(store: Arc<dyn ConfigStore>, secrets: Arc<dyn SecretStore>, registry: Arc<ProviderRegistry>) -> Self {
30        Self {
31            store,
32            secrets,
33            registry,
34            config: Mutex::new(ForgetopConfig::default()),
35            data_changed: watch::channel(0).0,
36        }
37    }
38
39    pub async fn load(&self) -> Result<()> {
40        let cfg = self.store.load().await?;
41        *self.config.lock().unwrap() = cfg;
42        Ok(())
43    }
44
45    pub fn snapshot(&self) -> ForgetopConfig {
46        self.config.lock().unwrap().clone()
47    }
48
49    async fn persist(&self, cfg: ForgetopConfig) -> Result<()> {
50        self.store.save(&cfg).await?;
51        let fetch_changed = {
52            let mut current = self.config.lock().unwrap();
53            let changed = !same_fetch_inputs(&current, &cfg);
54            *current = cfg;
55            changed
56        };
57        // Deliberately after the lock is released, and only for changes that alter what
58        // gets fetched: a theme or sort tweak must not cost a round of network calls.
59        if fetch_changed {
60            self.data_changed.send_modify(|generation| *generation += 1);
61        }
62        Ok(())
63    }
64
65    /// Fires whenever connections or section bindings change, from anywhere in the
66    /// process. A `watch` channel coalesces bursts, so the multi-step setup flow
67    /// (add connection, then bind each section) settles into one reload rather than four.
68    pub fn subscribe_data_changed(&self) -> watch::Receiver<u64> {
69        self.data_changed.subscribe()
70    }
71
72    fn ensure_supports(&self, cfg: &ForgetopConfig, connection_id: &str, section: Section) -> Result<()> {
73        let conn = cfg
74            .find_connection(connection_id)
75            .ok_or_else(|| Error::Config(format!("unknown connection '{connection_id}'")))?;
76        let caps = self
77            .registry
78            .describe(conn.provider_type)
79            .ok_or_else(|| Error::Config(format!("provider '{}' not registered", conn.provider_type.as_str())))?;
80        if !caps.supports(section) {
81            return Err(Error::Config(format!(
82                "connection '{}' ({}) does not support {section:?}",
83                conn.display_name,
84                conn.provider_type.as_str()
85            )));
86        }
87        Ok(())
88    }
89
90    pub async fn add_or_update_connection(&self, mut connection: Connection, secret: Option<String>) -> Result<()> {
91        let cred = connection.credential_ref.clone().unwrap_or_else(|| connection.id.clone());
92        connection.credential_ref = Some(cred.clone());
93
94        if let Some(secret) = secret {
95            if !self.secrets.is_writable() {
96                return Err(Error::Config(
97                    "secret store is read-only; provide the PAT via environment variable instead".into(),
98                ));
99            }
100            self.secrets.set(&cred, &secret)?;
101        }
102
103        let mut cfg = self.snapshot();
104        cfg.connections.retain(|c| c.id != connection.id);
105        cfg.connections.push(connection);
106        self.persist(cfg).await
107    }
108
109    pub async fn remove_connection(&self, connection_id: &str) -> Result<()> {
110        let mut cfg = self.snapshot();
111        if let Some(existing) = cfg.find_connection(connection_id).cloned() {
112            if let (Some(cred), true) = (existing.credential_ref.as_ref(), self.secrets.is_writable()) {
113                let _ = self.secrets.delete(cred);
114            }
115        }
116        cfg.connections.retain(|c| c.id != connection_id);
117        if let Some(b) = &mut cfg.pull_requests {
118            b.connection_ids.retain(|c| c != connection_id);
119            b.connection_id = b.connection_id.take().filter(|c| c != connection_id);
120            if b.ids().is_empty() {
121                cfg.pull_requests = None;
122            }
123        }
124        if let Some(b) = &mut cfg.work_items {
125            b.connection_ids.retain(|c| c != connection_id);
126            b.connection_id = b.connection_id.take().filter(|c| c != connection_id);
127            if b.ids().is_empty() {
128                cfg.work_items = None;
129            }
130        }
131        if let Some(p) = &mut cfg.pipelines {
132            p.subscriptions.retain(|s| s.connection_id != connection_id);
133        }
134        self.persist(cfg).await
135    }
136
137    pub async fn bind_pull_requests(&self, connection_id: &str) -> Result<()> {
138        let mut cfg = self.snapshot();
139        self.ensure_supports(&cfg, connection_id, Section::PullRequests)?;
140        let b = cfg.pull_requests.get_or_insert_with(PullRequestBinding::default);
141        let mut ids = b.ids();
142        if !ids.contains(&connection_id.to_string()) {
143            ids.push(connection_id.into());
144        }
145        *b = PullRequestBinding { connection_ids: ids, connection_id: None };
146        self.persist(cfg).await
147    }
148
149    pub async fn bind_work_items(&self, connection_id: &str) -> Result<()> {
150        let mut cfg = self.snapshot();
151        self.ensure_supports(&cfg, connection_id, Section::WorkItems)?;
152        let b = cfg.work_items.get_or_insert_with(WorkItemBinding::default);
153        let mut ids = b.ids();
154        if !ids.contains(&connection_id.to_string()) {
155            ids.push(connection_id.into());
156        }
157        *b = WorkItemBinding { connection_ids: ids, connection_id: None };
158        self.persist(cfg).await
159    }
160
161    /// Removes a single connection from the PR section (leaves others bound).
162    pub async fn unbind_pull_requests(&self, connection_id: &str) -> Result<()> {
163        let mut cfg = self.snapshot();
164        if let Some(b) = &mut cfg.pull_requests {
165            let ids: Vec<String> = b.ids().into_iter().filter(|c| c != connection_id).collect();
166            cfg.pull_requests = (!ids.is_empty()).then_some(PullRequestBinding { connection_ids: ids, connection_id: None });
167        }
168        self.persist(cfg).await
169    }
170
171    /// Removes a single connection from the work-item section (leaves others bound).
172    pub async fn unbind_work_items(&self, connection_id: &str) -> Result<()> {
173        let mut cfg = self.snapshot();
174        if let Some(b) = &mut cfg.work_items {
175            let ids: Vec<String> = b.ids().into_iter().filter(|c| c != connection_id).collect();
176            cfg.work_items = (!ids.is_empty()).then_some(WorkItemBinding { connection_ids: ids, connection_id: None });
177        }
178        self.persist(cfg).await
179    }
180
181    /// Drops a connection's pipeline subscription entirely (unbinds it from the section).
182    pub async fn unbind_pipelines(&self, connection_id: &str) -> Result<()> {
183        let mut cfg = self.snapshot();
184        if let Some(p) = &mut cfg.pipelines {
185            p.subscriptions.retain(|s| s.connection_id != connection_id);
186            if p.subscriptions.is_empty() {
187                cfg.pipelines = None;
188            }
189        }
190        self.persist(cfg).await
191    }
192
193    /// Replaces the PR section's connections with an explicit set (multi-bind).
194    pub async fn set_pull_request_connections(&self, connection_ids: Vec<String>) -> Result<()> {
195        let mut cfg = self.snapshot();
196        cfg.pull_requests = Some(PullRequestBinding { connection_ids, connection_id: None });
197        self.persist(cfg).await
198    }
199
200    pub async fn set_work_item_connections(&self, connection_ids: Vec<String>) -> Result<()> {
201        let mut cfg = self.snapshot();
202        cfg.work_items = Some(WorkItemBinding { connection_ids, connection_id: None });
203        self.persist(cfg).await
204    }
205
206    pub async fn unbind_section(&self, section: Section) -> Result<()> {
207        let mut cfg = self.snapshot();
208        match section {
209            Section::PullRequests => cfg.pull_requests = None,
210            Section::WorkItems => cfg.work_items = None,
211            Section::Pipelines => cfg.pipelines = None,
212        }
213        self.persist(cfg).await
214    }
215
216    async fn mutate_subscription(&self, connection_id: &str, f: impl FnOnce(&mut PipelineSubscription)) -> Result<()> {
217        let mut cfg = self.snapshot();
218        self.ensure_supports(&cfg, connection_id, Section::Pipelines)?;
219        let binding = cfg.pipelines.get_or_insert_with(PipelineBinding::default);
220        let sub = match binding.subscriptions.iter_mut().find(|s| s.connection_id == connection_id) {
221            Some(existing) => existing,
222            None => {
223                binding.subscriptions.push(PipelineSubscription {
224                    connection_id: connection_id.into(),
225                    definition_ids: Vec::new(),
226                    auto_discover_all: false,
227                });
228                binding.subscriptions.last_mut().unwrap()
229            }
230        };
231        f(sub);
232        self.persist(cfg).await
233    }
234
235    pub async fn subscribe_pipeline(&self, connection_id: &str, definition_id: &str) -> Result<()> {
236        let definition_id = definition_id.to_string();
237        self.mutate_subscription(connection_id, move |s| {
238            if !s.definition_ids.contains(&definition_id) {
239                s.definition_ids.push(definition_id);
240            }
241        })
242        .await
243    }
244
245    pub async fn set_pipeline_auto_discover(&self, connection_id: &str, auto_discover_all: bool) -> Result<()> {
246        self.mutate_subscription(connection_id, move |s| s.auto_discover_all = auto_discover_all).await
247    }
248
249    pub async fn unsubscribe_pipeline(&self, connection_id: &str, definition_id: &str) -> Result<()> {
250        self.mutate_subscription(connection_id, move |s| s.definition_ids.retain(|d| d != definition_id)).await
251    }
252
253    pub async fn remove_pipeline_connection(&self, connection_id: &str) -> Result<()> {
254        let mut cfg = self.snapshot();
255        if let Some(p) = &mut cfg.pipelines {
256            p.subscriptions.retain(|s| s.connection_id != connection_id);
257        }
258        self.persist(cfg).await
259    }
260
261    /// Replaces a connection's repository scope. `Some(vec![])` is a real, respected state — the
262    /// user chose no repositories — and must not be confused with `None`, which means the scope
263    /// was never established and the legacy single repository still applies.
264    pub async fn set_repo_scope(&self, connection_id: &str, scope: Option<Vec<String>>) -> Result<()> {
265        let mut cfg = self.snapshot();
266        let conn = cfg
267            .connections
268            .iter_mut()
269            .find(|c| c.id == connection_id)
270            .ok_or_else(|| Error::Config(format!("unknown connection '{connection_id}'")))?;
271        conn.repo_scope = scope;
272        self.persist(cfg).await
273    }
274
275    /// A connection's stored scope, exactly as stored (including the `None` / `Some([])`
276    /// distinction) — the picker needs to tell "never chosen" from "chose none".
277    pub fn repo_scope(&self, connection_id: &str) -> Option<Vec<String>> {
278        self.snapshot().find_connection(connection_id).and_then(|c| c.repo_scope.clone())
279    }
280
281    pub async fn set_theme(&self, theme: Option<String>) -> Result<()> {
282        let mut cfg = self.snapshot();
283        cfg.ui.theme = theme;
284        self.persist(cfg).await
285    }
286
287    pub async fn set_hidden_sections(&self, hidden: Vec<Section>) -> Result<()> {
288        let mut cfg = self.snapshot();
289        cfg.ui.hidden_sections = hidden;
290        self.persist(cfg).await
291    }
292
293    pub async fn set_hidden_work_item_states(&self, hidden: Vec<String>) -> Result<()> {
294        let mut cfg = self.snapshot();
295        cfg.ui.hidden_work_item_states = hidden;
296        self.persist(cfg).await
297    }
298
299    pub async fn set_dismissed_launchpad_items(&self, dismissed: Vec<String>) -> Result<()> {
300        let mut cfg = self.snapshot();
301        cfg.ui.dismissed_launchpad_items = dismissed;
302        self.persist(cfg).await
303    }
304
305    pub async fn set_notifications(&self, prefs: NotificationPrefs) -> Result<()> {
306        let mut cfg = self.snapshot();
307        cfg.ui.notifications = prefs;
308        self.persist(cfg).await
309    }
310
311    /// Persists the saved views for a section.
312    pub async fn set_views(&self, section: Section, views: Vec<SavedView>) -> Result<()> {
313        let mut cfg = self.snapshot();
314        match section {
315            Section::PullRequests => cfg.ui.pr_views = views,
316            Section::WorkItems => cfg.ui.work_item_views = views,
317            Section::Pipelines => cfg.ui.pipeline_views = views,
318        }
319        self.persist(cfg).await
320    }
321
322    /// Persists the sort for a section (`None` restores provider order).
323    pub async fn set_sort(&self, section: Section, sort: Option<SortPref>) -> Result<()> {
324        let mut cfg = self.snapshot();
325        match section {
326            Section::PullRequests => cfg.ui.pr_sort = sort,
327            Section::WorkItems => cfg.ui.work_item_sort = sort,
328            Section::Pipelines => cfg.ui.pipeline_sort = sort,
329        }
330        self.persist(cfg).await
331    }
332
333    /// Persists how the Pipelines list is grouped (`None` restores the default).
334    pub async fn set_pipeline_group(&self, group: Option<String>) -> Result<()> {
335        let mut cfg = self.snapshot();
336        cfg.ui.pipeline_group = group;
337        self.persist(cfg).await
338    }
339
340    /// Replaces a connection's tracked pipeline definitions with an explicit set
341    /// (turns off auto-discovery). An empty list tracks nothing.
342    pub async fn set_pipeline_definitions(&self, connection_id: &str, definition_ids: Vec<String>) -> Result<()> {
343        self.mutate_subscription(connection_id, move |s| {
344            s.auto_discover_all = false;
345            s.definition_ids = definition_ids;
346        })
347        .await
348    }
349}
350
351/// Turns a configured connection id into a live [`ProviderConnection`].
352pub struct ConnectionResolver {
353    config: Arc<ConfigService>,
354    registry: Arc<ProviderRegistry>,
355    secrets: Arc<dyn SecretStore>,
356}
357
358impl ConnectionResolver {
359    pub fn new(config: Arc<ConfigService>, registry: Arc<ProviderRegistry>, secrets: Arc<dyn SecretStore>) -> Self {
360        Self { config, registry, secrets }
361    }
362
363    pub async fn resolve(&self, connection_id: &str) -> Result<Option<Arc<dyn ProviderConnection>>> {
364        let cfg = self.config.snapshot();
365        let conn = match cfg.find_connection(connection_id) {
366            Some(c) => c.clone(),
367            None => return Ok(None),
368        };
369        if !self.registry.supports(conn.provider_type) {
370            return Ok(None);
371        }
372        let secret = match &conn.credential_ref {
373            Some(r) => self.secrets.get(r)?,
374            None => None,
375        };
376        Ok(Some(self.registry.create(&conn, secret)?))
377    }
378}
379
380/// One pipeline connection feeding the Pipelines section, with its subscription.
381pub struct PipelineFeed {
382    pub connection: Arc<dyn ProviderConnection>,
383    pub source: Arc<dyn PipelineSource>,
384    pub subscription: PipelineSubscription,
385}
386
387/// One connection feeding the aggregated Pull Requests list.
388pub struct PullRequestFeed {
389    pub connection: Arc<dyn ProviderConnection>,
390    pub source: Arc<dyn PullRequestSource>,
391}
392
393/// One connection feeding the aggregated Work Items list.
394pub struct WorkItemFeed {
395    pub connection: Arc<dyn ProviderConnection>,
396    pub source: Arc<dyn WorkItemSource>,
397}
398
399/// One connection contributing to the notification inbox. Unlike the section feeds this
400/// isn't binding-scoped — every connection with a notification feed contributes.
401pub struct NotificationFeed {
402    pub connection: Arc<dyn ProviderConnection>,
403    pub source: Arc<dyn NotificationSource>,
404}
405
406/// Resolves the live source(s) backing each section from the current bindings.
407pub struct SectionService {
408    config: Arc<ConfigService>,
409    resolver: Arc<ConnectionResolver>,
410}
411
412impl SectionService {
413    pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
414        Self { config, resolver }
415    }
416
417    /// One connection feeding the PR / Work-Item list (for aggregation).
418    pub async fn pull_request_feeds(&self) -> Result<Vec<PullRequestFeed>> {
419        let cfg = self.config.snapshot();
420        let Some(binding) = cfg.pull_requests else { return Ok(Vec::new()) };
421        let mut feeds = Vec::new();
422        for id in binding.ids() {
423            if let Some(conn) = self.resolver.resolve(&id).await? {
424                if let Some(source) = conn.pull_requests() {
425                    feeds.push(PullRequestFeed { connection: conn, source });
426                }
427            }
428        }
429        Ok(feeds)
430    }
431
432    pub async fn work_item_feeds(&self) -> Result<Vec<WorkItemFeed>> {
433        let cfg = self.config.snapshot();
434        let Some(binding) = cfg.work_items else { return Ok(Vec::new()) };
435        let mut feeds = Vec::new();
436        for id in binding.ids() {
437            if let Some(conn) = self.resolver.resolve(&id).await? {
438                if let Some(source) = conn.work_items() {
439                    feeds.push(WorkItemFeed { connection: conn, source });
440                }
441            }
442        }
443        Ok(feeds)
444    }
445
446    /// Every connection that exposes a notification feed (across all connections, not just
447    /// section-bound ones) — the inbox aggregates them all.
448    pub async fn notification_feeds(&self) -> Result<Vec<NotificationFeed>> {
449        let cfg = self.config.snapshot();
450        let mut feeds = Vec::new();
451        for c in &cfg.connections {
452            if let Some(conn) = self.resolver.resolve(&c.id).await? {
453                if let Some(source) = conn.notifications() {
454                    feeds.push(NotificationFeed { connection: conn, source });
455                }
456            }
457        }
458        Ok(feeds)
459    }
460
461    /// The first bound PR source (back-compat for single-source call sites).
462    pub async fn pull_request_source(&self) -> Result<Option<Arc<dyn PullRequestSource>>> {
463        Ok(self.pull_request_feeds().await?.into_iter().next().map(|f| f.source))
464    }
465
466    pub async fn work_item_source(&self) -> Result<Option<Arc<dyn WorkItemSource>>> {
467        Ok(self.work_item_feeds().await?.into_iter().next().map(|f| f.source))
468    }
469
470    /// Resolves a connection's pipeline source directly, regardless of whether it is
471    /// currently subscribed — used to discover definitions before subscribing.
472    pub async fn pipeline_source_for(&self, connection_id: &str) -> Result<Option<Arc<dyn PipelineSource>>> {
473        Ok(self.resolver.resolve(connection_id).await?.and_then(|c| c.pipelines()))
474    }
475
476    /// The repositories a connection's credentials can reach, for its scope picker.
477    pub async fn discover_repositories(&self, connection_id: &str) -> Result<RepositoryPage> {
478        match self.resolver.resolve(connection_id).await? {
479            Some(conn) => conn.discover_repositories().await,
480            None => Ok(RepositoryPage::default()),
481        }
482    }
483
484    pub async fn pipeline_feeds(&self) -> Result<Vec<PipelineFeed>> {
485        let cfg = self.config.snapshot();
486        let Some(binding) = cfg.pipelines else { return Ok(Vec::new()) };
487        let mut feeds = Vec::new();
488        for sub in binding.subscriptions {
489            if let Some(conn) = self.resolver.resolve(&sub.connection_id).await? {
490                if let Some(source) = conn.pipelines() {
491                    feeds.push(PipelineFeed { connection: conn, source, subscription: sub });
492                }
493            }
494        }
495        Ok(feeds)
496    }
497}
498
499/// How many repositories a brand-new connection starts scoped to — the most recently active
500/// ones. Small on purpose: the cost of a refresh is one list call per repository, and the user
501/// widens it from the scope picker.
502pub const DEFAULT_REPO_SCOPE_SIZE: usize = 5;
503
504/// Gives a freshly-added connection a starting repository scope: the most recently active
505/// repositories its credentials can reach, capped at [`DEFAULT_REPO_SCOPE_SIZE`].
506///
507/// Best-effort and deliberately inert on anything already established — it only acts when the
508/// scope has never been set *and* no legacy single repository was supplied, so it can neither
509/// overwrite a user's choice nor change an existing connection. If discovery fails or returns
510/// nothing, the scope stays `None` and the connection behaves exactly as it did before.
511pub async fn seed_default_repo_scope(
512    config: &ConfigService,
513    sections: &SectionService,
514    connection_id: &str,
515) -> Result<Option<Vec<String>>> {
516    let Some(conn) = config.snapshot().find_connection(connection_id).cloned() else { return Ok(None) };
517    if conn.repo_scope.is_some() || conn.repository.is_some() {
518        return Ok(None);
519    }
520    let page = sections.discover_repositories(connection_id).await.unwrap_or_default();
521    if page.repositories.is_empty() {
522        return Ok(None);
523    }
524    let scope: Vec<String> = page.repositories.into_iter().take(DEFAULT_REPO_SCOPE_SIZE).collect();
525    config.set_repo_scope(connection_id, Some(scope.clone())).await?;
526    Ok(Some(scope))
527}
528
529/// A configured connection and whether it's currently reachable/authed.
530pub struct ConnectionHealth {
531    pub connection: Connection,
532    pub healthy: bool,
533}
534
535/// Probes each configured connection for the connections health bar.
536pub struct ConnectionHealthService {
537    config: Arc<ConfigService>,
538    resolver: Arc<ConnectionResolver>,
539}
540
541impl ConnectionHealthService {
542    pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
543        Self { config, resolver }
544    }
545
546    pub async fn check_all(&self) -> Vec<ConnectionHealth> {
547        let cfg = self.config.snapshot();
548        let mut out = Vec::new();
549        for connection in cfg.connections {
550            let healthy = match self.resolver.resolve(&connection.id).await {
551                Ok(Some(live)) => live.check().await,
552                _ => false,
553            };
554            out.push(ConnectionHealth { connection, healthy });
555        }
556        out
557    }
558}
559
560
561/// Whether two configs would produce the same fetches. Compares every field except
562/// `ui`, so a new data-bearing field is included by default rather than being silently
563/// forgotten — the failure mode we want is a redundant refresh, not a stale screen.
564fn same_fetch_inputs(a: &ForgetopConfig, b: &ForgetopConfig) -> bool {
565    a.connections == b.connections
566        && a.pull_requests == b.pull_requests
567        && a.work_items == b.work_items
568        && a.pipelines == b.pipelines
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use crate::domain::ProviderType;
575    use crate::secret::InMemorySecretStore;
576    use async_trait::async_trait;
577
578    struct FakeConn {
579        caps: Capabilities,
580    }
581    #[async_trait]
582    impl ProviderConnection for FakeConn {
583        fn connection_id(&self) -> &str { "fake" }
584        fn provider_type(&self) -> ProviderType { ProviderType::GitHub }
585        fn display_name(&self) -> &str { "Fake" }
586        fn capabilities(&self) -> &Capabilities { &self.caps }
587        fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> { None }
588        fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> { None }
589        fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> { None }
590        async fn check(&self) -> bool { true }
591    }
592    struct FakeFactory {
593        provider: ProviderType,
594        caps: Capabilities,
595    }
596    impl ProviderFactory for FakeFactory {
597        fn provider_type(&self) -> ProviderType { self.provider }
598        fn describe_capabilities(&self) -> Capabilities { self.caps.clone() }
599        fn create(&self, _c: &Connection, _s: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
600            Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
601        }
602    }
603
604    fn registry() -> Arc<ProviderRegistry> {
605        Arc::new(ProviderRegistry::new(vec![
606            Arc::new(FakeFactory {
607                provider: ProviderType::GitHub,
608                caps: Capabilities { supports_pull_requests: true, supports_pipelines: true, ..Default::default() },
609            }),
610            Arc::new(FakeFactory {
611                provider: ProviderType::Linear,
612                caps: Capabilities { supports_work_items: true, ..Default::default() },
613            }),
614        ]))
615    }
616
617    fn conn(id: &str, provider: ProviderType) -> Connection {
618        Connection {
619            id: id.into(),
620            provider_type: provider,
621            display_name: id.into(),
622            base_url: None,
623            organization: None,
624            project: None,
625            repository: None,
626            username: None,
627            credential_ref: None,
628            repo_scope: None,
629        }
630    }
631
632    fn service() -> (Arc<ConfigService>, Arc<InMemorySecretStore>) {
633        let secrets = Arc::new(InMemorySecretStore::default());
634        let svc = Arc::new(ConfigService::new(
635            Arc::new(InMemoryConfigStore::default()),
636            secrets.clone(),
637            registry(),
638        ));
639        (svc, secrets)
640    }
641
642    #[tokio::test]
643    async fn add_connection_persists_and_stores_secret() {
644        let (svc, secrets) = service();
645        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
646        assert_eq!(svc.snapshot().connections.len(), 1);
647        assert_eq!(secrets.get("gh-1").unwrap().as_deref(), Some("pat"));
648    }
649
650    #[tokio::test]
651    async fn connection_and_binding_changes_notify_subscribers() {
652        let (svc, _) = service();
653        let mut rx = svc.subscribe_data_changed();
654
655        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
656        assert!(rx.has_changed().unwrap(), "adding a connection must wake the TUI");
657        rx.borrow_and_update();
658
659        svc.bind_pull_requests("gh-1").await.unwrap();
660        assert!(rx.has_changed().unwrap(), "binding a section must wake the TUI");
661        rx.borrow_and_update();
662
663        svc.remove_connection("gh-1").await.unwrap();
664        assert!(rx.has_changed().unwrap(), "removing a connection must wake the TUI");
665    }
666
667    #[tokio::test]
668    async fn ui_preferences_do_not_notify_subscribers() {
669        let (svc, _) = service();
670        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
671        let rx = svc.subscribe_data_changed();
672
673        // None of these change what gets fetched, so none should cost a round of
674        // network calls. This is the half of the behaviour that regresses silently.
675        svc.set_theme(Some("slate".into())).await.unwrap();
676        svc.set_hidden_sections(vec![Section::Pipelines]).await.unwrap();
677        svc.set_notifications(NotificationPrefs::default()).await.unwrap();
678
679        assert!(!rx.has_changed().unwrap(), "a UI preference must not trigger a refetch");
680    }
681
682    #[tokio::test]
683    async fn rewriting_an_identical_connection_does_not_notify() {
684        let (svc, _) = service();
685        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
686        let rx = svc.subscribe_data_changed();
687
688        // The dashboard re-saves on every form submit, including no-op ones.
689        let mut same = conn("gh-1", ProviderType::GitHub);
690        same.credential_ref = Some("gh-1".into());
691        svc.add_or_update_connection(same, None).await.unwrap();
692
693        assert!(!rx.has_changed().unwrap(), "an unchanged rewrite must not trigger a refetch");
694    }
695
696    #[tokio::test]
697    async fn bind_validates_capability() {
698        let (svc, _) = service();
699        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
700        svc.add_or_update_connection(conn("lin-1", ProviderType::Linear), None).await.unwrap();
701        svc.bind_pull_requests("gh-1").await.unwrap();
702        assert_eq!(svc.snapshot().pull_requests.unwrap().ids(), vec!["gh-1".to_string()]);
703        assert!(svc.bind_pull_requests("lin-1").await.is_err());
704    }
705
706    #[tokio::test]
707    async fn remove_connection_cascades() {
708        let (svc, secrets) = service();
709        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
710        svc.bind_pull_requests("gh-1").await.unwrap();
711        svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
712        svc.remove_connection("gh-1").await.unwrap();
713        let cfg = svc.snapshot();
714        assert!(cfg.connections.is_empty());
715        assert!(cfg.pull_requests.is_none());
716        assert!(cfg.pipelines.unwrap().subscriptions.is_empty());
717        assert_eq!(secrets.get("gh-1").unwrap(), None);
718    }
719
720    #[tokio::test]
721    async fn repo_scope_persists_including_the_empty_choice() {
722        let (svc, _) = service();
723        let mut c = conn("gh-1", ProviderType::GitHub);
724        c.repository = Some("acme/pay".into());
725        svc.add_or_update_connection(c, None).await.unwrap();
726
727        // Never set → absent, so the legacy repository is still what the factory resolves.
728        assert_eq!(svc.repo_scope("gh-1"), None);
729
730        svc.set_repo_scope("gh-1", Some(vec!["acme/pay".into(), "acme/ledger".into()])).await.unwrap();
731        assert_eq!(svc.repo_scope("gh-1").unwrap().len(), 2);
732
733        // "I chose none" is a real, persisted state — not the same as never having chosen.
734        svc.set_repo_scope("gh-1", Some(vec![])).await.unwrap();
735        assert_eq!(svc.repo_scope("gh-1"), Some(vec![]));
736        svc.load().await.unwrap();
737        assert_eq!(svc.repo_scope("gh-1"), Some(vec![]), "an emptied scope survives a reload");
738
739        assert!(svc.set_repo_scope("nope", None).await.is_err());
740    }
741
742    #[tokio::test]
743    async fn pipeline_subscribe_unsubscribe() {
744        let (svc, _) = service();
745        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
746        svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
747        svc.subscribe_pipeline("gh-1", "release").await.unwrap();
748        svc.subscribe_pipeline("gh-1", "ci").await.unwrap(); // duplicate ignored
749        assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 2);
750        svc.unsubscribe_pipeline("gh-1", "ci").await.unwrap();
751        assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 1);
752    }
753
754    #[tokio::test]
755    async fn set_pipeline_definitions_replaces_and_disables_auto() {
756        let (svc, _) = service();
757        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
758        svc.set_pipeline_auto_discover("gh-1", true).await.unwrap();
759        svc.set_pipeline_definitions("gh-1", vec!["ci".into(), "release".into()]).await.unwrap();
760        let cfg = svc.snapshot();
761        let sub = &cfg.pipelines.unwrap().subscriptions[0];
762        assert!(!sub.auto_discover_all);
763        assert_eq!(sub.definition_ids, vec!["ci".to_string(), "release".to_string()]);
764    }
765}