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}