Skip to main content

forgetop_core/
setup.rs

1//! Connection setup schema — the fields each provider needs, and which sections it can feed.
2//!
3//! Shared by the TUI's add-connection wizard and the web dashboard's settings page so the two
4//! ask for exactly the same things. Pure metadata: no I/O, no secrets.
5
6use serde::Serialize;
7
8use crate::domain::{ProviderType, Section};
9
10/// A field in a connection form. Maps onto a [`crate::provider::Connection`] field, except
11/// [`FieldKey::Pat`], which is the secret stored in the keychain (never in the config).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum FieldKey {
15    DisplayName,
16    BaseUrl,
17    Organization,
18    Project,
19    Repository,
20    Username,
21    Pat,
22}
23
24/// One input in a provider's connection form.
25#[derive(Debug, Clone, Serialize)]
26pub struct FieldSpec {
27    pub key: FieldKey,
28    pub label: String,
29    pub help: String,
30    pub required: bool,
31    /// True for the token/password field — masked in the UI, write-only to the keychain.
32    pub secret: bool,
33    /// A suggested default (e.g. the provider name for the display name).
34    pub default: Option<String>,
35}
36
37impl FieldSpec {
38    fn text(key: FieldKey, label: &str, help: &str, required: bool) -> Self {
39        FieldSpec { key, label: label.into(), help: help.into(), required, secret: false, default: None }
40    }
41    fn secret(label: &str, help: &str) -> Self {
42        FieldSpec { key: FieldKey::Pat, label: label.into(), help: help.into(), required: true, secret: true, default: None }
43    }
44}
45
46/// Providers whose items are addressed by a repository, and which therefore carry a repository
47/// scope. Jira is project-addressed and Linear team-addressed, so neither has anything for a
48/// repository scope to govern.
49///
50/// This keys off the provider, **not** off whether its repository field happens to be required —
51/// that field is now legitimately optional everywhere, so anything inferring repo-addressedness
52/// from `required` would have silently changed meaning.
53pub fn is_repo_addressed(provider: ProviderType) -> bool {
54    matches!(
55        provider,
56        ProviderType::GitHub | ProviderType::GitLab | ProviderType::AzureDevOps | ProviderType::Bitbucket
57    )
58}
59
60/// The shared help text on every optional repository field: the connection is an account, and
61/// which repositories it fetches from is chosen afterwards.
62const PICK_LATER: &str = "Leave blank to pick repositories after connecting";
63
64/// The ordered fields a connection to `provider` needs — display name first, secret last.
65///
66/// The rule behind which fields stay required: **a field that discovery itself is addressed by
67/// stays required.** Bitbucket lists repositories at `/repositories/{workspace}` and Azure at
68/// `{org}/_apis/git/repositories`, so a connection missing the workspace or the organization
69/// could never populate its scope picker — it would be empty with no way to fill it. GitHub and
70/// GitLab discover from the token alone, so they need no such field.
71pub fn connection_fields(provider: ProviderType) -> Vec<FieldSpec> {
72    let mut out = vec![FieldSpec {
73        key: FieldKey::DisplayName,
74        label: "Display name".into(),
75        help: "A label shown in the tab bar and connections list".into(),
76        required: true,
77        secret: false,
78        default: Some(provider.as_str().to_string()),
79    }];
80    match provider {
81        ProviderType::Demo => {}
82        ProviderType::GitHub => {
83            out.push(FieldSpec::text(FieldKey::Repository, "Repository (owner/repo)", PICK_LATER, false));
84            out.push(FieldSpec::secret("Personal access token", "github.com → Settings → Developer settings → Personal access tokens · scope: repo"));
85        }
86        ProviderType::AzureDevOps => {
87            out.push(FieldSpec::text(FieldKey::Organization, "Organization", "The {org} in dev.azure.com/{org}", true));
88            out.push(FieldSpec::text(FieldKey::Project, "Project", PICK_LATER, false));
89            out.push(FieldSpec::text(FieldKey::Repository, "Repository", PICK_LATER, false));
90            out.push(FieldSpec::secret("Personal access token", "dev.azure.com → User settings → Personal access tokens · Code, Work Items, Build"));
91        }
92        ProviderType::Linear => {
93            out.push(FieldSpec::secret("API key", "linear.app → Settings → Security & access → API → Personal API keys"));
94        }
95        ProviderType::GitLab => {
96            out.push(FieldSpec::text(FieldKey::Repository, "Project (group/project)", PICK_LATER, false));
97            out.push(FieldSpec::secret("Personal access token", "gitlab.com → Preferences → Access Tokens · scope: api"));
98        }
99        ProviderType::Jira => {
100            out.push(FieldSpec::text(FieldKey::BaseUrl, "Site URL", "Your Atlassian site, e.g. https://your-company.atlassian.net", true));
101            out.push(FieldSpec::text(FieldKey::Project, "Project key", "The prefix on issue keys, e.g. ENG in ENG-123", true));
102            out.push(FieldSpec::text(FieldKey::Username, "Email", "The email of your Atlassian account", true));
103            out.push(FieldSpec::secret("API token", "id.atlassian.com → Security → Create and manage API tokens"));
104        }
105        ProviderType::Bitbucket => {
106            out.push(FieldSpec::text(FieldKey::Organization, "Workspace", "The {workspace} in bitbucket.org/{workspace}", true));
107            out.push(FieldSpec::text(FieldKey::Repository, "Repository (slug)", PICK_LATER, false));
108            out.push(FieldSpec::text(FieldKey::Username, "Username", "Your Bitbucket username (not your email)", true));
109            out.push(FieldSpec::secret("App password", "Personal settings → App passwords · Pull requests + Pipelines (read & write)"));
110        }
111    }
112    out
113}
114
115/// The sections `provider` can populate (drives which bindings the form offers).
116pub fn provider_sections(provider: ProviderType) -> Vec<Section> {
117    match provider {
118        ProviderType::Linear | ProviderType::Jira => vec![Section::WorkItems],
119        ProviderType::Bitbucket => vec![Section::PullRequests, Section::Pipelines],
120        _ => vec![Section::PullRequests, Section::WorkItems, Section::Pipelines],
121    }
122}
123
124/// The provider types a user can set up (excludes `Demo`, which is only for `--demo`).
125pub fn selectable_providers() -> Vec<ProviderType> {
126    vec![
127        ProviderType::GitHub,
128        ProviderType::GitLab,
129        ProviderType::AzureDevOps,
130        ProviderType::Bitbucket,
131        ProviderType::Linear,
132        ProviderType::Jira,
133    ]
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn github_needs_a_token_and_feeds_three_sections() {
142        let fields = connection_fields(ProviderType::GitHub);
143        assert_eq!(fields[0].key, FieldKey::DisplayName);
144        assert!(fields.iter().any(|f| f.key == FieldKey::Pat && f.secret));
145        assert_eq!(provider_sections(ProviderType::GitHub).len(), 3);
146    }
147
148    /// Rule 1: every repo-addressed provider offers a repository field and does **not** demand it.
149    /// A connection is an account; which repositories it fetches from is chosen afterwards.
150    #[test]
151    fn repo_addressed_providers_offer_an_optional_repository_field() {
152        for provider in selectable_providers().into_iter().filter(|p| is_repo_addressed(*p)) {
153            let fields = connection_fields(provider);
154            let repo = fields
155                .iter()
156                .find(|f| f.key == FieldKey::Repository)
157                .unwrap_or_else(|| panic!("{} offers no repository field", provider.as_str()));
158            assert!(!repo.required, "{} still demands a repository", provider.as_str());
159        }
160        // …and the two that aren't repo-addressed don't offer one at all.
161        for provider in [ProviderType::Jira, ProviderType::Linear] {
162            assert!(!is_repo_addressed(provider));
163            assert!(connection_fields(provider).iter().all(|f| f.key != FieldKey::Repository));
164        }
165    }
166
167    /// Rule 2: a field that **discovery itself is addressed by** stays required, or the scope
168    /// picker would come up empty with no way to fill it.
169    #[test]
170    fn fields_that_discovery_is_addressed_by_stay_required() {
171        let required = |provider: ProviderType, key: FieldKey| {
172            connection_fields(provider).iter().find(|f| f.key == key).is_some_and(|f| f.required)
173        };
174        // Bitbucket discovery is `/repositories/{workspace}`.
175        assert!(required(ProviderType::Bitbucket, FieldKey::Organization), "Bitbucket workspace must stay required");
176        // Azure discovery is `{org}/_apis/git/repositories`.
177        assert!(required(ProviderType::AzureDevOps, FieldKey::Organization), "Azure organization must stay required");
178        // GitHub and GitLab discover from the token alone, so they need no such field.
179        assert!(connection_fields(ProviderType::GitHub).iter().all(|f| f.key != FieldKey::Organization));
180        assert!(connection_fields(ProviderType::GitLab).iter().all(|f| f.key != FieldKey::Organization));
181    }
182
183    #[test]
184    fn linear_is_work_items_only_and_just_a_key() {
185        assert_eq!(provider_sections(ProviderType::Linear), vec![Section::WorkItems]);
186        let fields = connection_fields(ProviderType::Linear);
187        // display name + the API key, nothing else.
188        assert_eq!(fields.len(), 2);
189        assert!(fields[1].secret);
190    }
191}