Skip to main content

torii_lib/platforms/
registry.rs

1//! 0.8.0 — registry of self-hosted platforms.
2//!
3//! `platforms.toml` lets the user declare instances that don't live
4//! on the well-known SaaS domains: a self-hosted GitLab, a Gitea /
5//! Forgejo instance, a GitHub Enterprise Server, a Bitbucket Data
6//! Center. Each entry maps a `domain` (matched against the remote
7//! URL) to the API + web base URLs the rest of torii's platform
8//! clients should hit.
9//!
10//! Two on-disk locations:
11//!
12//! - Global: `~/.config/torii/platforms.toml`
13//! - Per-repo: `<repo>/.torii/platforms.toml`
14//!
15//! Merge order: local overrides global, by `name`. Builtins
16//! (github.com, gitlab.com, codeberg.org, etc.) live in code and
17//! get returned at the end of `list()`; a user entry with the same
18//! `name` shadows the builtin.
19
20use std::collections::BTreeMap;
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25
26use crate::error::{Result, ToriiError};
27
28/// One platform entry. Mirrors the columns the `platforms list`
29/// command surfaces.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PlatformEntry {
32    /// Short identifier, e.g. "work-gitlab", "ghe", "forge".
33    pub name: String,
34    /// Implementation kind. Drives which client we construct.
35    /// Accepted: `gitlab`, `gitea`, `forgejo`, `codeberg`,
36    /// `github`, `github_enterprise`, `bitbucket`,
37    /// `bitbucket_data_center`. Codeberg/Forgejo route through the
38    /// Gitea client.
39    pub kind: String,
40    /// Domain matched against the remote URL host. e.g.
41    /// "gitlab.empresa.com", "ghe.work.io". Without scheme.
42    pub domain: String,
43    /// API base URL. e.g. `https://gitlab.empresa.com/api/v4` for
44    /// GitLab, `https://ghe.work.io/api/v3` for GitHub Enterprise.
45    pub api_base_url: String,
46    /// Web base URL — what we show users and what OAuth flows hit.
47    /// e.g. `https://gitlab.empresa.com`.
48    pub web_base_url: String,
49    /// OAuth `client_id` override. When set, supersedes the bundled
50    /// client_id for OAuth flows against this instance.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub client_id: Option<String>,
53}
54
55/// On-disk shape — TOML root carries a single `[[platform]]` array.
56#[derive(Debug, Default, Deserialize, Serialize)]
57struct OnDisk {
58    #[serde(default, rename = "platform")]
59    platforms: Vec<PlatformEntry>,
60}
61
62fn global_path() -> Option<PathBuf> {
63    dirs::config_dir().map(|d| d.join("torii").join("platforms.toml"))
64}
65
66fn local_path<P: AsRef<Path>>(repo_path: P) -> PathBuf {
67    repo_path.as_ref().join(".torii").join("platforms.toml")
68}
69
70fn load_file(path: &Path) -> Vec<PlatformEntry> {
71    if !path.exists() {
72        return Vec::new();
73    }
74    let Ok(text) = fs::read_to_string(path) else { return Vec::new() };
75    let Ok(parsed) = toml::from_str::<OnDisk>(&text) else { return Vec::new() };
76    parsed.platforms
77}
78
79fn save_file(path: &Path, entries: &[PlatformEntry]) -> Result<()> {
80    if let Some(parent) = path.parent() {
81        fs::create_dir_all(parent)
82            .map_err(|e| ToriiError::Fs(format!("mkdir {}: {}", parent.display(), e)))?;
83    }
84    let on_disk = OnDisk { platforms: entries.to_vec() };
85    let text = toml::to_string_pretty(&on_disk)
86        .map_err(|e| ToriiError::InvalidConfig(format!("serialise platforms.toml: {}", e)))?;
87    fs::write(path, text)
88        .map_err(|e| ToriiError::Fs(format!("write {}: {}", path.display(), e)))?;
89    Ok(())
90}
91
92/// Read the global registry only.
93pub fn load_global() -> Vec<PlatformEntry> {
94    global_path().map(|p| load_file(&p)).unwrap_or_default()
95}
96
97/// Read the per-repo registry only.
98pub fn load_local<P: AsRef<Path>>(repo_path: P) -> Vec<PlatformEntry> {
99    load_file(&local_path(repo_path))
100}
101
102/// Merge per-repo over global by `name`. Returns the effective
103/// registry torii's detection code reads from. Local entries win;
104/// global entries that aren't shadowed survive.
105pub fn merged<P: AsRef<Path>>(repo_path: P) -> Vec<PlatformEntry> {
106    let mut by_name: BTreeMap<String, PlatformEntry> = BTreeMap::new();
107    for e in load_global()       { by_name.insert(e.name.clone(), e); }
108    for e in load_local(repo_path){ by_name.insert(e.name.clone(), e); }
109    by_name.into_values().collect()
110}
111
112/// Builtins. Same domains the CLI has always recognised, exposed
113/// here so `platforms list` can show them alongside customs. Edit
114/// only when you add a new SaaS platform — self-hosted instances
115/// belong in platforms.toml.
116pub fn builtins() -> Vec<PlatformEntry> {
117    vec![
118        PlatformEntry {
119            name: "github.com".into(),
120            kind: "github".into(),
121            domain: "github.com".into(),
122            api_base_url: "https://api.github.com".into(),
123            web_base_url: "https://github.com".into(),
124            client_id: None,
125        },
126        PlatformEntry {
127            name: "gitlab.com".into(),
128            kind: "gitlab".into(),
129            domain: "gitlab.com".into(),
130            api_base_url: "https://gitlab.com/api/v4".into(),
131            web_base_url: "https://gitlab.com".into(),
132            client_id: None,
133        },
134        PlatformEntry {
135            name: "codeberg.org".into(),
136            kind: "codeberg".into(),
137            domain: "codeberg.org".into(),
138            api_base_url: "https://codeberg.org/api/v1".into(),
139            web_base_url: "https://codeberg.org".into(),
140            client_id: None,
141        },
142        PlatformEntry {
143            name: "bitbucket.org".into(),
144            kind: "bitbucket".into(),
145            domain: "bitbucket.org".into(),
146            api_base_url: "https://api.bitbucket.org/2.0".into(),
147            web_base_url: "https://bitbucket.org".into(),
148            client_id: None,
149        },
150    ]
151}
152
153/// All known platforms (builtins + merged). User entries with the
154/// same name shadow builtins. Order: shadowed-out builtins drop,
155/// custom entries first, surviving builtins last.
156pub fn all<P: AsRef<Path>>(repo_path: P) -> Vec<PlatformEntry> {
157    let user = merged(repo_path);
158    let user_names: std::collections::BTreeSet<&str> =
159        user.iter().map(|e| e.name.as_str()).collect();
160    let mut out: Vec<PlatformEntry> = user.clone();
161    for b in builtins() {
162        if !user_names.contains(b.name.as_str()) {
163            out.push(b);
164        }
165    }
166    out
167}
168
169/// Resolve a remote URL host to the matching platform entry. Tries
170/// custom first (most specific by domain length so subdomain
171/// matches don't shadow longer matches), then builtins.
172pub fn find_by_host<P: AsRef<Path>>(repo_path: P, host: &str) -> Option<PlatformEntry> {
173    let mut candidates = all(repo_path);
174    // Prefer longest-matching domain. Lets "gitea.work.io" beat a
175    // catch-all "work.io" entry.
176    candidates.sort_by_key(|e| std::cmp::Reverse(e.domain.len()));
177    candidates.into_iter().find(|e| host == e.domain || host.ends_with(&format!(".{}", e.domain)))
178}
179
180/// Add an entry to the per-repo registry when `local`, otherwise
181/// the global one. Replaces an existing entry with the same `name`.
182pub fn add_entry<P: AsRef<Path>>(repo_path: P, entry: PlatformEntry, local: bool) -> Result<()> {
183    let path = if local { local_path(&repo_path) } else { global_path()
184        .ok_or_else(|| ToriiError::InvalidConfig("no config dir".into()))? };
185    let mut entries = load_file(&path);
186    entries.retain(|e| e.name != entry.name);
187    entries.push(entry);
188    save_file(&path, &entries)
189}
190
191/// Remove the entry whose `name` matches. Errors if the registry
192/// doesn't carry one with that name (Ok(false)) — the caller can
193/// still fall through to "maybe it was a builtin, those can't be
194/// removed".
195pub fn remove_entry<P: AsRef<Path>>(repo_path: P, name: &str, local: bool) -> Result<bool> {
196    let path = if local { local_path(&repo_path) } else { global_path()
197        .ok_or_else(|| ToriiError::InvalidConfig("no config dir".into()))? };
198    let mut entries = load_file(&path);
199    let before = entries.len();
200    entries.retain(|e| e.name != name);
201    if entries.len() == before {
202        return Ok(false);
203    }
204    save_file(&path, &entries)?;
205    Ok(true)
206}