Skip to main content

anodizer_core/config/
completions.rs

1//! Shell-completion and man-page generation config for archive entries.
2//!
3//! Both [`CompletionsConfig`] and [`ManpagesConfig`] are turnkey sugar over
4//! the existing build-hook + `files:` glob machinery: a single block on an
5//! archive entry auto-generates completion/man files and bundles them into
6//! every archive (and any nfpm package whose `contents:` globs the shared
7//! dist staging directory).
8//!
9//! Three mutually-exclusive generation modes are supported, matching the
10//! patterns real projects use:
11//!
12//! - **Mode A — `generate:`**: run the built binary once on the host-native
13//!   target (e.g. `<bin> completions <shell>`), capturing stdout per shell.
14//!   Completions/man pages do not vary by architecture, so the host output is
15//!   reused for every archive across all targets.
16//! - **Mode B — `from_build_out:`**: harvest files a `build.rs`
17//!   (clap_complete / clap_mangen) already wrote into `target/.../out/` via a
18//!   per-target glob.
19//! - **Mode C — `copy:`**: copy committed files from a static path/glob.
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24/// Shell-completion generation for an archive entry.
25///
26/// Exactly one of `generate` / `from_build_out` / `copy` must be set
27/// (validated at deserialize time). Setting none is a no-op; setting more
28/// than one is a config error.
29///
30/// YAML examples:
31/// ```yaml
32/// completions:
33///   generate: "{{ ArtifactPath }} completions {{ Shell }}"   # mode A
34///   shells: [bash, zsh, fish, powershell, nushell, elvish]
35///   dst: "completions/"
36/// # mode B: from_build_out: "**/out/{{ Binary }}.{bash,fish}"
37/// # mode C: copy: "contrib/completion/*"
38/// ```
39///
40/// `{{ Binary }}` resolves to the host-native binary's recorded name; when
41/// no host artifact exists (modes B/C on a pure cross build) it falls back to
42/// the crate name. If your binary name differs from the crate name, spell it
43/// literally in the glob rather than relying on `{{ Binary }}` in that case.
44#[derive(Debug, Clone, Serialize, Default, JsonSchema, PartialEq)]
45#[serde(default, deny_unknown_fields)]
46pub struct CompletionsConfig {
47    /// Mode A: command run once on the host-native binary, per shell. The
48    /// `{{ Shell }}` template var is bound to each entry in `shells`, and
49    /// `{{ ArtifactPath }}` / `{{ Binary }}` reference the built binary.
50    /// Stdout is captured into one file per shell under `dst`.
51    pub generate: Option<String>,
52    /// Mode B: per-target glob harvesting files a `build.rs` wrote into the
53    /// crate's `OUT_DIR` (e.g. `"**/out/{{ Binary }}.{bash,fish}"`).
54    pub from_build_out: Option<String>,
55    /// Mode C: glob/path of committed completion files to copy verbatim.
56    pub copy: Option<String>,
57    /// Shells to generate for in mode A. Arbitrary user-supplied list — not
58    /// limited to bash/zsh/fish/powershell; `nushell`, `elvish`, `fig`, etc.
59    /// are all valid. Ignored by modes B and C. Defaults to the four common
60    /// shells when omitted in mode A.
61    pub shells: Option<Vec<String>>,
62    /// Destination directory inside the archive (and dist staging tree) for
63    /// the generated files. Defaults to `"completions/"`.
64    pub dst: Option<String>,
65}
66
67/// Man-page generation for an archive entry.
68///
69/// Same three mutually-exclusive modes as [`CompletionsConfig`], minus the
70/// per-shell `shells` axis (man pages are shell-agnostic).
71///
72/// YAML examples:
73/// ```yaml
74/// manpages:
75///   generate: "{{ ArtifactPath }} --man"     # mode A
76///   dst: "man/man1/"
77/// # mode B: from_build_out: "**/out/{{ Binary }}.1"
78/// # mode C: copy: "man/*.1"
79/// ```
80#[derive(Debug, Clone, Serialize, Default, JsonSchema, PartialEq)]
81#[serde(default, deny_unknown_fields)]
82pub struct ManpagesConfig {
83    /// Mode A: command run once on the host-native binary; stdout is captured
84    /// into a single man file under `dst` named `<binary>.1`.
85    pub generate: Option<String>,
86    /// Mode B: per-target glob harvesting man files a `build.rs` wrote into
87    /// the crate's `OUT_DIR` (e.g. `"**/out/{{ Binary }}.1"`).
88    pub from_build_out: Option<String>,
89    /// Mode C: glob/path of committed man files to copy verbatim.
90    pub copy: Option<String>,
91    /// Destination directory inside the archive (and dist staging tree) for
92    /// the generated man files. Defaults to `"man/man1/"`.
93    pub dst: Option<String>,
94}
95
96/// The resolved generation mode for a completions/manpages block.
97///
98/// Returned by [`CompletionsConfig::mode`] / [`ManpagesConfig::mode`] after
99/// the exactly-one-set invariant has been enforced at deserialize time, so
100/// the stage never has to re-validate mutual exclusivity.
101#[derive(Debug, Clone, PartialEq)]
102pub enum GenMode<'a> {
103    /// Run the host binary (mode A): the `generate:` command template.
104    Generate(&'a str),
105    /// Harvest a `build.rs` OUT_DIR via a per-target glob (mode B).
106    FromBuildOut(&'a str),
107    /// Copy committed files from a path/glob (mode C).
108    Copy(&'a str),
109    /// No mode set — the block is a no-op.
110    None,
111}
112
113/// Count how many of the three mutually-exclusive mode fields are set, and
114/// return a `serde`-friendly error when more than one is. Shared by the two
115/// blocks' `Deserialize` impls so the diagnostic wording stays in one place.
116fn enforce_single_mode<E: serde::de::Error>(
117    block: &str,
118    generate: bool,
119    from_build_out: bool,
120    copy: bool,
121) -> Result<(), E> {
122    let set: Vec<&str> = [
123        ("generate", generate),
124        ("from_build_out", from_build_out),
125        ("copy", copy),
126    ]
127    .into_iter()
128    .filter_map(|(name, on)| on.then_some(name))
129    .collect();
130    if set.len() > 1 {
131        return Err(E::custom(format!(
132            "{block}: only one of `generate`, `from_build_out`, `copy` may be set \
133             (got {}); these are mutually-exclusive generation modes",
134            set.join(", ")
135        )));
136    }
137    Ok(())
138}
139
140impl CompletionsConfig {
141    /// Default destination directory inside the archive.
142    pub const DEFAULT_DST: &'static str = "completions/";
143
144    /// The four shells generated for when `shells:` is omitted in mode A.
145    pub const DEFAULT_SHELLS: &'static [&'static str] = &["bash", "zsh", "fish", "powershell"];
146
147    /// Resolve which generation mode (if any) this block selects. Mutual
148    /// exclusivity is already guaranteed by the `Deserialize` impl, so this
149    /// returns the first set field deterministically.
150    pub fn mode(&self) -> GenMode<'_> {
151        if let Some(g) = self.generate.as_deref() {
152            GenMode::Generate(g)
153        } else if let Some(b) = self.from_build_out.as_deref() {
154            GenMode::FromBuildOut(b)
155        } else if let Some(c) = self.copy.as_deref() {
156            GenMode::Copy(c)
157        } else {
158            GenMode::None
159        }
160    }
161
162    /// Resolve the destination directory, falling back to [`Self::DEFAULT_DST`].
163    pub fn resolved_dst(&self) -> &str {
164        self.dst.as_deref().unwrap_or(Self::DEFAULT_DST)
165    }
166
167    /// Resolve the shells list for mode A, falling back to the four common
168    /// shells when the user did not supply one.
169    pub fn resolved_shells(&self) -> Vec<String> {
170        match &self.shells {
171            Some(s) if !s.is_empty() => s.clone(),
172            _ => Self::DEFAULT_SHELLS.iter().map(|s| s.to_string()).collect(),
173        }
174    }
175}
176
177impl ManpagesConfig {
178    /// Default destination directory inside the archive.
179    pub const DEFAULT_DST: &'static str = "man/man1/";
180
181    /// Resolve which generation mode (if any) this block selects.
182    pub fn mode(&self) -> GenMode<'_> {
183        if let Some(g) = self.generate.as_deref() {
184            GenMode::Generate(g)
185        } else if let Some(b) = self.from_build_out.as_deref() {
186            GenMode::FromBuildOut(b)
187        } else if let Some(c) = self.copy.as_deref() {
188            GenMode::Copy(c)
189        } else {
190            GenMode::None
191        }
192    }
193
194    /// Resolve the destination directory, falling back to [`Self::DEFAULT_DST`].
195    pub fn resolved_dst(&self) -> &str {
196        self.dst.as_deref().unwrap_or(Self::DEFAULT_DST)
197    }
198}
199
200// Custom Deserialize impls enforce the exactly-one-mode invariant at config
201// load time so the archive stage can `match self.mode()` without re-checking.
202// They mirror the field set of the derived struct (kept in sync by hand —
203// the field count is small and stable).
204
205impl<'de> Deserialize<'de> for CompletionsConfig {
206    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
207    where
208        D: serde::Deserializer<'de>,
209    {
210        #[derive(Deserialize, Default)]
211        #[serde(default, deny_unknown_fields)]
212        struct Raw {
213            generate: Option<String>,
214            from_build_out: Option<String>,
215            copy: Option<String>,
216            shells: Option<Vec<String>>,
217            dst: Option<String>,
218        }
219        let raw = Raw::deserialize(deserializer)?;
220        enforce_single_mode(
221            "completions",
222            raw.generate.is_some(),
223            raw.from_build_out.is_some(),
224            raw.copy.is_some(),
225        )?;
226        Ok(CompletionsConfig {
227            generate: raw.generate,
228            from_build_out: raw.from_build_out,
229            copy: raw.copy,
230            shells: raw.shells,
231            dst: raw.dst,
232        })
233    }
234}
235
236impl<'de> Deserialize<'de> for ManpagesConfig {
237    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
238    where
239        D: serde::Deserializer<'de>,
240    {
241        #[derive(Deserialize, Default)]
242        #[serde(default, deny_unknown_fields)]
243        struct Raw {
244            generate: Option<String>,
245            from_build_out: Option<String>,
246            copy: Option<String>,
247            dst: Option<String>,
248        }
249        let raw = Raw::deserialize(deserializer)?;
250        enforce_single_mode(
251            "manpages",
252            raw.generate.is_some(),
253            raw.from_build_out.is_some(),
254            raw.copy.is_some(),
255        )?;
256        Ok(ManpagesConfig {
257            generate: raw.generate,
258            from_build_out: raw.from_build_out,
259            copy: raw.copy,
260            dst: raw.dst,
261        })
262    }
263}
264
265/// The conventional on-disk filename a shell expects its completion file to
266/// have, given the binary name. Mirrors clap_complete's emitted names so
267/// generated files drop straight into the shell's lookup path once unpacked:
268///
269/// | shell      | filename        |
270/// |------------|-----------------|
271/// | bash       | `<name>`        |
272/// | zsh        | `_<name>`       |
273/// | fish       | `<name>.fish`   |
274/// | powershell | `_<name>.ps1`   |
275/// | elvish     | `<name>.elv`    |
276/// | nushell    | `<name>.nu`     |
277/// | fig        | `<name>.ts`     |
278///
279/// Unknown shells fall back to `<name>.<shell>` so an arbitrary user-supplied
280/// shell still produces a deterministic, collision-free filename.
281pub fn completion_filename(binary: &str, shell: &str) -> String {
282    match shell.to_ascii_lowercase().as_str() {
283        "bash" => binary.to_string(),
284        "zsh" => format!("_{binary}"),
285        "fish" => format!("{binary}.fish"),
286        "powershell" | "pwsh" => format!("_{binary}.ps1"),
287        "elvish" => format!("{binary}.elv"),
288        "nushell" | "nu" => format!("{binary}.nu"),
289        "fig" => format!("{binary}.ts"),
290        other => format!("{binary}.{other}"),
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn parse_completions(yaml: &str) -> Result<CompletionsConfig, serde_yaml_ng::Error> {
299        serde_yaml_ng::from_str(yaml)
300    }
301
302    #[test]
303    fn mode_a_generate_parses() {
304        let c = parse_completions(
305            "generate: \"{{ .ArtifactPath }} completions {{ .Shell }}\"\nshells: [bash, zsh, nushell, elvish]\ndst: \"completions/\"",
306        )
307        .unwrap();
308        assert_eq!(
309            c.mode(),
310            GenMode::Generate("{{ .ArtifactPath }} completions {{ .Shell }}")
311        );
312        assert_eq!(
313            c.resolved_shells(),
314            vec!["bash", "zsh", "nushell", "elvish"]
315        );
316        assert_eq!(c.resolved_dst(), "completions/");
317    }
318
319    #[test]
320    fn mode_b_from_build_out_parses() {
321        let c = parse_completions("from_build_out: \"**/out/{{ .Binary }}.{bash,fish}\"").unwrap();
322        assert_eq!(
323            c.mode(),
324            GenMode::FromBuildOut("**/out/{{ .Binary }}.{bash,fish}")
325        );
326    }
327
328    #[test]
329    fn mode_c_copy_parses() {
330        let c = parse_completions("copy: \"contrib/completion/*\"").unwrap();
331        assert_eq!(c.mode(), GenMode::Copy("contrib/completion/*"));
332    }
333
334    #[test]
335    fn two_modes_at_once_is_error() {
336        let err = parse_completions("generate: \"x\"\ncopy: \"y\"").unwrap_err();
337        assert!(
338            err.to_string().contains("only one of"),
339            "expected mutual-exclusivity error, got: {err}"
340        );
341    }
342
343    #[test]
344    fn no_mode_is_noop() {
345        let c = parse_completions("shells: [bash]").unwrap();
346        assert_eq!(c.mode(), GenMode::None);
347    }
348
349    #[test]
350    fn default_shells_when_omitted() {
351        let c = parse_completions("generate: \"x\"").unwrap();
352        assert_eq!(
353            c.resolved_shells(),
354            vec!["bash", "zsh", "fish", "powershell"]
355        );
356    }
357
358    #[test]
359    fn manpages_two_modes_error() {
360        let err: Result<ManpagesConfig, _> =
361            serde_yaml_ng::from_str("generate: \"x\"\nfrom_build_out: \"y\"");
362        assert!(err.unwrap_err().to_string().contains("only one of"));
363    }
364
365    #[test]
366    fn manpages_default_dst() {
367        let m: ManpagesConfig =
368            serde_yaml_ng::from_str("generate: \"{{ .ArtifactPath }} --man\"").unwrap();
369        assert_eq!(m.resolved_dst(), "man/man1/");
370    }
371
372    #[test]
373    fn completion_filenames_follow_clap_convention() {
374        assert_eq!(completion_filename("rg", "bash"), "rg");
375        assert_eq!(completion_filename("rg", "zsh"), "_rg");
376        assert_eq!(completion_filename("rg", "fish"), "rg.fish");
377        assert_eq!(completion_filename("rg", "powershell"), "_rg.ps1");
378        assert_eq!(completion_filename("rg", "elvish"), "rg.elv");
379        assert_eq!(completion_filename("rg", "nushell"), "rg.nu");
380        // arbitrary shell → deterministic fallback
381        assert_eq!(completion_filename("rg", "fig"), "rg.ts");
382        assert_eq!(completion_filename("rg", "weirdshell"), "rg.weirdshell");
383    }
384
385    #[test]
386    fn completion_filename_accepts_shell_aliases_case_insensitively() {
387        // `pwsh` maps to the same file as `powershell`; `nu` as `nushell`.
388        assert_eq!(completion_filename("rg", "pwsh"), "_rg.ps1");
389        assert_eq!(completion_filename("rg", "nu"), "rg.nu");
390        // Shell name is lower-cased before matching.
391        assert_eq!(completion_filename("rg", "BASH"), "rg");
392        assert_eq!(completion_filename("rg", "Fish"), "rg.fish");
393    }
394
395    #[test]
396    fn empty_shells_list_falls_back_to_defaults() {
397        // An explicit empty list is treated as "unset" — the four common shells.
398        let c = parse_completions("generate: \"x\"\nshells: []").unwrap();
399        assert_eq!(
400            c.resolved_shells(),
401            vec!["bash", "zsh", "fish", "powershell"]
402        );
403    }
404
405    #[test]
406    fn manpages_mode_resolves_all_three_variants() {
407        let generate: ManpagesConfig =
408            serde_yaml_ng::from_str("generate: \"{{ .ArtifactPath }} --man\"").unwrap();
409        assert_eq!(
410            generate.mode(),
411            GenMode::Generate("{{ .ArtifactPath }} --man")
412        );
413        let harvest: ManpagesConfig =
414            serde_yaml_ng::from_str("from_build_out: \"**/out/{{ .Binary }}.1\"").unwrap();
415        assert_eq!(
416            harvest.mode(),
417            GenMode::FromBuildOut("**/out/{{ .Binary }}.1")
418        );
419        let copy: ManpagesConfig = serde_yaml_ng::from_str("copy: \"man/*.1\"").unwrap();
420        assert_eq!(copy.mode(), GenMode::Copy("man/*.1"));
421        // No mode set is a no-op.
422        let none: ManpagesConfig = serde_yaml_ng::from_str("dst: \"man/\"").unwrap();
423        assert_eq!(none.mode(), GenMode::None);
424    }
425
426    #[test]
427    fn manpages_rejects_unknown_field() {
428        assert!(serde_yaml_ng::from_str::<ManpagesConfig>("shells: [bash]").is_err());
429    }
430}