ryra-core 0.8.7

Core library for ryra: config, registry, and service generation logic
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::capability::Capability;

/// A service definition from a registry's `services/<name>/service.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDef {
    pub service: ServiceMeta,
    #[serde(default)]
    pub requirements: Option<Requirements>,
    #[serde(default)]
    pub ports: Vec<PortDef>,
    #[serde(default)]
    pub env: Vec<EnvVar>,
    /// Optional, user-toggled bundles of env vars. A group is either fully
    /// enabled (every member lands in `.env`) or fully disabled (none do) —
    /// makes "client_id without client_secret" unrepresentable.
    #[serde(default, rename = "env_group")]
    pub env_groups: Vec<EnvGroup>,
    /// Mutually-exclusive choices. Exactly one option per choice is selected
    /// and only that option's env vars are written, so "none selected" and
    /// "two at once" are unrepresentable rather than rejected. The sum type
    /// to `env_group`'s product.
    #[serde(default, rename = "choice")]
    pub choices: Vec<Choice>,
    #[serde(default)]
    pub requires: Vec<ServiceRequirement>,
    #[serde(default)]
    pub mappings: Mappings,
    #[serde(default)]
    pub integrations: IntegrationFlags,
    /// Roles this service can play for *other* services. The dual of
    /// [`IntegrationFlags`] (which describes what this service consumes).
    /// Drives capability-based dispatch — see [`crate::capability`].
    #[serde(default)]
    pub capabilities: Capabilities,
    /// Backup configuration. Present only when the author has declared
    /// `backup = true` in `[integrations]` and the service needs more
    /// than the default "back up everything classified as data."
    /// Carries hooks (pre/post dump) and exclude lists.
    #[serde(default)]
    pub backup: Option<BackupConfig>,
    /// Prometheus-style metrics endpoint this service exposes. When set
    /// and a metrics-store provider is installed, ryra writes a file_sd
    /// scrape target and joins the service to the store's network.
    #[serde(default)]
    pub metrics: Option<MetricsDef>,
}

/// Where a service serves Prometheus-style metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricsDef {
    /// Name of the `[[ports]]` entry the metrics endpoint listens on.
    /// The scrape target uses that entry's *container* port — the store
    /// reaches the service over the shared podman network, not the host.
    pub port: String,
    /// HTTP path of the endpoint.
    #[serde(default = "default_metrics_path")]
    pub path: String,
    /// The service runs with `Network=host` (e.g. node-exporter, which
    /// needs the real interfaces). It can't join the store's bridge
    /// network, so the scrape target addresses the podman host gateway
    /// (`host.containers.internal`) at the *resolved host port* instead
    /// of container DNS.
    #[serde(default)]
    pub host_network: bool,
}

fn default_metrics_path() -> String {
    "/metrics".to_string()
}

/// Capability declarations on a service.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Capabilities {
    /// Capabilities this service offers to other services.
    #[serde(default)]
    pub provides: Vec<Capability>,
}

/// System resource requirements for a service.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Requirements {
    /// RAM requirements in megabytes.
    pub ram: RamRequirement,
    /// Disk requirements in gigabytes.
    #[serde(default)]
    pub disk: Option<DiskRequirement>,
}

/// RAM requirement with minimum and recommended thresholds.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RamRequirement {
    /// Minimum RAM in MB — service may fail below this.
    pub min: u64,
    /// Recommended RAM in MB — service will run well at this level.
    #[serde(default)]
    pub recommended: Option<u64>,
}

/// Disk requirement in gigabytes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiskRequirement {
    /// Minimum disk in GB — container images + data must fit.
    pub min: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceMeta {
    pub name: String,
    pub description: String,
    /// Optional URL to documentation or project homepage.
    #[serde(default)]
    pub url: Option<String>,
    #[serde(default)]
    pub kind: ServiceKind,
    /// Supported CPU architectures (e.g. ["amd64", "arm64"]).
    /// Empty means all architectures are supported.
    #[serde(default)]
    pub architecture: Vec<Arch>,
    /// Whether this service requires HTTPS to function.
    #[serde(default)]
    pub https: HttpsRequirement,
    /// How this service runs: a podman container (default) or a native process
    /// under systemd --user.
    #[serde(default)]
    pub runtime: Runtime,
    /// `runtime = "native"` only: the command ryra runs as the service (the
    /// unit's `ExecStart`), executed in the service's source dir. A binary
    /// (`target/release/app`), an interpreter (`bun run src/index.ts`), or a
    /// watcher (`bun --watch run …`) for save-and-reload. Required for native,
    /// forbidden for podman (enforced in `validate()`).
    #[serde(default)]
    pub run: Option<String>,
    /// `runtime = "native"` only: optional command run in the source dir before
    /// the service starts and on every `ryra upgrade` (e.g. `cargo build
    /// --release`, `bun install`). Omit when `run` needs no build step.
    #[serde(default)]
    pub build: Option<String>,
    /// Free-text guidance printed once after a successful `ryra add` —
    /// truly-unavoidable manual steps (initial web wizard, recommended
    /// dashboard imports). Keep it short; everything automatable should
    /// be automated instead.
    #[serde(default)]
    pub post_install: Option<String>,
}

/// What role this service plays in the system.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ServiceKind {
    #[default]
    Application,
    Infrastructure,
}

