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