Repository: ansible-collections/community.general

Issue:
cargo fails to find standard homedir path for cargo binary
### Summary

the cargo module fails with -

`TASK [Install tokei Rust package] ***************************************************************************************************************************
fatal: [hostname]: FAILED! => {"changed": false, "msg": "Failed to find required executable \"cargo\" in paths: /usr/local/bin:/usr/bin:/bin:/usr/games:/sbin:/usr/sbin:/usr/local/sbin"}`

cargo executable is located in default rustup install location `/home/username/.cargo/bin/` 

### Issue Type

Bug Report

### Component Name

cargo

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.12.3]
  config file = /home/username/foo/ansible.cfg
  configured module search path = ['/home/username/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  ansible collection location = /home/username/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/bin/ansible
  python version = 3.9.10 (main, Feb 22 2022, 13:54:07) [GCC 11.2.0]
  jinja version = 3.0.3
  libyaml = True
```


### Community.general Version

```console (paste below)
$ ansible-galaxy collection list community.general

# /usr/lib/python3/dist-packages/ansible_collections
Collection        Version
----------------- -------
community.general 4.5.0  
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed
DEFAULT_HOST_LIST(/home/username/foo/ansible.cfg) = ['/home/username/foo/HOSTS']
```


### OS / Environment

Debian Bookworm targeting Bookworm

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)

```
Run this module against home directory with the default rustup install location.

### Expected Results

cargo installs the package

### Actual Results

```console (paste below)
TASK [Install tokei Rust package] ***************************************************************************************************************************
fatal: [hostname]: FAILED! => {"changed": false, "msg": "Failed to find required executable \"cargo\" in paths: /usr/local/bin:/usr/bin:/bin:/usr/games:/sbin:/usr/sbin:/usr/local/sbin"}
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/packaging/language/cargo.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/packaging/language/cargo.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @radek-sprta
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Ansible looks in `/sbin`, `/usr/sbin`, `/usr/local/sbin` and `$PATH`. Your `$PATH` only seems to contain `/usr/local/bin`, `/usr/bin`, and `/bin`, but not `/home/username/.cargo/bin/`.
Yes ansible is ignoring the expanded path from the .bashrc and .profile (and this is how ansible normally works).

Rustup puts a `. ~/.cargo/env` in profile or bashrc and this is being ignored by ansible and so the cargo module can't work with this setup.

There is mention of this in the ansible cargo docs as working better with the hardcoded bin paths probably because of this issue.

edit-

This seems like a case where the module cargo should use something like -

`shell: bash -ilc 'which cargo'`

So that the proper environment is found.


Ansible does nowhere document that these files are loaded and/or interpreted. So why is this a bug? It is a mismatched expectation / missing feature (ability to specify extra paths where to look, diectly path to executable, or automatically add `~/.cargo/bin/` to search path), but I don't see how this is a bug.
This module seems like a busy sysadmins trap.

It doesn't detect the default install location for most peoples development environment (ie rustup based) that it's failing to find.

It's also going to invoke cargo install (which does build things) without respecting the developer rust environment.

There are many potential bugs by hardcoding things like this.

Right now this module seems unsuitable for a developer workstation deploy.

The module can work for people that only want to install ripgrep via an os installed cargo (thus bypassing the os installed ripgrep).

I think I would have nacked this module until both the path and cargo respected the remote environment.

But really it needs some rustup integration as well, as a big chunk of developer tooling is in rustup. -

https://github.com/hurricanehrndz/ansible-rustup

This following yaml was robust and useful in deploying a workstation
```
---
- hosts: workstations
  vars:
    cargo_installs: [tokei,cargo-crev,flamegraph]
    rustup_installs: [rustfmt, rust-src, clippy]
  tasks:
  - name: check if cargo is installed
    shell: bash -lc "command -v cargo"
    register: cargo_exists
    ignore_errors: no
  - name: Download rustup
    when: cargo_exists is failed
    get_url:
      url: https://sh.rustup.rs
      dest: /tmp/sh.rustup.rs
      mode: '0750'
      force: 'yes'
    tags:
      - rust
  - name: install rust/cargo
    when: cargo_exists is failed
    shell: /tmp/sh.rustup.rs -y
    tags:
      - rust
  - name: do the cargo installs
    with_items: "{{cargo_installs}}"
    shell: "bash -lc 'cargo install -q {{item}}'"
    loop: "{{cargo_installs}}"
    register: op
  - name: do the rustup installs
    with_items: "{{rustup_installs}}"
    shell: "bash -lc 'rustup component add {{item}}'"
  - name: rustup update
    shell: bash -lc "rustup update"
```
original source - https://waylonwalker.com/til/install-rust/#full-install-playbook
I think implementing `executable` argument to specify path to the binary (like some of the other modules do) seems to be the best solution. 
@radek-sprta I think that would solve the immediate issue but I'm not sure it will be correct in the medium term.

When you run "~/.cargo/bin/cargo" or the found cargo directly its still going to ignore -

https://doc.rust-lang.org/cargo/reference/environment-variables.html

If you don't run cargo install within the shell environment.

I have no idea who or in what circumstances would get future confusion or bugs.
Whjen I get back from PTO, I'll take a look at implementing those.
Files identified in the description:
* [`plugins/modules/cargo.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/cargo.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
It's old, but searches brings here. My current workaround is to directly alter the PATH:

```yaml
- name: install rust stuff
  environment:
    PATH: "{{ansible_env.PATH}}:{{ ansible_env.HOME }}/.cargo/bin"
  community.general.cargo:
    name:
      - ...
 ```

It has the advantage of making it explicit which cargo I target (but for that I would prefer an `executable` option still).

Resolving patch:
diff --git a/plugins/modules/cargo.py b/plugins/modules/cargo.py
--- a/plugins/modules/cargo.py
+++ b/plugins/modules/cargo.py
@@ -25,6 +25,12 @@
   diff_mode:
     support: none
 options:
+  executable:
+    description:
+      - Path to the C(cargo) installed in the system.
+      - If not specified, the module will look C(cargo) in E(PATH).
+    type: path
+    version_added: 7.5.0
   name:
     description:
       - The name of a Rust package to install.
@@ -60,7 +66,7 @@
     default: present
     choices: [ "present", "absent", "latest" ]
 requirements:
-    - cargo installed in bin path (recommended /usr/local/bin)
+    - cargo installed
 """
 
 EXAMPLES = r"""
@@ -103,14 +109,13 @@
 class Cargo(object):
     def __init__(self, module, **kwargs):
         self.module = module
+        self.executable = [kwargs["executable"] or module.get_bin_path("cargo", True)]
         self.name = kwargs["name"]
         self.path = kwargs["path"]
         self.state = kwargs["state"]
         self.version = kwargs["version"]
         self.locked = kwargs["locked"]
 
-        self.executable = [module.get_bin_path("cargo", True)]
-
     @property
     def path(self):
         return self._path
@@ -176,6 +181,7 @@ def uninstall(self, packages=None):
 
 def main():
     arg_spec = dict(
+        executable=dict(default=None, type="path"),
         name=dict(required=True, type="list", elements="str"),
         path=dict(default=None, type="path"),
         state=dict(default="present", choices=["present", "absent", "latest"]),
@@ -185,10 +191,8 @@ def main():
     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"]
-    locked = module.params["locked"]
 
     if not name:
         module.fail_json(msg="Package name must be specified")
@@ -198,7 +202,7 @@ def main():
         LANG="C", LC_ALL="C", LC_MESSAGES="C", LC_CTYPE="C"
     )
 
-    cargo = Cargo(module, name=name, path=path, state=state, version=version, locked=locked)
+    cargo = Cargo(module, **module.params)
     changed, out, err = False, None, None
     installed_packages = cargo.get_installed()
     if state == "present":


Test patch:
diff --git a/tests/integration/targets/cargo/tasks/main.yml b/tests/integration/targets/cargo/tasks/main.yml
--- a/tests/integration/targets/cargo/tasks/main.yml
+++ b/tests/integration/targets/cargo/tasks/main.yml
@@ -18,3 +18,5 @@
   - import_tasks: test_version.yml
   environment: "{{ cargo_environment }}"
   when: has_cargo | default(false)
+- import_tasks: test_rustup_cargo.yml
+  when: rustup_cargo_bin | default(false)
diff --git a/tests/integration/targets/cargo/tasks/setup.yml b/tests/integration/targets/cargo/tasks/setup.yml
--- a/tests/integration/targets/cargo/tasks/setup.yml
+++ b/tests/integration/targets/cargo/tasks/setup.yml
@@ -26,3 +26,17 @@
       has_cargo: true
   when:
     - ansible_system == 'FreeBSD' and ansible_distribution_version is version('13.0', '>')
+
+- block:
+  - name: Download rustup
+    get_url:
+      url: https://sh.rustup.rs
+      dest: /tmp/sh.rustup.rs
+      mode: "0750"
+      force: true
+  - name: Install rustup cargo
+    command: /tmp/sh.rustup.rs -y
+  - set_fact:
+      rustup_cargo_bin: "{{ lookup('env', 'HOME') }}/.cargo/bin/cargo"
+  when:
+    - ansible_distribution != 'CentOS' or ansible_distribution_version is version('7.0', '>=')
diff --git a/tests/integration/targets/cargo/tasks/test_rustup_cargo.yml b/tests/integration/targets/cargo/tasks/test_rustup_cargo.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/cargo/tasks/test_rustup_cargo.yml
@@ -0,0 +1,23 @@
+---
+# Copyright (c) Ansible Project
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+- name: Install application helloworld
+  community.general.cargo:
+    executable: "{{ rustup_cargo_bin }}"
+    name: helloworld
+  register: rustup_install_absent_helloworld
+
+- name: Uninstall application helloworld
+  community.general.cargo:
+    executable: "{{ rustup_cargo_bin }}"
+    state: absent
+    name: helloworld
+  register: rustup_uninstall_present_helloworld
+
+- name: Check assertions helloworld
+  assert:
+    that:
+        - rustup_install_absent_helloworld is changed
+        - rustup_uninstall_present_helloworld is changed