/// How a service is realized on the host.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Runtime {
    /// A rootless podman container via a quadlet (`Image=`). The default, and
    /// what every catalog service uses.
    #[default]
    Podman,
    /// A process run directly under `systemd --user`, no container. ryra runs
    /// the service's `run` command in its source dir (after the optional
    /// `build` step), with the same port/data/env contract a container gets.
    Native,
}

impl Runtime {
    /// Whether this is the default podman runtime. Used as a serde
    /// `skip_serializing_if` so podman installs don't carry a redundant
    /// `runtime = "podman"` in their metadata.
    pub fn is_podman(&self) -> bool {
        matches!(self, Runtime::Podman)
    }
}

/// CPU architecture for container images.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Arch {
    Amd64,
    Arm64,
}

impl std::fmt::Display for Arch {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Arch::Amd64 => write!(f, "amd64"),
            Arch::Arm64 => write!(f, "arm64"),
        }
    }
}

/// Whether this service requires HTTPS to function.
///
/// Declarative, per-service. No magic derivation from other fields — a
/// service that needs HTTPS must say so explicitly.
///
/// - `Never` (default): HTTP is fine. Per RFC 8252 loopback redirect URIs
///   (`http://127.0.0.1`, `http://localhost`) are valid OIDC callbacks, so
///   most services work over plain HTTP even with `--auth`.
/// - `Auth`: HTTPS required when `--auth` is used. For services whose OIDC
///   implementation rejects plain-HTTP even on loopback (e.g. nextcloud's
///   `user_oidc` refuses to render the SSO button over HTTP).
/// - `Always`: HTTPS required regardless of flags. For services that
///   refuse HTTP outright (e.g. authelia, vaultwarden).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum HttpsRequirement {
    #[default]
    Never,
    Auth,
    Always,
}

impl HttpsRequirement {
    /// Decide whether an install must be promoted to HTTPS.
    ///
    /// HTTPS is required when any of these hold:
    ///   1. The service declares `https = "always"`.
    ///   2. The service declares `https = "auth"` AND the user chose OIDC
    ///      auth (via `--auth` or the interactive prompt).
    ///   3. The user passed an `https://...` URL explicitly.
    pub fn needs_https(&self, auth_requested: bool, url: Option<&str>) -> bool {
        matches!(self, HttpsRequirement::Always)
            || (matches!(self, HttpsRequirement::Auth) && auth_requested)
            || url.is_some_and(|u| u.starts_with("https://"))
    }
}

/// Whether a port uses TCP or UDP.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PortProtocol {
    #[default]
    Tcp,
    Udp,
}

impl std::fmt::Display for PortProtocol {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PortProtocol::Tcp => write!(f, "tcp"),
            PortProtocol::Udp => write!(f, "udp"),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortDef {
    pub name: String,
    pub container_port: u16,
    /// Fixed host port (for privileged services like Caddy that need specific ports).
    /// If not set, ryra allocates a port dynamically.
    #[serde(default)]
    pub host_port: Option<u16>,
    #[serde(default)]
    pub protocol: PortProtocol,
    /// When set and the service is exposed with `--tailscale`, this port is
    /// served over the service's Tailscale vIP on the given HTTPS port (e.g.
    /// `443` for the web root, `8080` for an API). Tailnet-only `serve`
    /// accepts arbitrary ports, so the value is usually the port's own number
    /// (or `443` for the one port that should answer at the bare hostname).
    /// Ports without this stay loopback-only. Reachable in templates via
    /// `{{service.port_url.<name>}}`. Multi-port services (e.g. ente: a web
    /// UI plus a separate API) need this so each endpoint gets its own URL.
    #[serde(default)]
    pub tailscale_https: Option<u16>,
}

/// How an env var is presented to the user during `ryra add`.
///
/// - `default`: static value or template (e.g. `{{secret.password}}`),
///   not prompted — user can edit `.env` manually after install
/// - `prompted`: shown during `ryra add` with a default value — optional
///   but visible (e.g. API keys that can be left empty)
/// - `required`: must be provided during `ryra add` — no usable default,
///   blocks install if not provided. Tests must supply these via `env` overrides.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum EnvKind {
    /// Not prompted. Value is used as-is (may contain templates like `{{secret.*}}`).
    #[default]
    Default,
    /// Prompted during `ryra add` with a default. User can accept or change.
    Prompted,
    /// Must be provided. No usable default — fails in non-interactive mode
    /// unless supplied via env overrides.
    Required,
}

/// Format of an env var's value — used for secret generation and input validation.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EnvFormat {
    /// Free-form alphanumeric string (default).
    #[default]
    String,
    /// Hexadecimal characters only.
    Hex,
    /// Standard base64 encoding of N random bytes (`length` = byte count,
    /// default 32). Use for binary keys that the service base64-decodes to a
    /// fixed byte length — e.g. Ente's libsodium keys (32-byte encryption,
    /// 64-byte hash). A plain `string`/`hex` value decodes to the wrong length.
    Base64,
    /// URL-safe base64 (`-_` instead of `+/`) of N random bytes. Same use as
    /// `base64`, but for services that decode with URL-safe base64 — e.g.
    /// Ente's `jwt.secret` (Go `base64.URLEncoding`), which rejects `+`/`/`.
    Base64Url,
    /// UUID v4.
    Uuid,
    /// HS256-signed JWT. Requires `jwt_role` and `jwt_signing_key` on the env var.
    JwtHs256,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvVar {
    pub name: String,
    pub value: String,
    #[serde(default)]
    pub kind: EnvKind,
    /// Prompt message shown during `ryra add` (for `prompted` and `required` kinds).
    #[serde(default)]
    pub prompt: Option<String>,
    /// Value format — used to generate secrets and validate user input.
    #[serde(default)]
    pub format: EnvFormat,
    /// Length for generated secrets. Ignored for `uuid` and `jwt_hs256` formats.
    /// Defaults to 32 for `string`, 64 for `hex`.
    #[serde(default)]
    pub length: Option<u32>,
    /// JSON payload claims for `jwt_hs256` format (e.g., `{"role": "anon", "iss": "supabase"}`).
    /// `iat` and `exp` are added automatically if not present.
    #[serde(default)]
    pub jwt_claims: Option<std::collections::BTreeMap<std::string::String, serde_json::Value>>,
    /// Secret name used as the HS256 signing key (e.g., "jwt_secret"). Required for `jwt_hs256` format.
    #[serde(default)]
    pub jwt_signing_key: Option<std::string::String>,
}

