1use anyhow::{Result, anyhow};
9
10use crate::domain::{
11 CloudflareStatus, DomainStatus, fetch_domain_status, format_cloudflare_status,
12};
13
14pub struct ResolvedAppUrl {
15 pub url: String,
16 pub note: Option<String>,
17}
18
19const CONTROL_PROJECT_ID: &str = "fn0-control";
25
26pub fn default_app_url(control_url: &str, project_id: &str) -> Result<String> {
27 let mut url = reqwest::Url::parse(control_url)
28 .map_err(|e| anyhow!("control URL '{control_url}' is not a valid URL: {e}"))?;
29 let host = url
30 .host_str()
31 .ok_or_else(|| anyhow!("control URL '{control_url}' has no host"))?;
32 let apex_host = host
33 .strip_prefix(&format!("{CONTROL_PROJECT_ID}."))
34 .unwrap_or(host);
35 let app_host = format!("{project_id}.{apex_host}");
36 url.set_host(Some(&app_host))
37 .map_err(|e| anyhow!("could not build app host '{app_host}': {e}"))?;
38 url.set_path("");
39 Ok(url.to_string().trim_end_matches('/').to_string())
40}
41
42pub async fn resolve_app_url(project_id: &str) -> Result<ResolvedAppUrl> {
43 let creds = crate::credentials::load()?;
44 let control_url = creds
45 .as_ref()
46 .map(|c| c.control_url.clone())
47 .unwrap_or_else(crate::credentials::default_control_url);
48 let default_url = default_app_url(&control_url, project_id)?;
49
50 let Some(creds) = creds else {
51 return Ok(ResolvedAppUrl {
52 url: default_url,
53 note: Some("not signed in, so a custom domain could not be checked".to_string()),
54 });
55 };
56
57 let fallback = |note: String| ResolvedAppUrl {
58 url: default_url.clone(),
59 note: Some(note),
60 };
61
62 Ok(match fetch_domain_status(&creds, project_id).await {
63 Ok(DomainStatus::Configured {
64 domain,
65 cloudflare_status: CloudflareStatus::Active,
66 }) => ResolvedAppUrl {
67 url: format!("https://{domain}"),
68 note: None,
69 },
70 Ok(DomainStatus::Configured {
71 domain,
72 cloudflare_status,
73 }) => fallback(format!(
74 "custom domain '{domain}' is not serving yet ({}), so the default subdomain is used",
75 format_cloudflare_status(&cloudflare_status)
76 )),
77 Ok(DomainStatus::NotConfigured) => ResolvedAppUrl {
78 url: default_url,
79 note: None,
80 },
81 Ok(DomainStatus::NotLoggedIn) => {
82 fallback("control rejected the saved token; run `forte login` again".to_string())
83 }
84 Ok(DomainStatus::NotFound) => fallback(format!(
85 "project '{project_id}' was not found under your account; nothing may be deployed there"
86 )),
87 Ok(DomainStatus::InternalError) => {
88 fallback("control failed to report the custom domain".to_string())
89 }
90 Err(e) => fallback(format!(
91 "could not reach control to check a custom domain: {e}"
92 )),
93 })
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn builds_subdomain_under_the_control_host() {
102 assert_eq!(
103 default_app_url("https://fn0.dev", "abc123").unwrap(),
104 "https://abc123.fn0.dev"
105 );
106 }
107
108 #[test]
109 fn keeps_scheme_and_port_of_a_self_hosted_control() {
110 assert_eq!(
111 default_app_url("http://localhost:3000", "abc123").unwrap(),
112 "http://abc123.localhost:3000"
113 );
114 }
115
116 #[test]
117 fn drops_a_trailing_path_on_the_control_url() {
118 assert_eq!(
119 default_app_url("https://fn0.dev/", "abc123").unwrap(),
120 "https://abc123.fn0.dev"
121 );
122 }
123
124 #[test]
125 fn strips_the_control_projects_own_subdomain_to_reach_the_apex() {
126 assert_eq!(
127 default_app_url("https://fn0-control.fn0.dev", "abc123").unwrap(),
128 "https://abc123.fn0.dev"
129 );
130 }
131}