Skip to main content

apiplant_core/
config.rs

1//! `main.toml` — the top-level server configuration.
2//!
3//! Every field is optional: a missing file, or a file missing any given key,
4//! falls back to a safe default. The only piece of configuration that is
5//! *inferred* rather than declared is TLS: if the app directory contains an
6//! `https/` folder with a cert + key, the server serves HTTPS.
7//!
8//! ## Environment variables
9//!
10//! Any string value here — like any string in any of the app's TOML files — may
11//! reference the environment: `url = "$DATABASE_URL"`,
12//! `port = "${PORT:-8080}"`. See [`crate::env`] for the syntax.
13
14use serde::Deserialize;
15use std::path::Path;
16
17/// Fully-resolved server configuration.
18#[derive(Debug, Clone, Default, Deserialize)]
19#[serde(default)]
20pub struct Config {
21    pub app: AppConfig,
22    pub server: ServerConfig,
23    pub database: DatabaseConfig,
24    pub auth: AuthConfig,
25    pub docs: DocsConfig,
26    pub admin: AdminConfig,
27    pub public: PublicConfig,
28    pub email: EmailConfig,
29    pub cache: CacheConfig,
30    pub payments: PaymentsConfig,
31    pub ai: AiConfig,
32    pub oauth: OAuthConfig,
33}
34
35/// What the app calls itself.
36///
37/// The directory an app lives in is a developer's filing decision —
38/// `07-functions`, `api-v2`, `backend` — and the dashboard header is read by
39/// people who never see it. This is where an app says the name they should
40/// read instead.
41#[derive(Debug, Clone, Default, Deserialize)]
42#[serde(default)]
43pub struct AppConfig {
44    /// Display name, used wherever the app is named to a person — the admin
45    /// dashboard's header and title. Unset falls back to the directory name.
46    pub name: Option<String>,
47}
48
49/// Accepts either a bare string or a list of them, so a config that names one
50/// domain doesn't have to be written as a one-element list.
51fn one_or_many<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Vec<String>, D::Error> {
52    #[derive(Deserialize)]
53    #[serde(untagged)]
54    enum OneOrMany {
55        One(String),
56        Many(Vec<String>),
57    }
58    Ok(match OneOrMany::deserialize(de)? {
59        OneOrMany::One(s) => vec![s],
60        OneOrMany::Many(v) => v,
61    })
62}
63
64#[derive(Debug, Clone, Deserialize)]
65#[serde(default)]
66pub struct ServerConfig {
67    /// Interface to bind, e.g. `0.0.0.0`. Empty or `*` means every interface
68    /// and is normalised to `0.0.0.0` on load.
69    pub host: String,
70    /// TCP port.
71    pub port: u16,
72    /// Only answer requests whose `Host:` header is one of these. Written as a
73    /// single string (`domain = "api.example.com"`) or a list
74    /// (`domain = ["api.example.com", "www.example.com"]`). Unset — or the
75    /// catch-all spellings `""`, `*` and `_` (nginx's `server_name _`) —
76    /// answers any host, and all of them normalise to an empty list on load.
77    #[serde(deserialize_with = "one_or_many")]
78    pub domain: Vec<String>,
79    /// Sub-path the API is mounted under, e.g. `/api`. Always starts with `/`
80    /// and never ends with one (normalised on load).
81    pub base_path: String,
82    /// Number of worker threads. `None` = one per CPU.
83    pub workers: Option<usize>,
84    /// The origin this server is reached at from outside — `https://api.example.com`.
85    ///
86    /// Only links that leave the process need it: an invitation email has to
87    /// name a URL, and a request's own `Host:` header is the wrong source for
88    /// one (a message is composed once and read anywhere, possibly behind a
89    /// proxy that rewrote it). Unset falls back to the first configured
90    /// `domain`, then to `http://<host>:<port>`, which is right for local
91    /// development and wrong in front of a load balancer — so set it there.
92    pub public_url: String,
93}
94
95impl Default for ServerConfig {
96    fn default() -> Self {
97        ServerConfig {
98            host: "0.0.0.0".to_string(),
99            port: 8080,
100            domain: Vec::new(),
101            base_path: "/".to_string(),
102            workers: None,
103            public_url: String::new(),
104        }
105    }
106}
107
108impl ServerConfig {
109    /// The origin to put in a link that will be read outside this process.
110    ///
111    /// Prefers what the app declared, then the first domain it answers for,
112    /// and only then the socket it happens to be bound to.
113    pub fn public_origin(&self) -> String {
114        if !self.public_url.is_empty() {
115            return self.public_url.trim_end_matches('/').to_string();
116        }
117        if let Some(domain) = self.domain.first() {
118            // A bare domain is a hostname, not a URL; assume the scheme every
119            // deployment that has a domain name is using.
120            return if domain.contains("://") {
121                domain.trim_end_matches('/').to_string()
122            } else {
123                format!("https://{domain}")
124            };
125        }
126        let host = match self.host.as_str() {
127            "0.0.0.0" | "" | "*" | "::" => "localhost",
128            host => host,
129        };
130        format!("http://{host}:{}", self.port)
131    }
132}
133
134#[derive(Debug, Clone, Deserialize)]
135#[serde(default)]
136pub struct DatabaseConfig {
137    /// Full connection URL. When empty it is assembled from the parts below.
138    pub url: String,
139    pub host: String,
140    pub port: u16,
141    pub name: String,
142    pub user: String,
143    pub password: String,
144    /// Max pool connections.
145    pub max_connections: u32,
146    /// Run pending migrations on boot.
147    pub auto_migrate: bool,
148}
149
150impl Default for DatabaseConfig {
151    fn default() -> Self {
152        DatabaseConfig {
153            url: String::new(),
154            host: "localhost".to_string(),
155            port: 5432,
156            name: "apiplant".to_string(),
157            user: "postgres".to_string(),
158            password: "postgres".to_string(),
159            max_connections: 16,
160            auto_migrate: true,
161        }
162    }
163}
164
165impl DatabaseConfig {
166    /// The connection URL, assembled from parts when `url` was left empty.
167    pub fn resolved_url(&self) -> String {
168        if !self.url.is_empty() {
169            return self.url.clone();
170        }
171        format!(
172            "postgres://{}:{}@{}:{}/{}",
173            self.user, self.password, self.host, self.port, self.name
174        )
175    }
176}
177
178#[derive(Debug, Clone, Deserialize)]
179#[serde(default)]
180pub struct AuthConfig {
181    /// Secret used to sign session JWTs. Auto-generated (and warned about) when
182    /// left empty — set it in production so tokens survive restarts.
183    pub jwt_secret: String,
184    /// Session token lifetime in seconds.
185    pub session_ttl_secs: u64,
186    /// Allow self-service signup on `POST /auth/register`.
187    pub allow_registration: bool,
188
189    // --- the three features that need a mailbox to reach ------------------
190    //
191    // Each is `Option<bool>` rather than `bool` because their honest default is
192    // not a constant: it is "yes, if this app can send email". Leaving one unset
193    // means it follows `[email]`, so configuring a provider turns all three on
194    // and configuring none leaves them off — and neither one asks the developer
195    // to keep two sections in step. Setting one explicitly always wins, which is
196    // how an app that sends mail can still refuse, say, open registration.
197    /// Require a new account to confirm its address before it can sign in.
198    /// Unset follows `[email]`.
199    pub require_email_verification: Option<bool>,
200    /// Offer `POST /auth/invitations`, so an admin can add someone who has no
201    /// account yet. Unset follows `[email]`.
202    pub allow_invitations: Option<bool>,
203    /// Offer `POST /auth/password/forgot` and `/auth/password/reset`. Unset
204    /// follows `[email]`.
205    pub allow_password_reset: Option<bool>,
206
207    /// How long an organisation invitation stays valid (default 7 days).
208    pub invite_ttl_secs: u64,
209    /// How long an address-confirmation link stays valid (default 24 hours).
210    pub verification_ttl_secs: u64,
211    /// How long a password-reset link stays valid (default 1 hour). Short on
212    /// purpose: it is a live credential sitting in a mailbox.
213    pub password_reset_ttl_secs: u64,
214}
215
216impl Default for AuthConfig {
217    fn default() -> Self {
218        AuthConfig {
219            jwt_secret: String::new(),
220            session_ttl_secs: 60 * 60 * 24 * 7,
221            allow_registration: true,
222            require_email_verification: None,
223            allow_invitations: None,
224            allow_password_reset: None,
225            invite_ttl_secs: 60 * 60 * 24 * 7,
226            verification_ttl_secs: 60 * 60 * 24,
227            password_reset_ttl_secs: 60 * 60,
228        }
229    }
230}
231
232impl AuthConfig {
233    /// Whether new accounts must confirm their address, given whether the app
234    /// can send mail at all. An unset flag follows the mailer: asking for a
235    /// confirmation nobody can deliver would lock every new account out.
236    pub fn requires_email_verification(&self, email_enabled: bool) -> bool {
237        self.require_email_verification.unwrap_or(email_enabled) && email_enabled
238    }
239
240    /// Whether invitations are offered. See
241    /// [`requires_email_verification`](Self::requires_email_verification) for
242    /// why an explicit `true` still needs a mailer.
243    pub fn invitations_enabled(&self, email_enabled: bool) -> bool {
244        self.allow_invitations.unwrap_or(email_enabled) && email_enabled
245    }
246
247    /// Whether password reset is offered.
248    pub fn password_reset_enabled(&self, email_enabled: bool) -> bool {
249        self.allow_password_reset.unwrap_or(email_enabled) && email_enabled
250    }
251}
252
253/// Interactive API documentation (OpenAPI spec + Swagger UI).
254#[derive(Debug, Clone, Deserialize)]
255#[serde(default)]
256pub struct DocsConfig {
257    /// Serve the OpenAPI spec and Swagger UI (default true).
258    pub enabled: bool,
259    /// Path (under `base_path`) the Swagger UI is served at.
260    pub path: String,
261    /// Title shown in the UI and the spec's `info.title`. Unset falls back to
262    /// the app's name — see [`App::docs_title`](crate::App::docs_title) — so an
263    /// app that renames itself renames its docs too.
264    pub title: Option<String>,
265}
266
267impl Default for DocsConfig {
268    fn default() -> Self {
269        DocsConfig {
270            enabled: true,
271            path: "/docs".to_string(),
272            title: None,
273        }
274    }
275}
276
277/// The built-in admin dashboard, served from the binary itself.
278///
279/// Every app gets one, and there is only one: the interface is embedded in
280/// `apiplant` and its manifest is derived from the app on boot. Turn it off for
281/// a deployment that shouldn't expose an operator console at all — an app that
282/// wants its own can serve one from `public/` like any other page.
283#[derive(Debug, Clone, Deserialize)]
284#[serde(default)]
285pub struct AdminConfig {
286    /// Serve the admin dashboard (default true).
287    pub enabled: bool,
288    /// Path the dashboard is served at, outside `base_path`.
289    pub path: String,
290    /// Image shown in place of the apiplant mark, as a URL the browser can
291    /// fetch — usually a file in `public/`. Unset keeps the apiplant mark.
292    pub logo: Option<String>,
293    /// Optional AI help for writing text in the admin dashboard.
294    pub ai_assistance: AdminAiAssistanceConfig,
295}
296
297/// Extra browser-side prompting that fills text fields through the app's
298/// configured AI provider.
299#[derive(Debug, Clone, Deserialize)]
300#[serde(default)]
301pub struct AdminAiAssistanceConfig {
302    /// Show the "fill with AI" control in the dashboard.
303    pub enabled: bool,
304    /// Optional system prompt sent only by the dashboard's own field helper.
305    pub system: String,
306    /// Placeholder shown in the helper's prompt box.
307    pub prompt_placeholder: String,
308}
309
310impl Default for AdminAiAssistanceConfig {
311    fn default() -> Self {
312        AdminAiAssistanceConfig {
313            enabled: false,
314            system: String::new(),
315            prompt_placeholder: "Describe what you want AI to write for this field.".to_string(),
316        }
317    }
318}
319
320impl Default for AdminConfig {
321    fn default() -> Self {
322        AdminConfig {
323            enabled: true,
324            path: "/admin".to_string(),
325            logo: None,
326            ai_assistance: AdminAiAssistanceConfig::default(),
327        }
328    }
329}
330
331/// Static files served from the app's `public/` directory.
332///
333/// When the directory exists its contents are served at the site root, so
334/// `public/index.html` answers `/` and `public/style.css` answers `/style.css`.
335#[derive(Debug, Clone, Deserialize)]
336#[serde(default)]
337pub struct PublicConfig {
338    /// Serve `dir` at the root when it exists (default true).
339    pub enabled: bool,
340    /// Directory (relative to the app root) holding the static site.
341    pub dir: String,
342    /// Page returned for requests that match nothing, relative to `dir`.
343    /// Defaults to `404.html` when that file exists.
344    pub not_found: Option<String>,
345}
346
347impl Default for PublicConfig {
348    fn default() -> Self {
349        PublicConfig {
350            enabled: true,
351            dir: "public".to_string(),
352            not_found: None,
353        }
354    }
355}
356
357/// Outbound email: which provider sends it, and the credentials to do so.
358///
359/// Off by default (`provider = "none"`): an app that never sends mail carries
360/// no configuration and no client. Turning it on is one line plus a key, and
361/// every provider is reached through the same [`send_email`] call from a
362/// function — swapping SendGrid for SES is a config change, not a code change.
363///
364/// [`send_email`]: https://docs.rs/apiplant-function
365#[derive(Debug, Clone, Deserialize)]
366#[serde(default)]
367pub struct EmailConfig {
368    /// `none` (default), `smtp`, `ses`, `sendgrid`, `brevo` (aka `sendinblue`),
369    /// `mailjet`, `mailgun`, `postmark` or `resend`.
370    pub provider: String,
371    /// Envelope sender, e.g. `no-reply@example.com`. Required once enabled; a
372    /// message may override it per-send.
373    pub from: String,
374    /// Display name shown beside `from`.
375    pub from_name: String,
376    /// Default `Reply-To`. Empty = none.
377    pub reply_to: String,
378    /// The provider's API key. For `ses` this is the AWS access key id; for
379    /// `mailjet` the public key; for `smtp` it is unused (see [`SmtpConfig`]).
380    pub api_key: String,
381    /// The second half of a two-part credential: the AWS secret access key for
382    /// `ses`, the private key for `mailjet`. Unused elsewhere.
383    pub api_secret: String,
384    /// AWS region for `ses`, e.g. `eu-west-1`.
385    pub region: String,
386    /// Sending domain for `mailgun`, e.g. `mg.example.com`.
387    pub domain: String,
388    /// How long one send may take before it is abandoned.
389    pub timeout_secs: u64,
390    /// The mark shown in the banner of the messages the framework sends, as a
391    /// path inside [`PublicConfig::dir`] — `logo.png` or `/img/logo.svg`, both
392    /// of which mean the same file. It is turned into an absolute URL against
393    /// `[server] public_url`, because a mail client fetches it from the
394    /// internet rather than from a page. An empty string, or a path with no
395    /// file behind it, leaves the banner showing the app's name alone.
396    pub logo: String,
397    /// Connection details for `provider = "smtp"`.
398    pub smtp: SmtpConfig,
399}
400
401impl Default for EmailConfig {
402    fn default() -> Self {
403        EmailConfig {
404            provider: "none".to_string(),
405            from: String::new(),
406            from_name: String::new(),
407            reply_to: String::new(),
408            api_key: String::new(),
409            api_secret: String::new(),
410            region: String::new(),
411            domain: String::new(),
412            timeout_secs: 15,
413            logo: "logo.png".to_string(),
414            smtp: SmtpConfig::default(),
415        }
416    }
417}
418
419impl EmailConfig {
420    /// Whether a provider is configured at all. `none` and the empty string
421    /// both mean "this app doesn't send mail".
422    pub fn enabled(&self) -> bool {
423        !matches!(
424            self.provider.trim().to_ascii_lowercase().as_str(),
425            "" | "none"
426        )
427    }
428}
429
430/// SMTP transport settings, used only when `provider = "smtp"`.
431///
432/// Every provider here also speaks SMTP, so this is the escape hatch for one
433/// that has no first-class entry above — or for a company relay that has no API
434/// at all.
435#[derive(Debug, Clone, Deserialize)]
436#[serde(default)]
437pub struct SmtpConfig {
438    pub host: String,
439    /// `0` (the default) picks the port that matches `encryption`: 465 for
440    /// `tls`, 587 for `starttls`, 25 for `none`.
441    pub port: u16,
442    pub username: String,
443    pub password: String,
444    /// `starttls` (default), `tls` (implicit TLS, usually port 465) or `none`.
445    pub encryption: String,
446}
447
448impl Default for SmtpConfig {
449    fn default() -> Self {
450        SmtpConfig {
451            host: String::new(),
452            port: 0,
453            username: String::new(),
454            password: String::new(),
455            encryption: "starttls".to_string(),
456        }
457    }
458}
459
460/// An optional Redis cache.
461///
462/// Nothing in the framework caches through it: resources, permissions and the
463/// admin manifest all behave exactly the same whether it is configured or not.
464/// It exists so a *function* has somewhere to put a rate-limit counter, a
465/// memoised third-party response or a short-lived token — see the `cache_*`
466/// helpers on a function's `Context`.
467///
468/// Off unless `url` is set, so an app that doesn't want one pays nothing.
469#[derive(Debug, Clone, Deserialize)]
470#[serde(default)]
471pub struct CacheConfig {
472    /// Turn the configured cache off without deleting its settings.
473    pub enabled: bool,
474    /// Connection URL, e.g. `redis://127.0.0.1:6379` or `rediss://…/0`. Empty
475    /// (the default) means no cache.
476    pub url: String,
477    /// Prepended to every key a function uses, so several apps can share one
478    /// Redis without colliding.
479    pub prefix: String,
480    /// Expiry applied to a `set` that doesn't ask for one. `0` = keys persist.
481    pub default_ttl_secs: u64,
482    /// How long one cache operation may take before it is abandoned.
483    pub timeout_secs: u64,
484}
485
486impl Default for CacheConfig {
487    fn default() -> Self {
488        CacheConfig {
489            enabled: true,
490            url: String::new(),
491            prefix: String::new(),
492            default_ttl_secs: 0,
493            timeout_secs: 5,
494        }
495    }
496}
497
498impl CacheConfig {
499    /// Whether a cache should be connected: switched on *and* pointed at a
500    /// server.
501    pub fn is_active(&self) -> bool {
502        self.enabled && !self.url.trim().is_empty()
503    }
504}
505
506/// Signing in with somebody else's account.
507///
508/// Each `[oauth.<provider>]` block turns one provider on, and a block needs
509/// only the two credentials that provider issued:
510///
511/// ```toml
512/// [oauth.github]
513/// client_id     = "${GITHUB_CLIENT_ID}"
514/// client_secret = "${GITHUB_CLIENT_SECRET}"
515///
516/// [oauth.google]
517/// client_id     = "${GOOGLE_CLIENT_ID}"
518/// client_secret = "${GOOGLE_CLIENT_SECRET}"
519/// ```
520///
521/// Everything else — the authorize URL, the token URL, where the profile is
522/// read from, which scopes ask for an email, whether the provider wants PKCE,
523/// whether it insists on the client secret as HTTP Basic — apiplant knows for
524/// `github`, `google`, `linkedin` and `x`. A provider it does not know is
525/// configured in full (see [`OAuthProviderConfig::style`]), which is how a
526/// fifth one is added without waiting for a release.
527///
528/// Turning any of this on mounts `<base>/auth/oauth/…` and adds the
529/// `oauth_state` [resource](crate::defaults). With no block at all, none of it
530/// exists.
531#[derive(Debug, Clone, Deserialize)]
532#[serde(default)]
533pub struct OAuthConfig {
534    /// Whether a **verified** address from a provider may sign somebody in to
535    /// an existing account carrying the same address (default true).
536    ///
537    /// This is the convenience that makes "I registered with a password, then
538    /// came back through Google" work, and it is safe only because the address
539    /// must be one the provider says it verified. An unverified address is
540    /// never matched, whatever this is set to — that is not a policy, it is the
541    /// difference between signing in and taking over. Set it false and a
542    /// matching address is refused with an answer that says how to connect the
543    /// two deliberately — sign in the way you already can, then link the
544    /// provider from an authenticated session. Inconvenient, and never wrong.
545    ///
546    /// The same refusal is what an *unverified* matching address always gets,
547    /// whatever this is set to.
548    pub link_by_verified_email: bool,
549    /// How long a started sign-in stays completable, in seconds (default 600).
550    /// Long enough to read a consent screen, short enough that an abandoned
551    /// flow is not a lasting hole. Clamped to 60–3600.
552    pub state_ttl_secs: u64,
553    /// Where the browser lands after a successful sign-in through the
554    /// *redirecting* endpoint, as a path on this site (default `/`).
555    ///
556    /// A caller can override it per flow with `?return_to=/somewhere`, which is
557    /// accepted only as a path — never a full URL — because a redirect target
558    /// somebody else chooses is how a sign-in page becomes a phishing hop.
559    pub success_redirect: String,
560    /// Where a *failed* sign-in lands, as a path. Empty (the default) answers
561    /// with a plain JSON error instead, which is what you want while setting
562    /// providers up and not what you want in front of users.
563    pub failure_redirect: String,
564    /// How the session token reaches the browser on the redirecting endpoint:
565    ///
566    /// | Value | Effect |
567    /// |---|---|
568    /// | `fragment` (default) | `…/#token=…` — a fragment is never sent to a server, so it stays out of proxy logs and `Referer` headers |
569    /// | `query` | `…?token=…` — easier to read from a server-rendered page, and it *is* in those logs |
570    /// | `json` | no redirect at all: the callback answers `{ "token": …, "user": … }`, which is what a single-page app posting the code itself wants |
571    pub token_delivery: String,
572    /// The `user` column a provider's name is written to on sign-in, or empty
573    /// to write none. `display_name` is in the built-in model; an app that
574    /// calls it something else names it here, and one that would rather keep
575    /// its own copy of a name sets this to `""`.
576    pub name_field: String,
577    /// The `user` column a provider's picture is written to, or empty for none.
578    /// Same bargain as `name_field`.
579    ///
580    /// Both are written on *every* sign-in, not only the first: people change
581    /// their name and their picture, and a copy that is only ever right on the
582    /// day the account was created is worse than no copy.
583    pub avatar_field: String,
584    /// The providers, keyed by name. Written as `[oauth.github]` rather than
585    /// `[oauth.providers.github]` — the flattening is what buys that, and the
586    /// cost is that a mistyped setting above becomes a provider nobody asked
587    /// for, which is refused at boot rather than ignored.
588    #[serde(flatten)]
589    pub providers: std::collections::BTreeMap<String, OAuthProviderConfig>,
590}
591
592impl Default for OAuthConfig {
593    fn default() -> Self {
594        OAuthConfig {
595            link_by_verified_email: true,
596            state_ttl_secs: 600,
597            success_redirect: "/".to_string(),
598            failure_redirect: String::new(),
599            token_delivery: "fragment".to_string(),
600            name_field: "display_name".to_string(),
601            avatar_field: "avatar_url".to_string(),
602            providers: std::collections::BTreeMap::new(),
603        }
604    }
605}
606
607impl OAuthConfig {
608    /// Whether any provider is usable — which is what mounts the routes.
609    pub fn enabled(&self) -> bool {
610        self.providers.values().any(OAuthProviderConfig::is_active)
611    }
612
613    /// The names of the providers that are on, in a stable order.
614    pub fn active_providers(&self) -> Vec<&str> {
615        self.providers
616            .iter()
617            .filter(|(_, p)| p.is_active())
618            .map(|(name, _)| name.as_str())
619            .collect()
620    }
621
622    /// `state_ttl_secs`, clamped to something a sign-in can actually happen in.
623    pub fn state_ttl(&self) -> u64 {
624        self.state_ttl_secs.clamp(60, 3600)
625    }
626}
627
628/// One provider's credentials, and the overrides an unknown provider needs.
629#[derive(Debug, Clone, Default, Deserialize)]
630#[serde(default)]
631pub struct OAuthProviderConfig {
632    /// The client id the provider issued. An empty one leaves the provider off,
633    /// which is what lets a committed config name every provider and a
634    /// deployment supply only the credentials it has.
635    pub client_id: String,
636    /// The client secret. Required for every provider apiplant ships, all of
637    /// which are confidential clients.
638    pub client_secret: String,
639    /// Space-separated scopes, overriding the built-in default. The defaults
640    /// ask for the least that identifies somebody; widen this only for scopes
641    /// the app will actually use, since every one is another line on a consent
642    /// screen and another reason to press Cancel.
643    pub scopes: String,
644    /// Where the browser is sent to consent. Required for an unknown provider.
645    pub authorize_url: String,
646    /// Where the code is redeemed. Required for an unknown provider.
647    pub token_url: String,
648    /// Where the profile is read. Required for an unknown provider.
649    pub userinfo_url: String,
650    /// How to read that profile, for a provider apiplant does not ship:
651    /// `oidc` (default — standard `sub`/`email`/`email_verified`/`name`/
652    /// `picture` claims, which is what almost everything speaks today) or
653    /// `github` (GitHub's older shape).
654    pub style: String,
655    /// What the sign-in button should say. Defaults to the built-in label, or
656    /// to the provider's own name capitalised.
657    pub label: String,
658    /// The redirect URI registered with the provider. Empty (the default)
659    /// derives it — `<public_url><base_path>/auth/oauth/<provider>/callback` —
660    /// which is right unless something in front of this server rewrites paths.
661    pub redirect_uri: String,
662    /// Whether PKCE is used. Unset follows what the provider supports; X
663    /// *requires* it, GitHub does not offer it.
664    pub pkce: Option<bool>,
665    /// Set false to keep a fully credentialed provider switched off — the way
666    /// to take a sign-in button away for a while without deleting the secrets.
667    pub enabled: Option<bool>,
668    /// A logo for the sign-in button, as a URL a browser can fetch — usually a
669    /// file in [`public/`](PublicConfig), such as `/oauth/gitlab.svg`.
670    ///
671    /// apiplant draws GitHub, Google, LinkedIn and X itself, so this is for the
672    /// providers it does not ship: without it their button gets the provider's
673    /// initial on a plain tile, which works and looks like what it is.
674    ///
675    /// <https://github.com/edent/SuperTinyIcons> is a good place to get one —
676    /// several hundred brand marks, each a few hundred bytes of hand-drawn SVG,
677    /// MIT licensed. They are what apiplant's own four are drawn from. Save the
678    /// file into `public/` and point this at it.
679    pub icon: String,
680}
681
682impl OAuthProviderConfig {
683    /// Whether this block is complete enough to sign anybody in.
684    pub fn is_active(&self) -> bool {
685        self.enabled.unwrap_or(true) && !self.client_id.trim().is_empty()
686    }
687}
688
689/// Payments: who takes the money, and how the checkout is set up.
690///
691/// Off by default (`provider = "none"`). Turning it on does three things an
692/// app would otherwise build by hand: it connects a Stripe client, it adds the
693/// `billing_*` [resources](crate::defaults) — catalogue, customers,
694/// subscriptions, payments — so billing state is queryable through the same
695/// permissions and roles as everything else, and it mounts the `/billing`
696/// endpoints that start a checkout and receive Stripe's webhooks.
697///
698/// Nothing here is a price. Prices live in `billing_price` rows, because a
699/// price is data an operator changes on a Tuesday, not configuration that
700/// wants a deployment.
701#[derive(Debug, Clone, Deserialize)]
702#[serde(default)]
703pub struct PaymentsConfig {
704    /// `none` (default) or `stripe`.
705    pub provider: String,
706    /// Stripe secret key (`sk_live_…` / `sk_test_…`). Required once enabled.
707    pub secret_key: String,
708    /// Stripe publishable key (`pk_live_…`). Not a secret: it is handed to the
709    /// browser by `GET <base>/billing/config`, which is how a front end
710    /// mounts Stripe's own elements without hardcoding a key per environment.
711    pub publishable_key: String,
712    /// Signing secret for the webhook endpoint (`whsec_…`).
713    ///
714    /// Without it `POST <base>/billing/webhook` refuses every delivery — an
715    /// unverified webhook is an unauthenticated request that edits
716    /// subscriptions, and accepting one because it is inconvenient not to is
717    /// how somebody else grants themselves a plan.
718    pub webhook_secret: String,
719    /// ISO 4217 currency for prices that don't name one, e.g. `eur`.
720    pub currency: String,
721    /// Let Stripe Tax work out and apply the right tax for the customer's
722    /// location (default true).
723    ///
724    /// On means the amounts here are what you charge *before* tax and Stripe
725    /// adds what the buyer owes. It needs an origin address and active
726    /// registrations in the Stripe dashboard; with none, Stripe adds nothing
727    /// and the charge is the price.
728    pub automatic_tax: bool,
729    /// Ask the buyer for a VAT/GST number at checkout (default true when
730    /// `automatic_tax` is on — a business buyer's number is what makes the
731    /// reverse charge apply).
732    pub tax_id_collection: Option<bool>,
733    /// Collect a full billing address at checkout rather than only what the
734    /// card requires. `auto` (default) or `required`; automatic tax needs an
735    /// address, so `auto` still collects enough to place the customer.
736    pub billing_address: String,
737    /// Where Stripe returns the buyer after a completed checkout. Empty falls
738    /// back to the dashboard's billing screen — see
739    /// [`ServerConfig::public_origin`].
740    pub success_url: String,
741    /// Where Stripe returns a buyer who backed out. Empty falls back to the
742    /// dashboard's billing screen.
743    pub cancel_url: String,
744    /// Where the Stripe customer portal returns to. Empty falls back to the
745    /// dashboard's billing screen.
746    pub portal_return_url: String,
747    /// How long one Stripe API call may take before it is abandoned.
748    pub timeout_secs: u64,
749}
750
751impl Default for PaymentsConfig {
752    fn default() -> Self {
753        PaymentsConfig {
754            provider: "none".to_string(),
755            secret_key: String::new(),
756            publishable_key: String::new(),
757            webhook_secret: String::new(),
758            currency: "usd".to_string(),
759            automatic_tax: true,
760            tax_id_collection: None,
761            billing_address: "auto".to_string(),
762            success_url: String::new(),
763            cancel_url: String::new(),
764            portal_return_url: String::new(),
765            timeout_secs: 20,
766        }
767    }
768}
769
770impl PaymentsConfig {
771    /// Whether a provider is configured at all. `none` and the empty string
772    /// both mean "this app doesn't take money".
773    pub fn enabled(&self) -> bool {
774        !matches!(
775            self.provider.trim().to_ascii_lowercase().as_str(),
776            "" | "none"
777        )
778    }
779
780    /// The currency to use for an amount that didn't name one, lowercased the
781    /// way Stripe wants it.
782    pub fn default_currency(&self) -> String {
783        let currency = self.currency.trim().to_ascii_lowercase();
784        if currency.is_empty() {
785            "usd".to_string()
786        } else {
787            currency
788        }
789    }
790
791    /// Whether checkout asks for a tax number. Unset follows `automatic_tax`:
792    /// collecting a VAT number is only useful to somebody computing tax with
793    /// it, and asking for one you ignore is a field that does nothing.
794    pub fn collects_tax_ids(&self) -> bool {
795        self.tax_id_collection.unwrap_or(self.automatic_tax)
796    }
797
798    /// Whether the webhook endpoint can verify a delivery. Payments still work
799    /// without it — the checkout completes and Stripe has the money — but
800    /// nothing of ours would ever hear about it.
801    pub fn webhooks_enabled(&self) -> bool {
802        self.enabled() && !self.webhook_secret.trim().is_empty()
803    }
804}
805
806/// An AI chat assistant: which service answers, and what to say to it.
807///
808/// Off by default (`provider = "none"`). Turning it on connects one client and
809/// mounts `<base>/ai/chat`, which takes a list of messages and streams the
810/// reply back token by token — and gives every function a `chat` call over the
811/// same provider.
812///
813/// The three providers differ only in wire format. `custom` is the one that
814/// matters most in practice: anything speaking the OpenAI chat-completions
815/// shape — llama.cpp, vLLM, Ollama, LM Studio, a gateway of your own — is
816/// reached by pointing [`endpoint`](Self::endpoint) at it, with no key at all
817/// if it wants none.
818#[derive(Debug, Clone, Deserialize)]
819#[serde(default)]
820pub struct AiConfig {
821    /// `none` (default), `openai`, `anthropic` or `custom`.
822    pub provider: String,
823    /// Where to send the request.
824    ///
825    /// Empty uses the provider's own API (`https://api.openai.com`,
826    /// `https://api.anthropic.com`) and is required for `custom`. A bare origin
827    /// or a base path (`http://localhost:8080`, `.../v1`) gets the provider's
828    /// standard path appended; a URL that already names the full path
829    /// (`…/v1/chat/completions`, `…/v1/messages`) is used exactly as written,
830    /// for a gateway that mounts it somewhere of its own.
831    pub endpoint: String,
832    /// Model to ask for when a request doesn't name one, e.g. `gpt-4o-mini`.
833    /// Some local servers serve a single model and ignore this.
834    pub model: String,
835    /// The provider's API key. **Optional**: a local model behind
836    /// `provider = "custom"` usually wants no credential, and sending an empty
837    /// one is different from sending none — so an empty key means the request
838    /// carries no authorization header at all.
839    pub api_key: String,
840    /// Prepended to every conversation as the system prompt, unless the request
841    /// carries its own. Empty = none.
842    pub system: String,
843    /// Cap on the tokens generated per reply. Anthropic requires one, so this
844    /// is sent to every provider rather than being special-cased.
845    pub max_tokens: u32,
846    /// Sampling temperature sent when a request doesn't name one. Negative
847    /// (the default) sends nothing and lets the provider choose.
848    pub temperature: f32,
849    /// Whether provider reasoning should be surfaced to callers when the
850    /// provider emits it. This is a *display* decision and says nothing about
851    /// whether the model thinks — see `thinking` for that.
852    pub reasoning: bool,
853    /// Whether to ask the provider to think, using its own switch for it.
854    ///
855    /// `None` (the default) sends nothing and leaves the model on whatever its
856    /// template does. `Some(false)` turns thinking off, `Some(true)` turns it
857    /// on. Worth setting: thinking is billed against `max_tokens` like any
858    /// other output, so a thinking model on a small budget can spend the whole
859    /// thing reasoning and answer with nothing at all.
860    ///
861    /// How it is sent depends on the provider: Anthropic has a `thinking`
862    /// parameter, and OpenAI-compatible local servers (llama.cpp, vLLM, SGLang,
863    /// Ollama) take `chat_template_kwargs.enable_thinking`, which is what the
864    /// Qwen-family templates read. OpenAI's own reasoning models expose only
865    /// `reasoning_effort` and cannot be switched off, so this is not sent to
866    /// them.
867    pub thinking: Option<bool>,
868    /// Who may call `<base>/ai/chat`, in the grammar a resource's
869    /// `[permissions]` uses: `public`, `authenticated` (the default), `member`,
870    /// `role:<name>`.
871    ///
872    /// Defaulting to `authenticated` is deliberate. The endpoint spends money
873    /// (or a GPU) on behalf of whoever calls it, and a public one is an open
874    /// proxy to your provider account — which is a decision an app should have
875    /// to write down.
876    pub access: String,
877    /// How long one completion may take before it is abandoned. Generous by
878    /// default: a long answer from a local model is slow, not broken.
879    pub timeout_secs: u64,
880}
881
882impl Default for AiConfig {
883    fn default() -> Self {
884        AiConfig {
885            provider: "none".to_string(),
886            endpoint: String::new(),
887            model: String::new(),
888            api_key: String::new(),
889            system: String::new(),
890            max_tokens: 2048,
891            temperature: -1.0,
892            reasoning: false,
893            thinking: None,
894            access: "authenticated".to_string(),
895            timeout_secs: 300,
896        }
897    }
898}
899
900impl AiConfig {
901    /// Whether a provider is configured at all. `none` and the empty string
902    /// both mean "this app has no assistant".
903    pub fn enabled(&self) -> bool {
904        !matches!(
905            self.provider.trim().to_ascii_lowercase().as_str(),
906            "" | "none"
907        )
908    }
909
910    /// The sampling temperature to send, or `None` to let the provider decide.
911    pub fn default_temperature(&self) -> Option<f32> {
912        (self.temperature >= 0.0).then_some(self.temperature)
913    }
914}
915
916impl Config {
917    /// Load `main.toml` from an app directory, applying defaults for anything
918    /// absent. A missing file is not an error.
919    pub fn load(app_dir: &Path) -> crate::Result<Self> {
920        let path = app_dir.join("main.toml");
921        let mut config = if path.exists() {
922            let text = std::fs::read_to_string(&path).map_err(|e| crate::Error::Io {
923                path: path.clone(),
924                source: e,
925            })?;
926            // `$VAR` in any string value is read from the environment here,
927            // which is what keeps credentials out of a committed main.toml.
928            crate::env::parse_toml::<Config>(&text, "main.toml")
929                .map_err(|e| crate::Error::Toml { path, source: e })?
930        } else {
931            tracing::info!("no main.toml found, using defaults");
932            Config::default()
933        };
934        config.normalise();
935        Ok(config)
936    }
937
938    fn normalise(&mut self) {
939        // "bind everywhere" has three spellings people arrive with: leaving it
940        // out, the wildcard, and the address itself. They all mean 0.0.0.0.
941        let host = self.server.host.trim();
942        if host.is_empty() || host == "*" {
943            self.server.host = "0.0.0.0".to_string();
944        } else {
945            self.server.host = host.to_string();
946        }
947
948        // Same idea for the vhost filter: an empty or wildcard `domain` is a
949        // request for no filter at all, not a filter for the empty host. `_` is
950        // there because nginx spells its catch-all `server_name _`. A wildcard
951        // anywhere in the list wins — it already answers every host, so the
952        // named entries beside it can't narrow anything.
953        let domains = std::mem::take(&mut self.server.domain);
954        let mut wildcard = false;
955        for d in domains {
956            match d.trim() {
957                "" | "*" | "_" | "0.0.0.0" => wildcard = true,
958                d => self.server.domain.push(d.to_string()),
959            }
960        }
961        if wildcard {
962            self.server.domain.clear();
963        }
964
965        let bp = self.server.base_path.trim_end_matches('/');
966        self.server.base_path = if bp.is_empty() {
967            String::new()
968        } else if bp.starts_with('/') {
969            bp.to_string()
970        } else {
971            format!("/{bp}")
972        };
973
974        if !self.docs.path.starts_with('/') {
975            self.docs.path = format!("/{}", self.docs.path);
976        }
977
978        let admin = self.admin.path.trim_matches('/');
979        self.admin.path = if admin.is_empty() {
980            AdminConfig::default().path
981        } else {
982            format!("/{admin}")
983        };
984    }
985}
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990    use std::fs;
991    use std::time::{SystemTime, UNIX_EPOCH};
992
993    fn temp_dir(label: &str) -> std::path::PathBuf {
994        let mut dir = std::env::temp_dir();
995        let stamp = SystemTime::now()
996            .duration_since(UNIX_EPOCH)
997            .unwrap()
998            .as_nanos();
999        dir.push(format!(
1000            "apiplant-config-{label}-{}-{stamp}",
1001            std::process::id()
1002        ));
1003        fs::create_dir_all(&dir).unwrap();
1004        dir
1005    }
1006
1007    #[test]
1008    fn missing_main_toml_uses_defaults() {
1009        let dir = temp_dir("defaults");
1010        let config = Config::load(&dir).unwrap();
1011
1012        assert_eq!(config.server.host, "0.0.0.0");
1013        assert_eq!(config.server.port, 8080);
1014        assert_eq!(config.server.base_path, "");
1015        assert_eq!(
1016            config.database.resolved_url(),
1017            "postgres://postgres:postgres@localhost:5432/apiplant"
1018        );
1019        assert!(config.auth.allow_registration);
1020        assert!(config.docs.enabled);
1021        assert_eq!(config.docs.path, "/docs");
1022        // The dashboard and the public site are on by default; an app opts out.
1023        assert!(config.admin.enabled);
1024        assert_eq!(config.admin.path, "/admin");
1025        assert!(!config.admin.ai_assistance.enabled);
1026        assert_eq!(
1027            config.admin.ai_assistance.prompt_placeholder,
1028            "Describe what you want AI to write for this field."
1029        );
1030        assert!(config.public.enabled);
1031        assert_eq!(config.public.dir, "public");
1032        assert_eq!(config.public.not_found, None);
1033        // Email, cache and payments are opt-in: an app that says nothing gets
1034        // none of them.
1035        assert!(!config.email.enabled());
1036        assert!(!config.cache.is_active());
1037        assert!(!config.payments.enabled());
1038
1039        fs::remove_dir_all(dir).unwrap();
1040    }
1041
1042    #[test]
1043    fn email_and_cache_load_from_their_sections() {
1044        let dir = temp_dir("email-cache");
1045        fs::write(
1046            dir.join("main.toml"),
1047            r#"
1048[email]
1049provider = "sendgrid"
1050from = "no-reply@example.com"
1051from_name = "Example"
1052api_key = "SG.literal"
1053
1054[cache]
1055url = "redis://127.0.0.1:6379"
1056prefix = "example:"
1057default_ttl_secs = 300
1058"#,
1059        )
1060        .unwrap();
1061
1062        let config = Config::load(&dir).unwrap();
1063
1064        assert!(config.email.enabled());
1065        assert_eq!(config.email.provider, "sendgrid");
1066        assert_eq!(config.email.from, "no-reply@example.com");
1067        assert_eq!(config.email.api_key, "SG.literal");
1068        // Untouched defaults still apply inside a section that was given.
1069        assert_eq!(config.email.timeout_secs, 15);
1070        assert_eq!(config.email.smtp.encryption, "starttls");
1071
1072        assert!(config.cache.is_active());
1073        assert_eq!(config.cache.prefix, "example:");
1074        assert_eq!(config.cache.default_ttl_secs, 300);
1075
1076        fs::remove_dir_all(dir).unwrap();
1077    }
1078
1079    #[test]
1080    fn payments_load_from_their_section() {
1081        let dir = temp_dir("payments");
1082        fs::write(
1083            dir.join("main.toml"),
1084            r#"
1085[payments]
1086provider = "stripe"
1087secret_key = "sk_test_literal"
1088webhook_secret = "whsec_literal"
1089currency = "EUR"
1090"#,
1091        )
1092        .unwrap();
1093
1094        let config = Config::load(&dir).unwrap();
1095
1096        assert!(config.payments.enabled());
1097        assert!(config.payments.webhooks_enabled());
1098        // Stripe wants a lowercase currency, and nobody writes one.
1099        assert_eq!(config.payments.default_currency(), "eur");
1100        // Untouched defaults still apply inside a section that was given.
1101        assert!(config.payments.automatic_tax);
1102        assert_eq!(config.payments.timeout_secs, 20);
1103
1104        fs::remove_dir_all(dir).unwrap();
1105    }
1106
1107    /// A configured provider with no signing secret still takes money — the
1108    /// checkout is Stripe's page — but nothing of ours would hear that it
1109    /// worked, so the two questions are answered separately.
1110    #[test]
1111    fn webhooks_need_their_own_secret() {
1112        let payments = PaymentsConfig {
1113            provider: "stripe".into(),
1114            secret_key: "sk_test".into(),
1115            ..PaymentsConfig::default()
1116        };
1117        assert!(payments.enabled());
1118        assert!(!payments.webhooks_enabled());
1119    }
1120
1121    /// Asking for a VAT number is only useful to somebody computing tax with
1122    /// it, so the default follows automatic tax — and an app can still say
1123    /// otherwise in either direction.
1124    #[test]
1125    fn tax_id_collection_follows_automatic_tax_unless_told_otherwise() {
1126        let with_tax = PaymentsConfig::default();
1127        assert!(with_tax.automatic_tax && with_tax.collects_tax_ids());
1128
1129        let no_tax = PaymentsConfig {
1130            automatic_tax: false,
1131            ..PaymentsConfig::default()
1132        };
1133        assert!(!no_tax.collects_tax_ids());
1134
1135        let explicit = PaymentsConfig {
1136            automatic_tax: false,
1137            tax_id_collection: Some(true),
1138            ..PaymentsConfig::default()
1139        };
1140        assert!(explicit.collects_tax_ids());
1141    }
1142
1143    /// `enabled = false` has to beat a perfectly good URL, or switching the
1144    /// cache off would mean deleting the settings needed to switch it back on.
1145    #[test]
1146    fn a_disabled_cache_stays_off_even_with_a_url() {
1147        let config = CacheConfig {
1148            enabled: false,
1149            url: "redis://127.0.0.1:6379".into(),
1150            ..CacheConfig::default()
1151        };
1152        assert!(!config.is_active());
1153    }
1154
1155    /// `Config::load` reads its file through the same expansion every other
1156    /// app-directory TOML gets — including a URL assembled from several
1157    /// variables, which is the case a whole-value substitution can't do.
1158    #[test]
1159    fn load_expands_environment_references_anywhere_in_the_file() {
1160        std::env::set_var("APIPLANT_TEST_JWT", "from-env-jwt");
1161        std::env::set_var("APIPLANT_TEST_MAIL", "from-env-key");
1162        std::env::set_var("APIPLANT_TEST_DB_USER", "alice");
1163        std::env::set_var("APIPLANT_TEST_DB_PASS", "s3cret");
1164        let dir = temp_dir("env");
1165        fs::write(
1166            dir.join("main.toml"),
1167            r#"
1168[server]
1169domain = "${APIPLANT_TEST_DOMAIN:-api.example.com}"
1170
1171[database]
1172url = "postgres://$APIPLANT_TEST_DB_USER:$APIPLANT_TEST_DB_PASS@db:5432/app"
1173
1174[auth]
1175jwt_secret = "$APIPLANT_TEST_JWT"
1176
1177[email]
1178provider = "brevo"
1179api_key = "${APIPLANT_TEST_MAIL}"
1180from = "no-reply@example.com"
1181"#,
1182        )
1183        .unwrap();
1184
1185        let config = Config::load(&dir).unwrap();
1186        assert_eq!(
1187            config.database.resolved_url(),
1188            "postgres://alice:s3cret@db:5432/app"
1189        );
1190        assert_eq!(config.auth.jwt_secret, "from-env-jwt");
1191        assert_eq!(config.email.api_key, "from-env-key");
1192        // An unset variable falls back to the default written beside it.
1193        assert_eq!(config.server.domain, ["api.example.com"]);
1194
1195        for name in [
1196            "APIPLANT_TEST_JWT",
1197            "APIPLANT_TEST_MAIL",
1198            "APIPLANT_TEST_DB_USER",
1199            "APIPLANT_TEST_DB_PASS",
1200        ] {
1201            std::env::remove_var(name);
1202        }
1203        fs::remove_dir_all(dir).unwrap();
1204    }
1205
1206    #[test]
1207    fn load_treats_wildcard_host_and_domain_as_everything() {
1208        for (host, domain) in [
1209            ("", "\"\""),
1210            ("*", "\"*\""),
1211            (" 0.0.0.0 ", "\"_\""),
1212            ("*", "[]"),
1213            // A wildcard beside named hosts still means "answer any host".
1214            ("*", "[\"api.example.com\", \"*\"]"),
1215        ] {
1216            let dir = temp_dir("wildcards");
1217            fs::write(
1218                dir.join("main.toml"),
1219                format!("[server]\nhost = \"{host}\"\ndomain = {domain}\n"),
1220            )
1221            .unwrap();
1222
1223            let config = Config::load(&dir).unwrap();
1224
1225            assert_eq!(config.server.host, "0.0.0.0", "host {host:?}");
1226            assert!(config.server.domain.is_empty(), "domain {domain}");
1227            fs::remove_dir_all(&dir).unwrap();
1228        }
1229    }
1230
1231    /// `domain` takes a list as readily as a single string, and each entry is
1232    /// trimmed the same way.
1233    #[test]
1234    fn load_accepts_a_list_of_domains() {
1235        let dir = temp_dir("domains");
1236        fs::write(
1237            dir.join("main.toml"),
1238            "[server]\ndomain = [\"api.example.com\", \" www.example.com \"]\n",
1239        )
1240        .unwrap();
1241
1242        let config = Config::load(&dir).unwrap();
1243
1244        assert_eq!(config.server.domain, ["api.example.com", "www.example.com"]);
1245        fs::remove_dir_all(dir).unwrap();
1246    }
1247
1248    #[test]
1249    fn load_normalises_paths_and_prefers_explicit_database_url() {
1250        let dir = temp_dir("normalise");
1251        fs::write(
1252            dir.join("main.toml"),
1253            r#"
1254[server]
1255base_path = "api/"
1256workers = 8
1257
1258[database]
1259url = "postgres://db.example/custom"
1260host = "ignored"
1261port = 9999
1262name = "ignored"
1263user = "ignored"
1264password = "ignored"
1265
1266[docs]
1267path = "swagger"
1268
1269[admin]
1270path = "console/"
1271
1272[admin.ai_assistance]
1273enabled = true
1274system = "Return only the field content."
1275prompt_placeholder = "Tell AI what to draft"
1276
1277[public]
1278dir = "site"
1279not_found = "oops.html"
1280"#,
1281        )
1282        .unwrap();
1283
1284        let config = Config::load(&dir).unwrap();
1285
1286        assert_eq!(config.server.base_path, "/api");
1287        assert_eq!(config.server.workers, Some(8));
1288        assert_eq!(config.docs.path, "/swagger");
1289        assert_eq!(config.admin.path, "/console");
1290        assert!(config.admin.ai_assistance.enabled);
1291        assert_eq!(
1292            config.admin.ai_assistance.system,
1293            "Return only the field content."
1294        );
1295        assert_eq!(
1296            config.admin.ai_assistance.prompt_placeholder,
1297            "Tell AI what to draft"
1298        );
1299        assert_eq!(config.public.dir, "site");
1300        assert_eq!(config.public.not_found.as_deref(), Some("oops.html"));
1301        assert_eq!(
1302            config.database.resolved_url(),
1303            "postgres://db.example/custom"
1304        );
1305
1306        fs::remove_dir_all(dir).unwrap();
1307    }
1308
1309    #[test]
1310    fn resolved_url_is_assembled_from_parts_when_url_is_empty() {
1311        let config = DatabaseConfig {
1312            url: String::new(),
1313            host: "db".into(),
1314            port: 5433,
1315            name: "plants".into(),
1316            user: "alice".into(),
1317            password: "secret".into(),
1318            max_connections: 16,
1319            auto_migrate: true,
1320        };
1321
1322        assert_eq!(
1323            config.resolved_url(),
1324            "postgres://alice:secret@db:5433/plants"
1325        );
1326    }
1327}