Skip to main content

volant_protocol/
modules.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//! The native modules and what the controller needs to know about them before the agent runs
3//! them. Written once here; the agent's implementation table and the documentation are
4//! checked against it.
5
6use std::fmt::Write as _;
7
8use serde_json::Value;
9
10/// What one module does with one of its arguments.
11///
12/// A module being in [`NATIVE_MODULES`] says it runs; it says nothing about the rest of its API,
13/// and an argument accepted and then dropped reports success having done something other than
14/// what the playbook asked for. The status is per argument so that neither answer is a guess.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ArgStatus {
17    /// Read, and the value decides what runs.
18    ///
19    /// It says the argument is read, not that it is coerced the way the reference coerces it.
20    /// `chdir`, `creates` and `removes` are `type='path'` there, which expands `~` and `$VAR`
21    /// before the value is used, and this release takes the value as it is written.
22    Honoured,
23    /// The reference accepts the name on this module and acts on it nowhere, which is what this
24    /// release does with it too - `executable` on `command`, where no shell runs for it to
25    /// select. Nothing to promise and nothing to refuse, so it is out of the generated page.
26    Inert,
27    /// The reference has it and this release does not do what it asks for. Named by the
28    /// pre-flight, before the first connection, rather than accepted and forgotten.
29    ///
30    /// The value carried is the one that is refused, the way `check_mode` is refused for `true`
31    /// and runs for `false`: the other value asks for what this release already does, and
32    /// refusing that would stop a playbook the two engines agreed on. `None` refuses the name
33    /// whatever the value says.
34    Refused(Option<bool>),
35}
36
37/// A value read the way the reference reads an argument declared `type='bool'`.
38///
39/// Measured on ansible-core 2.19.12, whose own refusal lists the spellings it takes: `0, 1, 'n',
40/// 'on', 'true', 'f', 'false', 'y', 'yes', 'no', '0', '1', 't', 'off'`, and a string outside that
41/// set fails the task there. It normalises with `.lower().strip()`, so the value is case folded
42/// **and** trimmed before it is matched: a trailing newline off a `lookup('file')`, or the space a
43/// template leaves behind, reads the same there as the bare word. `Value::as_bool` sees none of
44/// them, so an argument written `"false"` - which a whole-expression template produces on its own
45/// - used to read back as the default and do the opposite of what it says.
46///
47/// This is not the set the YAML scalar resolver uses, and the two must not be merged: that one
48/// has no `y`, `n`, `t` or `f`, and matches three fixed capitalisations where this one folds
49/// case. The `bool` filter has a third set of its own. Three readers, three measurements, one
50/// place each.
51pub fn arg_bool(value: &Value) -> Option<bool> {
52    let text = match value {
53        Value::Bool(b) => return Some(*b),
54        Value::Number(n) => n.to_string(),
55        Value::String(s) => s.trim().to_ascii_lowercase(),
56        _ => return None,
57    };
58    match text.as_str() {
59        "y" | "yes" | "on" | "1" | "1.0" | "true" | "t" => Some(true),
60        "n" | "no" | "off" | "0" | "0.0" | "false" | "f" => Some(false),
61        _ => None,
62    }
63}
64
65/// One argument of one module, and what this release does with it.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct ModuleArg {
68    pub name: &'static str,
69    pub status: ArgStatus,
70}
71
72/// One native module: its Ansible name and how the controller parses its arguments.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct ModuleSpec {
75    /// Short name, without the `ansible.builtin.` prefix.
76    pub name: &'static str,
77    /// The task's string form is a command line kept whole as `_raw_params`,
78    /// instead of `key=value` pairs.
79    pub free_form: bool,
80    /// One line for the generated documentation.
81    pub summary: &'static str,
82    /// Every argument the reference accepts for this module, sorted, with what this release does
83    /// with each. Empty for a module whose arguments nothing here checks yet.
84    pub args: &'static [ModuleArg],
85    /// The name the reference validates this module's arguments under, when it validates them at
86    /// all, and so the name its refusal carries.
87    ///
88    /// Measured on ansible-core 2.19.12: `shell` is executed by the `command` module, so an
89    /// argument neither module has is refused as `ansible.legacy.command` under both names and
90    /// against one list. `raw` never reaches a module at all - its action plugin hands the line
91    /// to the shell - so it takes any argument without looking at it, and a run that refused one
92    /// would refuse a playbook the reference runs.
93    pub validated_as: Option<&'static str>,
94}
95
96/// The parameters the reference's `command` module accepts, sorted, as its own refusal lists
97/// them - which is not what `ansible-doc` reports. Measured on ansible-core 2.19.12: the refusal
98/// names `_raw_params`, `_uses_shell` and `executable`, which the documentation leaves out, and
99/// leaves out `free_form`, which the documentation has. The refusal is the list that decides, so
100/// it is the list written here.
101///
102/// The two internal keys are in it for that reason and not as an exemption: the controller writes
103/// `_raw_params` for every free-form task and `_uses_shell` marks the shell semantics, and the
104/// reference accepts both by name. `cmd` is the ordinary spelling of the same command line.
105///
106/// `shell` shares these names because the reference shares the module. Two statuses differ, so
107/// the names are written once and the differences are arguments: `executable` is the shell
108/// `shell` hands its line to, and on `command` the reference starts no shell for it to select;
109/// `expand_argument_vars` is a `command` argument the reference does not give `shell` at all.
110const fn command_args(executable: ArgStatus, expand_argument_vars: ArgStatus) -> [ModuleArg; 12] {
111    [
112        ModuleArg {
113            name: "_raw_params",
114            status: ArgStatus::Honoured,
115        },
116        ModuleArg {
117            name: "_uses_shell",
118            status: ArgStatus::Honoured,
119        },
120        ModuleArg {
121            name: "argv",
122            status: ArgStatus::Honoured,
123        },
124        ModuleArg {
125            name: "chdir",
126            status: ArgStatus::Honoured,
127        },
128        ModuleArg {
129            name: "cmd",
130            status: ArgStatus::Honoured,
131        },
132        ModuleArg {
133            name: "creates",
134            status: ArgStatus::Honoured,
135        },
136        ModuleArg {
137            name: "executable",
138            status: executable,
139        },
140        // Measured: it decides whether the arguments handed to the program have their shell
141        // variables expanded first - `/bin/echo $HOME` prints the home directory by default and the
142        // five characters `$HOME` when it is off. This release expands nothing, which is what `false`
143        // asks for, so on `command` only `true` is refused: refusing `false` would stop a playbook
144        // that ran the same under both engines. The default is the value that diverges, and it is
145        // the one nobody writes; `docs/src/modules.md` says so, because no refusal can.
146        ModuleArg {
147            name: "expand_argument_vars",
148            status: expand_argument_vars,
149        },
150        ModuleArg {
151            name: "removes",
152            status: ArgStatus::Honoured,
153        },
154        ModuleArg {
155            name: "stdin",
156            status: ArgStatus::Honoured,
157        },
158        ModuleArg {
159            name: "stdin_add_newline",
160            status: ArgStatus::Honoured,
161        },
162        ModuleArg {
163            name: "strip_empty_ends",
164            status: ArgStatus::Honoured,
165        },
166    ]
167}
168
169/// The list `command` answers with: `executable` is accepted and does nothing, there as here.
170const COMMAND_ARGS: [ModuleArg; 12] =
171    command_args(ArgStatus::Inert, ArgStatus::Refused(Some(true)));
172/// The same names, with the argument `shell` reads and `command` does not, and the argument the
173/// reference gives `command` and refuses on `shell`.
174///
175/// Measured on ansible-core 2.19.12: `shell: /bin/echo $HOME` with `expand_argument_vars` set to
176/// either value answers `Unsupported parameters for (shell) module: expand_argument_vars` and
177/// fails the task, while the same line without the argument prints the home directory, because
178/// the shell expands it rather than the module. So a `shell` task diverges here for no value of
179/// this argument, and writing it at all is a playbook the reference refuses: the name is refused
180/// whatever it says, which is the reference's own answer, given before the first connection.
181///
182/// One spelling goes the other way. `expand_argument_vars: "{{ omit }}"` on `shell` takes the
183/// key out of the arguments there and runs the task, while a refusal tied to no value cannot
184/// read a template and refuses it here. It is the only value of the only argument where the two
185/// disagree in that direction, against every literal value where refusing is what the reference
186/// does, and a refusal before the first connection says exactly what it refused.
187const SHELL_ARGS: [ModuleArg; 12] = command_args(ArgStatus::Honoured, ArgStatus::Refused(None));
188
189pub const COMMAND: ModuleSpec = ModuleSpec {
190    name: "command",
191    free_form: true,
192    summary: "Run a program directly, without a shell.",
193    args: &COMMAND_ARGS,
194    validated_as: Some("ansible.legacy.command"),
195};
196pub const RAW: ModuleSpec = ModuleSpec {
197    name: "raw",
198    free_form: true,
199    summary: "Run a command line through the remote shell, with no module machinery around it.",
200    args: &[ModuleArg {
201        name: "executable",
202        status: ArgStatus::Honoured,
203    }],
204    validated_as: None,
205};
206pub const SHELL: ModuleSpec = ModuleSpec {
207    name: "shell",
208    free_form: true,
209    summary: "Run a command line through a shell, `sh` unless `executable` names another.",
210    args: &SHELL_ARGS,
211    validated_as: Some("ansible.legacy.command"),
212};
213
214/// Every module the agent implements natively, sorted by name.
215pub const NATIVE_MODULES: &[ModuleSpec] = &[COMMAND, RAW, SHELL];
216
217pub const DEBUG: ModuleSpec = ModuleSpec {
218    name: "debug",
219    free_form: false,
220    summary: "Print a message or the value of a variable.",
221    args: &[],
222    validated_as: None,
223};
224pub const SET_FACT: ModuleSpec = ModuleSpec {
225    name: "set_fact",
226    free_form: false,
227    summary: "Set facts for a host, for the rest of the run.",
228    args: &[],
229    validated_as: None,
230};
231
232pub const INCLUDE_VARS: ModuleSpec = ModuleSpec {
233    name: "include_vars",
234    free_form: true,
235    summary: "Read a file of variables and set them on the host, for the rest of the run.",
236    args: &[],
237    validated_as: None,
238};
239
240pub const VALIDATE_ARGUMENT_SPEC: ModuleSpec = ModuleSpec {
241    name: "validate_argument_spec",
242    free_form: false,
243    summary: "Check a role's arguments against the specification in `meta/argument_specs.yml`.",
244    args: &[],
245    validated_as: None,
246};
247
248/// Modules the controller runs itself and never sends to a host, sorted by name.
249pub const LOCAL_MODULES: &[ModuleSpec] = &[DEBUG, INCLUDE_VARS, SET_FACT, VALIDATE_ARGUMENT_SPEC];
250
251/// The three statements that read a file while the play is being compiled instead of naming
252/// work for a host, with whether their string form is one raw argument.
253///
254/// They are written as modules and the loader reads them as modules, but nothing sends them
255/// anywhere: the compiler splices what they name into the step list and no step is left behind.
256/// `import_role` is the odd one out on the free-form column - measured, `import_playbook: x.yml`
257/// takes the file as a raw parameter while `import_role` refuses one and wants `name=`.
258pub const IMPORT_MODULES: &[(&str, bool)] = &[
259    ("import_playbook", true),
260    ("import_role", false),
261    ("import_tasks", true),
262];
263
264/// Whether the module is one of the three import statements, and whether its string form is raw.
265pub fn import_module(module: &str) -> Option<bool> {
266    let short = short_name(module);
267    IMPORT_MODULES
268        .iter()
269        .find(|(name, _)| *name == short)
270        .map(|(_, free_form)| *free_form)
271}
272
273/// The two statements that name work read while the play is **running** rather than while it is
274/// being compiled, with whether their string form is one raw argument.
275///
276/// They are the imports' dynamic twins: what they name is not known until the host that reaches
277/// them has rendered its own variables, so the coordinator reads it then and splices the steps in
278/// behind the statement. `include_role` refuses a raw parameter and wants `name=`, the way
279/// `import_role` does - measured on ansible-core 2.19.12.
280///
281/// `include_vars` is not here: it is an ordinary controller-side module in [`LOCAL_MODULES`],
282/// because what it produces is variables rather than steps.
283pub const INCLUDE_MODULES: &[(&str, bool)] = &[("include_role", false), ("include_tasks", true)];
284
285/// Whether the module is one of the two include statements, and whether its string form is raw.
286pub fn include_module(module: &str) -> Option<bool> {
287    let short = short_name(module);
288    INCLUDE_MODULES
289        .iter()
290        .find(|(name, _)| *name == short)
291        .map(|(_, free_form)| *free_form)
292}
293
294/// `ansible.builtin.` and `ansible.legacy.` name the same modules as the bare name does.
295/// Other collections are returned whole: `community.general.command` is not our `command`.
296pub fn short_name(module: &str) -> &str {
297    module
298        .strip_prefix("ansible.builtin.")
299        .or_else(|| module.strip_prefix("ansible.legacy."))
300        .unwrap_or(module)
301}
302
303pub fn native(module: &str) -> Option<&'static ModuleSpec> {
304    let short = short_name(module);
305    NATIVE_MODULES.iter().find(|m| m.name == short)
306}
307
308pub fn local(module: &str) -> Option<&'static ModuleSpec> {
309    let short = short_name(module);
310    LOCAL_MODULES.iter().find(|m| m.name == short)
311}
312
313/// Every module `ansible.builtin` ships in ansible-core 2.19.12, as `ansible-doc -l -t module
314/// ansible.builtin` lists them, sorted and short-named. A playbook naming one of these and
315/// none of `NATIVE_MODULES` or `LOCAL_MODULES` is a playbook this release cannot run yet,
316/// which the operator needs to hear differently from a name that resolves to no module at all:
317/// the first waits on us, the second is a typo.
318pub const BUILTIN_MODULES: &[&str] = &[
319    "add_host",
320    "apt",
321    "apt_key",
322    "apt_repository",
323    "assemble",
324    "assert",
325    "async_status",
326    "blockinfile",
327    "command",
328    "copy",
329    "cron",
330    "deb822_repository",
331    "debconf",
332    "debug",
333    "dnf",
334    "dnf5",
335    "dpkg_selections",
336    "expect",
337    "fail",
338    "fetch",
339    "file",
340    "find",
341    "gather_facts",
342    "get_url",
343    "getent",
344    "git",
345    "group",
346    "group_by",
347    "hostname",
348    "import_playbook",
349    "import_role",
350    "import_tasks",
351    "include_role",
352    "include_tasks",
353    "include_vars",
354    "iptables",
355    "known_hosts",
356    "lineinfile",
357    "meta",
358    "mount_facts",
359    "package",
360    "package_facts",
361    "pause",
362    "ping",
363    "pip",
364    "raw",
365    "reboot",
366    "replace",
367    "rpm_key",
368    "script",
369    "service",
370    "service_facts",
371    "set_fact",
372    "set_stats",
373    "setup",
374    "shell",
375    "slurp",
376    "stat",
377    "subversion",
378    "systemd",
379    "systemd_service",
380    "sysvinit",
381    "tempfile",
382    "template",
383    "unarchive",
384    "uri",
385    "user",
386    "validate_argument_spec",
387    "wait_for",
388    "wait_for_connection",
389    "yum_repository",
390];
391
392/// Whether `module` names a builtin, bare or under the two prefixes that mean the same thing.
393/// Another collection's module is never one, however familiar the short name looks: if
394/// `community.general.command` were taken for a builtin, a playbook naming it would be told to
395/// wait for a release that will never contain it.
396pub fn is_builtin(module: &str) -> bool {
397    let ours = !module.contains('.')
398        || module.starts_with("ansible.builtin.")
399        || module.starts_with("ansible.legacy.");
400    ours && BUILTIN_MODULES.contains(&short_name(module))
401}
402
403/// Whether this engine can run the module at all, natively on the agent or on the controller.
404/// The two tables are all there is, so a playbook naming anything else can be refused while it
405/// is being loaded, which is where the reference refuses a module it cannot resolve.
406pub fn is_known(module: &str) -> bool {
407    native(module).is_some() || local(module).is_some()
408}
409
410/// The Markdown table published in the documentation, generated so it cannot drift.
411pub fn documentation_table() -> String {
412    let mut out = String::from(
413        "# Modules\n\nThese are the modules Volant runs. A playbook naming any other module is refused when it is loaded, before the first task, the way Ansible refuses a module it cannot resolve. A file a dynamic `include_tasks` or `include_role` names is read when a host reaches the statement, so a module named there is refused at that moment instead: the statement fails for the host that asked, and nothing in the file runs. What `import_tasks` and `import_role` name is compiled with the play and checked with it. Everything else waits on the warm Python path.\n\n## On the agent\n\nThe agent runs these on the host, without Python. The arguments column lists what each module reads, and each is read as the playbook writes it. Ansible declares `chdir`, `creates` and `removes` as paths, which expands `~` and `$VAR` in them before the value is used; this release does not, so `creates: ~/.provisioned` looks for a directory named `~`. No other argument is expanded either. Ansible runs `command: /bin/echo $HOME` with the variable already replaced, and here the program is handed the five characters `$HOME`. A `shell` task prints the same thing under both engines, because there the shell does the expanding rather than the module. The list under the table names the arguments Ansible has and this release refuses.\n\n| Module | Free-form arguments | Arguments | What it does |\n|---|---|---|---|\n",
414    );
415    // The internal keys carry the command line itself rather than being written in a playbook,
416    // so they are in the registry and out of the page.
417    let honoured = |m: &ModuleSpec| {
418        m.args
419            .iter()
420            .filter(|a| a.status == ArgStatus::Honoured && !a.name.starts_with('_'))
421            .map(|a| format!("`{}`", a.name))
422            .collect::<Vec<_>>()
423            .join(", ")
424    };
425    // A refusal tied to one value names that value: the other one is what this release does.
426    let refused = |m: &ModuleSpec| {
427        m.args
428            .iter()
429            .filter(|a| !a.name.starts_with('_'))
430            .filter_map(|a| match a.status {
431                ArgStatus::Refused(Some(v)) => Some(format!("`{}: {v}`", a.name)),
432                ArgStatus::Refused(None) => Some(format!("`{}`", a.name)),
433                _ => None,
434            })
435            .collect::<Vec<_>>()
436            .join(", ")
437    };
438    let rows = |specs: &[ModuleSpec], args: bool, out: &mut String| {
439        for m in specs {
440            let _ = write!(
441                out,
442                "| `{}` | {} |",
443                m.name,
444                if m.free_form { "yes" } else { "no" }
445            );
446            if args {
447                let read = honoured(m);
448                let _ = write!(
449                    out,
450                    " {} |",
451                    if read.is_empty() { "-" } else { read.as_str() }
452                );
453            }
454            let _ = writeln!(out, " {} |", m.summary);
455        }
456    };
457    rows(NATIVE_MODULES, true, &mut out);
458    let notes: String = NATIVE_MODULES
459        .iter()
460        .filter_map(|m| {
461            let names = refused(m);
462            (!names.is_empty()).then(|| format!("- `{}`: {names}\n", m.name))
463        })
464        .collect();
465    if !notes.is_empty() {
466        out.push_str("\nVolant refuses these before the run reaches a host:\n\n");
467        out.push_str(&notes);
468    }
469    out.push_str(
470        "\n## On the controller\n\nThe controller runs these itself, so they need no connection to the host.\n\n| Module | Free-form arguments | What it does |\n|---|---|---|\n",
471    );
472    rows(LOCAL_MODULES, false, &mut out);
473    out
474}
475
476#[cfg(test)]
477mod tests {
478    use serde_json::json;
479
480    use super::*;
481
482    #[test]
483    fn builtin_prefixes_are_stripped_and_collections_kept() {
484        assert_eq!(short_name("ansible.builtin.command"), "command");
485        assert_eq!(short_name("ansible.legacy.shell"), "shell");
486        assert_eq!(short_name("command"), "command");
487        assert_eq!(short_name("community.general.ufw"), "community.general.ufw");
488    }
489
490    #[test]
491    fn native_lookup_uses_the_short_name() {
492        assert_eq!(native("ansible.builtin.raw").map(|m| m.name), Some("raw"));
493        assert!(native("file").is_none(), "file is not native yet");
494        assert!(
495            native("community.general.command").is_none(),
496            "another collection's command is not ours"
497        );
498    }
499
500    #[test]
501    fn names_are_unique_and_sorted() {
502        for table in [NATIVE_MODULES, LOCAL_MODULES] {
503            let names: Vec<&str> = table.iter().map(|m| m.name).collect();
504            let mut sorted = names.clone();
505            sorted.sort_unstable();
506            assert_eq!(names, sorted);
507        }
508        let mut all: Vec<&str> = NATIVE_MODULES
509            .iter()
510            .chain(LOCAL_MODULES)
511            .map(|m| m.name)
512            .collect();
513        let total = all.len();
514        all.sort_unstable();
515        all.dedup();
516        assert_eq!(all.len(), total, "a module name is in both tables");
517    }
518
519    #[test]
520    fn only_the_two_tables_are_known() {
521        assert!(is_known("ansible.builtin.shell"));
522        assert!(is_known("set_fact"));
523        assert!(is_known("ansible.legacy.debug"));
524        assert!(!is_known("nosuchmodule"));
525        assert!(!is_known("file"), "not implemented yet, so not known");
526        assert!(!is_known("community.general.debug"));
527    }
528
529    /// The three dynamic statements, each in the table that answers for it.
530    ///
531    /// `include_vars` is a controller-side module - it produces variables, and a result line with
532    /// them - while `include_tasks` and `include_role` produce steps and never run as modules at
533    /// all. Told apart here because the pre-flight reads exactly this: a name in neither table is
534    /// refused as "not available in this release", which is what all three were before the
535    /// coordinator could splice.
536    ///
537    /// What would make this red: `include_vars` left out of the controller-side table, which
538    /// refuses a playbook this release now runs; or either statement added to it, which would
539    /// send a step naming work to `run_local` and fail it as "not a controller-side module".
540    #[test]
541    fn the_three_dynamic_statements_are_each_in_one_table() {
542        assert!(is_known("include_vars"));
543        assert!(is_known("ansible.builtin.include_vars"));
544        assert!(include_module("include_vars").is_none());
545        for statement in ["include_tasks", "include_role"] {
546            assert!(include_module(statement).is_some(), "{statement}");
547            assert!(!is_known(statement), "{statement}");
548        }
549        assert_eq!(include_module("include_tasks"), Some(true));
550        assert_eq!(include_module("ansible.builtin.include_role"), Some(false));
551    }
552
553    /// The three states a module name can be in have to stay three. Collapsing them - which an
554    /// `&&`/`||` precedence slip does in one character - would tell an operator with a typo to
555    /// wait for a release, or an operator waiting for `lineinfile` that they misspelled it.
556    ///
557    /// What would make this red: `is_builtin` answering true for a name no collection has, or
558    /// false for one `ansible.builtin` ships.
559    #[test]
560    fn a_builtin_is_told_apart_from_a_name_that_resolves_to_nothing() {
561        for yes in [
562            "lineinfile",
563            "ansible.builtin.lineinfile",
564            "ansible.legacy.file",
565            "command",
566        ] {
567            assert!(is_builtin(yes), "{yes}");
568        }
569        for no in [
570            "nosuchmodule",
571            "ansible.builtin.nosuchmodule",
572            "community.general.ufw",
573            "community.general.command",
574        ] {
575            assert!(!is_builtin(no), "{no}");
576        }
577        assert!(
578            !is_known("lineinfile") && is_builtin("lineinfile"),
579            "a builtin we have not written is neither runnable nor a typo"
580        );
581    }
582
583    /// What would make this red: a native or controller-side module added under a name
584    /// `ansible.builtin` does not have, which would make it unreachable for anyone writing the
585    /// reference's spelling.
586    #[test]
587    fn the_builtin_table_is_sorted_and_covers_both_registries() {
588        let mut sorted = BUILTIN_MODULES.to_vec();
589        sorted.sort_unstable();
590        sorted.dedup();
591        assert_eq!(BUILTIN_MODULES, sorted.as_slice());
592        for m in NATIVE_MODULES.iter().chain(LOCAL_MODULES) {
593            assert!(
594                BUILTIN_MODULES.contains(&m.name),
595                "{} is not a name ansible.builtin has",
596                m.name
597            );
598        }
599        for table in [IMPORT_MODULES, INCLUDE_MODULES] {
600            let names: Vec<&str> = table.iter().map(|(n, _)| *n).collect();
601            let mut sorted = names.clone();
602            sorted.sort_unstable();
603            sorted.dedup();
604            assert_eq!(names, sorted);
605            for name in names {
606                assert!(BUILTIN_MODULES.contains(&name), "{name}");
607                assert!(
608                    !is_known(name),
609                    "{name} names work rather than being it, so nothing runs it as a module"
610                );
611            }
612        }
613    }
614
615    /// Every argument list is sorted, free of duplicates, and total: a module the reference
616    /// validates carries every name the reference's own refusal lists, because that refusal is
617    /// built from this list and a name missing here would be quoted as unsupported while the
618    /// reference supports it.
619    ///
620    /// What would make this red: a name appended rather than inserted in order, which puts the
621    /// reference's sentence out of order too; a name listed twice, which prints it twice; or a
622    /// module gaining a `validated_as` with nothing to validate against, which would refuse every
623    /// argument a playbook writes.
624    #[test]
625    fn every_argument_list_is_sorted_and_covers_what_the_reference_validates() {
626        for m in NATIVE_MODULES.iter().chain(LOCAL_MODULES) {
627            let names: Vec<&str> = m.args.iter().map(|a| a.name).collect();
628            let mut sorted = names.clone();
629            sorted.sort_unstable();
630            sorted.dedup();
631            assert_eq!(names, sorted, "{}", m.name);
632            assert!(
633                m.validated_as.is_none() || !names.is_empty(),
634                "{} validates against an empty list",
635                m.name
636            );
637        }
638        // Measured on ansible-core 2.19.12, through `args:` on both modules: one module name,
639        // one list of names, and one refusal. The ad-hoc path validates nothing, because
640        // `free_form` swallows the whole line into `_raw_params`.
641        let names = |m: &ModuleSpec| m.args.iter().map(|a| a.name).collect::<Vec<_>>();
642        assert_eq!(
643            names(&COMMAND),
644            names(&SHELL),
645            "the reference shares the module"
646        );
647        assert_eq!(SHELL.validated_as, Some("ansible.legacy.command"));
648        assert_eq!(
649            RAW.validated_as, None,
650            "raw's action plugin never validates an argument"
651        );
652    }
653
654    /// `executable` names the shell a command line is handed to, so it means something only where
655    /// a shell runs. The reference accepts it on `command` and starts no shell there, and so does
656    /// this release - which is parity, not a gap, and so belongs in neither column of the page.
657    ///
658    /// What would make this red: `command` claiming to read it, which the generated page then
659    /// tells an operator, who writes it and gets no shell, no refusal and no hint why.
660    #[test]
661    fn executable_is_read_only_where_a_shell_runs() {
662        let status = |m: &ModuleSpec| {
663            m.args
664                .iter()
665                .find(|a| a.name == "executable")
666                .map(|a| a.status)
667        };
668        assert_eq!(status(&COMMAND), Some(ArgStatus::Inert));
669        assert_eq!(status(&SHELL), Some(ArgStatus::Honoured));
670        assert_eq!(status(&RAW), Some(ArgStatus::Honoured));
671        let page = documentation_table();
672        let row = |name: &str| {
673            page.lines()
674                .find(|l| l.starts_with(&format!("| `{name}` |")))
675                .unwrap_or_default()
676                .to_string()
677        };
678        assert!(!row("command").contains("executable"), "{}", row("command"));
679        assert!(row("shell").contains("`executable`"), "{}", row("shell"));
680    }
681
682    /// `expand_argument_vars` is a `command` argument, and the two modules answer for it
683    /// differently because the reference does.
684    ///
685    /// Measured on ansible-core 2.19.12: on `command` the default expands and `false` does not,
686    /// so `true` is the only value that asks for something this release cannot do. On `shell`
687    /// the reference has no such argument at all and fails the task with `Unsupported parameters
688    /// for (shell) module: expand_argument_vars` whatever the value says, while a `shell` line
689    /// with the argument left out expands its variables under both engines, the shell doing it
690    /// rather than the module.
691    ///
692    /// What would make this red: `shell` carrying `command`'s value-tied refusal, which lets
693    /// `expand_argument_vars: false` through on a task the reference refuses; or `command`
694    /// refusing the name, which stops a playbook that ran the same under both engines.
695    #[test]
696    fn expand_argument_vars_is_refused_by_the_value_on_one_module_and_by_name_on_the_other() {
697        let status = |m: &ModuleSpec| {
698            m.args
699                .iter()
700                .find(|a| a.name == "expand_argument_vars")
701                .map(|a| a.status)
702        };
703        assert_eq!(status(&COMMAND), Some(ArgStatus::Refused(Some(true))));
704        assert_eq!(status(&SHELL), Some(ArgStatus::Refused(None)));
705        assert_eq!(status(&RAW), None);
706        let page = documentation_table();
707        assert!(
708            page.contains("- `command`: `expand_argument_vars: true`\n"),
709            "{page}"
710        );
711        assert!(
712            page.contains("- `shell`: `expand_argument_vars`\n"),
713            "{page}"
714        );
715    }
716
717    /// The divergence no refusal can name is the default, so the page has to name it instead.
718    /// This is the argument slice of the same class of gap the refusals close: an argument read
719    /// otherwise than the reference reads it, with the playbook saying nothing about it.
720    ///
721    /// What would make this red: the sentence dropped from the generated page, which leaves an
722    /// operator writing `creates: ~/.provisioned` or `command: mkdir -p $HOME/releases` with
723    /// nothing published to read it against.
724    #[test]
725    fn the_page_says_arguments_are_read_as_written() {
726        let page = documentation_table();
727        for phrase in [
728            "read as the playbook writes it",
729            "expands `~` and `$VAR`",
730            "`command: /bin/echo $HOME`",
731        ] {
732            assert!(page.contains(phrase), "{phrase} is missing from:\n{page}");
733        }
734    }
735
736    /// The spellings the reference takes for an argument declared `type='bool'`, read from its
737    /// own refusal of a value outside the set and measured task by task: `"false"`, `"no"`, `0`
738    /// and `"Off"` all turn the argument off there.
739    ///
740    /// The reference normalises with `.lower().strip()`, so surrounding whitespace is part of no
741    /// spelling: `"false "` is `false` there, and a value coming out of a template or a
742    /// `lookup('file')` carries that whitespace more often than a hand-written one does.
743    ///
744    /// What would make this red: reading the value with `Value::as_bool`, which answers `None`
745    /// for every spelling but a YAML boolean and leaves the caller on its default - so a task
746    /// asking for the opposite of the default gets the default and reports success; or matching
747    /// the string untrimmed, which does the same to every value with a newline on the end.
748    #[test]
749    fn a_boolean_argument_reads_every_spelling_the_reference_takes() {
750        for yes in [
751            json!(true),
752            json!("true"),
753            json!("True"),
754            json!("YES"),
755            json!("t"),
756            json!("on"),
757            json!(1),
758            json!(1.0),
759            json!(" true"),
760        ] {
761            assert_eq!(arg_bool(&yes), Some(true), "{yes}");
762        }
763        for no in [
764            json!(false),
765            json!("false"),
766            json!("no"),
767            json!("Off"),
768            json!("f"),
769            json!(0),
770            json!("0"),
771            json!("false "),
772            json!("no\n"),
773        ] {
774            assert_eq!(arg_bool(&no), Some(false), "{no}");
775        }
776        for neither in [
777            json!("maybe"),
778            json!(2),
779            json!(null),
780            json!([true]),
781            json!(""),
782        ] {
783            assert_eq!(arg_bool(&neither), None, "{neither}");
784        }
785    }
786
787    #[test]
788    fn the_documentation_table_matches_the_registry() {
789        let path =
790            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/src/modules.md");
791        let expected = documentation_table();
792        if std::env::var_os("VOLANT_UPDATE_DOCS").is_some() {
793            std::fs::write(&path, &expected).unwrap();
794        }
795        let actual = std::fs::read_to_string(&path).unwrap_or_default();
796        assert_eq!(
797            actual, expected,
798            "run `just docs-modules` to regenerate docs/src/modules.md"
799        );
800    }
801}