/// A user-toggled bundle of env vars. Enabling the group writes every
/// member into `.env`; disabling it writes none of them.
///
/// Members reuse the full [`EnvVar`] shape — `kind = "default"` members are
/// auto-included with their rendered template when the group is on,
/// `prompted` members get shown with a default, `required` members must be
/// supplied (interactively or via process env).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvGroup {
    /// Identifier used by the `--enable <name>` CLI flag. Lowercase
    /// snake_case by convention.
    pub name: String,
    /// Yes/no question shown during `ryra add` to toggle the group.
    pub prompt: String,
    #[serde(default)]
    pub env: Vec<EnvVar>,
}

/// A mutually-exclusive choice between two or more [`ChoiceOption`]s. Where an
/// [`EnvGroup`] is an independent on/off bundle (any subset may be enabled), a
/// choice's selection is a single value, so the illegal states ("nothing
/// selected", "two selected at once") cannot be constructed. This is the
/// config-layer expression of "make invalid state unrepresentable": a sum
/// type, the dual of `env_group`'s product.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Choice {
    /// Identifier, lowercase snake_case. Names the `--choose <name>=<option>`
    /// flag and the key recorded in metadata.
    pub name: String,
    /// Single-select question shown during `ryra add`.
    pub prompt: String,
    /// Option selected non-interactively (and pre-highlighted in the prompt).
    /// Must name one of `options`; enforced by [`ServiceDef::validate`].
    pub default: String,
    #[serde(default, rename = "option")]
    pub options: Vec<ChoiceOption>,
}

/// One alternative within a [`Choice`]. Its `env` members reuse the full
/// [`EnvVar`] shape and are written to `.env` only when this option is the
/// selected one; every other option's members stay absent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChoiceOption {
    /// Identifier within the choice, lowercase snake_case.
    pub name: String,
    /// Human-facing text shown in the select list. Falls back to `name`.
    #[serde(default)]
    pub label: Option<String>,
    #[serde(default)]
    pub env: Vec<EnvVar>,
    /// Owned sidecar quadlet filenames (in this service's own `quadlets/`
    /// dir) included only when this option is selected. A quadlet claimed by
    /// any option is gated; unclaimed quadlets always install. So `external`
    /// claiming none means the bundled-DB `.container` is never symlinked and
    /// its image never pulled, while `internal` brings it in. The kind-2
    /// counterpart to `requires`' cross-service edges.
    #[serde(default)]
    pub quadlets: Vec<String>,
    /// `[[ports]]` allocated only when this option is selected. ryra hands each
    /// a free host port (exposed as `${SERVICE_PORT_<NAME>}` and
    /// `{{service.ports.<name>}}`), so a gated container (e.g. a bundled
    /// postgres) publishes to an allocated loopback port instead of a hardcoded
    /// one that could clash.
    #[serde(default, rename = "ports")]
    pub ports: Vec<PortDef>,
}

/// A service that must already be installed on the system before this one.
///
/// References separately-installed ryra services whose env vars
/// and ports can be referenced via `{{services.<name>.*}}` templates.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceRequirement {
    pub service: String,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Mappings {
    #[serde(default)]
    pub smtp: BTreeMap<String, String>,
    #[serde(default)]
    pub auth: BTreeMap<String, String>,
}

/// What kind of auth integration a service supports.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuthKind {
    /// Service handles OIDC auth itself (e.g. affine, forgejo).
    Oidc,
}

impl std::fmt::Display for AuthKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AuthKind::Oidc => write!(f, "oidc"),
        }
    }
}

/// OIDC token endpoint authentication method for authelia client registration.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TokenAuthMethod {
    #[default]
    ClientSecretPost,
    ClientSecretBasic,
    /// PKCE public client — no client_secret sent. Used by apps like Zammad
    /// that only support the public-client + PKCE OIDC flow.
    None,
}

