Skip to main content

simit/
config.rs

1//! Project-level simit configuration.
2//!
3//! Packager setting precedence is intentionally centralized here. For each
4//! setting, the value comes from, in order:
5//!
6//! 1. The CLI flag, when provided.
7//! 2. The corresponding simit project config field, when a project config
8//!    source exists and the field is present.
9//! 3. The Cargo package metadata fallback, where one exists.
10//! 4. An error.
11//!
12//! Settings that have no Cargo fallback, such as `tap_url` and
13//! `download_repo`, error if neither a CLI flag nor config value provides them.
14
15use 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/// `[vscode]` — publish a VS Code/Open VSX extension from Forgejo CI.
56#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
57#[serde(deny_unknown_fields)]
58pub struct VscodeConfig {
59    /// Extension package directory, relative to the repository root.
60    #[serde(default = "default_vscode_extension_dir")]
61    pub extension_dir: String,
62    /// Optional runner override. Defaults to the normal release/CI runner.
63    #[serde(default)]
64    pub runner: Option<String>,
65    /// Codeberg `<owner>/<repo>` receiving release assets.
66    pub codeberg_repo: String,
67    /// REST API base URL.
68    #[serde(default = "default_codeberg_api_base")]
69    pub codeberg_api_base: String,
70    /// Actions secret exposed as `CODEBERG_TOKEN`.
71    #[serde(default = "default_codeberg_token_secret")]
72    pub codeberg_token_secret: String,
73    /// Source used for Marketplace/Open VSX PATs.
74    #[serde(default)]
75    pub pat_source: VscodePatSource,
76    /// Runner file-env variable pointing at the VS Code Marketplace PAT file.
77    #[serde(default = "default_vscode_vsce_pat_file_env")]
78    pub vsce_pat_file_env: String,
79    /// Runner file-env variable pointing at the Open VSX PAT file.
80    #[serde(default = "default_vscode_ovsx_pat_file_env")]
81    pub ovsx_pat_file_env: String,
82    /// Actions secret containing the VS Code Marketplace PAT.
83    #[serde(default = "default_vscode_vsce_pat_secret")]
84    pub vsce_pat_secret: String,
85    /// Actions secret containing the Open VSX PAT.
86    #[serde(default = "default_vscode_ovsx_pat_secret")]
87    pub ovsx_pat_secret: String,
88    /// Command that packages release assets. `$VERSION` contains the tag version.
89    #[serde(default = "default_vscode_package_command")]
90    pub package_command: String,
91    /// Optional Cargo package whose version must match the release tag.
92    #[serde(default)]
93    pub cargo_package: Option<String>,
94    /// Project-specific checks/build commands run before packaging/publishing.
95    #[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/// `[flatpak]` — open a Flathub manifest-update PR on stable releases.
133#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
134#[serde(deny_unknown_fields)]
135pub struct FlatpakConfig {
136    /// GitHub `flathub/<app-id>` repository.
137    pub repo: String,
138    /// Flatpak application id.
139    pub app_id: String,
140    /// Manifest files copied into the Flathub PR.
141    #[serde(default)]
142    pub manifest_files: Vec<String>,
143    /// Base branch the PR targets.
144    #[serde(default = "default_flatpak_base_branch")]
145    pub base_branch: String,
146    /// CI secret holding the GitHub PAT.
147    #[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/// `[winget]` — submit a winget-pkgs manifest PR on stable releases.
160#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
161#[serde(deny_unknown_fields)]
162pub struct WingetConfig {
163    /// winget `PackageIdentifier`, e.g. `Caniko.Modde`.
164    pub package_id: String,
165    /// Codeberg/GitHub `<owner>/<repo>` for the installer download URL.
166    pub download_repo: String,
167    /// Windows zip file name with a literal `{version}` placeholder, used as the
168    /// installer URL, e.g. `modde-{version}-x86_64-windows.zip`.
169    pub zip_archive: String,
170    /// CI secret holding the GitHub PAT.
171    #[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/// `[ci.pages]` — Codeberg Pages publication through a repository-local deploy app.
321#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
322#[serde(deny_unknown_fields)]
323pub struct CodebergPagesConfig {
324    /// Codeberg `<owner>/<repo>` receiving the generated `pages` branch.
325    pub repo: String,
326    /// Canonical hostname expected in the generated `.domains` file.
327    #[serde(default)]
328    pub canonical_domain: Option<String>,
329    /// Nix installable that builds the generated static site.
330    #[serde(default = "default_pages_site_output")]
331    pub site_output: String,
332    /// CI secret exposed as `CODEBERG_TOKEN` for authenticated branch pushes.
333    #[serde(default = "default_codeberg_token_secret")]
334    pub token_secret: String,
335    /// Source branch that triggers the Pages deployment workflow.
336    #[serde(default = "default_pages_source_branch")]
337    pub source_branch: String,
338    /// Nix app that builds and pushes the generated site.
339    #[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    /// Codeberg/Forgejo release publication via the REST API.
353    #[serde(default)]
354    pub codeberg: Option<CodebergReleaseConfig>,
355    /// Build matrix + signing knobs for the comprehensive release workflow.
356    #[serde(default)]
357    pub artifacts: ArtifactsConfig,
358    /// Optional Attic (Nix binary cache) push.
359    #[serde(default)]
360    pub attic: Option<AtticConfig>,
361    /// Optional Mastodon/Matrix stable-release announcements.
362    #[serde(default)]
363    pub announce: Option<AnnounceConfig>,
364    /// Optional Windows Authenticode signing of release `.exe`s.
365    #[serde(default)]
366    pub windows_signing: Option<WindowsSigningConfig>,
367}
368
369/// `[release.publish]` — downstream publisher failure policy.
370#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
371#[serde(deny_unknown_fields)]
372pub struct ReleasePublishConfig {
373    /// How generated CI decides whether downstream publishers are hard-required.
374    #[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    /// Preserve the historical behavior: credentials marked required by the
382    /// generated contract fail preflight; optional publishers may skip.
383    #[default]
384    Declared,
385    /// Probe public package destinations. Missing credentials/artifacts are
386    /// soft until the channel has evidence of a previous successful publish,
387    /// then become hard failures.
388    ActivatedRemote,
389}
390
391/// `[release.announce]` — post a stable-release note to Mastodon and/or Matrix.
392/// Presence enables the step; each backend is skipped when its secrets are unset.
393#[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/// `[release.windows_signing]` — Authenticode-sign and package Windows `.exe`s.
425#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
426#[serde(deny_unknown_fields)]
427pub struct WindowsSigningConfig {
428    /// Directory holding the built `.exe`s, relative to the workspace.
429    #[serde(default = "default_windows_dir")]
430    pub dir: String,
431    /// Binary basenames (without `.exe`).
432    pub binaries: Vec<String>,
433    /// osslsigncode `-n` program name.
434    pub sign_name: String,
435    /// osslsigncode `-i` info URL.
436    pub sign_url: String,
437    /// RFC3161 timestamp URL.
438    #[serde(default = "default_timestamp_url")]
439    pub timestamp_url: String,
440    /// tar.gz archive name with `{version}` placeholder.
441    pub tar_archive: String,
442    /// zip archive name with `{version}` placeholder.
443    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/// `[release.artifacts]` — release workflow build + signing configuration.
469#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
470#[serde(deny_unknown_fields)]
471pub struct ArtifactsConfig {
472    /// Runner label for the release job; defaults to the CI runner or `atlas`.
473    pub runner: Option<String>,
474    /// `nix.conf` substituters added in the install-nix step.
475    #[serde(default)]
476    pub substituters: Vec<String>,
477    /// `nix.conf` trusted-public-keys added in the install-nix step.
478    #[serde(default)]
479    pub trusted_public_keys: Vec<String>,
480    /// Flake attribute whose `.version` must equal the tag
481    /// (`nix eval --raw .#<attr>.version`). Skipped when unset.
482    pub version_attr: Option<String>,
483    /// Optional supply-chain gate command run before building.
484    pub supply_chain_command: Option<String>,
485    /// Build-step body lines, emitted verbatim (project-specific).
486    #[serde(default)]
487    pub build_commands: Vec<String>,
488    /// SBOM / supply-chain report command lines, emitted verbatim before the
489    /// build step when non-empty.
490    #[serde(default)]
491    pub sbom_commands: Vec<String>,
492    /// `sha256sum` arguments (globs relative to `release/`) for SHA256SUMS.txt.
493    #[serde(default)]
494    pub checksum_globs: Vec<String>,
495    /// Committed minisign public key used to verify the signed checksums.
496    #[serde(default = "default_minisign_pub")]
497    pub minisign_pub: String,
498    /// Skip artifact signing (minisign + cosign) when false.
499    #[serde(default = "default_true")]
500    pub sign: bool,
501}
502
503fn default_minisign_pub() -> String {
504    "keys/minisign.pub".to_owned()
505}
506
507/// `[release.attic]` — push built Nix closures to an Attic cache.
508#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
509#[serde(deny_unknown_fields)]
510pub struct AtticConfig {
511    /// Cache name, e.g. `canix`.
512    pub cache: String,
513    /// Attic server URL.
514    pub url: String,
515    /// Env var holding the directory of per-project token files.
516    #[serde(default = "default_attic_token_dir_env")]
517    pub token_dir_env: String,
518    /// Token file name within `$<token_dir_env>`.
519    pub token_name: String,
520    /// `--out-link` result paths pushed to the cache.
521    #[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/// `[release.codeberg]` — create the Codeberg/Forgejo release and upload assets.
530#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
531#[serde(deny_unknown_fields)]
532pub struct CodebergReleaseConfig {
533    /// `<owner>/<repo>` whose release receives the uploaded assets.
534    pub repo: String,
535    /// REST API base URL.
536    #[serde(default = "default_codeberg_api_base")]
537    pub api_base: String,
538    /// CI secret holding the API token.
539    #[serde(default = "default_codeberg_token_secret")]
540    pub token_secret: String,
541    /// `target_commitish` the release tag points at.
542    #[serde(default = "default_release_target_branch")]
543    pub target_branch: String,
544    /// Use `CHANGELOG.md` as the release body.
545    #[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    /// OpenPGP fingerprint used to sign and verify release tags.
595    pub key: Option<String>,
596
597    /// Public keyring committed for CI-side `git verify-tag`.
598    #[serde(default = "default_release_trust_root")]
599    pub trust_root: String,
600
601    /// Whether release publishing must have a usable signing trust root.
602    #[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    /// Command run after artifact signing/provenance and before publishing.
624    pub command: Option<String>,
625}
626
627#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
628#[serde(deny_unknown_fields)]
629pub struct HomebrewConfig {
630    /// Formula name; if omitted, derived from Cargo package name.
631    pub name: Option<String>,
632
633    /// Binaries to install. If omitted, defaults to `[name]`.
634    #[serde(default)]
635    pub binaries: Vec<String>,
636
637    /// Tap repo URL. Required when `[homebrew]` is present.
638    pub tap_url: String,
639
640    /// Actions secret sourced into `HOMEBREW_TAP_TOKEN`.
641    #[serde(default = "default_homebrew_tap_token_secret")]
642    pub tap_token_secret: String,
643
644    /// Description for the formula. If omitted, derived from Cargo metadata.
645    pub description: Option<String>,
646
647    /// Homepage URL. If omitted, derived from Cargo metadata.
648    pub homepage: Option<String>,
649
650    /// SPDX license identifier. If omitted, derived from Cargo metadata.
651    pub license: Option<String>,
652
653    /// Codeberg/GitHub `<owner>/<repo>` for release downloads.
654    pub download_repo: String,
655
656    /// Archive filename pattern with `{name}`, `{version}`, `{arch}`, `{os}`.
657    #[serde(default = "default_archive_pattern")]
658    pub archive_pattern: String,
659
660    /// Per-platform enable flags. Each defaults to true.
661    #[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    /// Chocolatey package display name; if omitted, derived from Cargo package name.
694    pub name: Option<String>,
695
696    /// Nuspec package identifier; if omitted, derived from the resolved name.
697    pub id: Option<String>,
698
699    /// Nuspec title; if omitted, derived from the resolved name.
700    pub title: Option<String>,
701
702    /// Nuspec authors. If omitted, derived from Cargo package authors when available.
703    pub authors: Option<String>,
704
705    /// Nuspec description. If omitted, derived from Cargo metadata.
706    pub description: Option<String>,
707
708    /// Nuspec summary.
709    pub summary: Option<String>,
710
711    /// Project URL. If omitted, derived from Cargo package homepage.
712    pub project_url: Option<String>,
713
714    /// License URL.
715    pub license_url: Option<String>,
716
717    /// Package icon URL.
718    pub icon_url: Option<String>,
719
720    /// URL for the package source.
721    pub package_source_url: Option<String>,
722
723    /// Documentation URL.
724    pub docs_url: Option<String>,
725
726    /// Bug tracker URL.
727    pub bug_tracker_url: Option<String>,
728
729    /// Project source URL.
730    pub project_source_url: Option<String>,
731
732    /// Space-separated Chocolatey tags.
733    pub tags: Option<String>,
734
735    /// Release notes URL.
736    pub release_notes_url: Option<String>,
737
738    /// Codeberg/GitHub `<owner>/<repo>` for release downloads.
739    pub download_repo: String,
740
741    /// Archive filename pattern with `{name}`, `{version}`, `{arch}`.
742    #[serde(default = "default_windows_archive_pattern")]
743    pub archive_pattern: String,
744
745    /// Chocolatey push settings.
746    #[serde(default)]
747    pub push: ChocolateyPushConfig,
748
749    /// `nix shell` packages providing `choco` (and `simit`) for the generated
750    /// release step. Lets a project point at a fork that already ships the
751    /// chocolatey package — e.g.
752    /// `github:caniko/nixpkgs/add-chocolatey-scoop#chocolatey github:caniko/simit`
753    /// — until it lands in upstream nixpkgs. Defaults to `nixpkgs#chocolatey`.
754    #[serde(default = "default_chocolatey_nix_tool")]
755    pub nix_tool: String,
756
757    /// Env var the publish step reads the push API key from (passed to
758    /// `--api-key-env`). Defaults to `CHOCOLATEY_API_KEY`.
759    #[serde(default = "default_chocolatey_api_key_env")]
760    pub api_key_env: String,
761
762    /// Actions secret sourced into `api_key_env`. Defaults to
763    /// `chocolatey_api_key`. Ignored when `api_key_from_runner` is true.
764    #[serde(default = "default_chocolatey_api_key_secret")]
765    pub api_key_secret: String,
766
767    /// When true, do not source the key from an Actions secret — assume the
768    /// forge runner already provides `api_key_env` in the job environment (e.g.
769    /// a Forgejo runner credential exposed as a container env var).
770    #[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    /// Scoop manifest name; if omitted, derived from Cargo package name.
809    pub name: Option<String>,
810
811    /// Bucket repo URL. Required when `[scoop]` is present.
812    pub bucket_url: String,
813
814    /// Actions secret sourced into `SCOOP_BUCKET_TOKEN`.
815    #[serde(default = "default_scoop_bucket_token_secret")]
816    pub bucket_token_secret: String,
817
818    /// Manifest description. If omitted, derived from Cargo metadata.
819    pub description: Option<String>,
820
821    /// Manifest homepage. If omitted, derived from Cargo metadata.
822    pub homepage: Option<String>,
823
824    /// Manifest license. If omitted, derived from Cargo metadata.
825    pub license: Option<String>,
826
827    /// Codeberg/GitHub `<owner>/<repo>` for release downloads.
828    pub download_repo: String,
829
830    /// Archive filename pattern with `{name}`, `{version}`, `{arch}`.
831    #[serde(default = "default_windows_archive_pattern")]
832    pub archive_pattern: String,
833
834    /// Binaries to expose. If omitted, defaults to `[name]`.
835    #[serde(default)]
836    pub binaries: Vec<String>,
837
838    /// Per-architecture enable flags. Each defaults to true.
839    #[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/// `[aur]` — Arch User Repository packaging across source/binary/VCS flavors.
961#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
962#[serde(deny_unknown_fields)]
963pub struct AurConfig {
964    /// pkgbase / source pkgname; if omitted, derived from the Cargo package name.
965    pub name: Option<String>,
966    /// `pkgdesc`; if omitted, derived from Cargo metadata.
967    pub description: Option<String>,
968    /// Project `url`; if omitted, derived from Cargo package homepage.
969    pub url: Option<String>,
970    /// SPDX `license`; if omitted, derived from Cargo metadata.
971    pub license: Option<String>,
972    /// Maintainer line, e.g. `Name <email>`. Emitted as a `# Maintainer:` comment.
973    pub maintainer: Option<String>,
974    /// Maintainer OpenPGP fingerprint, emitted as a `# Maintainer GPG key:` comment.
975    pub maintainer_gpg: Option<String>,
976    #[serde(default = "default_aur_arch")]
977    pub arch: String,
978    /// Runtime `depends`.
979    #[serde(default)]
980    pub depends: Vec<String>,
981    /// Build-time `makedepends` for the source/VCS flavors (e.g. cargo, rust, cmake).
982    #[serde(default)]
983    pub makedepends: Vec<String>,
984    /// Minimum glibc for the `-bin` flavor; when set, `glibc` becomes `glibc>=<min>`.
985    pub bin_glibc_min: Option<String>,
986    /// Binaries installed into `/usr/bin`. If omitted, defaults to `[name]`.
987    #[serde(default)]
988    pub binaries: Vec<String>,
989    /// Extra non-binary install assets (desktop/icon/metainfo, ...).
990    #[serde(default)]
991    pub assets: Vec<AurAsset>,
992    /// `LICENSE` file installed into `/usr/share/licenses/<pkg>/`.
993    #[serde(default = "default_aur_license_file")]
994    pub license_file: Option<String>,
995    /// README installed into `/usr/share/doc/<pkg>/`.
996    #[serde(default = "default_aur_readme")]
997    pub readme: Option<String>,
998    /// CHANGELOG installed into `/usr/share/doc/<pkg>/`.
999    #[serde(default = "default_aur_doc_changelog")]
1000    pub changelog: Option<String>,
1001    /// Codeberg/GitHub `<owner>/<repo>` used for release-download source URLs.
1002    pub download_repo: String,
1003    /// Source tarball pattern with `{repo}`, `{version}`.
1004    #[serde(default = "default_aur_source_archive_pattern")]
1005    pub source_archive_pattern: String,
1006    /// Prebuilt-binary archive pattern with `{name}`, `{version}`.
1007    #[serde(default = "default_aur_binary_archive_pattern")]
1008    pub binary_archive_pattern: String,
1009    /// `git+` source URL for the `-git` flavor; defaults to `https://<host>/<repo>.git`.
1010    pub git_url: Option<String>,
1011    /// Which flavors to emit. Each defaults to true.
1012    #[serde(default)]
1013    pub flavors: AurFlavors,
1014    /// SSH remote base for publishing, e.g. `ssh://aur@aur.archlinux.org`.
1015    #[serde(default = "default_aur_ssh_remote")]
1016    pub ssh_remote: String,
1017    /// CI secret holding the AUR SSH private key.
1018    #[serde(default = "default_aur_ssh_key_secret")]
1019    pub ssh_key_secret: String,
1020    /// Skip AUR publishing for prerelease versions.
1021    #[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    /// Source path relative to the extracted tree.
1029    pub source: String,
1030    /// Install destination under `$pkgdir`.
1031    pub dest: String,
1032    /// Install mode; defaults to `644`.
1033    #[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/// `[copr]` — Fedora COPR packaging (RPM spec + `.copr/Makefile` SRPM build).
1069#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
1070#[serde(deny_unknown_fields)]
1071pub struct CoprConfig {
1072    /// `%global crate` / `Name`; if omitted, derived from the Cargo package name.
1073    pub name: Option<String>,
1074    /// `Summary`; if omitted, derived from Cargo metadata.
1075    pub summary: Option<String>,
1076    /// Longer `%description` prose; if omitted, falls back to the summary.
1077    pub description: Option<String>,
1078    /// SPDX `License`; if omitted, derived from Cargo metadata.
1079    pub license: Option<String>,
1080    /// Project `URL`; if omitted, derived from Cargo package homepage.
1081    pub url: Option<String>,
1082    /// Codeberg/GitHub `<owner>/<repo>` used for the source-archive URL.
1083    pub download_repo: String,
1084    /// `BuildRequires` entries.
1085    #[serde(default)]
1086    pub build_requires: Vec<String>,
1087    /// Binaries installed by `%install` into `%{_bindir}`. Defaults to `[name]`.
1088    #[serde(default)]
1089    pub binaries: Vec<String>,
1090    /// Spec path relative to the workspace root; defaults to `<name>.spec`.
1091    #[serde(default = "default_copr_spec_path")]
1092    pub spec_path: Option<String>,
1093    /// COPR project for stable releases, e.g. `owner/project`.
1094    pub project: Option<String>,
1095    /// COPR project for prereleases; defaults to `<project>-testing`.
1096    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    /// Nix installable that provides `copr-cli` for publish steps.
1104    #[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/// `[apt]` — Debian packaging published via reprepro to a Codeberg Pages repo.
1125#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
1126#[serde(deny_unknown_fields)]
1127pub struct AptConfig {
1128    /// Git remote of the apt repository, e.g.
1129    /// `ssh://git@codeberg.org/<owner>/<name>-apt.git`.
1130    pub repo_url: String,
1131    /// Branch served by Codeberg Pages.
1132    #[serde(default = "default_apt_branch")]
1133    pub branch: String,
1134    /// reprepro `Codename`/distribution, e.g. `stable`.
1135    #[serde(default = "default_apt_distribution")]
1136    pub distribution: String,
1137    /// Human label for the `Origin`/`Label`; defaults to the package name.
1138    pub label: Option<String>,
1139    /// reprepro architectures line; defaults to `amd64`.
1140    #[serde(default = "default_apt_architectures")]
1141    pub architectures: String,
1142    /// reprepro components line; defaults to `main`.
1143    #[serde(default = "default_apt_components")]
1144    pub components: String,
1145    /// Debian release used by legacy downstream configuration.
1146    ///
1147    /// Generated release flows build Debian packages through the project
1148    /// devshell and `cargo-deb`; they do not require debootstrap or sudo.
1149    #[serde(default = "default_apt_debian_release")]
1150    pub debian_release: String,
1151    /// Cargo packages built with `cargo deb -p <pkg>`.
1152    #[serde(default)]
1153    pub packages: Vec<String>,
1154    /// Legacy Debian build dependencies retained for configuration
1155    /// compatibility. Generated local release flows do not create a chroot.
1156    #[serde(default)]
1157    pub build_deps: Vec<String>,
1158    /// `cargo-deb` version expected by legacy downstream configuration.
1159    #[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    /// Repository basename (the `<repo>` of `<owner>/<repo>`).
1320    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    /// Validate the optional `[homebrew]` section.
1636    ///
1637    /// Validation is intentionally lazy: `load` only parses the file, and
1638    /// callers that consume Homebrew settings decide when malformed Homebrew
1639    /// config should become fatal.
1640    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    /// Validate the optional `[chocolatey]` section.
1675    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    /// Validate the optional `[scoop]` section.
1704    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    /// Resolve Homebrew settings for one Cargo package.
1727    ///
1728    /// Workspaces may contain multiple packages; the caller is responsible for
1729    /// selecting the package whose metadata should provide fallbacks.
1730    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    /// Resolve Chocolatey settings for one Cargo package.
1807    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    /// Resolve Scoop settings for one Cargo package.
1946    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    /// Validate the optional `[aur]` section.
2023    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    /// Validate the optional `[copr]` section.
2038    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    /// Validate the optional `[apt]` section.
2050    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        // The apt repo_url is an SSH git remote (e.g. ssh://git@host/...), where a
2058        // `user@` userinfo is expected; only reject an embedded `:password@`.
2059        reject_password_url("simit project config: [apt].repo_url", &apt.repo_url)?;
2060        Ok(())
2061    }
2062
2063    /// Resolve AUR settings for one Cargo package.
2064    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    /// Resolve COPR settings for one Cargo package.
2146    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    /// Resolve APT settings for one Cargo package.
2232    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    /// Resolve the `[release.codeberg]` section, if present.
2285    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    /// Resolve the `[ci.pages]` section, if present.
2303    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    /// Resolve the `[vscode]` section, if present.
2326    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
2363/// Reject a `scheme://user:password@host/...` URL while permitting a bare
2364/// `scheme://user@host/...` userinfo, which SSH git remotes legitimately use.
2365fn 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}