1use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, Serialize, Default, JsonSchema, PartialEq)]
45#[serde(default, deny_unknown_fields)]
46pub struct CompletionsConfig {
47 pub generate: Option<String>,
52 pub from_build_out: Option<String>,
55 pub copy: Option<String>,
57 pub shells: Option<Vec<String>>,
62 pub dst: Option<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Default, JsonSchema, PartialEq)]
81#[serde(default, deny_unknown_fields)]
82pub struct ManpagesConfig {
83 pub generate: Option<String>,
86 pub from_build_out: Option<String>,
89 pub copy: Option<String>,
91 pub dst: Option<String>,
94}
95
96#[derive(Debug, Clone, PartialEq)]
102pub enum GenMode<'a> {
103 Generate(&'a str),
105 FromBuildOut(&'a str),
107 Copy(&'a str),
109 None,
111}
112
113fn 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 pub const DEFAULT_DST: &'static str = "completions/";
143
144 pub const DEFAULT_SHELLS: &'static [&'static str] = &["bash", "zsh", "fish", "powershell"];
146
147 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 pub fn resolved_dst(&self) -> &str {
164 self.dst.as_deref().unwrap_or(Self::DEFAULT_DST)
165 }
166
167 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 pub const DEFAULT_DST: &'static str = "man/man1/";
180
181 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 pub fn resolved_dst(&self) -> &str {
196 self.dst.as_deref().unwrap_or(Self::DEFAULT_DST)
197 }
198}
199
200impl<'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
265pub 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 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 assert_eq!(completion_filename("rg", "pwsh"), "_rg.ps1");
389 assert_eq!(completion_filename("rg", "nu"), "rg.nu");
390 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 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 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}