impl TokenAuthMethod {
    pub fn as_str(&self) -> &'static str {
        match self {
            TokenAuthMethod::ClientSecretPost => "client_secret_post",
            TokenAuthMethod::ClientSecretBasic => "client_secret_basic",
            TokenAuthMethod::None => "none",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrationFlags {
    /// Auth types this service supports. Empty = no auth support.
    #[serde(default)]
    pub auth: Vec<AuthKind>,
    /// OIDC token endpoint auth method for authelia client registration.
    #[serde(default)]
    pub token_auth_method: TokenAuthMethod,
    /// OIDC callback path suffixes registered with the auth provider.
    /// Appended to the service's base URL(s) to form redirect_uris.
    #[serde(default)]
    pub oidc_callbacks: Vec<String>,
    #[serde(default = "default_true")]
    pub smtp: bool,
    /// True if the service author has certified this service can be
    /// backed up safely. The default is `false` (explicit opt-in)
    /// because the worst failure mode is a backup that takes cleanly
    /// but won't restore (e.g. forgot to write a pg_dump hook), so
    /// authors must consciously declare support.
    ///
    /// When `true`, an accompanying `[backup]` section MAY provide
    /// hooks and excludes; when absent, the default behaviour is to
    /// back up every top-level child of the service home dir that the
    /// classifier marks as data.
    #[serde(default)]
    pub backup: bool,
}

impl Default for IntegrationFlags {
    fn default() -> Self {
        Self {
            auth: vec![],
            token_auth_method: TokenAuthMethod::default(),
            oidc_callbacks: vec![],
            smtp: true,
            backup: false,
        }
    }
}

fn default_true() -> bool {
    true
}

/// Per-service backup configuration. Present only when the service's
/// `[integrations]` section sets `backup = true` AND the service needs
/// non-default behaviour (excludes or hooks).
///
/// Hooks are filenames inside `configs/scripts/` (same convention as
/// the existing `ExecStartPost=` scripts). They run with the same env
/// as those scripts: `$SERVICE_HOME` plus everything in the service's
/// `.env` file.
///
/// Pre/post hooks form a pair around the operation:
///
/// ```text
/// backup:  [pre_backup]  -> restic snapshot   -> [post_backup]
/// restore: [pre_restore] -> restic restore    -> [post_restore]
/// ```
///
/// Hooks must dump to `$SERVICE_HOME/.backup/` (a sibling of `data/`)
/// so it's clear which files are user-owned data versus snapshot
/// artefacts. Listing `.backup/<file>` in `paths` is required if the
/// hook writes one; nothing is implicitly included.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackupConfig {
    /// Explicit list of paths (relative to service home) to include in
    /// the snapshot. When empty, the default is "every top-level child
    /// of the service home dir that the classifier marks as data."
    #[serde(default)]
    pub paths: Vec<String>,
    /// Restic-style exclude patterns relative to service home.
    /// Useful for skipping caches, previews, transcoding artefacts.
    #[serde(default)]
    pub exclude: Vec<String>,
    /// Script filename (in `configs/scripts/`) run before the restic
    /// snapshot. Typically dumps a database to `$SERVICE_HOME/.backup/`.
    #[serde(default)]
    pub pre_backup: Option<String>,
    /// Script filename run after a successful restic snapshot.
    /// Typically cleans up `$SERVICE_HOME/.backup/`.
    #[serde(default)]
    pub post_backup: Option<String>,
    /// Script filename run before restoring (typically stops the
    /// service and wipes the live data dir).
    #[serde(default)]
    pub pre_restore: Option<String>,
    /// Script filename run after restoring (typically imports the
    /// dump back into the live database and restarts the service).
    #[serde(default)]
    pub post_restore: Option<String>,
}

// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------

impl ServiceDef {
    /// Check if this service supports the current system architecture.
    /// Returns None if supported (or no restriction), Some(error) if not.
    pub fn check_architecture(&self) -> Option<String> {
        if self.service.architecture.is_empty() {
            return None;
        }
        let current = current_architecture();
        if self.service.architecture.contains(&current) {
            None
        } else {
            let supported: Vec<_> = self
                .service
                .architecture
                .iter()
                .map(|a| a.to_string())
                .collect();
            Some(format!(
                "{} only supports {} — this system is {current}",
                self.service.name,
                supported.join(", "),
            ))
        }
    }

    /// Returns env var names that are required — must be provided during install.
    pub fn required_env_vars(&self) -> Vec<&str> {
        self.env
            .iter()
            .filter(|e| e.kind == EnvKind::Required)
            .map(|e| e.name.as_str())
            .collect()
    }

