1use std::collections::BTreeMap;
16use std::path::Path;
17use std::process::Command;
18
19use anyhow::{Context, Result, anyhow, bail};
20use serde::Deserialize;
21use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value, value};
22
23use crate::cli::Runtime;
24use crate::user_config::validate_runner_label;
25
26#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
27#[serde(deny_unknown_fields)]
28pub struct ProjectConfig {
29 #[serde(default)]
30 pub release: ReleaseConfig,
31 #[serde(default)]
32 pub flake: FlakeConfig,
33 #[serde(default)]
34 pub ci: CiConfig,
35 #[serde(default)]
36 pub homebrew: Option<HomebrewConfig>,
37 #[serde(default)]
38 pub chocolatey: Option<ChocolateyConfig>,
39 #[serde(default)]
40 pub scoop: Option<ScoopConfig>,
41 #[serde(default)]
42 pub aur: Option<AurConfig>,
43 #[serde(default)]
44 pub copr: Option<CoprConfig>,
45 #[serde(default)]
46 pub apt: Option<AptConfig>,
47 #[serde(default)]
48 pub flatpak: Option<FlatpakConfig>,
49 #[serde(default)]
50 pub winget: Option<WingetConfig>,
51 #[serde(default)]
52 pub vscode: Option<VscodeConfig>,
53}
54
55#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
57#[serde(deny_unknown_fields)]
58pub struct VscodeConfig {
59 #[serde(default = "default_vscode_extension_dir")]
61 pub extension_dir: String,
62 #[serde(default)]
64 pub runner: Option<String>,
65 pub codeberg_repo: String,
67 #[serde(default = "default_codeberg_api_base")]
69 pub codeberg_api_base: String,
70 #[serde(default = "default_codeberg_token_secret")]
72 pub codeberg_token_secret: String,
73 #[serde(default)]
75 pub pat_source: VscodePatSource,
76 #[serde(default = "default_vscode_vsce_pat_file_env")]
78 pub vsce_pat_file_env: String,
79 #[serde(default = "default_vscode_ovsx_pat_file_env")]
81 pub ovsx_pat_file_env: String,
82 #[serde(default = "default_vscode_vsce_pat_secret")]
84 pub vsce_pat_secret: String,
85 #[serde(default = "default_vscode_ovsx_pat_secret")]
87 pub ovsx_pat_secret: String,
88 #[serde(default = "default_vscode_package_command")]
90 pub package_command: String,
91 #[serde(default)]
93 pub cargo_package: Option<String>,
94 #[serde(default)]
96 pub prepublish_commands: Vec<String>,
97}
98
99#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
100#[serde(rename_all = "kebab-case")]
101pub enum VscodePatSource {
102 FileEnv,
103 ActionsSecret,
104 #[default]
105 Both,
106}
107
108fn default_vscode_extension_dir() -> String {
109 "vscode".to_owned()
110}
111
112fn default_vscode_vsce_pat_file_env() -> String {
113 "VSCE_PAT_FILE".to_owned()
114}
115
116fn default_vscode_ovsx_pat_file_env() -> String {
117 "OVSX_PAT_FILE".to_owned()
118}
119
120fn default_vscode_vsce_pat_secret() -> String {
121 "VSCE_PAT".to_owned()
122}
123
124fn default_vscode_ovsx_pat_secret() -> String {
125 "OVSX_PAT".to_owned()
126}
127
128fn default_vscode_package_command() -> String {
129 "nix run .#package-release-assets -- \"$VERSION\"".to_owned()
130}
131
132#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
134#[serde(deny_unknown_fields)]
135pub struct FlatpakConfig {
136 pub repo: String,
138 pub app_id: String,
140 #[serde(default)]
142 pub manifest_files: Vec<String>,
143 #[serde(default = "default_flatpak_base_branch")]
145 pub base_branch: String,
146 #[serde(default = "default_flathub_token_secret")]
148 pub token_secret: String,
149}
150
151fn default_flatpak_base_branch() -> String {
152 "master".to_owned()
153}
154
155fn default_flathub_token_secret() -> String {
156 "FLATHUB_TOKEN".to_owned()
157}
158
159#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
161#[serde(deny_unknown_fields)]
162pub struct WingetConfig {
163 pub package_id: String,
165 pub download_repo: String,
167 pub zip_archive: String,
170 #[serde(default = "default_winget_token_secret")]
172 pub token_secret: String,
173}
174
175fn default_winget_token_secret() -> String {
176 "WINGET_PAT".to_owned()
177}
178
179#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
180#[serde(deny_unknown_fields)]
181pub struct FlakeConfig {
182 #[serde(default)]
183 pub scope: Option<FlakeScope>,
184 #[serde(default)]
185 pub mode: FlakeMode,
186 #[serde(default)]
187 pub backend: FlakeBackend,
188 #[serde(default = "default_toolchain_binding")]
189 pub toolchain_binding: String,
190 #[serde(default = "default_crane_lib_binding")]
191 pub crane_lib_binding: String,
192 #[serde(default = "default_package_binding")]
193 pub package_binding: String,
194 #[serde(default = "default_true")]
195 pub formatter_output: bool,
196 #[serde(default = "default_true")]
197 pub formatting_check: bool,
198 #[serde(default = "default_true")]
199 pub pre_commit_shell_hook: bool,
200 #[serde(default)]
201 pub expected_outputs: FlakeExpectedOutputs,
202}
203
204#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
205#[serde(rename_all = "kebab-case")]
206pub enum FlakeScope {
207 HooksOnly,
208 Full,
209}
210
211#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
212#[serde(rename_all = "lowercase")]
213pub enum FlakeMode {
214 #[default]
215 Canonical,
216 Custom,
217}
218
219#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
220#[serde(rename_all = "kebab-case")]
221pub enum FlakeBackend {
222 #[default]
223 RustCrane,
224 PyHarbor,
225}
226
227#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
228#[serde(deny_unknown_fields)]
229pub struct FlakeExpectedOutputs {
230 #[serde(default)]
231 pub packages: Vec<String>,
232 #[serde(default)]
233 pub apps: Vec<String>,
234 #[serde(default)]
235 pub dev_shells: Vec<String>,
236 #[serde(default)]
237 pub checks: Vec<String>,
238 #[serde(default)]
239 pub top_level: Vec<String>,
240}
241
242impl Default for FlakeConfig {
243 fn default() -> Self {
244 Self {
245 scope: None,
246 mode: FlakeMode::Canonical,
247 backend: FlakeBackend::RustCrane,
248 toolchain_binding: default_toolchain_binding(),
249 crane_lib_binding: default_crane_lib_binding(),
250 package_binding: default_package_binding(),
251 formatter_output: true,
252 formatting_check: true,
253 pre_commit_shell_hook: true,
254 expected_outputs: FlakeExpectedOutputs::default(),
255 }
256 }
257}
258
259fn default_toolchain_binding() -> String {
260 "rustToolchain".to_owned()
261}
262
263fn default_crane_lib_binding() -> String {
264 "craneLib".to_owned()
265}
266
267fn default_package_binding() -> String {
268 "package".to_owned()
269}
270
271#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
272#[serde(deny_unknown_fields)]
273pub struct CiConfig {
274 #[serde(default)]
275 pub runtime: Option<Runtime>,
276 #[serde(default)]
277 pub runner: Option<String>,
278 #[serde(default)]
279 pub windows_runner: Option<String>,
280 #[serde(default)]
281 pub workspace: bool,
282 #[serde(default)]
283 pub packages: Vec<String>,
284 #[serde(default)]
285 pub with_nextest: bool,
286 #[serde(default)]
287 pub with_msrv: bool,
288 #[serde(default)]
289 pub with_audit: bool,
290 #[serde(default)]
291 pub with_deny: bool,
292 #[serde(default)]
293 pub with_docs: bool,
294 #[serde(default)]
295 pub with_artifacts: bool,
296 #[serde(default)]
297 pub with_pypi_publish: bool,
298 #[serde(default)]
299 pub publish_crates: bool,
300 #[serde(default)]
301 pub extra_setup: Vec<String>,
302 #[serde(default)]
303 pub extra_env: BTreeMap<String, String>,
304 #[serde(default)]
305 pub required_secrets: Vec<String>,
306 #[serde(default)]
307 pub required_env: Vec<String>,
308 #[serde(default)]
309 pub om_ci: bool,
310 #[serde(default)]
311 pub om_ci_augment: bool,
312 #[serde(default)]
313 pub omnix_ref: Option<String>,
314 #[serde(default)]
315 pub pages: Option<CodebergPagesConfig>,
316 #[serde(default)]
317 pub step_runners: BTreeMap<String, String>,
318}
319
320#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
322#[serde(deny_unknown_fields)]
323pub struct CodebergPagesConfig {
324 pub repo: String,
326 #[serde(default)]
328 pub canonical_domain: Option<String>,
329 #[serde(default = "default_pages_site_output")]
331 pub site_output: String,
332 #[serde(default = "default_codeberg_token_secret")]
334 pub token_secret: String,
335 #[serde(default = "default_pages_source_branch")]
337 pub source_branch: String,
338 #[serde(default = "default_pages_deploy_app")]
340 pub deploy_app: String,
341}
342
343#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
344#[serde(deny_unknown_fields)]
345pub struct ReleaseConfig {
346 #[serde(default)]
347 pub signing: ReleaseSigningConfig,
348 #[serde(default)]
349 pub publish: ReleasePublishConfig,
350 #[serde(default)]
351 pub smoke: ReleaseSmokeConfig,
352 #[serde(default)]
354 pub codeberg: Option<CodebergReleaseConfig>,
355 #[serde(default)]
357 pub artifacts: ArtifactsConfig,
358 #[serde(default)]
360 pub attic: Option<AtticConfig>,
361 #[serde(default)]
363 pub announce: Option<AnnounceConfig>,
364 #[serde(default)]
366 pub windows_signing: Option<WindowsSigningConfig>,
367}
368
369#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
371#[serde(deny_unknown_fields)]
372pub struct ReleasePublishConfig {
373 #[serde(default)]
375 pub enforcement: ReleasePublisherEnforcement,
376}
377
378#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
379#[serde(rename_all = "kebab-case")]
380pub enum ReleasePublisherEnforcement {
381 #[default]
384 Declared,
385 ActivatedRemote,
389}
390
391#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
394#[serde(deny_unknown_fields)]
395pub struct AnnounceConfig {
396 #[serde(default = "default_mastodon_token_secret")]
397 pub mastodon_token_secret: String,
398 #[serde(default = "default_mastodon_base_url_secret")]
399 pub mastodon_base_url_secret: String,
400 #[serde(default = "default_matrix_token_secret")]
401 pub matrix_token_secret: String,
402 #[serde(default = "default_matrix_homeserver_secret")]
403 pub matrix_homeserver_secret: String,
404 #[serde(default = "default_matrix_room_secret")]
405 pub matrix_room_secret: String,
406}
407
408fn default_mastodon_token_secret() -> String {
409 "MASTODON_TOKEN".to_owned()
410}
411fn default_mastodon_base_url_secret() -> String {
412 "MASTODON_BASE_URL".to_owned()
413}
414fn default_matrix_token_secret() -> String {
415 "MATRIX_TOKEN".to_owned()
416}
417fn default_matrix_homeserver_secret() -> String {
418 "MATRIX_HOMESERVER".to_owned()
419}
420fn default_matrix_room_secret() -> String {
421 "MATRIX_ROOM".to_owned()
422}
423
424#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
426#[serde(deny_unknown_fields)]
427pub struct WindowsSigningConfig {
428 #[serde(default = "default_windows_dir")]
430 pub dir: String,
431 pub binaries: Vec<String>,
433 pub sign_name: String,
435 pub sign_url: String,
437 #[serde(default = "default_timestamp_url")]
439 pub timestamp_url: String,
440 pub tar_archive: String,
442 pub zip_archive: String,
444 #[serde(default = "default_windows_pfx_secret")]
445 pub pfx_secret: String,
446 #[serde(default = "default_windows_pass_secret")]
447 pub pass_secret: String,
448 #[serde(default = "default_windows_subject_secret")]
449 pub subject_secret: String,
450}
451
452fn default_windows_dir() -> String {
453 "release/windows-x86_64".to_owned()
454}
455fn default_timestamp_url() -> String {
456 "http://timestamp.digicert.com".to_owned()
457}
458fn default_windows_pfx_secret() -> String {
459 "WINDOWS_SIGNING_PFX".to_owned()
460}
461fn default_windows_pass_secret() -> String {
462 "WINDOWS_SIGNING_PASS".to_owned()
463}
464fn default_windows_subject_secret() -> String {
465 "WINDOWS_SIGNING_SUBJECT".to_owned()
466}
467
468#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
470#[serde(deny_unknown_fields)]
471pub struct ArtifactsConfig {
472 pub runner: Option<String>,
474 #[serde(default)]
476 pub substituters: Vec<String>,
477 #[serde(default)]
479 pub trusted_public_keys: Vec<String>,
480 pub version_attr: Option<String>,
483 pub supply_chain_command: Option<String>,
485 #[serde(default)]
487 pub build_commands: Vec<String>,
488 #[serde(default)]
491 pub sbom_commands: Vec<String>,
492 #[serde(default)]
494 pub checksum_globs: Vec<String>,
495 #[serde(default = "default_minisign_pub")]
497 pub minisign_pub: String,
498 #[serde(default = "default_true")]
500 pub sign: bool,
501}
502
503fn default_minisign_pub() -> String {
504 "keys/minisign.pub".to_owned()
505}
506
507#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
509#[serde(deny_unknown_fields)]
510pub struct AtticConfig {
511 pub cache: String,
513 pub url: String,
515 #[serde(default = "default_attic_token_dir_env")]
517 pub token_dir_env: String,
518 pub token_name: String,
520 #[serde(default)]
522 pub result_links: Vec<String>,
523}
524
525fn default_attic_token_dir_env() -> String {
526 "ATTIC_TOKENS_DIR".to_owned()
527}
528
529#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
531#[serde(deny_unknown_fields)]
532pub struct CodebergReleaseConfig {
533 pub repo: String,
535 #[serde(default = "default_codeberg_api_base")]
537 pub api_base: String,
538 #[serde(default = "default_codeberg_token_secret")]
540 pub token_secret: String,
541 #[serde(default = "default_release_target_branch")]
543 pub target_branch: String,
544 #[serde(default = "default_true")]
546 pub body_from_changelog: bool,
547}
548
549fn default_release_target_branch() -> String {
550 "main".to_owned()
551}
552
553#[derive(Debug, Clone, PartialEq, Eq)]
554pub struct ResolvedCodebergRelease {
555 pub repo: String,
556 pub api_base: String,
557 pub token_secret: String,
558 pub target_branch: String,
559 pub body_from_changelog: bool,
560}
561
562#[derive(Debug, Clone, PartialEq, Eq)]
563pub struct ResolvedCodebergPages {
564 pub repo: String,
565 pub owner: String,
566 pub canonical_domain: Option<String>,
567 pub site_output: String,
568 pub token_secret: String,
569 pub source_branch: String,
570 pub deploy_app: String,
571}
572
573#[derive(Debug, Clone, PartialEq, Eq)]
574pub struct ResolvedVscode {
575 pub extension_dir: String,
576 pub runner: Option<String>,
577 pub codeberg_repo: String,
578 pub codeberg_owner: String,
579 pub codeberg_api_base: String,
580 pub codeberg_token_secret: String,
581 pub pat_source: VscodePatSource,
582 pub vsce_pat_file_env: String,
583 pub ovsx_pat_file_env: String,
584 pub vsce_pat_secret: String,
585 pub ovsx_pat_secret: String,
586 pub package_command: String,
587 pub cargo_package: Option<String>,
588 pub prepublish_commands: Vec<String>,
589}
590
591#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
592#[serde(deny_unknown_fields)]
593pub struct ReleaseSigningConfig {
594 pub key: Option<String>,
596
597 #[serde(default = "default_release_trust_root")]
599 pub trust_root: String,
600
601 #[serde(default = "default_true")]
603 pub required: bool,
604}
605
606impl Default for ReleaseSigningConfig {
607 fn default() -> Self {
608 Self {
609 key: None,
610 trust_root: default_release_trust_root(),
611 required: true,
612 }
613 }
614}
615
616fn default_release_trust_root() -> String {
617 "keys/maintainers.gpg".to_owned()
618}
619
620#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
621#[serde(deny_unknown_fields)]
622pub struct ReleaseSmokeConfig {
623 pub command: Option<String>,
625}
626
627#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
628#[serde(deny_unknown_fields)]
629pub struct HomebrewConfig {
630 pub name: Option<String>,
632
633 #[serde(default)]
635 pub binaries: Vec<String>,
636
637 pub tap_url: String,
639
640 #[serde(default = "default_homebrew_tap_token_secret")]
642 pub tap_token_secret: String,
643
644 pub description: Option<String>,
646
647 pub homepage: Option<String>,
649
650 pub license: Option<String>,
652
653 pub download_repo: String,
655
656 #[serde(default = "default_archive_pattern")]
658 pub archive_pattern: String,
659
660 #[serde(default)]
662 pub platforms: HomebrewPlatformsConfig,
663}
664
665fn default_archive_pattern() -> String {
666 "{name}-{version}-{arch}-{os}.tar.gz".to_owned()
667}
668
669fn default_homebrew_tap_token_secret() -> String {
670 "homebrew_tap_token".to_owned()
671}
672
673fn default_windows_archive_pattern() -> String {
674 "{name}-{version}-{arch}-windows.zip".to_owned()
675}
676
677#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
678#[serde(deny_unknown_fields)]
679pub struct HomebrewPlatformsConfig {
680 #[serde(default = "default_true")]
681 pub darwin_arm: bool,
682 #[serde(default)]
683 pub darwin_intel: bool,
684 #[serde(default = "default_true")]
685 pub linux_arm: bool,
686 #[serde(default = "default_true")]
687 pub linux_intel: bool,
688}
689
690#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
691#[serde(deny_unknown_fields)]
692pub struct ChocolateyConfig {
693 pub name: Option<String>,
695
696 pub id: Option<String>,
698
699 pub title: Option<String>,
701
702 pub authors: Option<String>,
704
705 pub description: Option<String>,
707
708 pub summary: Option<String>,
710
711 pub project_url: Option<String>,
713
714 pub license_url: Option<String>,
716
717 pub icon_url: Option<String>,
719
720 pub package_source_url: Option<String>,
722
723 pub docs_url: Option<String>,
725
726 pub bug_tracker_url: Option<String>,
728
729 pub project_source_url: Option<String>,
731
732 pub tags: Option<String>,
734
735 pub release_notes_url: Option<String>,
737
738 pub download_repo: String,
740
741 #[serde(default = "default_windows_archive_pattern")]
743 pub archive_pattern: String,
744
745 #[serde(default)]
747 pub push: ChocolateyPushConfig,
748
749 #[serde(default = "default_chocolatey_nix_tool")]
755 pub nix_tool: String,
756
757 #[serde(default = "default_chocolatey_api_key_env")]
760 pub api_key_env: String,
761
762 #[serde(default = "default_chocolatey_api_key_secret")]
765 pub api_key_secret: String,
766
767 #[serde(default)]
771 pub api_key_from_runner: bool,
772}
773
774fn default_chocolatey_nix_tool() -> String {
775 "nixpkgs#chocolatey".to_owned()
776}
777
778fn default_chocolatey_api_key_env() -> String {
779 "CHOCOLATEY_API_KEY".to_owned()
780}
781
782fn default_chocolatey_api_key_secret() -> String {
783 "chocolatey_api_key".to_owned()
784}
785
786#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
787#[serde(deny_unknown_fields)]
788pub struct ChocolateyPushConfig {
789 #[serde(default = "default_chocolatey_push_source")]
790 pub source: String,
791}
792
793impl Default for ChocolateyPushConfig {
794 fn default() -> Self {
795 Self {
796 source: default_chocolatey_push_source(),
797 }
798 }
799}
800
801fn default_chocolatey_push_source() -> String {
802 "https://push.chocolatey.org/".to_owned()
803}
804
805#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
806#[serde(deny_unknown_fields)]
807pub struct ScoopConfig {
808 pub name: Option<String>,
810
811 pub bucket_url: String,
813
814 #[serde(default = "default_scoop_bucket_token_secret")]
816 pub bucket_token_secret: String,
817
818 pub description: Option<String>,
820
821 pub homepage: Option<String>,
823
824 pub license: Option<String>,
826
827 pub download_repo: String,
829
830 #[serde(default = "default_windows_archive_pattern")]
832 pub archive_pattern: String,
833
834 #[serde(default)]
836 pub binaries: Vec<String>,
837
838 #[serde(default)]
840 pub architectures: ScoopArchSet,
841}
842
843#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
844#[serde(deny_unknown_fields)]
845pub struct ScoopArchSet {
846 #[serde(default = "default_true")]
847 pub x64: bool,
848 #[serde(default = "default_true")]
849 pub arm64: bool,
850}
851
852impl ScoopArchSet {
853 pub fn any_enabled(&self) -> bool {
854 self.x64 || self.arm64
855 }
856}
857
858impl Default for ScoopArchSet {
859 fn default() -> Self {
860 Self {
861 x64: true,
862 arm64: true,
863 }
864 }
865}
866
867fn default_scoop_bucket_token_secret() -> String {
868 "SCOOP_BUCKET_TOKEN".to_owned()
869}
870
871impl HomebrewPlatformsConfig {
872 pub fn any_enabled(&self) -> bool {
873 self.darwin_arm || self.darwin_intel || self.linux_arm || self.linux_intel
874 }
875}
876
877impl Default for HomebrewPlatformsConfig {
878 fn default() -> Self {
879 Self {
880 darwin_arm: true,
881 darwin_intel: false,
882 linux_arm: true,
883 linux_intel: true,
884 }
885 }
886}
887
888fn default_true() -> bool {
889 true
890}
891
892fn default_aur_arch() -> String {
893 "x86_64".to_owned()
894}
895
896fn default_aur_ssh_remote() -> String {
897 "ssh://aur@aur.archlinux.org".to_owned()
898}
899
900fn default_aur_ssh_key_secret() -> String {
901 "AUR_SSH_KEY".to_owned()
902}
903
904fn default_aur_source_archive_pattern() -> String {
905 "{repo}-{version}.tar.gz".to_owned()
906}
907
908fn default_aur_binary_archive_pattern() -> String {
909 "{name}-{version}-x86_64-linux.tar.gz".to_owned()
910}
911
912fn default_codeberg_api_base() -> String {
913 "https://codeberg.org/api/v1".to_owned()
914}
915
916fn default_codeberg_token_secret() -> String {
917 "codeberg_token".to_owned()
918}
919
920fn default_pages_source_branch() -> String {
921 "trunk".to_owned()
922}
923
924fn default_pages_deploy_app() -> String {
925 ".#deploy-pages".to_owned()
926}
927
928fn default_pages_site_output() -> String {
929 ".#site".to_owned()
930}
931
932fn default_copr_spec_path() -> Option<String> {
933 None
934}
935
936fn default_apt_branch() -> String {
937 "pages".to_owned()
938}
939
940fn default_apt_distribution() -> String {
941 "stable".to_owned()
942}
943
944fn default_apt_debian_release() -> String {
945 "bookworm".to_owned()
946}
947
948fn default_aur_doc_changelog() -> Option<String> {
949 Some("CHANGELOG.md".to_owned())
950}
951
952fn default_aur_license_file() -> Option<String> {
953 Some("LICENSE".to_owned())
954}
955
956fn default_aur_readme() -> Option<String> {
957 Some("README.md".to_owned())
958}
959
960#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
962#[serde(deny_unknown_fields)]
963pub struct AurConfig {
964 pub name: Option<String>,
966 pub description: Option<String>,
968 pub url: Option<String>,
970 pub license: Option<String>,
972 pub maintainer: Option<String>,
974 pub maintainer_gpg: Option<String>,
976 #[serde(default = "default_aur_arch")]
977 pub arch: String,
978 #[serde(default)]
980 pub depends: Vec<String>,
981 #[serde(default)]
983 pub makedepends: Vec<String>,
984 pub bin_glibc_min: Option<String>,
986 #[serde(default)]
988 pub binaries: Vec<String>,
989 #[serde(default)]
991 pub assets: Vec<AurAsset>,
992 #[serde(default = "default_aur_license_file")]
994 pub license_file: Option<String>,
995 #[serde(default = "default_aur_readme")]
997 pub readme: Option<String>,
998 #[serde(default = "default_aur_doc_changelog")]
1000 pub changelog: Option<String>,
1001 pub download_repo: String,
1003 #[serde(default = "default_aur_source_archive_pattern")]
1005 pub source_archive_pattern: String,
1006 #[serde(default = "default_aur_binary_archive_pattern")]
1008 pub binary_archive_pattern: String,
1009 pub git_url: Option<String>,
1011 #[serde(default)]
1013 pub flavors: AurFlavors,
1014 #[serde(default = "default_aur_ssh_remote")]
1016 pub ssh_remote: String,
1017 #[serde(default = "default_aur_ssh_key_secret")]
1019 pub ssh_key_secret: String,
1020 #[serde(default = "default_true")]
1022 pub stable_only: bool,
1023}
1024
1025#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
1026#[serde(deny_unknown_fields)]
1027pub struct AurAsset {
1028 pub source: String,
1030 pub dest: String,
1032 #[serde(default = "default_asset_mode")]
1034 pub mode: String,
1035}
1036
1037fn default_asset_mode() -> String {
1038 "644".to_owned()
1039}
1040
1041#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
1042#[serde(deny_unknown_fields)]
1043pub struct AurFlavors {
1044 #[serde(default = "default_true")]
1045 pub source: bool,
1046 #[serde(default = "default_true")]
1047 pub bin: bool,
1048 #[serde(default = "default_true")]
1049 pub git: bool,
1050}
1051
1052impl AurFlavors {
1053 pub fn any_enabled(&self) -> bool {
1054 self.source || self.bin || self.git
1055 }
1056}
1057
1058impl Default for AurFlavors {
1059 fn default() -> Self {
1060 Self {
1061 source: true,
1062 bin: true,
1063 git: true,
1064 }
1065 }
1066}
1067
1068#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
1070#[serde(deny_unknown_fields)]
1071pub struct CoprConfig {
1072 pub name: Option<String>,
1074 pub summary: Option<String>,
1076 pub description: Option<String>,
1078 pub license: Option<String>,
1080 pub url: Option<String>,
1082 pub download_repo: String,
1084 #[serde(default)]
1086 pub build_requires: Vec<String>,
1087 #[serde(default)]
1089 pub binaries: Vec<String>,
1090 #[serde(default = "default_copr_spec_path")]
1092 pub spec_path: Option<String>,
1093 pub project: Option<String>,
1095 pub testing_project: Option<String>,
1097 #[serde(default = "default_copr_login_secret")]
1098 pub login_secret: String,
1099 #[serde(default = "default_copr_username_secret")]
1100 pub username_secret: String,
1101 #[serde(default = "default_copr_token_secret")]
1102 pub token_secret: String,
1103 #[serde(default = "default_copr_nix_tool")]
1105 pub nix_tool: String,
1106}
1107
1108fn default_copr_login_secret() -> String {
1109 "copr_login".to_owned()
1110}
1111
1112fn default_copr_username_secret() -> String {
1113 "copr_username".to_owned()
1114}
1115
1116fn default_copr_token_secret() -> String {
1117 "copr_token".to_owned()
1118}
1119
1120fn default_copr_nix_tool() -> String {
1121 "nixpkgs#copr-cli".to_owned()
1122}
1123
1124#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
1126#[serde(deny_unknown_fields)]
1127pub struct AptConfig {
1128 pub repo_url: String,
1131 #[serde(default = "default_apt_branch")]
1133 pub branch: String,
1134 #[serde(default = "default_apt_distribution")]
1136 pub distribution: String,
1137 pub label: Option<String>,
1139 #[serde(default = "default_apt_architectures")]
1141 pub architectures: String,
1142 #[serde(default = "default_apt_components")]
1144 pub components: String,
1145 #[serde(default = "default_apt_debian_release")]
1150 pub debian_release: String,
1151 #[serde(default)]
1153 pub packages: Vec<String>,
1154 #[serde(default)]
1157 pub build_deps: Vec<String>,
1158 #[serde(default = "default_cargo_deb_version")]
1160 pub cargo_deb_version: String,
1161 #[serde(default = "default_apt_gpg_key_secret")]
1162 pub gpg_key_secret: String,
1163 #[serde(default = "default_apt_gpg_key_id_secret")]
1164 pub gpg_key_id_secret: String,
1165 #[serde(default = "default_apt_gpg_passphrase_secret")]
1166 pub gpg_passphrase_secret: String,
1167 #[serde(default = "default_apt_ssh_key_secret")]
1168 pub ssh_key_secret: String,
1169}
1170
1171fn default_apt_architectures() -> String {
1172 "amd64".to_owned()
1173}
1174
1175fn default_apt_components() -> String {
1176 "main".to_owned()
1177}
1178
1179fn default_apt_gpg_key_secret() -> String {
1180 "apt_repo_gpg_key".to_owned()
1181}
1182
1183fn default_apt_gpg_key_id_secret() -> String {
1184 "apt_repo_gpg_key_id".to_owned()
1185}
1186
1187fn default_apt_gpg_passphrase_secret() -> String {
1188 "apt_repo_gpg_passphrase".to_owned()
1189}
1190
1191fn default_apt_ssh_key_secret() -> String {
1192 "apt_repo_ssh_key".to_owned()
1193}
1194
1195fn default_cargo_deb_version() -> String {
1196 "2.5.0".to_owned()
1197}
1198
1199#[derive(Debug, Clone, PartialEq, Eq)]
1200pub struct ResolvedHomebrew {
1201 pub name: String,
1202 pub binaries: Vec<String>,
1203 pub tap_url: String,
1204 pub tap_token_secret: String,
1205 pub description: String,
1206 pub homepage: String,
1207 pub license: String,
1208 pub download_repo: String,
1209 pub archive_pattern: String,
1210 pub platforms: HomebrewPlatformsConfig,
1211}
1212
1213#[derive(Debug, Clone, Copy, Default)]
1214pub struct HomebrewOverrides<'a> {
1215 pub name: Option<&'a str>,
1216 pub binaries: Option<&'a [String]>,
1217 pub tap_url: Option<&'a str>,
1218 pub description: Option<&'a str>,
1219 pub homepage: Option<&'a str>,
1220 pub license: Option<&'a str>,
1221 pub download_repo: Option<&'a str>,
1222 pub archive_pattern: Option<&'a str>,
1223 pub disabled_platforms: &'a [String],
1224}
1225
1226#[derive(Debug, Clone, PartialEq, Eq)]
1227pub struct ResolvedChocolatey {
1228 pub name: String,
1229 pub id: String,
1230 pub title: String,
1231 pub authors: Option<String>,
1232 pub description: String,
1233 pub summary: Option<String>,
1234 pub project_url: String,
1235 pub license_url: Option<String>,
1236 pub icon_url: Option<String>,
1237 pub package_source_url: Option<String>,
1238 pub docs_url: Option<String>,
1239 pub bug_tracker_url: Option<String>,
1240 pub project_source_url: Option<String>,
1241 pub tags: Option<String>,
1242 pub release_notes_url: Option<String>,
1243 pub download_repo: String,
1244 pub archive_pattern: String,
1245 pub push: ChocolateyPushConfig,
1246 pub nix_tool: String,
1247 pub api_key_env: String,
1248 pub api_key_secret: String,
1249 pub api_key_from_runner: bool,
1250}
1251
1252#[derive(Debug, Clone, Copy, Default)]
1253pub struct ChocolateyOverrides<'a> {
1254 pub name: Option<&'a str>,
1255 pub id: Option<&'a str>,
1256 pub title: Option<&'a str>,
1257 pub authors: Option<&'a str>,
1258 pub description: Option<&'a str>,
1259 pub summary: Option<&'a str>,
1260 pub project_url: Option<&'a str>,
1261 pub license_url: Option<&'a str>,
1262 pub icon_url: Option<&'a str>,
1263 pub package_source_url: Option<&'a str>,
1264 pub docs_url: Option<&'a str>,
1265 pub bug_tracker_url: Option<&'a str>,
1266 pub project_source_url: Option<&'a str>,
1267 pub tags: Option<&'a str>,
1268 pub release_notes_url: Option<&'a str>,
1269 pub download_repo: Option<&'a str>,
1270 pub archive_pattern: Option<&'a str>,
1271 pub push_source: Option<&'a str>,
1272}
1273
1274#[derive(Debug, Clone, PartialEq, Eq)]
1275pub struct ResolvedScoop {
1276 pub name: String,
1277 pub bucket_url: String,
1278 pub bucket_token_secret: String,
1279 pub description: String,
1280 pub homepage: String,
1281 pub license: String,
1282 pub download_repo: String,
1283 pub archive_pattern: String,
1284 pub binaries: Vec<String>,
1285 pub architectures: ScoopArchSet,
1286}
1287
1288#[derive(Debug, Clone, Copy, Default)]
1289pub struct ScoopOverrides<'a> {
1290 pub name: Option<&'a str>,
1291 pub bucket_url: Option<&'a str>,
1292 pub description: Option<&'a str>,
1293 pub homepage: Option<&'a str>,
1294 pub license: Option<&'a str>,
1295 pub download_repo: Option<&'a str>,
1296 pub archive_pattern: Option<&'a str>,
1297 pub binaries: Option<&'a [String]>,
1298 pub disabled_architectures: &'a [String],
1299}
1300
1301#[derive(Debug, Clone, PartialEq, Eq)]
1302pub struct ResolvedAur {
1303 pub name: String,
1304 pub description: String,
1305 pub url: String,
1306 pub license: String,
1307 pub maintainer: Option<String>,
1308 pub maintainer_gpg: Option<String>,
1309 pub arch: String,
1310 pub depends: Vec<String>,
1311 pub makedepends: Vec<String>,
1312 pub bin_glibc_min: Option<String>,
1313 pub binaries: Vec<String>,
1314 pub assets: Vec<AurAsset>,
1315 pub license_file: Option<String>,
1316 pub readme: Option<String>,
1317 pub changelog: Option<String>,
1318 pub download_repo: String,
1319 pub repo: String,
1321 pub source_archive_pattern: String,
1322 pub binary_archive_pattern: String,
1323 pub git_url: String,
1324 pub flavors: AurFlavors,
1325 pub ssh_remote: String,
1326 pub ssh_key_secret: String,
1327 pub stable_only: bool,
1328}
1329
1330#[derive(Debug, Clone, Copy, Default)]
1331pub struct AurOverrides<'a> {
1332 pub name: Option<&'a str>,
1333 pub description: Option<&'a str>,
1334 pub url: Option<&'a str>,
1335 pub license: Option<&'a str>,
1336 pub download_repo: Option<&'a str>,
1337}
1338
1339#[derive(Debug, Clone, PartialEq, Eq)]
1340pub struct ResolvedCopr {
1341 pub name: String,
1342 pub summary: String,
1343 pub description: String,
1344 pub license: String,
1345 pub url: String,
1346 pub download_repo: String,
1347 pub repo: String,
1348 pub build_requires: Vec<String>,
1349 pub binaries: Vec<String>,
1350 pub spec_path: String,
1351 pub project: Option<String>,
1352 pub testing_project: Option<String>,
1353 pub login_secret: String,
1354 pub username_secret: String,
1355 pub token_secret: String,
1356 pub nix_tool: String,
1357}
1358
1359#[derive(Debug, Clone, Copy, Default)]
1360pub struct CoprOverrides<'a> {
1361 pub name: Option<&'a str>,
1362 pub summary: Option<&'a str>,
1363 pub description: Option<&'a str>,
1364 pub license: Option<&'a str>,
1365 pub url: Option<&'a str>,
1366 pub download_repo: Option<&'a str>,
1367 pub project: Option<&'a str>,
1368}
1369
1370#[derive(Debug, Clone, PartialEq, Eq)]
1371pub struct ResolvedApt {
1372 pub label: String,
1373 pub repo_url: String,
1374 pub branch: String,
1375 pub distribution: String,
1376 pub architectures: String,
1377 pub components: String,
1378 pub debian_release: String,
1379 pub packages: Vec<String>,
1380 pub build_deps: Vec<String>,
1381 pub cargo_deb_version: String,
1382 pub gpg_key_secret: String,
1383 pub gpg_key_id_secret: String,
1384 pub gpg_passphrase_secret: String,
1385 pub ssh_key_secret: String,
1386}
1387
1388#[derive(Debug, Clone, Copy, Default)]
1389pub struct AptOverrides<'a> {
1390 pub repo_url: Option<&'a str>,
1391 pub label: Option<&'a str>,
1392}
1393
1394impl ProjectConfig {
1395 pub fn load(workspace_root: &Path) -> Result<Self> {
1396 let sources = Self::load_sources(workspace_root)?;
1397 match sources.as_slice() {
1398 [] => Ok(Self::default()),
1399 [source] => {
1400 source.config.validate_common()?;
1401 Ok(source.config.clone())
1402 }
1403 _ => {
1404 let labels = sources
1405 .iter()
1406 .map(|source| source.label.as_str())
1407 .collect::<Vec<_>>()
1408 .join(", ");
1409 bail!(
1410 "multiple simit project config sources found ({labels}); keep exactly one of simit.toml, Cargo.toml [workspace.metadata.simit], Cargo.toml [package.metadata.simit], or flake outputs.simitConfig"
1411 )
1412 }
1413 }
1414 }
1415
1416 fn validate_common(&self) -> Result<()> {
1417 if self.ci.workspace && !self.ci.packages.is_empty() {
1418 bail!("simit project config: [ci].workspace cannot be true when [ci].packages is set");
1419 }
1420 validate_nonempty_strings("simit project config: [ci].packages", &self.ci.packages)?;
1421 validate_runner_label_opt("[ci].runner", self.ci.runner.as_deref())?;
1422 validate_runner_label_opt("[ci].windows_runner", self.ci.windows_runner.as_deref())?;
1423 validate_nonempty_strings(
1424 "simit project config: [flake.expected_outputs].packages",
1425 &self.flake.expected_outputs.packages,
1426 )?;
1427 validate_nonempty_strings(
1428 "simit project config: [flake.expected_outputs].apps",
1429 &self.flake.expected_outputs.apps,
1430 )?;
1431 validate_nonempty_strings(
1432 "simit project config: [flake.expected_outputs].dev_shells",
1433 &self.flake.expected_outputs.dev_shells,
1434 )?;
1435 validate_nonempty_strings(
1436 "simit project config: [flake.expected_outputs].checks",
1437 &self.flake.expected_outputs.checks,
1438 )?;
1439 validate_nonempty_strings(
1440 "simit project config: [flake.expected_outputs].top_level",
1441 &self.flake.expected_outputs.top_level,
1442 )?;
1443 validate_nonempty_strings(
1444 "simit project config: [ci].required_secrets",
1445 &self.ci.required_secrets,
1446 )?;
1447 validate_nonempty_strings(
1448 "simit project config: [ci].required_env",
1449 &self.ci.required_env,
1450 )?;
1451 validate_nonempty_strings(
1452 "simit project config: [ci].extra_setup",
1453 &self.ci.extra_setup,
1454 )?;
1455 if let Some(pages) = &self.ci.pages {
1456 validate_owner_repo("simit project config: [ci.pages].repo", &pages.repo)?;
1457 if let Some(canonical_domain) = &pages.canonical_domain {
1458 validate_nonempty_string(
1459 "simit project config: [ci.pages].canonical_domain",
1460 canonical_domain,
1461 )?;
1462 }
1463 validate_nonempty_string(
1464 "simit project config: [ci.pages].site_output",
1465 &pages.site_output,
1466 )?;
1467 validate_nonempty_string(
1468 "simit project config: [ci.pages].token_secret",
1469 &pages.token_secret,
1470 )?;
1471 validate_nonempty_string(
1472 "simit project config: [ci.pages].source_branch",
1473 &pages.source_branch,
1474 )?;
1475 validate_nonempty_string(
1476 "simit project config: [ci.pages].deploy_app",
1477 &pages.deploy_app,
1478 )?;
1479 }
1480 if let Some(vscode) = &self.vscode {
1481 validate_nonempty_string(
1482 "simit project config: [vscode].extension_dir",
1483 &vscode.extension_dir,
1484 )?;
1485 validate_runner_label_opt("[vscode].runner", vscode.runner.as_deref())?;
1486 validate_owner_repo(
1487 "simit project config: [vscode].codeberg_repo",
1488 &vscode.codeberg_repo,
1489 )?;
1490 validate_nonempty_string(
1491 "simit project config: [vscode].codeberg_api_base",
1492 &vscode.codeberg_api_base,
1493 )?;
1494 validate_nonempty_string(
1495 "simit project config: [vscode].codeberg_token_secret",
1496 &vscode.codeberg_token_secret,
1497 )?;
1498 validate_nonempty_string(
1499 "simit project config: [vscode].vsce_pat_file_env",
1500 &vscode.vsce_pat_file_env,
1501 )?;
1502 validate_nonempty_string(
1503 "simit project config: [vscode].ovsx_pat_file_env",
1504 &vscode.ovsx_pat_file_env,
1505 )?;
1506 validate_nonempty_string(
1507 "simit project config: [vscode].vsce_pat_secret",
1508 &vscode.vsce_pat_secret,
1509 )?;
1510 validate_nonempty_string(
1511 "simit project config: [vscode].ovsx_pat_secret",
1512 &vscode.ovsx_pat_secret,
1513 )?;
1514 validate_nonempty_string(
1515 "simit project config: [vscode].package_command",
1516 &vscode.package_command,
1517 )?;
1518 if let Some(cargo_package) = &vscode.cargo_package {
1519 validate_nonempty_string(
1520 "simit project config: [vscode].cargo_package",
1521 cargo_package,
1522 )?;
1523 }
1524 validate_nonempty_strings(
1525 "simit project config: [vscode].prepublish_commands",
1526 &vscode.prepublish_commands,
1527 )?;
1528 }
1529 for (key, value) in &self.ci.extra_env {
1530 if key.trim().is_empty() {
1531 bail!("simit project config: [ci].extra_env keys must not be empty");
1532 }
1533 if key.contains('\n') || value.contains('\n') {
1534 bail!(
1535 "simit project config: [ci].extra_env must not contain multiline keys or values"
1536 );
1537 }
1538 }
1539 Ok(())
1540 }
1541
1542 pub fn write_ci(workspace_root: &Path, ci: &CiConfig) -> Result<bool> {
1543 let sources = Self::load_sources(workspace_root)?;
1544 if sources.is_empty() && *ci == CiConfig::default() {
1545 return Ok(false);
1546 }
1547 match sources.as_slice() {
1548 [] => {}
1549 [source] if source.label == "simit.toml" => {}
1550 [source] => {
1551 bail!(
1552 "cannot persist [ci] into simit.toml while project config comes from {}; move project config into simit.toml first",
1553 source.label
1554 );
1555 }
1556 _ => {
1557 let labels = sources
1558 .iter()
1559 .map(|source| source.label.as_str())
1560 .collect::<Vec<_>>()
1561 .join(", ");
1562 bail!(
1563 "multiple simit project config sources found ({labels}); keep exactly one of simit.toml, Cargo.toml [workspace.metadata.simit], Cargo.toml [package.metadata.simit], or flake outputs.simitConfig"
1564 );
1565 }
1566 }
1567
1568 if sources.len() == 1 && sources[0].config.ci == *ci {
1569 return Ok(false);
1570 }
1571
1572 let path = workspace_root.join("simit.toml");
1573 let current_text = if path.exists() {
1574 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?
1575 } else {
1576 String::new()
1577 };
1578 let mut document = if current_text.trim().is_empty() {
1579 DocumentMut::new()
1580 } else {
1581 current_text
1582 .parse::<DocumentMut>()
1583 .with_context(|| format!("parsing {}", path.display()))?
1584 };
1585 let mut table = match document.remove("ci") {
1586 Some(Item::Table(table)) => table,
1587 Some(_) => bail!("parsing {}: [ci] must be a table", path.display()),
1588 None => Table::new(),
1589 };
1590 table.set_implicit(false);
1591 set_ci_table(&mut table, ci);
1592 document["ci"] = Item::Table(table);
1593
1594 std::fs::write(&path, document.to_string())
1595 .with_context(|| format!("writing {}", path.display()))?;
1596 Ok(true)
1597 }
1598
1599 pub fn can_persist_ci(workspace_root: &Path) -> Result<bool> {
1600 let sources = Self::load_sources(workspace_root)?;
1601 match sources.as_slice() {
1602 [] => Ok(true),
1603 [source] if source.label == "simit.toml" => Ok(true),
1604 [_] => Ok(false),
1605 _ => {
1606 let labels = sources
1607 .iter()
1608 .map(|source| source.label.as_str())
1609 .collect::<Vec<_>>()
1610 .join(", ");
1611 bail!(
1612 "multiple simit project config sources found ({labels}); keep exactly one of simit.toml, Cargo.toml [workspace.metadata.simit], Cargo.toml [package.metadata.simit], or flake outputs.simitConfig"
1613 );
1614 }
1615 }
1616 }
1617
1618 fn load_sources(workspace_root: &Path) -> Result<Vec<ProjectConfigSource>> {
1619 let mut sources = Vec::new();
1620
1621 if let Some(source) = load_simit_toml(workspace_root)? {
1622 sources.push(source);
1623 }
1624 sources.extend(load_cargo_metadata_config(workspace_root)?);
1625 if let Some(source) = load_flake_config(workspace_root)? {
1626 sources.push(source);
1627 }
1628
1629 Ok(sources
1630 .into_iter()
1631 .filter(|source| !source.config.is_empty())
1632 .collect())
1633 }
1634
1635 pub fn validate_homebrew(&self) -> Result<()> {
1641 let Some(homebrew) = &self.homebrew else {
1642 return Ok(());
1643 };
1644
1645 if homebrew.tap_url.is_empty() {
1646 bail!("simit project config: [homebrew].tap_url is required");
1647 }
1648 reject_basic_auth_url(
1649 "simit project config: [homebrew].tap_url",
1650 &homebrew.tap_url,
1651 )?;
1652 if homebrew.download_repo.is_empty() {
1653 bail!("simit project config: [homebrew].download_repo is required");
1654 }
1655 if let Some(desc) = &homebrew.description {
1656 if desc.chars().count() > 80 {
1657 bail!(
1658 "simit project config: [homebrew].description must be 80 characters or fewer"
1659 );
1660 }
1661 }
1662 if let Some(home) = &homebrew.homepage {
1663 if !home.starts_with("https://") {
1664 bail!("simit project config: [homebrew].homepage must start with https://");
1665 }
1666 }
1667 if !homebrew.platforms.any_enabled() {
1668 bail!("simit project config: [homebrew].platforms has all platforms disabled");
1669 }
1670
1671 Ok(())
1672 }
1673
1674 pub fn validate_chocolatey(&self) -> Result<()> {
1676 let Some(chocolatey) = &self.chocolatey else {
1677 return Ok(());
1678 };
1679
1680 if chocolatey.download_repo.is_empty() {
1681 bail!("simit project config: [chocolatey].download_repo is required");
1682 }
1683 if let Some(desc) = &chocolatey.description {
1684 if desc.chars().count() > 4000 {
1685 bail!(
1686 "simit project config: [chocolatey].description must be 4000 characters or fewer"
1687 );
1688 }
1689 }
1690 if let Some(tags) = &chocolatey.tags {
1691 if tags.chars().count() > 4000 {
1692 bail!("simit project config: [chocolatey].tags must be 4000 characters or fewer");
1693 }
1694 }
1695 reject_basic_auth_url(
1696 "simit project config: [chocolatey].push.source",
1697 &chocolatey.push.source,
1698 )?;
1699
1700 Ok(())
1701 }
1702
1703 pub fn validate_scoop(&self) -> Result<()> {
1705 let Some(scoop) = &self.scoop else {
1706 return Ok(());
1707 };
1708
1709 if scoop.bucket_url.is_empty() {
1710 bail!("simit project config: [scoop].bucket_url is required");
1711 }
1712 reject_basic_auth_url(
1713 "simit project config: [scoop].bucket_url",
1714 &scoop.bucket_url,
1715 )?;
1716 if scoop.download_repo.is_empty() {
1717 bail!("simit project config: [scoop].download_repo is required");
1718 }
1719 if !scoop.architectures.any_enabled() {
1720 bail!("simit project config: [scoop].architectures has all architectures disabled");
1721 }
1722
1723 Ok(())
1724 }
1725
1726 pub fn resolve_homebrew(
1731 &self,
1732 overrides: HomebrewOverrides<'_>,
1733 package: &crate::cargo::Package,
1734 ) -> Result<ResolvedHomebrew> {
1735 self.validate_homebrew()?;
1736
1737 let cfg = self.homebrew.as_ref();
1738 let name = merge(
1739 overrides.name.map(str::to_owned),
1740 cfg.and_then(|homebrew| homebrew.name.clone()),
1741 Some(package.name.clone()),
1742 "name",
1743 )?;
1744 let tap_url = merge(
1745 overrides.tap_url.map(str::to_owned),
1746 cfg.map(|homebrew| homebrew.tap_url.clone()),
1747 None,
1748 "tap_url",
1749 )?;
1750 reject_basic_auth_url("homebrew.tap_url", &tap_url)?;
1751 let description = merge(
1752 overrides.description.map(str::to_owned),
1753 cfg.and_then(|homebrew| homebrew.description.clone()),
1754 package.description.clone(),
1755 "description",
1756 )?;
1757 let homepage = merge(
1758 overrides.homepage.map(str::to_owned),
1759 cfg.and_then(|homebrew| homebrew.homepage.clone()),
1760 package.homepage.clone(),
1761 "homepage",
1762 )?;
1763 let license = merge(
1764 overrides.license.map(str::to_owned),
1765 cfg.and_then(|homebrew| homebrew.license.clone()),
1766 package.license.clone(),
1767 "license",
1768 )?;
1769 let download_repo = merge(
1770 overrides.download_repo.map(str::to_owned),
1771 cfg.map(|homebrew| homebrew.download_repo.clone()),
1772 None,
1773 "download_repo",
1774 )?;
1775 let archive_pattern = overrides
1776 .archive_pattern
1777 .map(str::to_owned)
1778 .or_else(|| cfg.map(|homebrew| homebrew.archive_pattern.clone()))
1779 .unwrap_or_else(default_archive_pattern);
1780 let binaries = resolve_binaries(overrides.binaries, cfg, &name);
1781 let mut platforms = cfg
1782 .map(|homebrew| homebrew.platforms.clone())
1783 .unwrap_or_default();
1784 apply_disabled_platforms(&mut platforms, overrides.disabled_platforms)?;
1785
1786 if !platforms.any_enabled() {
1787 bail!("homebrew.platforms has all platforms disabled");
1788 }
1789
1790 Ok(ResolvedHomebrew {
1791 name,
1792 binaries,
1793 tap_url,
1794 tap_token_secret: cfg.map_or_else(default_homebrew_tap_token_secret, |homebrew| {
1795 homebrew.tap_token_secret.clone()
1796 }),
1797 description,
1798 homepage,
1799 license,
1800 download_repo,
1801 archive_pattern,
1802 platforms,
1803 })
1804 }
1805
1806 pub fn resolve_chocolatey(
1808 &self,
1809 overrides: ChocolateyOverrides<'_>,
1810 package: &crate::cargo::Package,
1811 ) -> Result<ResolvedChocolatey> {
1812 self.validate_chocolatey()?;
1813
1814 let cfg = self.chocolatey.as_ref();
1815 let name = merge_packager(
1816 overrides.name.map(str::to_owned),
1817 cfg.and_then(|chocolatey| chocolatey.name.clone()),
1818 Some(package.name.clone()),
1819 missing_chocolatey_message("name"),
1820 )?;
1821 let id = overrides
1822 .id
1823 .map(str::to_owned)
1824 .or_else(|| cfg.and_then(|chocolatey| chocolatey.id.clone()))
1825 .unwrap_or_else(|| name.clone());
1826 let title = overrides
1827 .title
1828 .map(str::to_owned)
1829 .or_else(|| cfg.and_then(|chocolatey| chocolatey.title.clone()))
1830 .unwrap_or_else(|| name.clone());
1831 let authors = overrides
1832 .authors
1833 .map(str::to_owned)
1834 .or_else(|| cfg.and_then(|chocolatey| chocolatey.authors.clone()));
1835 let authors =
1836 authors.or_else(|| (!package.authors.is_empty()).then(|| package.authors.join(", ")));
1837 let download_repo = merge_packager(
1838 overrides.download_repo.map(str::to_owned),
1839 cfg.map(|chocolatey| chocolatey.download_repo.clone()),
1840 None,
1841 missing_chocolatey_message("download_repo"),
1842 )?;
1843 let description = merge_packager(
1844 overrides.description.map(str::to_owned),
1845 cfg.and_then(|chocolatey| chocolatey.description.clone()),
1846 package.description.clone(),
1847 missing_chocolatey_message("description"),
1848 )?;
1849 if description.chars().count() > 4000 {
1850 bail!("chocolatey.description must be 4000 characters or fewer");
1851 }
1852 let summary = overrides
1853 .summary
1854 .map(str::to_owned)
1855 .or_else(|| cfg.and_then(|chocolatey| chocolatey.summary.clone()));
1856 let project_url = merge_packager(
1857 overrides.project_url.map(str::to_owned),
1858 cfg.and_then(|chocolatey| chocolatey.project_url.clone()),
1859 package.homepage.clone(),
1860 missing_chocolatey_message("project_url"),
1861 )?;
1862 let license_url = overrides
1863 .license_url
1864 .map(str::to_owned)
1865 .or_else(|| cfg.and_then(|chocolatey| chocolatey.license_url.clone()));
1866 let icon_url = overrides
1867 .icon_url
1868 .map(str::to_owned)
1869 .or_else(|| cfg.and_then(|chocolatey| chocolatey.icon_url.clone()));
1870 let package_source_url = overrides
1871 .package_source_url
1872 .map(str::to_owned)
1873 .or_else(|| cfg.and_then(|chocolatey| chocolatey.package_source_url.clone()));
1874 let docs_url = overrides
1875 .docs_url
1876 .map(str::to_owned)
1877 .or_else(|| cfg.and_then(|chocolatey| chocolatey.docs_url.clone()));
1878 let bug_tracker_url = overrides
1879 .bug_tracker_url
1880 .map(str::to_owned)
1881 .or_else(|| cfg.and_then(|chocolatey| chocolatey.bug_tracker_url.clone()));
1882 let project_source_url = overrides
1883 .project_source_url
1884 .map(str::to_owned)
1885 .or_else(|| cfg.and_then(|chocolatey| chocolatey.project_source_url.clone()));
1886 let tags = overrides
1887 .tags
1888 .map(str::to_owned)
1889 .or_else(|| cfg.and_then(|chocolatey| chocolatey.tags.clone()));
1890 if let Some(tags) = &tags {
1891 if tags.chars().count() > 4000 {
1892 bail!("chocolatey.tags must be 4000 characters or fewer");
1893 }
1894 }
1895 let release_notes_url = overrides
1896 .release_notes_url
1897 .map(str::to_owned)
1898 .or_else(|| cfg.and_then(|chocolatey| chocolatey.release_notes_url.clone()));
1899 let archive_pattern = overrides
1900 .archive_pattern
1901 .map(str::to_owned)
1902 .or_else(|| cfg.map(|chocolatey| chocolatey.archive_pattern.clone()))
1903 .unwrap_or_else(default_windows_archive_pattern);
1904 let push_source = overrides
1905 .push_source
1906 .map(str::to_owned)
1907 .or_else(|| cfg.map(|chocolatey| chocolatey.push.source.clone()))
1908 .unwrap_or_else(default_chocolatey_push_source);
1909 reject_basic_auth_url("chocolatey.push.source", &push_source)?;
1910
1911 Ok(ResolvedChocolatey {
1912 name,
1913 id,
1914 title,
1915 authors,
1916 description,
1917 summary,
1918 project_url,
1919 license_url,
1920 icon_url,
1921 package_source_url,
1922 docs_url,
1923 bug_tracker_url,
1924 project_source_url,
1925 tags,
1926 release_notes_url,
1927 download_repo,
1928 archive_pattern,
1929 push: ChocolateyPushConfig {
1930 source: push_source,
1931 },
1932 nix_tool: cfg.map_or_else(default_chocolatey_nix_tool, |chocolatey| {
1933 chocolatey.nix_tool.clone()
1934 }),
1935 api_key_env: cfg.map_or_else(default_chocolatey_api_key_env, |chocolatey| {
1936 chocolatey.api_key_env.clone()
1937 }),
1938 api_key_secret: cfg.map_or_else(default_chocolatey_api_key_secret, |chocolatey| {
1939 chocolatey.api_key_secret.clone()
1940 }),
1941 api_key_from_runner: cfg.is_some_and(|chocolatey| chocolatey.api_key_from_runner),
1942 })
1943 }
1944
1945 pub fn resolve_scoop(
1947 &self,
1948 overrides: ScoopOverrides<'_>,
1949 package: &crate::cargo::Package,
1950 ) -> Result<ResolvedScoop> {
1951 self.validate_scoop()?;
1952
1953 let cfg = self.scoop.as_ref();
1954 let name = merge_packager(
1955 overrides.name.map(str::to_owned),
1956 cfg.and_then(|scoop| scoop.name.clone()),
1957 Some(package.name.clone()),
1958 missing_scoop_message("name"),
1959 )?;
1960 let bucket_url = merge_packager(
1961 overrides.bucket_url.map(str::to_owned),
1962 cfg.map(|scoop| scoop.bucket_url.clone()),
1963 None,
1964 missing_scoop_message("bucket_url"),
1965 )?;
1966 reject_basic_auth_url("scoop.bucket_url", &bucket_url)?;
1967 let description = merge_packager(
1968 overrides.description.map(str::to_owned),
1969 cfg.and_then(|scoop| scoop.description.clone()),
1970 package.description.clone(),
1971 missing_scoop_message("description"),
1972 )?;
1973 let homepage = merge_packager(
1974 overrides.homepage.map(str::to_owned),
1975 cfg.and_then(|scoop| scoop.homepage.clone()),
1976 package.homepage.clone(),
1977 missing_scoop_message("homepage"),
1978 )?;
1979 let license = merge_packager(
1980 overrides.license.map(str::to_owned),
1981 cfg.and_then(|scoop| scoop.license.clone()),
1982 package.license.clone(),
1983 missing_scoop_message("license"),
1984 )?;
1985 let download_repo = merge_packager(
1986 overrides.download_repo.map(str::to_owned),
1987 cfg.map(|scoop| scoop.download_repo.clone()),
1988 None,
1989 missing_scoop_message("download_repo"),
1990 )?;
1991 let archive_pattern = overrides
1992 .archive_pattern
1993 .map(str::to_owned)
1994 .or_else(|| cfg.map(|scoop| scoop.archive_pattern.clone()))
1995 .unwrap_or_else(default_windows_archive_pattern);
1996 let binaries = resolve_scoop_binaries(overrides.binaries, cfg, &name);
1997 let mut architectures = cfg
1998 .map(|scoop| scoop.architectures.clone())
1999 .unwrap_or_default();
2000 apply_disabled_architectures(&mut architectures, overrides.disabled_architectures)?;
2001
2002 if !architectures.any_enabled() {
2003 bail!("scoop.architectures has all architectures disabled");
2004 }
2005
2006 Ok(ResolvedScoop {
2007 name,
2008 bucket_url,
2009 bucket_token_secret: cfg.map_or_else(default_scoop_bucket_token_secret, |scoop| {
2010 scoop.bucket_token_secret.clone()
2011 }),
2012 description,
2013 homepage,
2014 license,
2015 download_repo,
2016 archive_pattern,
2017 binaries,
2018 architectures,
2019 })
2020 }
2021
2022 pub fn validate_aur(&self) -> Result<()> {
2024 let Some(aur) = &self.aur else {
2025 return Ok(());
2026 };
2027 validate_owner_repo(
2028 "simit project config: [aur].download_repo",
2029 &aur.download_repo,
2030 )?;
2031 if !aur.flavors.any_enabled() {
2032 bail!("simit project config: [aur].flavors has all flavors disabled");
2033 }
2034 Ok(())
2035 }
2036
2037 pub fn validate_copr(&self) -> Result<()> {
2039 let Some(copr) = &self.copr else {
2040 return Ok(());
2041 };
2042 validate_owner_repo(
2043 "simit project config: [copr].download_repo",
2044 &copr.download_repo,
2045 )?;
2046 Ok(())
2047 }
2048
2049 pub fn validate_apt(&self) -> Result<()> {
2051 let Some(apt) = &self.apt else {
2052 return Ok(());
2053 };
2054 if apt.repo_url.is_empty() {
2055 bail!("simit project config: [apt].repo_url is required");
2056 }
2057 reject_password_url("simit project config: [apt].repo_url", &apt.repo_url)?;
2060 Ok(())
2061 }
2062
2063 pub fn resolve_aur(
2065 &self,
2066 overrides: AurOverrides<'_>,
2067 package: &crate::cargo::Package,
2068 ) -> Result<ResolvedAur> {
2069 self.validate_aur()?;
2070 let cfg = self.aur.as_ref();
2071 let name = merge_packager(
2072 overrides.name.map(str::to_owned),
2073 cfg.and_then(|aur| aur.name.clone()),
2074 Some(package.name.clone()),
2075 missing_packager_message("aur", "name", "--aur-name", "package.name"),
2076 )?;
2077 let description = merge_packager(
2078 overrides.description.map(str::to_owned),
2079 cfg.and_then(|aur| aur.description.clone()),
2080 package.description.clone(),
2081 missing_packager_message(
2082 "aur",
2083 "description",
2084 "--aur-description",
2085 "package.description",
2086 ),
2087 )?;
2088 let license = merge_packager(
2089 overrides.license.map(str::to_owned),
2090 cfg.and_then(|aur| aur.license.clone()),
2091 package.license.clone(),
2092 missing_packager_message("aur", "license", "--aur-license", "package.license"),
2093 )?;
2094 let download_repo = merge_packager(
2095 overrides.download_repo.map(str::to_owned),
2096 cfg.map(|aur| aur.download_repo.clone()),
2097 None,
2098 missing_packager_message("aur", "download_repo", "--aur-download-repo", ""),
2099 )?;
2100 validate_owner_repo("aur.download_repo", &download_repo)?;
2101 let repo = repo_basename(&download_repo);
2102 let url = overrides
2103 .url
2104 .map(str::to_owned)
2105 .or_else(|| cfg.and_then(|aur| aur.url.clone()))
2106 .unwrap_or_else(|| format!("https://codeberg.org/{download_repo}"));
2107 let binaries = resolve_named_binaries(cfg.map(|aur| aur.binaries.as_slice()), &name);
2108 let git_url = cfg
2109 .and_then(|aur| aur.git_url.clone())
2110 .unwrap_or_else(|| format!("https://codeberg.org/{download_repo}.git"));
2111
2112 Ok(ResolvedAur {
2113 name,
2114 description,
2115 url,
2116 license,
2117 maintainer: cfg.and_then(|aur| aur.maintainer.clone()),
2118 maintainer_gpg: cfg.and_then(|aur| aur.maintainer_gpg.clone()),
2119 arch: cfg.map_or_else(default_aur_arch, |aur| aur.arch.clone()),
2120 depends: cfg.map(|aur| aur.depends.clone()).unwrap_or_default(),
2121 makedepends: cfg.map(|aur| aur.makedepends.clone()).unwrap_or_default(),
2122 bin_glibc_min: cfg.and_then(|aur| aur.bin_glibc_min.clone()),
2123 binaries,
2124 assets: cfg.map(|aur| aur.assets.clone()).unwrap_or_default(),
2125 license_file: cfg.map_or_else(default_aur_license_file, |aur| aur.license_file.clone()),
2126 readme: cfg.map_or_else(default_aur_readme, |aur| aur.readme.clone()),
2127 changelog: cfg.map_or_else(default_aur_doc_changelog, |aur| aur.changelog.clone()),
2128 download_repo,
2129 repo,
2130 source_archive_pattern: cfg.map_or_else(default_aur_source_archive_pattern, |aur| {
2131 aur.source_archive_pattern.clone()
2132 }),
2133 binary_archive_pattern: cfg.map_or_else(default_aur_binary_archive_pattern, |aur| {
2134 aur.binary_archive_pattern.clone()
2135 }),
2136 git_url,
2137 flavors: cfg.map(|aur| aur.flavors).unwrap_or_default(),
2138 ssh_remote: cfg.map_or_else(default_aur_ssh_remote, |aur| aur.ssh_remote.clone()),
2139 ssh_key_secret: cfg
2140 .map_or_else(default_aur_ssh_key_secret, |aur| aur.ssh_key_secret.clone()),
2141 stable_only: cfg.is_none_or(|aur| aur.stable_only),
2142 })
2143 }
2144
2145 pub fn resolve_copr(
2147 &self,
2148 overrides: CoprOverrides<'_>,
2149 package: &crate::cargo::Package,
2150 ) -> Result<ResolvedCopr> {
2151 self.validate_copr()?;
2152 let cfg = self.copr.as_ref();
2153 let name = merge_packager(
2154 overrides.name.map(str::to_owned),
2155 cfg.and_then(|copr| copr.name.clone()),
2156 Some(package.name.clone()),
2157 missing_packager_message("copr", "name", "--copr-name", "package.name"),
2158 )?;
2159 let summary = merge_packager(
2160 overrides.summary.map(str::to_owned),
2161 cfg.and_then(|copr| copr.summary.clone()),
2162 package.description.clone(),
2163 missing_packager_message("copr", "summary", "--copr-summary", "package.description"),
2164 )?;
2165 let description = overrides
2166 .description
2167 .map(str::to_owned)
2168 .or_else(|| cfg.and_then(|copr| copr.description.clone()))
2169 .unwrap_or_else(|| summary.clone());
2170 let license = merge_packager(
2171 overrides.license.map(str::to_owned),
2172 cfg.and_then(|copr| copr.license.clone()),
2173 package.license.clone(),
2174 missing_packager_message("copr", "license", "--copr-license", "package.license"),
2175 )?;
2176 let download_repo = merge_packager(
2177 overrides.download_repo.map(str::to_owned),
2178 cfg.map(|copr| copr.download_repo.clone()),
2179 None,
2180 missing_packager_message("copr", "download_repo", "--copr-download-repo", ""),
2181 )?;
2182 validate_owner_repo("copr.download_repo", &download_repo)?;
2183 let repo = repo_basename(&download_repo);
2184 let url = overrides
2185 .url
2186 .map(str::to_owned)
2187 .or_else(|| cfg.and_then(|copr| copr.url.clone()))
2188 .unwrap_or_else(|| format!("https://codeberg.org/{download_repo}"));
2189 let binaries = resolve_named_binaries(cfg.map(|copr| copr.binaries.as_slice()), &name);
2190 let spec_path = cfg
2191 .and_then(|copr| copr.spec_path.clone())
2192 .unwrap_or_else(|| format!("{name}.spec"));
2193 let project = overrides
2194 .project
2195 .map(str::to_owned)
2196 .or_else(|| cfg.and_then(|copr| copr.project.clone()));
2197 let testing_project = cfg
2198 .and_then(|copr| copr.testing_project.clone())
2199 .or_else(|| {
2200 project
2201 .as_deref()
2202 .map(|project| format!("{project}-testing"))
2203 });
2204
2205 Ok(ResolvedCopr {
2206 name,
2207 summary,
2208 description,
2209 license,
2210 url,
2211 download_repo,
2212 repo,
2213 build_requires: cfg
2214 .map(|copr| copr.build_requires.clone())
2215 .unwrap_or_default(),
2216 binaries,
2217 spec_path,
2218 project,
2219 testing_project,
2220 login_secret: cfg
2221 .map_or_else(default_copr_login_secret, |copr| copr.login_secret.clone()),
2222 username_secret: cfg.map_or_else(default_copr_username_secret, |copr| {
2223 copr.username_secret.clone()
2224 }),
2225 token_secret: cfg
2226 .map_or_else(default_copr_token_secret, |copr| copr.token_secret.clone()),
2227 nix_tool: cfg.map_or_else(default_copr_nix_tool, |copr| copr.nix_tool.clone()),
2228 })
2229 }
2230
2231 pub fn resolve_apt(
2233 &self,
2234 overrides: AptOverrides<'_>,
2235 package: &crate::cargo::Package,
2236 ) -> Result<ResolvedApt> {
2237 self.validate_apt()?;
2238 let cfg = self.apt.as_ref();
2239 let repo_url = merge_packager(
2240 overrides.repo_url.map(str::to_owned),
2241 cfg.map(|apt| apt.repo_url.clone()),
2242 None,
2243 missing_packager_message("apt", "repo_url", "--apt-repo-url", ""),
2244 )?;
2245 reject_password_url("apt.repo_url", &repo_url)?;
2246 let label = overrides
2247 .label
2248 .map(str::to_owned)
2249 .or_else(|| cfg.and_then(|apt| apt.label.clone()))
2250 .unwrap_or_else(|| package.name.clone());
2251 let packages = cfg
2252 .map(|apt| apt.packages.clone())
2253 .filter(|packages| !packages.is_empty())
2254 .unwrap_or_else(|| vec![package.name.clone()]);
2255
2256 Ok(ResolvedApt {
2257 label,
2258 repo_url,
2259 branch: cfg.map_or_else(default_apt_branch, |apt| apt.branch.clone()),
2260 distribution: cfg.map_or_else(default_apt_distribution, |apt| apt.distribution.clone()),
2261 architectures: cfg
2262 .map_or_else(default_apt_architectures, |apt| apt.architectures.clone()),
2263 components: cfg.map_or_else(default_apt_components, |apt| apt.components.clone()),
2264 debian_release: cfg
2265 .map_or_else(default_apt_debian_release, |apt| apt.debian_release.clone()),
2266 packages,
2267 build_deps: cfg.map(|apt| apt.build_deps.clone()).unwrap_or_default(),
2268 cargo_deb_version: cfg.map_or_else(default_cargo_deb_version, |apt| {
2269 apt.cargo_deb_version.clone()
2270 }),
2271 gpg_key_secret: cfg
2272 .map_or_else(default_apt_gpg_key_secret, |apt| apt.gpg_key_secret.clone()),
2273 gpg_key_id_secret: cfg.map_or_else(default_apt_gpg_key_id_secret, |apt| {
2274 apt.gpg_key_id_secret.clone()
2275 }),
2276 gpg_passphrase_secret: cfg.map_or_else(default_apt_gpg_passphrase_secret, |apt| {
2277 apt.gpg_passphrase_secret.clone()
2278 }),
2279 ssh_key_secret: cfg
2280 .map_or_else(default_apt_ssh_key_secret, |apt| apt.ssh_key_secret.clone()),
2281 })
2282 }
2283
2284 pub fn resolve_codeberg_release(&self) -> Result<Option<ResolvedCodebergRelease>> {
2286 let Some(codeberg) = &self.release.codeberg else {
2287 return Ok(None);
2288 };
2289 validate_owner_repo(
2290 "simit project config: [release.codeberg].repo",
2291 &codeberg.repo,
2292 )?;
2293 Ok(Some(ResolvedCodebergRelease {
2294 repo: codeberg.repo.clone(),
2295 api_base: codeberg.api_base.clone(),
2296 token_secret: codeberg.token_secret.clone(),
2297 target_branch: codeberg.target_branch.clone(),
2298 body_from_changelog: codeberg.body_from_changelog,
2299 }))
2300 }
2301
2302 pub fn resolve_codeberg_pages(&self) -> Result<Option<ResolvedCodebergPages>> {
2304 let Some(pages) = &self.ci.pages else {
2305 return Ok(None);
2306 };
2307 validate_owner_repo("simit project config: [ci.pages].repo", &pages.repo)?;
2308 let owner = pages
2309 .repo
2310 .split_once('/')
2311 .expect("validated owner/repo")
2312 .0
2313 .to_owned();
2314 Ok(Some(ResolvedCodebergPages {
2315 repo: pages.repo.clone(),
2316 owner,
2317 canonical_domain: pages.canonical_domain.clone(),
2318 site_output: pages.site_output.clone(),
2319 token_secret: pages.token_secret.clone(),
2320 source_branch: pages.source_branch.clone(),
2321 deploy_app: pages.deploy_app.clone(),
2322 }))
2323 }
2324
2325 pub fn resolve_vscode(&self) -> Result<Option<ResolvedVscode>> {
2327 let Some(vscode) = &self.vscode else {
2328 return Ok(None);
2329 };
2330 validate_owner_repo(
2331 "simit project config: [vscode].codeberg_repo",
2332 &vscode.codeberg_repo,
2333 )?;
2334 let codeberg_owner = vscode
2335 .codeberg_repo
2336 .split_once('/')
2337 .expect("validated owner/repo")
2338 .0
2339 .to_owned();
2340 Ok(Some(ResolvedVscode {
2341 extension_dir: vscode.extension_dir.clone(),
2342 runner: vscode.runner.clone(),
2343 codeberg_repo: vscode.codeberg_repo.clone(),
2344 codeberg_owner,
2345 codeberg_api_base: vscode.codeberg_api_base.clone(),
2346 codeberg_token_secret: vscode.codeberg_token_secret.clone(),
2347 pat_source: vscode.pat_source,
2348 vsce_pat_file_env: vscode.vsce_pat_file_env.clone(),
2349 ovsx_pat_file_env: vscode.ovsx_pat_file_env.clone(),
2350 vsce_pat_secret: vscode.vsce_pat_secret.clone(),
2351 ovsx_pat_secret: vscode.ovsx_pat_secret.clone(),
2352 package_command: vscode.package_command.clone(),
2353 cargo_package: vscode.cargo_package.clone(),
2354 prepublish_commands: vscode.prepublish_commands.clone(),
2355 }))
2356 }
2357
2358 fn is_empty(&self) -> bool {
2359 self == &Self::default()
2360 }
2361}
2362
2363fn reject_password_url(name: &str, value: &str) -> Result<()> {
2366 let Some(scheme_end) = value.find("://") else {
2367 return Ok(());
2368 };
2369 let authority_start = scheme_end + 3;
2370 let authority_end = value[authority_start..]
2371 .find(['/', '?', '#'])
2372 .map_or(value.len(), |offset| authority_start + offset);
2373 let authority = &value[authority_start..authority_end];
2374 if let Some((userinfo, _host)) = authority.rsplit_once('@') {
2375 if userinfo.contains(':') {
2376 bail!("{name} must not include an embedded password");
2377 }
2378 }
2379 Ok(())
2380}
2381
2382fn validate_owner_repo(name: &str, value: &str) -> Result<()> {
2383 if value.split('/').count() != 2 || value.split('/').any(str::is_empty) {
2384 bail!("{name} must be OWNER/REPO");
2385 }
2386 Ok(())
2387}
2388
2389fn repo_basename(download_repo: &str) -> String {
2390 download_repo
2391 .rsplit('/')
2392 .next()
2393 .unwrap_or(download_repo)
2394 .to_owned()
2395}
2396
2397fn resolve_named_binaries(cfg: Option<&[String]>, name: &str) -> Vec<String> {
2398 cfg.filter(|binaries| !binaries.is_empty())
2399 .map(<[String]>::to_vec)
2400 .unwrap_or_else(|| vec![name.to_owned()])
2401}
2402
2403fn missing_packager_message(
2404 channel: &str,
2405 field: &str,
2406 flag: &str,
2407 cargo_fallback: &str,
2408) -> String {
2409 let config_hint = config_hint();
2410 if cargo_fallback.is_empty() {
2411 format!(
2412 "{channel}.{field} not set: provide it via {flag} or {config_hint} [{channel}].{field}"
2413 )
2414 } else {
2415 format!(
2416 "{channel}.{field} not set: provide it via {flag}, {config_hint} [{channel}].{field}, or Cargo.toml {cargo_fallback}"
2417 )
2418 }
2419}
2420
2421fn validate_nonempty_strings(name: &str, values: &[String]) -> Result<()> {
2422 if values.iter().any(|value| value.trim().is_empty()) {
2423 bail!("{name} must not contain empty values");
2424 }
2425 Ok(())
2426}
2427
2428fn validate_nonempty_string(name: &str, value: &str) -> Result<()> {
2429 if value.trim().is_empty() {
2430 bail!("{name} must not be empty");
2431 }
2432 Ok(())
2433}
2434
2435fn validate_runner_label_opt(name: &str, value: Option<&str>) -> Result<()> {
2436 if let Some(value) = value {
2437 validate_runner_label(value).map_err(|err| anyhow!("{name}: {err}"))?;
2438 }
2439 Ok(())
2440}
2441
2442fn set_ci_table(table: &mut Table, ci: &CiConfig) {
2443 set_optional_string(table, "runtime", ci.runtime.map(runtime_name));
2444 set_optional_string(table, "runner", ci.runner.as_deref());
2445 set_optional_string(table, "windows_runner", ci.windows_runner.as_deref());
2446 set_bool(table, "workspace", ci.workspace);
2447 set_string_array(table, "packages", &ci.packages);
2448 set_bool(table, "with_nextest", ci.with_nextest);
2449 set_bool(table, "with_msrv", ci.with_msrv);
2450 set_bool(table, "with_audit", ci.with_audit);
2451 set_bool(table, "with_deny", ci.with_deny);
2452 set_bool(table, "with_docs", ci.with_docs);
2453 set_bool(table, "with_artifacts", ci.with_artifacts);
2454 set_bool(table, "with_pypi_publish", ci.with_pypi_publish);
2455 set_bool(table, "publish_crates", ci.publish_crates);
2456 set_string_array(table, "extra_setup", &ci.extra_setup);
2457 set_string_map(table, "extra_env", &ci.extra_env);
2458 set_string_map(table, "step_runners", &ci.step_runners);
2459 set_string_array(table, "required_secrets", &ci.required_secrets);
2460 set_string_array(table, "required_env", &ci.required_env);
2461 set_bool(table, "om_ci", ci.om_ci);
2462 set_bool(table, "om_ci_augment", ci.om_ci_augment);
2463 set_optional_string(table, "omnix_ref", ci.omnix_ref.as_deref());
2464 set_optional_pages_table(table, ci.pages.as_ref());
2465}
2466
2467fn set_optional_pages_table(table: &mut Table, pages: Option<&CodebergPagesConfig>) {
2468 let Some(pages) = pages else {
2469 table.remove("pages");
2470 return;
2471 };
2472
2473 let mut pages_table = Table::new();
2474 pages_table.set_implicit(false);
2475 pages_table["repo"] = value(pages.repo.as_str());
2476 if let Some(canonical_domain) = &pages.canonical_domain {
2477 pages_table["canonical_domain"] = value(canonical_domain.as_str());
2478 }
2479 if pages.site_output != default_pages_site_output() {
2480 pages_table["site_output"] = value(pages.site_output.as_str());
2481 }
2482 if pages.token_secret != default_codeberg_token_secret() {
2483 pages_table["token_secret"] = value(pages.token_secret.as_str());
2484 }
2485 if pages.source_branch != default_pages_source_branch() {
2486 pages_table["source_branch"] = value(pages.source_branch.as_str());
2487 }
2488 if pages.deploy_app != default_pages_deploy_app() {
2489 pages_table["deploy_app"] = value(pages.deploy_app.as_str());
2490 }
2491 table["pages"] = Item::Table(pages_table);
2492}
2493
2494fn set_optional_string(table: &mut Table, key: &str, value_text: Option<&str>) {
2495 match value_text {
2496 Some(value_text) => table[key] = value(value_text),
2497 None => {
2498 table.remove(key);
2499 }
2500 }
2501}
2502
2503fn set_bool(table: &mut Table, key: &str, enabled: bool) {
2504 if enabled {
2505 table[key] = value(enabled);
2506 } else {
2507 table.remove(key);
2508 }
2509}
2510
2511fn set_string_array(table: &mut Table, key: &str, values: &[String]) {
2512 if values.is_empty() {
2513 table.remove(key);
2514 return;
2515 }
2516 let mut array = Array::new();
2517 for value_text in values {
2518 array.push(value_text.as_str());
2519 }
2520 table[key] = Item::Value(Value::Array(array));
2521}
2522
2523fn set_string_map(table: &mut Table, key: &str, values: &BTreeMap<String, String>) {
2524 if values.is_empty() {
2525 table.remove(key);
2526 return;
2527 }
2528 let mut inline = InlineTable::default();
2529 for (map_key, map_value) in values {
2530 inline.insert(map_key, Value::from(map_value.as_str()));
2531 }
2532 table[key] = Item::Value(Value::InlineTable(inline));
2533}
2534
2535fn runtime_name(runtime: Runtime) -> &'static str {
2536 match runtime {
2537 Runtime::Cargo => "cargo",
2538 Runtime::Nix => "nix",
2539 }
2540}
2541
2542#[derive(Debug)]
2543struct ProjectConfigSource {
2544 label: String,
2545 config: ProjectConfig,
2546}
2547
2548#[derive(Debug, Default, Deserialize)]
2549struct CargoManifestConfig {
2550 #[serde(default)]
2551 package: Option<CargoPackageConfig>,
2552 #[serde(default)]
2553 workspace: Option<CargoWorkspaceConfig>,
2554}
2555
2556#[derive(Debug, Default, Deserialize)]
2557struct CargoPackageConfig {
2558 #[serde(default)]
2559 metadata: CargoMetadataConfig,
2560}
2561
2562#[derive(Debug, Default, Deserialize)]
2563struct CargoWorkspaceConfig {
2564 #[serde(default)]
2565 metadata: CargoMetadataConfig,
2566}
2567
2568#[derive(Debug, Default, Deserialize)]
2569struct CargoMetadataConfig {
2570 #[serde(default)]
2571 simit: Option<ProjectConfig>,
2572}
2573
2574fn load_simit_toml(workspace_root: &Path) -> Result<Option<ProjectConfigSource>> {
2575 let path = workspace_root.join("simit.toml");
2576 if !path.exists() {
2577 return Ok(None);
2578 }
2579
2580 let text =
2581 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2582 let config =
2583 toml_edit::de::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
2584 Ok(Some(ProjectConfigSource {
2585 label: "simit.toml".to_owned(),
2586 config,
2587 }))
2588}
2589
2590fn load_cargo_metadata_config(workspace_root: &Path) -> Result<Vec<ProjectConfigSource>> {
2591 let path = workspace_root.join("Cargo.toml");
2592 if !path.exists() {
2593 return Ok(Vec::new());
2594 }
2595
2596 let text =
2597 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2598 let manifest: CargoManifestConfig =
2599 toml_edit::de::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
2600
2601 let mut sources = Vec::new();
2602 if let Some(config) = manifest
2603 .workspace
2604 .and_then(|workspace| workspace.metadata.simit)
2605 {
2606 sources.push(ProjectConfigSource {
2607 label: "Cargo.toml [workspace.metadata.simit]".to_owned(),
2608 config,
2609 });
2610 }
2611 if let Some(config) = manifest.package.and_then(|package| package.metadata.simit) {
2612 sources.push(ProjectConfigSource {
2613 label: "Cargo.toml [package.metadata.simit]".to_owned(),
2614 config,
2615 });
2616 }
2617
2618 Ok(sources)
2619}
2620
2621fn load_flake_config(workspace_root: &Path) -> Result<Option<ProjectConfigSource>> {
2622 let path = workspace_root.join("flake.nix");
2623 if !path.exists() || !flake_declares_simit_config(&path)? {
2624 return Ok(None);
2625 }
2626
2627 let command_text = "nix --extra-experimental-features 'nix-command flakes' eval --json --no-write-lock-file .#simitConfig";
2628 let output = Command::new("nix")
2629 .current_dir(workspace_root)
2630 .args([
2631 "--extra-experimental-features",
2632 "nix-command flakes",
2633 "eval",
2634 "--json",
2635 "--no-write-lock-file",
2636 ".#simitConfig",
2637 ])
2638 .output()
2639 .with_context(|| {
2640 format!(
2641 "running `{command_text}` to read flake outputs.simitConfig; ensure Nix is installed and flakes are enabled"
2642 )
2643 })?;
2644
2645 if !output.status.success() {
2646 bail!(
2647 "flake outputs.simitConfig could not be evaluated; fix the flake and rerun `{command_text}` to validate it:\n{}",
2648 String::from_utf8_lossy(&output.stderr).trim()
2649 );
2650 }
2651
2652 let config =
2653 serde_json::from_slice(&output.stdout).context("parsing flake outputs.simitConfig JSON")?;
2654 Ok(Some(ProjectConfigSource {
2655 label: "flake outputs.simitConfig".to_owned(),
2656 config,
2657 }))
2658}
2659
2660fn flake_declares_simit_config(path: &Path) -> Result<bool> {
2661 let text =
2662 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2663 Ok(text
2664 .lines()
2665 .map(str::trim_start)
2666 .any(|line| !line.starts_with('#') && line.contains("simitConfig") && line.contains('=')))
2667}
2668
2669fn reject_basic_auth_url(name: &str, value: &str) -> Result<()> {
2670 let Some(scheme_end) = value.find("://") else {
2671 return Ok(());
2672 };
2673 let authority_start = scheme_end + 3;
2674 let authority_end = value[authority_start..]
2675 .find(['/', '?', '#'])
2676 .map_or(value.len(), |offset| authority_start + offset);
2677 if value[authority_start..authority_end].contains('@') {
2678 bail!("{name} must not include embedded credentials");
2679 }
2680 Ok(())
2681}
2682
2683fn resolve_binaries(
2684 cli: Option<&[String]>,
2685 cfg: Option<&HomebrewConfig>,
2686 name: &str,
2687) -> Vec<String> {
2688 cli.filter(|values| !values.is_empty())
2689 .map(|values| values.to_vec())
2690 .or_else(|| {
2691 cfg.and_then(|homebrew| {
2692 (!homebrew.binaries.is_empty()).then(|| homebrew.binaries.clone())
2693 })
2694 })
2695 .unwrap_or_else(|| vec![name.to_owned()])
2696}
2697
2698fn resolve_scoop_binaries(
2699 cli: Option<&[String]>,
2700 cfg: Option<&ScoopConfig>,
2701 name: &str,
2702) -> Vec<String> {
2703 cli.filter(|values| !values.is_empty())
2704 .map(|values| values.to_vec())
2705 .or_else(|| scoop_config_binaries(cfg))
2706 .unwrap_or_else(|| vec![name.to_owned()])
2707}
2708
2709fn scoop_config_binaries(cfg: Option<&ScoopConfig>) -> Option<Vec<String>> {
2710 cfg.and_then(|scoop| (!scoop.binaries.is_empty()).then(|| scoop.binaries.clone()))
2711}
2712
2713fn apply_disabled_platforms(
2714 platforms: &mut HomebrewPlatformsConfig,
2715 disabled: &[String],
2716) -> Result<()> {
2717 for key in disabled {
2718 match key.as_str() {
2719 "darwin_arm" => platforms.darwin_arm = false,
2720 "darwin_intel" => platforms.darwin_intel = false,
2721 "linux_arm" => platforms.linux_arm = false,
2722 "linux_intel" => platforms.linux_intel = false,
2723 _ => bail!(
2724 "homebrew disabled platform must be one of darwin_arm, darwin_intel, linux_arm, linux_intel"
2725 ),
2726 }
2727 }
2728
2729 Ok(())
2730}
2731
2732fn apply_disabled_architectures(
2733 architectures: &mut ScoopArchSet,
2734 disabled: &[String],
2735) -> Result<()> {
2736 for key in disabled {
2737 match key.as_str() {
2738 "x64" => architectures.x64 = false,
2739 "arm64" => architectures.arm64 = false,
2740 _ => bail!("scoop disabled architecture must be one of x64, arm64"),
2741 }
2742 }
2743
2744 Ok(())
2745}
2746
2747fn merge<T>(cli: Option<T>, cfg: Option<T>, metadata: Option<T>, name: &str) -> Result<T> {
2748 cli.or(cfg)
2749 .or(metadata)
2750 .ok_or_else(|| anyhow!("{}", missing_message(name)))
2751}
2752
2753fn merge_packager<T>(
2754 cli: Option<T>,
2755 cfg: Option<T>,
2756 metadata: Option<T>,
2757 missing: String,
2758) -> Result<T> {
2759 cli.or(cfg)
2760 .or(metadata)
2761 .ok_or_else(|| anyhow!("{}", missing))
2762}
2763
2764fn missing_message(name: &str) -> String {
2765 let config_hint = config_hint();
2766 match name {
2767 "name" => {
2768 format!(
2769 "homebrew.name not set: provide it via --homebrew-name, {config_hint} [homebrew].name, or Cargo.toml package.name"
2770 )
2771 }
2772 "tap_url" => {
2773 format!(
2774 "homebrew.tap_url not set: provide it via --homebrew-tap or {config_hint} [homebrew].tap_url"
2775 )
2776 }
2777 "description" => {
2778 format!(
2779 "homebrew.description not set: provide it via --homebrew-description, {config_hint} [homebrew].description, or Cargo.toml package.description"
2780 )
2781 }
2782 "homepage" => {
2783 format!(
2784 "homebrew.homepage not set: provide it via --homebrew-homepage, {config_hint} [homebrew].homepage, or Cargo.toml package.homepage"
2785 )
2786 }
2787 "license" => {
2788 format!(
2789 "homebrew.license not set: provide it via --homebrew-license, {config_hint} [homebrew].license, or Cargo.toml package.license"
2790 )
2791 }
2792 "download_repo" => {
2793 format!(
2794 "homebrew.download_repo not set: provide it via --homebrew-download-repo or {config_hint} [homebrew].download_repo"
2795 )
2796 }
2797 _ => "homebrew setting not set".to_owned(),
2798 }
2799}
2800
2801fn missing_chocolatey_message(name: &str) -> String {
2802 let config_hint = config_hint();
2803 match name {
2804 "name" => {
2805 format!(
2806 "chocolatey.name not set: provide it via --choco-name, {config_hint} [chocolatey].name, or Cargo.toml package.name"
2807 )
2808 }
2809 "description" => {
2810 format!(
2811 "chocolatey.description not set: provide it via --choco-description, {config_hint} [chocolatey].description, or Cargo.toml package.description"
2812 )
2813 }
2814 "project_url" => {
2815 format!(
2816 "chocolatey.project_url not set: provide it via --choco-project-url, {config_hint} [chocolatey].project_url, or Cargo.toml package.homepage"
2817 )
2818 }
2819 "download_repo" => {
2820 format!(
2821 "chocolatey.download_repo not set: provide it via --choco-download-repo or {config_hint} [chocolatey].download_repo"
2822 )
2823 }
2824 _ => "chocolatey setting not set".to_owned(),
2825 }
2826}
2827
2828fn missing_scoop_message(name: &str) -> String {
2829 let config_hint = config_hint();
2830 match name {
2831 "name" => {
2832 format!(
2833 "scoop.name not set: provide it via --scoop-name, {config_hint} [scoop].name, or Cargo.toml package.name"
2834 )
2835 }
2836 "bucket_url" => {
2837 format!(
2838 "scoop.bucket_url not set: provide it via --scoop-bucket or {config_hint} [scoop].bucket_url"
2839 )
2840 }
2841 "description" => {
2842 format!(
2843 "scoop.description not set: provide it via --scoop-description, {config_hint} [scoop].description, or Cargo.toml package.description"
2844 )
2845 }
2846 "homepage" => {
2847 format!(
2848 "scoop.homepage not set: provide it via --scoop-homepage, {config_hint} [scoop].homepage, or Cargo.toml package.homepage"
2849 )
2850 }
2851 "license" => {
2852 format!(
2853 "scoop.license not set: provide it via --scoop-license, {config_hint} [scoop].license, or Cargo.toml package.license"
2854 )
2855 }
2856 "download_repo" => {
2857 format!(
2858 "scoop.download_repo not set: provide it via --scoop-download-repo or {config_hint} [scoop].download_repo"
2859 )
2860 }
2861 _ => "scoop setting not set".to_owned(),
2862 }
2863}
2864
2865fn config_hint() -> &'static str {
2866 "simit.toml, Cargo.toml [workspace.metadata.simit]/[package.metadata.simit], or flake outputs.simitConfig"
2867}