Repository: ansible-collections/community.general

Issue:
Add cargo module
### Summary

This collection has modules for many package managers but there isn't one for Rust's cargo yet. Considering the popularity of the language and the popularity of some of the binary crates ([bat](https://github.com/sharkdp/bat), [ripgrep](https://github.com/BurntSushi/ripgrep), [exa](https://github.com/ogham/exa), [fd-find](https://github.com/sharkdp/fd), [git-delta](https://github.com/dandavison/delta), etc.), it seems like a reasonable addition to the existing packaging modules.

### Issue Type

Feature Idea

### Component Name

cargo

### Additional Information

```yaml
- name: playbook name
  hosts: localhost
  tasks:
    - name: install bat
      community.general.cargo:
        name: bat
        locked: true
        state: present
    - name: install ripgrep
      community.general.cargo:
        name: ripgrep
        version: 13.0.0
        state: present
    - name: install/upgrade exa
      community.general.cargo:
        name: exa
        no_default_features: true
        state: latest
    - name: uninstall git-delta
      community.general.cargo:
        name: git-delta
        state: absent
    - name: install fd-find from git
      community.general.cargo:
        # crate name is only required when the repository contains multiple crates
        # name: fd-find
        git: https://github.com/sharkdp/fd
        state: present
# etc...
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:

Resolving patch:
diff --git a/plugins/modules/cargo.py b/plugins/modules/cargo.py
new file mode 120000
--- /dev/null
+++ b/plugins/modules/cargo.py
@@ -0,0 +1 @@
+packaging/language/cargo.py
\ No newline at end of file
diff --git a/plugins/modules/packaging/language/cargo.py b/plugins/modules/packaging/language/cargo.py
new file mode 100644
--- /dev/null
+++ b/plugins/modules/packaging/language/cargo.py
@@ -0,0 +1,205 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021 Radek Sprta <mail@radeksprta.eu>
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+
+__metaclass__ = type
+
+
+DOCUMENTATION = r"""
+---
+module: cargo
+short_description: Manage Rust packages with cargo
+version_added: 4.3.0
+description:
+  - Manage Rust packages with cargo.
+author: "Radek Sprta (@radek-sprta)"
+options:
+  name:
+    description:
+      - The name of a Rust package to install.
+    type: list
+    elements: str
+    required: true
+  path:
+    description:
+      ->
+      The base path where to install the Rust packages. Cargo automatically appends
+      C(/bin). In other words, C(/usr/local) will become C(/usr/local/bin).
+    type: path
+  version:
+    description:
+      ->
+      The version to install. If I(name) contains multiple values, the module will
+      try to install all of them in this version.
+    type: str
+    required: false
+  state:
+    description:
+      - The state of the Rust package.
+    required: false
+    type: str
+    default: present
+    choices: [ "present", "absent", "latest" ]
+requirements:
+    - cargo installed in bin path (recommended /usr/local/bin)
+"""
+
+EXAMPLES = r"""
+- name: Install "ludusavi" Rust package
+  community.general.cargo:
+    name: ludusavi
+
+- name: Install "ludusavi" Rust package in version 0.10.0
+  community.general.cargo:
+    name: ludusavi
+    version: '0.10.0'
+
+- name: Install "ludusavi" Rust package to global location
+  community.general.cargo:
+    name: ludusavi
+    path: /usr/local
+
+- name: Remove "ludusavi" Rust package
+  community.general.cargo:
+    name: ludusavi
+    state: absent
+
+- name: Update "ludusavi" Rust package its latest version
+  community.general.cargo:
+    name: ludusavi
+    state: latest
+"""
+
+import os
+import re
+
+from ansible.module_utils.basic import AnsibleModule
+
+
+class Cargo(object):
+    def __init__(self, module, **kwargs):
+        self.module = module
+        self.name = kwargs["name"]
+        self.path = kwargs["path"]
+        self.state = kwargs["state"]
+        self.version = kwargs["version"]
+
+        self.executable = [module.get_bin_path("cargo", True)]
+
+    @property
+    def path(self):
+        return self._path
+
+    @path.setter
+    def path(self, path):
+        if path is not None and not os.path.isdir(path):
+            self.module.fail_json(msg="Path %s is not a directory" % path)
+        self._path = path
+
+    def _exec(
+        self, args, run_in_check_mode=False, check_rc=True, add_package_name=True
+    ):
+        if not self.module.check_mode or (self.module.check_mode and run_in_check_mode):
+            cmd = self.executable + args
+            rc, out, err = self.module.run_command(cmd, check_rc=check_rc)
+            return out, err
+        return "", ""
+
+    def get_installed(self):
+        cmd = ["install", "--list"]
+        data, dummy = self._exec(cmd, True, False, False)
+
+        package_regex = re.compile(r"^(\w+) v(.+):$")
+        installed = {}
+        for line in data.splitlines():
+            package_info = package_regex.match(line)
+            if package_info:
+                installed[package_info.group(1)] = package_info.group(2)
+
+        return installed
+
+    def install(self, packages=None):
+        cmd = ["install"]
+        cmd.extend(packages or self.name)
+        if self.path:
+            cmd.append("--root")
+            cmd.append(self.path)
+        if self.version:
+            cmd.append("--version")
+            cmd.append(self.version)
+        return self._exec(cmd)
+
+    def is_outdated(self, name):
+        installed_version = self.get_installed().get(name)
+
+        cmd = ["search", name, "--limit", "1"]
+        data = self._exec(cmd, True, False, False)
+
+        match = re.search(r'"(.+)"', data)
+        if match:
+            latest_version = match[1]
+
+        return installed_version != latest_version
+
+    def uninstall(self, packages=None):
+        cmd = ["uninstall"]
+        cmd.extend(packages or self.name)
+        return self._exec(cmd)
+
+
+def main():
+    arg_spec = dict(
+        name=dict(required=True, type="list", elements="str"),
+        path=dict(default=None, type="path"),
+        state=dict(default="present", choices=["present", "absent", "latest"]),
+        version=dict(default=None, type="str"),
+    )
+    module = AnsibleModule(argument_spec=arg_spec, supports_check_mode=True)
+
+    name = module.params["name"]
+    path = module.params["path"]
+    state = module.params["state"]
+    version = module.params["version"]
+
+    if not name:
+        module.fail_json(msg="Package name must be specified")
+
+    # Set LANG env since we parse stdout
+    module.run_command_environ_update = dict(
+        LANG="C", LC_ALL="C", LC_MESSAGES="C", LC_CTYPE="C"
+    )
+
+    cargo = Cargo(module, name=name, path=path, state=state, version=version)
+    changed, out, err = False, None, None
+    installed_packages = cargo.get_installed()
+    if state == "present":
+        to_install = [
+            n
+            for n in name
+            if (n not in installed_packages)
+            or (version and version != installed_packages[n])
+        ]
+        if to_install:
+            changed = True
+            out, err = cargo.install(to_install)
+    elif state == "latest":
+        to_update = [
+            n for n in name if n not in installed_packages or cargo.is_outdated(n)
+        ]
+        if to_update:
+            changed = True
+            out, err = cargo.install(to_update)
+    else:  # absent
+        to_uninstall = [n for n in name if n in installed_packages]
+        if to_uninstall:
+            changed = True
+            out, err = cargo.uninstall(to_uninstall)
+
+    module.exit_json(changed=changed, stdout=out, stderr=err)
+
+
+if __name__ == "__main__":
+    main()


Test patch:
diff --git a/tests/integration/targets/cargo/aliases b/tests/integration/targets/cargo/aliases
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/cargo/aliases
@@ -0,0 +1,3 @@
+destructive
+shippable/posix/group2
+skip/aix
diff --git a/tests/integration/targets/cargo/tasks/main.yml b/tests/integration/targets/cargo/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/cargo/tasks/main.yml
@@ -0,0 +1,5 @@
+- import_tasks: setup.yml
+- block:
+  - import_tasks: test_general.yml
+  - import_tasks: test_version.yml
+  when: has_cargo | default(false)
diff --git a/tests/integration/targets/cargo/tasks/setup.yml b/tests/integration/targets/cargo/tasks/setup.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/cargo/tasks/setup.yml
@@ -0,0 +1,14 @@
+---
+- block:
+  - name: Install cargo
+    package:
+      name: cargo
+      state: present
+  - set_fact:
+      has_cargo: true
+  when:
+    - ansible_system != 'FreeBSD' or ansible_distribution_version is version('13.0', '>')
+    - ansible_distribution != 'MacOSX'
+    - ansible_distribution != 'RedHat' or ansible_distribution_version is version('8.0', '>=')
+    - ansible_distribution != 'CentOS' or ansible_distribution_version is version('7.0', '>=')
+    - ansible_distribution != 'Ubuntu' or ansible_distribution_version is version('18', '>=')
diff --git a/tests/integration/targets/cargo/tasks/test_general.yml b/tests/integration/targets/cargo/tasks/test_general.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/cargo/tasks/test_general.yml
@@ -0,0 +1,31 @@
+---
+- name: Ensure application helloworld is uninstalled
+  community.general.cargo:
+    state: absent
+    name: helloworld
+  register: uninstall_absent_helloworld
+
+- name: Install application helloworld
+  community.general.cargo:
+    name: helloworld
+  register: install_absent_helloworld
+
+- name: Install application helloworld again
+  community.general.cargo:
+    name: helloworld
+  register: install_present_helloworld
+  ignore_errors: yes
+
+- name: Uninstall application helloworld
+  community.general.cargo:
+    state: absent
+    name: helloworld
+  register: uninstall_present_helloworld
+
+- name: Check assertions helloworld
+  assert:
+    that:
+        - uninstall_absent_helloworld is not changed
+        - install_absent_helloworld is changed
+        - install_present_helloworld is not changed
+        - uninstall_present_helloworld is changed
diff --git a/tests/integration/targets/cargo/tasks/test_version.yml b/tests/integration/targets/cargo/tasks/test_version.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/cargo/tasks/test_version.yml
@@ -0,0 +1,25 @@
+---
+- name: Install application helloworld-yliu 0.1.0
+  community.general.cargo:
+    name: helloworld-yliu
+    version: 0.1.0
+  register: install_helloworld_010
+
+- name: Upgrade helloworld-yliu 0.1.0
+  community.general.cargo:
+    name: helloworld-yliu
+    state: latest
+  register: upgrade_helloworld_010
+
+- name: Downgrade helloworld-yliu 0.1.0
+  community.general.cargo:
+    name: helloworld-yliu
+    version: 0.1.0
+  register: downgrade_helloworld_010
+
+- name: Check assertions helloworld-yliu 0.1.0
+  assert:
+    that:
+        - install_helloworld_010 is changed
+        - upgrade_helloworld_010 is changed
+        - downgrade_helloworld_010 is changed