    /// Validate structural invariants that serde can't enforce.
    /// Called once after deserialization — if this returns Ok, the definition
    /// is safe to use without further checks.
    pub fn validate(&self) -> Result<(), String> {
        let name = &self.service.name;
        let mut errors: Vec<String> = Vec::new();

        // --- Duplicate names ---

        let mut seen_ports = std::collections::HashSet::new();
        let mut seen_ts_https = std::collections::HashSet::new();
        for p in &self.ports {
            if !seen_ports.insert(&p.name) {
                errors.push(format!("duplicate port name '{}'", p.name));
            }
            // `container_port = 0` is the "fill in later" placeholder `ryra init`
            // writes for a blank port. Refuse to install until it's a real port.
            if p.container_port == 0 {
                errors.push(format!(
                    "port '{}' has container_port = 0 — fill in the port your service listens on",
                    p.name
                ));
            }
            // Two ports can't be served on the same Tailscale HTTPS port —
            // the second `tailscale serve --https=<p>` would clobber the first.
            if let Some(https) = p.tailscale_https
                && !seen_ts_https.insert(https)
            {
                errors.push(format!(
                    "two ports map to the same tailscale_https port {https}"
                ));
            }
        }
        // If any port opts into Tailscale exposure, exactly one must own 443 —
        // that's the web root answering at the bare `<svc>.<tailnet>.ts.net`.
        let ts_ports: Vec<&PortDef> = self
            .ports
            .iter()
            .filter(|p| p.tailscale_https.is_some())
            .collect();
        if !ts_ports.is_empty()
            && ts_ports
                .iter()
                .filter(|p| p.tailscale_https == Some(443))
                .count()
                != 1
        {
            errors.push(
                "services exposing ports over Tailscale must mark exactly one port \
                 tailscale_https = 443 (the web root)"
                    .to_string(),
            );
        }

        // [metrics] must reference a declared port — the scrape target is
        // built from that entry's container_port.
        if let Some(metrics) = &self.metrics
            && !self.ports.iter().any(|p| p.name == metrics.port)
        {
            errors.push(format!(
                "[metrics] references port '{}' but no [[ports]] entry has that name",
                metrics.port
            ));
        }

        // Every env var name (top-level + every group member) must be unique
        // across the whole service — podman's .env is a flat keyspace so two
        // FOO= lines would be ambiguous.
        let mut seen_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
        for e in &self.env {
            if !seen_envs.insert(&e.name) {
                errors.push(format!("duplicate env var name '{}'", e.name));
            }
        }
        for g in &self.env_groups {
            for e in &g.env {
                if !seen_envs.insert(&e.name) {
                    errors.push(format!(
                        "env var '{}' in group '{}' collides with another env var",
                        e.name, g.name
                    ));
                }
            }
        }
        // Choice options: at most one option per choice is ever active and
        // sibling options are mutually exclusive, so two options of the *same*
        // choice may reuse a name (e.g. every billing option sets
        // BILLING_MODE). But a name shared with a top-level env, a group, or a
        // *different* choice can be active simultaneously, so those still
        // collide. So we check each option against `seen_envs` (top-level +
        // groups + earlier choices) but merge only the choice's deduped union
        // back in, never sibling-by-sibling.
        for c in &self.choices {
            let mut choice_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
            for o in &c.options {
                let mut option_envs: std::collections::HashSet<&str> =
                    std::collections::HashSet::new();
                for e in &o.env {
                    if !option_envs.insert(e.name.as_str()) {
                        errors.push(format!(
                            "env var '{}' is declared twice in choice '{}' option '{}'",
                            e.name, c.name, o.name
                        ));
                    }
                    if seen_envs.contains(e.name.as_str()) {
                        errors.push(format!(
                            "env var '{}' in choice '{}' option '{}' collides with another env var",
                            e.name, c.name, o.name
                        ));
                    }
                    choice_envs.insert(e.name.as_str());
                }
            }
            seen_envs.extend(choice_envs);
        }

        // --- Env var name format + kind consistency ---

        for e in &self.env {
            check_env_var(e, EnvLoc::TopLevel, &mut errors);
        }

        // --- Env group names + members ---

        let mut seen_groups = std::collections::HashSet::new();
        for g in &self.env_groups {
            if !seen_groups.insert(&g.name) {
                errors.push(format!("duplicate env_group name '{}'", g.name));
            }
            if g.name.is_empty() {
                errors.push("env_group has empty name".to_string());
            } else if !g
                .name
                .chars()
                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
            {
                errors.push(format!(
                    "env_group '{}' must be lowercase snake_case ([a-z0-9_])",
                    g.name
                ));
            }
            if g.prompt.is_empty() {
                errors.push(format!("env_group '{}' has empty prompt", g.name));
            }
            if g.env.is_empty() {
                errors.push(format!("env_group '{}' has no env vars", g.name));
            }
            for e in &g.env {
                check_env_var(e, EnvLoc::Group(&g.name), &mut errors);
            }
        }

        // --- Choice names + options ---
        //
        // The "exactly one selected" guarantee comes from the metadata shape (a
        // single value per choice), so here we only police the static
        // structure: a snake_case name distinct from groups, a prompt, two or
        // more uniquely-named options, and a default that names one of them.
        let mut seen_choices: std::collections::HashSet<&str> = std::collections::HashSet::new();
        for c in &self.choices {
            if !seen_choices.insert(c.name.as_str()) {
                errors.push(format!("duplicate choice name '{}'", c.name));
            }
            if self.env_groups.iter().any(|g| g.name == c.name) {
                errors.push(format!(
                    "choice '{}' shares a name with an env_group; names must be distinct",
                    c.name
                ));
            }
            if c.name.is_empty() {
                errors.push("choice has empty name".to_string());
            } else if !c
                .name
                .chars()
                .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
            {
                errors.push(format!(
                    "choice '{}' must be lowercase snake_case ([a-z0-9_])",
                    c.name
                ));
            }
            if c.prompt.is_empty() {
                errors.push(format!("choice '{}' has empty prompt", c.name));
            }
            // Fewer than two options is not a choice.
            if c.options.len() < 2 {
                errors.push(format!(
                    "choice '{}' has {} option(s); a choice needs at least two",
                    c.name,
                    c.options.len()
                ));
            }
            let mut seen_options: std::collections::HashSet<&str> =
                std::collections::HashSet::new();
            for o in &c.options {
                if !seen_options.insert(o.name.as_str()) {
                    errors.push(format!(
                        "duplicate option '{}' in choice '{}'",
                        o.name, c.name
                    ));
                }
                if o.name.is_empty() {
                    errors.push(format!("choice '{}' has an option with empty name", c.name));
                } else if !o
                    .name
                    .chars()
                    .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
                {
                    errors.push(format!(
                        "option '{}' in choice '{}' must be lowercase snake_case ([a-z0-9_])",
                        o.name, c.name
                    ));
                }
                for e in &o.env {
                    check_env_var(
                        e,
                        EnvLoc::ChoiceOption {
                            choice: &c.name,
                            option: &o.name,
                        },
                        &mut errors,
                    );
                }
            }
            // The default must name a real option, else a non-interactive add
            // would resolve to nothing.
            if !c.options.iter().any(|o| o.name == c.default) {
                errors.push(format!(
                    "choice '{}' default '{}' names no option (have: {})",
                    c.name,
                    c.default,
                    c.options
                        .iter()
                        .map(|o| o.name.as_str())
                        .collect::<Vec<_>>()
                        .join(", ")
                ));
            }
        }

        // --- RAM requirements consistency ---

        if let Some(ref req) = self.requirements
            && let Some(rec) = req.ram.recommended
            && rec < req.ram.min
        {
            errors.push(format!(
                "recommended RAM ({rec}MB) is less than minimum ({}MB)",
                req.ram.min
            ));
        }

        // --- Backup consistency ---
        //
        // The `[backup]` section is only meaningful when the author has
        // certified the service is backup-safe via `backup = true`. If
        // they wrote hooks/excludes without flipping the flag we'd
        // silently ship a service whose backup support is half-declared,
        // so reject it loudly.
        if let Some(ref backup) = self.backup
            && !self.integrations.backup
        {
            errors.push("[backup] section requires `backup = true` in [integrations]".to_string());
            // No-op read so the binding isn't unused if all sub-checks
            // below get gated out by serde defaults.
            let _ = backup;
        }
        if let Some(ref backup) = self.backup {
            for (label, hook) in [
                ("pre_backup", &backup.pre_backup),
                ("post_backup", &backup.post_backup),
                ("pre_restore", &backup.pre_restore),
                ("post_restore", &backup.post_restore),
            ] {
                if let Some(script) = hook
                    && (script.is_empty() || script.contains('/') || script.contains(".."))
                {
                    errors.push(format!(
                        "backup hook '{label}' must be a bare filename under configs/scripts/ \
                         (got {script:?})"
                    ));
                }
            }
            for p in &backup.paths {
                if p.is_empty() || p.starts_with('/') || p.contains("..") {
                    errors.push(format!(
                        "backup path {p:?} must be a relative path within the service home"
                    ));
                }
            }
        }

        // --- Runtime / build consistency ---
        // Make "native without a build target" and "podman with a build
        // section" unrepresentable past load: a native service needs to know
        // which binary to run; a podman service has no business declaring one.
        match self.service.runtime {
            Runtime::Native => match &self.service.run {
                None => errors.push(
                    "runtime = \"native\" requires a `run` command under [service]".to_string(),
                ),
                Some(run) if run.trim().is_empty() => {
                    errors.push("[service].run must not be empty".to_string())
                }
                Some(_) => {}
            },
            Runtime::Podman => {
                if self.service.run.is_some() || self.service.build.is_some() {
                    errors.push(
                        "`run` / `build` are only valid for runtime = \"native\" services"
                            .to_string(),
                    );
                }
            }
        }

        if errors.is_empty() {
            Ok(())
        } else {
            Err(format!("{name}: {}", errors.join("; ")))
        }
    }
}

/// Where an [`EnvVar`] is declared, used to locate it in validation errors. A
/// closed sum type rather than a free-form string, so a caller can't pass an
/// arbitrary label and [`check_env_var`] `match`es it to build the suffix.
enum EnvLoc<'a> {
    TopLevel,
    Group(&'a str),
    ChoiceOption { choice: &'a str, option: &'a str },
}

impl std::fmt::Display for EnvLoc<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            EnvLoc::TopLevel => Ok(()),
            EnvLoc::Group(g) => write!(f, " in group '{g}'"),
            EnvLoc::ChoiceOption { choice, option } => {
                write!(f, " in choice '{choice}' option '{option}'")
            }
        }
    }
}

