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_preview_hidden(&self, hidden: Vec<Section>) -> Result<()> {
294        let mut cfg = self.snapshot();
295        cfg.ui.preview_hidden = hidden;
296        self.persist(cfg).await
297    }
298
299    pub async fn set_hidden_work_item_states(&self, hidden: Vec<String>) -> Result<()> {
300        let mut cfg = self.snapshot();
301        cfg.ui.hidden_work_item_states = hidden;
302        self.persist(cfg).await
303    }
304
305    pub async fn set_dismissed_launchpad_items(&self, dismissed: Vec<String>) -> Result<()> {
306        let mut cfg = self.snapshot();
307        cfg.ui.dismissed_launchpad_items = dismissed;
308        self.persist(cfg).await
309    }
310
311    pub async fn set_dismissed_notifications(&self, dismissed: Vec<String>) -> Result<()> {
312        let mut cfg = self.snapshot();
313        cfg.ui.dismissed_notifications = dismissed;
314        self.persist(cfg).await
315    }
316
317    pub async fn set_notifications(&self, prefs: NotificationPrefs) -> Result<()> {
318        let mut cfg = self.snapshot();
319        cfg.ui.notifications = prefs;
320        self.persist(cfg).await
321    }
322
323    /// Persists the saved views for a section.
324    pub async fn set_views(&self, section: Section, views: Vec<SavedView>) -> Result<()> {
325        let mut cfg = self.snapshot();
326        match section {
327            Section::PullRequests => cfg.ui.pr_views = views,
328            Section::WorkItems => cfg.ui.work_item_views = views,
329            Section::Pipelines => cfg.ui.pipeline_views = views,
330        }
331        self.persist(cfg).await
332    }
333
334    /// Persists the sort for a section (`None` restores provider order).
335    pub async fn set_sort(&self, section: Section, sort: Option<SortPref>) -> Result<()> {
336        let mut cfg = self.snapshot();
337        match section {
338            Section::PullRequests => cfg.ui.pr_sort = sort,
339            Section::WorkItems => cfg.ui.work_item_sort = sort,
340            Section::Pipelines => cfg.ui.pipeline_sort = sort,
341        }
342        self.persist(cfg).await
343    }
344
345    /// Persists how the Pipelines list is grouped (`None` restores the default).
346    pub async fn set_pipeline_group(&self, group: Option<String>) -> Result<()> {
347        let mut cfg = self.snapshot();
348        cfg.ui.pipeline_group = group;
349        self.persist(cfg).await
350    }
351
352    /// Replaces a connection's tracked pipeline definitions with an explicit set
353    /// (turns off auto-discovery). An empty list tracks nothing.
354    pub async fn set_pipeline_definitions(&self, connection_id: &str, definition_ids: Vec<String>) -> Result<()> {
355        self.mutate_subscription(connection_id, move |s| {
356            s.auto_discover_all = false;
357            s.definition_ids = definition_ids;
358        })
359        .await
360    }
361}
362
363/// Turns a configured connection id into a live [`ProviderConnection`].
364pub struct ConnectionResolver {
365    config: Arc<ConfigService>,
366    registry: Arc<ProviderRegistry>,
367    secrets: Arc<dyn SecretStore>,
368}
369
370impl ConnectionResolver {
371    pub fn new(config: Arc<ConfigService>, registry: Arc<ProviderRegistry>, secrets: Arc<dyn SecretStore>) -> Self {
372        Self { config, registry, secrets }
373    }
374
375    pub async fn resolve(&self, connection_id: &str) -> Result<Option<Arc<dyn ProviderConnection>>> {
376        let cfg = self.config.snapshot();
377        let conn = match cfg.find_connection(connection_id) {
378            Some(c) => c.clone(),
379            None => return Ok(None),
380        };
381        if !self.registry.supports(conn.provider_type) {
382            return Ok(None);
383        }
384        let secret = match &conn.credential_ref {
385            Some(r) => self.secrets.get(r)?,
386            None => None,
387        };
388        Ok(Some(self.registry.create(&conn, secret)?))
389    }
390}
391
392/// One pipeline connection feeding the Pipelines section, with its subscription.
393pub struct PipelineFeed {
394    pub connection: Arc<dyn ProviderConnection>,
395    pub source: Arc<dyn PipelineSource>,
396    pub subscription: PipelineSubscription,
397}
398
399/// One connection feeding the aggregated Pull Requests list.
400pub struct PullRequestFeed {
401    pub connection: Arc<dyn ProviderConnection>,
402    pub source: Arc<dyn PullRequestSource>,
403}
404
405/// One connection feeding the aggregated Work Items list.
406pub struct WorkItemFeed {
407    pub connection: Arc<dyn ProviderConnection>,
408    pub source: Arc<dyn WorkItemSource>,
409}
410
411/// One connection contributing to the notification inbox. Unlike the section feeds this
412/// isn't binding-scoped — every connection with a notification feed contributes.
413pub struct NotificationFeed {
414    pub connection: Arc<dyn ProviderConnection>,
415    pub source: Arc<dyn NotificationSource>,
416}
417
418/// Resolves the live source(s) backing each section from the current bindings.
419pub struct SectionService {
420    config: Arc<ConfigService>,
421    resolver: Arc<ConnectionResolver>,
422}
423
424impl SectionService {
425    pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
426        Self { config, resolver }
427    }
428
429    /// One connection feeding the PR / Work-Item list (for aggregation).
430    pub async fn pull_request_feeds(&self) -> Result<Vec<PullRequestFeed>> {
431        let cfg = self.config.snapshot();
432        let Some(binding) = cfg.pull_requests else { return Ok(Vec::new()) };
433        let mut feeds = Vec::new();
434        for id in binding.ids() {
435            if let Some(conn) = self.resolver.resolve(&id).await? {
436                if let Some(source) = conn.pull_requests() {
437                    feeds.push(PullRequestFeed { connection: conn, source });
438                }
439            }
440        }
441        Ok(feeds)
442    }
443
444    pub async fn work_item_feeds(&self) -> Result<Vec<WorkItemFeed>> {
445        let cfg = self.config.snapshot();
446        let Some(binding) = cfg.work_items else { return Ok(Vec::new()) };
447        let mut feeds = Vec::new();
448        for id in binding.ids() {
449            if let Some(conn) = self.resolver.resolve(&id).await? {
450                if let Some(source) = conn.work_items() {
451                    feeds.push(WorkItemFeed { connection: conn, source });
452                }
453            }
454        }
455        Ok(feeds)
456    }
457
458    /// Every connection that exposes a notification feed (across all connections, not just
459    /// section-bound ones) — the inbox aggregates them all.
460    pub async fn notification_feeds(&self) -> Result<Vec<NotificationFeed>> {
461        let cfg = self.config.snapshot();
462        let mut feeds = Vec::new();
463        for c in &cfg.connections {
464            if let Some(conn) = self.resolver.resolve(&c.id).await? {
465                if let Some(source) = conn.notifications() {
466                    feeds.push(NotificationFeed { connection: conn, source });
467                }
468            }
469        }
470        Ok(feeds)
471    }
472
473    /// The first bound PR source (back-compat for single-source call sites).
474    pub async fn pull_request_source(&self) -> Result<Option<Arc<dyn PullRequestSource>>> {
475        Ok(self.pull_request_feeds().await?.into_iter().next().map(|f| f.source))
476    }
477
478    pub async fn work_item_source(&self) -> Result<Option<Arc<dyn WorkItemSource>>> {
479        Ok(self.work_item_feeds().await?.into_iter().next().map(|f| f.source))
480    }
481
482    /// Resolves a connection's pipeline source directly, regardless of whether it is
483    /// currently subscribed — used to discover definitions before subscribing.
484    pub async fn pipeline_source_for(&self, connection_id: &str) -> Result<Option<Arc<dyn PipelineSource>>> {
485        Ok(self.resolver.resolve(connection_id).await?.and_then(|c| c.pipelines()))
486    }
487
488    /// The repositories a connection's credentials can reach, for its scope picker.
489    pub async fn discover_repositories(&self, connection_id: &str) -> Result<RepositoryPage> {
490        match self.resolver.resolve(connection_id).await? {
491            Some(conn) => conn.discover_repositories().await,
492            None => Ok(RepositoryPage::default()),
493        }
494    }
495
496    pub async fn pipeline_feeds(&self) -> Result<Vec<PipelineFeed>> {
497        let cfg = self.config.snapshot();
498        let Some(binding) = cfg.pipelines else { return Ok(Vec::new()) };
499        let mut feeds = Vec::new();
500        for sub in binding.subscriptions {
501            if let Some(conn) = self.resolver.resolve(&sub.connection_id).await? {
502                if let Some(source) = conn.pipelines() {
503                    feeds.push(PipelineFeed { connection: conn, source, subscription: sub });
504                }
505            }
506        }
507        Ok(feeds)
508    }
509}
510
511/// How many repositories a brand-new connection starts scoped to — the most recently active
512/// ones. Small on purpose: the cost of a refresh is one list call per repository, and the user
513/// widens it from the scope picker.
514pub const DEFAULT_REPO_SCOPE_SIZE: usize = 5;
515
516/// Gives a freshly-added connection a starting repository scope: the most recently active
517/// repositories its credentials can reach, capped at [`DEFAULT_REPO_SCOPE_SIZE`].
518///
519/// Best-effort and deliberately inert on anything already established — it only acts when the
520/// scope has never been set *and* no legacy single repository was supplied, so it can neither
521/// overwrite a user's choice nor change an existing connection. If discovery fails or returns
522/// nothing, the scope stays `None` and the connection behaves exactly as it did before.
523pub async fn seed_default_repo_scope(
524    config: &ConfigService,
525    sections: &SectionService,
526    connection_id: &str,
527) -> Result<Option<Vec<String>>> {
528    let Some(conn) = config.snapshot().find_connection(connection_id).cloned() else { return Ok(None) };
529    if conn.repo_scope.is_some() || conn.repository.is_some() {
530        return Ok(None);
531    }
532    let page = sections.discover_repositories(connection_id).await.unwrap_or_default();
533    if page.repositories.is_empty() {
534        return Ok(None);
535    }
536    let scope: Vec<String> = page.repositories.into_iter().take(DEFAULT_REPO_SCOPE_SIZE).collect();
537    config.set_repo_scope(connection_id, Some(scope.clone())).await?;
538    Ok(Some(scope))
539}
540
541/// A configured connection and whether it's currently reachable/authed.
542pub struct ConnectionHealth {
543    pub connection: Connection,
544    pub healthy: bool,
545}
546
547/// Probes each configured connection for the connections health bar.
548pub struct ConnectionHealthService {
549    config: Arc<ConfigService>,
550    resolver: Arc<ConnectionResolver>,
551}
552
553impl ConnectionHealthService {
554    pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
555        Self { config, resolver }
556    }
557
558    pub async fn check_all(&self) -> Vec<ConnectionHealth> {
559        let cfg = self.config.snapshot();
560        let mut out = Vec::new();
561        for connection in cfg.connections {
562            let healthy = match self.resolver.resolve(&connection.id).await {
563                Ok(Some(live)) => live.check().await,
564                _ => false,
565            };
566            out.push(ConnectionHealth { connection, healthy });
567        }
568        out
569    }
570}
571
572
573/// Whether two configs would produce the same fetches. Compares every field except
574/// `ui`, so a new data-bearing field is included by default rather than being silently
575/// forgotten — the failure mode we want is a redundant refresh, not a stale screen.
576fn same_fetch_inputs(a: &ForgetopConfig, b: &ForgetopConfig) -> bool {
577    a.connections == b.connections
578        && a.pull_requests == b.pull_requests
579        && a.work_items == b.work_items
580        && a.pipelines == b.pipelines
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::domain::ProviderType;
587    use crate::secret::InMemorySecretStore;
588    use async_trait::async_trait;
589
590    struct FakeConn {
591        caps: Capabilities,
592    }
593    #[async_trait]
594    impl ProviderConnection for FakeConn {
595        fn connection_id(&self) -> &str { "fake" }
596        fn provider_type(&self) -> ProviderType { ProviderType::GitHub }
597        fn display_name(&self) -> &str { "Fake" }
598        fn capabilities(&self) -> &Capabilities { &self.caps }
599        fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> { None }
600        fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> { None }
601        fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> { None }
602        async fn check(&self) -> bool { true }
603    }
604    struct FakeFactory {
605        provider: ProviderType,
606        caps: Capabilities,
607    }
608    impl ProviderFactory for FakeFactory {
609        fn provider_type(&self) -> ProviderType { self.provider }
610        fn describe_capabilities(&self) -> Capabilities { self.caps.clone() }
611        fn create(&self, _c: &Connection, _s: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
612            Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
613        }
614    }
615
616    fn registry() -> Arc<ProviderRegistry> {
617        Arc::new(ProviderRegistry::new(vec![
618            Arc::new(FakeFactory {
619                provider: ProviderType::GitHub,
620                caps: Capabilities { supports_pull_requests: true, supports_pipelines: true, ..Default::default() },
621            }),
622            Arc::new(FakeFactory {
623                provider: ProviderType::Linear,
624                caps: Capabilities { supports_work_items: true, ..Default::default() },
625            }),
626        ]))
627    }
628
629    fn conn(id: &str, provider: ProviderType) -> Connection {
630        Connection {
631            id: id.into(),
632            provider_type: provider,
633            display_name: id.into(),
634            base_url: None,
635            organization: None,
636            project: None,
637            repository: None,
638            username: None,
639            credential_ref: None,
640            repo_scope: None,
641        }
642    }
643
644    fn service() -> (Arc<ConfigService>, Arc<InMemorySecretStore>) {
645        let secrets = Arc::new(InMemorySecretStore::default());
646        let svc = Arc::new(ConfigService::new(
647            Arc::new(InMemoryConfigStore::default()),
648            secrets.clone(),
649            registry(),
650        ));
651        (svc, secrets)
652    }
653
654    #[tokio::test]
655    async fn add_connection_persists_and_stores_secret() {
656        let (svc, secrets) = service();
657        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
658        assert_eq!(svc.snapshot().connections.len(), 1);
659        assert_eq!(secrets.get("gh-1").unwrap().as_deref(), Some("pat"));
660    }
661
662    #[tokio::test]
663    async fn connection_and_binding_changes_notify_subscribers() {
664        let (svc, _) = service();
665        let mut rx = svc.subscribe_data_changed();
666
667        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
668        assert!(rx.has_changed().unwrap(), "adding a connection must wake the TUI");
669        rx.borrow_and_update();
670
671        svc.bind_pull_requests("gh-1").await.unwrap();
672        assert!(rx.has_changed().unwrap(), "binding a section must wake the TUI");
673        rx.borrow_and_update();
674
675        svc.remove_connection("gh-1").await.unwrap();
676        assert!(rx.has_changed().unwrap(), "removing a connection must wake the TUI");
677    }
678
679    #[tokio::test]
680    async fn ui_preferences_do_not_notify_subscribers() {
681        let (svc, _) = service();
682        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
683        let rx = svc.subscribe_data_changed();
684
685        // None of these change what gets fetched, so none should cost a round of
686        // network calls. This is the half of the behaviour that regresses silently.
687        svc.set_theme(Some("slate".into())).await.unwrap();
688        svc.set_hidden_sections(vec![Section::Pipelines]).await.unwrap();
689        svc.set_notifications(NotificationPrefs::default()).await.unwrap();
690
691        assert!(!rx.has_changed().unwrap(), "a UI preference must not trigger a refetch");
692    }
693
694    #[tokio::test]
695    async fn rewriting_an_identical_connection_does_not_notify() {
696        let (svc, _) = service();
697        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
698        let rx = svc.subscribe_data_changed();
699
700        // The dashboard re-saves on every form submit, including no-op ones.
701        let mut same = conn("gh-1", ProviderType::GitHub);
702        same.credential_ref = Some("gh-1".into());
703        svc.add_or_update_connection(same, None).await.unwrap();
704
705        assert!(!rx.has_changed().unwrap(), "an unchanged rewrite must not trigger a refetch");
706    }
707
708    #[tokio::test]
709    async fn bind_validates_capability() {
710        let (svc, _) = service();
711        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
712        svc.add_or_update_connection(conn("lin-1", ProviderType::Linear), None).await.unwrap();
713        svc.bind_pull_requests("gh-1").await.unwrap();
714        assert_eq!(svc.snapshot().pull_requests.unwrap().ids(), vec!["gh-1".to_string()]);
715        assert!(svc.bind_pull_requests("lin-1").await.is_err());
716    }
717
718    #[tokio::test]
719    async fn remove_connection_cascades() {
720        let (svc, secrets) = service();
721        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
722        svc.bind_pull_requests("gh-1").await.unwrap();
723        svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
724        svc.remove_connection("gh-1").await.unwrap();
725        let cfg = svc.snapshot();
726        assert!(cfg.connections.is_empty());
727        assert!(cfg.pull_requests.is_none());
728        assert!(cfg.pipelines.unwrap().subscriptions.is_empty());
729        assert_eq!(secrets.get("gh-1").unwrap(), None);
730    }
731
732    #[tokio::test]
733    async fn repo_scope_persists_including_the_empty_choice() {
734        let (svc, _) = service();
735        let mut c = conn("gh-1", ProviderType::GitHub);
736        c.repository = Some("acme/pay".into());
737        svc.add_or_update_connection(c, None).await.unwrap();
738
739        // Never set → absent, so the legacy repository is still what the factory resolves.
740        assert_eq!(svc.repo_scope("gh-1"), None);
741
742        svc.set_repo_scope("gh-1", Some(vec!["acme/pay".into(), "acme/ledger".into()])).await.unwrap();
743        assert_eq!(svc.repo_scope("gh-1").unwrap().len(), 2);
744
745        // "I chose none" is a real, persisted state — not the same as never having chosen.
746        svc.set_repo_scope("gh-1", Some(vec![])).await.unwrap();
747        assert_eq!(svc.repo_scope("gh-1"), Some(vec![]));
748        svc.load().await.unwrap();
749        assert_eq!(svc.repo_scope("gh-1"), Some(vec![]), "an emptied scope survives a reload");
750
751        assert!(svc.set_repo_scope("nope", None).await.is_err());
752    }
753
754    #[tokio::test]
755    async fn pipeline_subscribe_unsubscribe() {
756        let (svc, _) = service();
757        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
758        svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
759        svc.subscribe_pipeline("gh-1", "release").await.unwrap();
760        svc.subscribe_pipeline("gh-1", "ci").await.unwrap(); // duplicate ignored
761        assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 2);
762        svc.unsubscribe_pipeline("gh-1", "ci").await.unwrap();
763        assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 1);
764    }
765
766    #[tokio::test]
767    async fn set_pipeline_definitions_replaces_and_disables_auto() {
768        let (svc, _) = service();
769        svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
770        svc.set_pipeline_auto_discover("gh-1", true).await.unwrap();
771        svc.set_pipeline_definitions("gh-1", vec!["ci".into(), "release".into()]).await.unwrap();
772        let cfg = svc.snapshot();
773        let sub = &cfg.pipelines.unwrap().subscriptions[0];
774        assert!(!sub.auto_discover_all);
775        assert_eq!(sub.definition_ids, vec!["ci".to_string(), "release".to_string()]);
776    }
777}