Skip to main content

difflore_core/cloud/
endpoints.rs

1//! Single source of truth for cloud-side URLs.
2//!
3//! Runtime cloud API calls and browser deep links route through this
4//! module. `DIFFLORE_CLOUD_URL` overrides the production API base.
5//!
6//! Display-only marketing labels stay outside this runtime URL table.
7
8/// Cloud API base used when cloud URL overrides are unset.
9///
10/// Production host; override with `DIFFLORE_CLOUD_URL`.
11pub const DEFAULT_API_BASE: &str = "https://difflore.dev/api";
12
13/// Canonical environment variable that overrides the default base. Honoured by
14/// every helper in this module.
15pub const ENV_CLOUD_URL: &str = crate::env::DIFFLORE_CLOUD_URL;
16
17/// Legacy spelling accepted as a compatibility fallback.
18pub const LEGACY_ENV_CLOUD_URL: &str = crate::env::DIFF_LORE_CLOUD_URL;
19
20/// Read the configured API base, falling back to `DEFAULT_API_BASE`.
21/// Empty values are treated as unset so empty env vars do not silently break
22/// clients.
23pub fn api_base() -> String {
24    env_api_base(ENV_CLOUD_URL)
25        .or_else(|| env_api_base(LEGACY_ENV_CLOUD_URL))
26        .unwrap_or_else(|| DEFAULT_API_BASE.to_owned())
27}
28
29fn env_api_base(name: &str) -> Option<String> {
30    crate::env::var(name)
31        .map(|v| v.trim().to_owned())
32        .filter(|v| !v.is_empty())
33}
34
35/// Browser origin derived from the API base. A trailing `/api` is
36/// stripped so deep links target public web routes.
37pub fn web_origin() -> String {
38    web_origin_from(&api_base())
39}
40
41/// Pure variant exposed for testing — derive a web origin from any
42/// API-base string.
43pub fn web_origin_from(api_base: &str) -> String {
44    let trimmed = api_base.trim_end_matches('/');
45    trimmed
46        .strip_suffix("/api")
47        .map_or_else(|| trimmed.to_owned(), ToOwned::to_owned)
48}
49
50/// Scheme + authority (`scheme://host[:port]`) used to bind saved auth
51/// tokens to the origin that issued them.
52pub fn api_origin() -> String {
53    origin_of(&api_base())
54}
55
56/// Default origin assumed for saved credentials without a stored host.
57pub fn default_api_origin() -> String {
58    origin_of(DEFAULT_API_BASE)
59}
60
61/// `scheme://host[:port]` from any URL, dropping the path. Keeps the
62/// scheme so credentials never cross `http`/`https` boundaries.
63pub fn origin_of(url: &str) -> String {
64    let trimmed = url.trim();
65    let Some((scheme, rest)) = trimmed.split_once("://") else {
66        return trimmed.to_owned();
67    };
68    let authority = rest.split('/').next().unwrap_or(rest);
69    // Scheme and host are case-insensitive; lowercasing avoids false
70    // mismatches without merging distinct hosts.
71    format!(
72        "{}://{}",
73        scheme.to_ascii_lowercase(),
74        authority.to_ascii_lowercase()
75    )
76}
77
78/// Build a full URL to a web page on the cloud origin. `path` may
79/// include a leading `/`; both forms produce the same result.
80pub fn web_link(path: &str) -> String {
81    web_link_from(&api_base(), path)
82}
83
84/// Pure variant exposed for testing.
85pub fn web_link_from(api_base: &str, path: &str) -> String {
86    let base = web_origin_from(api_base);
87    let path = path.trim_start_matches('/');
88    if path.is_empty() {
89        base
90    } else {
91        format!("{base}/{path}")
92    }
93}
94
95/// Bare host (and port, if non-default) for display strings.
96pub fn web_host_display() -> String {
97    let origin = web_origin();
98    origin
99        .strip_prefix("https://")
100        .or_else(|| origin.strip_prefix("http://"))
101        .unwrap_or(&origin)
102        .to_owned()
103}
104
105/// Cloud pricing page on the configured web origin.
106pub fn pricing_url() -> String {
107    web_link("pricing")
108}
109
110// ── GitHub repository ────────────────────────────────────────────────
111//
112// Central `owner/name` slug used by GitHub links and release metadata.
113
114/// `owner/name` slug for the project's canonical GitHub repository.
115pub const GITHUB_REPO: &str = "difflore/difflore-cli";
116
117/// `https://github.com/<owner>/<name>`.
118pub fn github_repo_url() -> String {
119    format!("https://github.com/{GITHUB_REPO}")
120}
121
122/// `…/issues` — used in bug-report footers and the 5xx error help
123/// pointer.
124pub fn github_issues_url() -> String {
125    format!("https://github.com/{GITHUB_REPO}/issues")
126}
127
128/// `…/releases/tag/v{version}` — emitted by the upgrade checker so
129/// users can read release notes before pulling.
130pub fn github_release_tag_url(version: &str) -> String {
131    format!("https://github.com/{GITHUB_REPO}/releases/tag/v{version}")
132}
133
134// ── Device registration ──────────────────────────────────────────────
135
136use openapi_contract::api;
137
138use super::api_types::RegisterDeviceResult;
139use super::client::CloudClient;
140
141pub async fn register(
142    client: &CloudClient,
143    name: &str,
144    platform: &str,
145) -> crate::Result<RegisterDeviceResult> {
146    let payload = serde_json::json!({ "name": name, "platform": platform });
147    let device: RegisterDeviceResult = api!(POST "/auth/devices", body = &payload)
148        .fetch(client)
149        .await?;
150    Ok(device)
151}
152
153pub const fn detect_platform() -> &'static str {
154    if cfg!(target_os = "windows") {
155        "windows"
156    } else if cfg!(target_os = "macos") {
157        "macos"
158    } else {
159        "linux"
160    }
161}
162
163pub fn detect_hostname() -> String {
164    for key in ["COMPUTERNAME", "HOSTNAME", "HOST"] {
165        if let Some(name) = crate::env::var(key) {
166            return name;
167        }
168    }
169    // Unix fallback for shells that do not export a hostname.
170    if !cfg!(target_os = "windows")
171        && let Ok(out) = std::process::Command::new("hostname").output()
172        && out.status.success()
173    {
174        let name = String::from_utf8_lossy(&out.stdout).trim().to_owned();
175        if !name.is_empty() {
176            return name;
177        }
178    }
179    "unknown-device".to_owned()
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn web_origin_strips_trailing_api_segment() {
188        assert_eq!(
189            web_origin_from("https://difflore.dev/api"),
190            "https://difflore.dev"
191        );
192        assert_eq!(
193            web_origin_from("https://difflore.dev/api/"),
194            "https://difflore.dev"
195        );
196    }
197
198    #[test]
199    fn web_origin_passes_through_when_no_api_suffix() {
200        assert_eq!(
201            web_origin_from("https://difflore.dev"),
202            "https://difflore.dev"
203        );
204    }
205
206    #[test]
207    fn origin_of_keeps_scheme_and_authority_drops_path() {
208        assert_eq!(
209            origin_of("https://difflore.dev/api"),
210            "https://difflore.dev"
211        );
212        assert_eq!(
213            origin_of("https://difflore.dev/api/"),
214            "https://difflore.dev"
215        );
216        assert_eq!(
217            origin_of("http://127.0.0.1:3017/api"),
218            "http://127.0.0.1:3017"
219        );
220        assert_eq!(
221            origin_of("https://staging.example.com:8443/api/v1"),
222            "https://staging.example.com:8443"
223        );
224        assert_eq!(
225            origin_of(" https://difflore.dev/api "),
226            "https://difflore.dev"
227        );
228    }
229
230    #[test]
231    fn origin_of_distinguishes_scheme_and_host_so_tokens_cannot_cross() {
232        // Saved credentials must not cross scheme or host boundaries.
233        assert_ne!(
234            origin_of("https://difflore.dev/api"),
235            origin_of("http://difflore.dev/api")
236        );
237        assert_ne!(
238            origin_of("https://difflore.dev/api"),
239            origin_of("https://attacker.example/api")
240        );
241    }
242
243    #[test]
244    fn origin_of_is_case_insensitive_on_scheme_and_host() {
245        // A benign case difference must not lock a user out of their own token.
246        assert_eq!(
247            origin_of("HTTPS://Difflore.DEV/api"),
248            origin_of("https://difflore.dev/api"),
249        );
250        assert_eq!(
251            origin_of("HTTPS://Difflore.DEV/api"),
252            "https://difflore.dev"
253        );
254    }
255
256    #[test]
257    fn default_api_origin_is_the_production_host() {
258        assert_eq!(default_api_origin(), "https://difflore.dev");
259    }
260
261    #[test]
262    fn web_link_handles_leading_slash_either_way() {
263        assert_eq!(
264            web_link_from("https://difflore.dev/api", "/pricing"),
265            "https://difflore.dev/pricing"
266        );
267        assert_eq!(
268            web_link_from("https://difflore.dev/api", "pricing"),
269            "https://difflore.dev/pricing"
270        );
271        assert_eq!(
272            web_link_from("https://difflore.dev/api", ""),
273            "https://difflore.dev"
274        );
275    }
276
277    #[test]
278    fn api_base_honors_canonical_cloud_url_env() {
279        temp_env::with_vars(
280            [
281                (ENV_CLOUD_URL, Some(" http://127.0.0.1:3017/api ")),
282                (LEGACY_ENV_CLOUD_URL, None),
283            ],
284            || {
285                assert_eq!(api_base(), "http://127.0.0.1:3017/api");
286            },
287        );
288    }
289
290    #[test]
291    fn api_base_honors_legacy_cloud_url_env() {
292        temp_env::with_vars(
293            [
294                (ENV_CLOUD_URL, None),
295                (LEGACY_ENV_CLOUD_URL, Some(" http://127.0.0.1:3018/api ")),
296            ],
297            || {
298                assert_eq!(api_base(), "http://127.0.0.1:3018/api");
299            },
300        );
301    }
302
303    #[test]
304    fn canonical_cloud_url_env_wins_over_legacy() {
305        temp_env::with_vars(
306            [
307                (ENV_CLOUD_URL, Some("http://127.0.0.1:3017/api")),
308                (LEGACY_ENV_CLOUD_URL, Some("http://127.0.0.1:3018/api")),
309            ],
310            || {
311                assert_eq!(api_base(), "http://127.0.0.1:3017/api");
312            },
313        );
314    }
315}