/// Name-format + kind-consistency check for a single `EnvVar`, shared by
/// top-level `[[env]]`, `[[env_group.env]]`, and `[[choice.option.env]]`. `loc`
/// is woven into each error so the offending declaration is locatable.
fn check_env_var(e: &EnvVar, loc: EnvLoc, errors: &mut Vec<String>) {
    if e.name.is_empty() {
        errors.push(format!("env var has empty name{loc}"));
    } else if !e
        .name
        .chars()
        .next()
        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
    {
        errors.push(format!(
            "env var '{}'{loc} must start with a letter or _",
            e.name
        ));
    } else if !e
        .name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '_')
    {
        errors.push(format!(
            "env var '{}'{loc} contains invalid characters (must match [A-Za-z0-9_])",
            e.name
        ));
    }
    if e.kind == EnvKind::Required && e.value.contains("{{secret.") {
        errors.push(format!(
            "env var '{}'{loc} is kind=required but has a secret template default; use kind=prompted or kind=default",
            e.name
        ));
    }
}

/// Detect the current system architecture using OCI/Docker naming conventions.
pub fn current_architecture() -> Arch {
    match std::env::consts::ARCH {
        "x86_64" => Arch::Amd64,
        "aarch64" => Arch::Arm64,
        // Fallback: default to amd64 for unknown architectures.
        // The service's check_architecture() will catch unsupported ones.
        _ => Arch::Amd64,
    }
}

#[cfg(test)]
mod backup_tests {
    use super::*;

    fn parse(toml_src: &str) -> ServiceDef {
        toml::from_str(toml_src).expect("parse")
    }

    #[test]
    fn tailscale_https_requires_exactly_one_root() {
        // Two tailscale-exposed ports but neither owns 443 → rejected.
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[[ports]]
name = "http"
container_port = 8080
tailscale_https = 8080

[[ports]]
name = "photos"
container_port = 3000
tailscale_https = 3000
"#,
        );
        let err = svc.validate().expect_err("must reject");
        assert!(err.contains("tailscale_https = 443"), "got: {err}");
    }

    #[test]
    fn tailscale_https_duplicate_port_rejected() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[[ports]]
name = "a"
container_port = 1
tailscale_https = 443

[[ports]]
name = "b"
container_port = 2
tailscale_https = 443
"#,
        );
        let err = svc.validate().expect_err("must reject");
        assert!(err.contains("same tailscale_https"), "got: {err}");
    }

    #[test]
    fn tailscale_https_one_root_plus_api_validates() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[[ports]]
name = "http"
container_port = 8080
tailscale_https = 8080

[[ports]]
name = "photos"
container_port = 3000
tailscale_https = 443
"#,
        );
        svc.validate()
            .expect("one 443 root + one api port is valid");
    }

    #[test]
    fn backup_defaults_to_false_when_omitted() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"
"#,
        );
        assert!(!svc.integrations.backup);
        assert!(svc.backup.is_none());
        svc.validate().expect("default is valid");
    }

    #[test]
    fn backup_section_alone_is_rejected_without_integration_flag() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[backup]
"#,
        );
        let err = svc.validate().expect_err("must reject");
        assert!(
            err.contains("backup = true"),
            "error mentions the required flag: {err}"
        );
    }

    #[test]
    fn backup_supported_without_hooks_validates() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[integrations]
backup = true
"#,
        );
        assert!(svc.integrations.backup);
        assert!(svc.backup.is_none());
        svc.validate().expect("ok without [backup] table");
    }

    #[test]
    fn backup_with_full_hooks_validates() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[integrations]
backup = true

[backup]
paths = [".backup/db.sql.gz", "data"]
exclude = ["data/cache"]
pre_backup = "backup-pre.sh"
post_backup = "backup-post.sh"
pre_restore = "restore-pre.sh"
post_restore = "restore-post.sh"
"#,
        );
        svc.validate().expect("ok");
        let backup = svc.backup.as_ref().expect("section present");
        assert_eq!(backup.paths, vec![".backup/db.sql.gz", "data"]);
        assert_eq!(backup.pre_backup.as_deref(), Some("backup-pre.sh"));
    }

    #[test]
    fn backup_hook_with_slash_is_rejected() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[integrations]
backup = true

[backup]
pre_backup = "subdir/script.sh"
"#,
        );
        let err = svc.validate().expect_err("must reject");
        assert!(err.contains("pre_backup"), "{err}");
    }

    #[test]
    fn backup_hook_with_dotdot_is_rejected() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[integrations]
backup = true

[backup]
post_backup = "../escape.sh"
"#,
        );
        let err = svc.validate().expect_err("must reject");
        assert!(err.contains("post_backup"), "{err}");
    }

    #[test]
    fn backup_absolute_path_is_rejected() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[integrations]
backup = true

[backup]
paths = ["/etc/passwd"]
"#,
        );
        let err = svc.validate().expect_err("must reject");
        assert!(err.contains("/etc/passwd"), "{err}");
    }

    #[test]
    fn backup_path_with_dotdot_is_rejected() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[integrations]
backup = true

[backup]
paths = ["../../somewhere"]
"#,
        );
        let err = svc.validate().expect_err("must reject");
        assert!(err.contains("somewhere"), "{err}");
    }
}

#[cfg(test)]
mod https_requirement_tests {
    use super::*;

    fn parse(toml_src: &str) -> ServiceDef {
        toml::from_str(toml_src).expect("parse")
    }

    /// Every shipped registry `service.toml` must parse and validate under the
    /// current schema. Guards against a core change (a new field, a stricter
    /// rule) silently breaking a catalog service. Skips gracefully if the
    /// registry dir isn't present (e.g. a packaged build of just the crate).
    #[test]
    fn all_registry_services_parse_and_validate() {
        let registry = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../registry");
        if !registry.is_dir() {
            eprintln!("registry dir not found ({}); skipping", registry.display());
            return;
        }
        let mut failures = Vec::new();
        let entries = std::fs::read_dir(&registry).expect("read registry dir");
        for entry in entries {
            let entry = entry.expect("dir entry");
            let svc_toml = entry.path().join("service.toml");
            if !svc_toml.is_file() {
                continue;
            }
            let name = entry.file_name().to_string_lossy().into_owned();
            let text = std::fs::read_to_string(&svc_toml).expect("read service.toml");
            match toml::from_str::<ServiceDef>(&text) {
                Ok(def) => {
                    if let Err(e) = def.validate() {
                        failures.push(format!("{name}: validate: {e}"));
                    }
                }
                Err(e) => failures.push(format!("{name}: parse: {e}")),
            }
        }
        assert!(
            failures.is_empty(),
            "registry service.toml failures:\n  {}",
            failures.join("\n  ")
        );
    }

    #[test]
    fn never_service_stays_http() {
        assert!(!HttpsRequirement::Never.needs_https(false, None));
        // Even with --auth, a service that didn't opt into HTTPS stays HTTP.
        // This is the RFC 8252 loopback case: http://127.0.0.1 is a valid
        // OIDC redirect_uri and most services (forgejo, etc.) work fine
        // that way.
        assert!(!HttpsRequirement::Never.needs_https(true, None));
        // Explicit http:// URL also stays HTTP.
        assert!(!HttpsRequirement::Never.needs_https(true, Some("http://foo.example.com")));
    }

    #[test]
    fn always_service_always_promotes() {
        assert!(HttpsRequirement::Always.needs_https(false, None));
        assert!(HttpsRequirement::Always.needs_https(false, Some("http://foo.example.com")));
    }

    #[test]
    fn auth_service_promotes_only_with_auth() {
        // The regression this guards: `ryra add nextcloud --auth` without
        // --url used to quietly install over HTTP and the SSO button never
        // rendered (user_oidc refuses to show it without HTTPS).
        assert!(HttpsRequirement::Auth.needs_https(true, None));
        // Without --auth, even an `https = "auth"` service stays HTTP.
        assert!(!HttpsRequirement::Auth.needs_https(false, None));
    }

    #[test]
    fn explicit_https_url_promotes() {
        assert!(HttpsRequirement::Never.needs_https(false, Some("https://foo.example.com")));
    }

    // --- [[choice]] validation ---

    const BILLING_CHOICE: &str = r#"
[service]
name = "x"
description = "x"

[[choice]]
name = "billing"
prompt = "Billing mode"
default = "mock"

[[choice.option]]
name = "live"
label = "Stripe"
[[choice.option.env]]
name = "BILLING_MODE"
value = "live"
[[choice.option.env]]
name = "STRIPE_SECRET_KEY"
value = ""
kind = "required"

[[choice.option]]
name = "mock"
[[choice.option.env]]
name = "BILLING_MODE"
value = "mock"
"#;

    #[test]
    fn valid_choice_validates() {
        parse(BILLING_CHOICE)
            .validate()
            .expect("a well-formed choice is valid");
    }

    #[test]
    fn choice_option_carries_quadlets() {
        let def = parse(
            r#"
[service]
name = "x"
description = "x"

[[choice]]
name = "database"
prompt = "Database"
default = "internal"

[[choice.option]]
name = "internal"
quadlets = ["x-postgres.container"]
[[choice.option.env]]
name = "DATABASE_URL"
value = "postgres://ryra@postgres/x"

[[choice.option]]
name = "external"
[[choice.option.env]]
name = "DATABASE_URL"
value = ""
kind = "required"
"#,
        );
        def.validate().expect("valid");
        let internal = &def.choices[0].options[0];
        assert_eq!(internal.quadlets, vec!["x-postgres.container".to_string()]);
        assert!(def.choices[0].options[1].quadlets.is_empty());
    }

    #[test]
    fn sibling_options_may_reuse_an_env_name() {
        // Both `live` and `mock` set BILLING_MODE — allowed, since at most one
        // option is ever active.
        let def = parse(BILLING_CHOICE);
        let billing = &def.choices[0];
        assert!(
            billing
                .options
                .iter()
                .all(|o| o.env.iter().any(|e| e.name == "BILLING_MODE"))
        );
        def.validate().expect("sibling reuse is allowed");
    }

    #[test]
    fn choice_needs_at_least_two_options() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[[choice]]
name = "billing"
prompt = "p"
default = "only"

[[choice.option]]
name = "only"
"#,
        );
        let err = svc.validate().expect_err("one option is not a choice");
        assert!(err.contains("at least two"), "got: {err}");
    }

    #[test]
    fn choice_default_must_name_an_option() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[[choice]]
name = "billing"
prompt = "p"
default = "nope"

[[choice.option]]
name = "live"
[[choice.option]]
name = "mock"
"#,
        );
        let err = svc.validate().expect_err("bad default rejected");
        assert!(err.contains("names no option"), "got: {err}");
    }

    #[test]
    fn duplicate_option_name_rejected() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[[choice]]
name = "billing"
prompt = "p"
default = "live"

[[choice.option]]
name = "live"
[[choice.option]]
name = "live"
"#,
        );
        let err = svc.validate().expect_err("dup option rejected");
        assert!(err.contains("duplicate option"), "got: {err}");
    }

    #[test]
    fn two_choices_sharing_an_env_name_collide() {
        // Different choices can both be active, so a shared name is a real
        // collision (unlike sibling options of one choice).
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[[choice]]
name = "a"
prompt = "p"
default = "one"
[[choice.option]]
name = "one"
[[choice.option.env]]
name = "SHARED"
value = "1"
[[choice.option]]
name = "two"

[[choice]]
name = "b"
prompt = "p"
default = "one"
[[choice.option]]
name = "one"
[[choice.option.env]]
name = "SHARED"
value = "2"
[[choice.option]]
name = "two"
"#,
        );
        let err = svc.validate().expect_err("cross-choice collision rejected");
        assert!(err.contains("collides"), "got: {err}");
    }

    #[test]
    fn choice_name_colliding_with_group_rejected() {
        let svc = parse(
            r#"
[service]
name = "x"
description = "x"

[[env_group]]
name = "billing"
prompt = "p"
[[env_group.env]]
name = "FOO"
value = "1"

[[choice]]
name = "billing"
prompt = "p"
default = "live"
[[choice.option]]
name = "live"
[[choice.option]]
name = "mock"
"#,
        );
        let err = svc.validate().expect_err("name clash rejected");
        assert!(err.contains("shares a name"), "got: {err}");
    }
}