Skip to main content

leviath_cli/commands/
remove.rs

1//! `lev remove` - Remove an installed agent
2
3use clap::Args;
4
5#[derive(Args)]
6pub struct RemoveArgs {
7    /// Name of the installed agent to remove
8    #[arg(value_name = "NAME")]
9    pub name: String,
10}
11
12pub async fn execute(args: RemoveArgs) -> anyhow::Result<()> {
13    let installer = leviath_package::AgentInstaller::new();
14    remove_agent(&installer, &args.name)
15}
16
17/// Core removal logic, parameterized by installer so it can be tested
18/// against a tempdir instead of the real `~/.leviath/agents`.
19fn remove_agent(installer: &leviath_package::AgentInstaller, name: &str) -> anyhow::Result<()> {
20    remove_agent_with(installer, name, &|n| installer.uninstall(n))
21}
22
23/// [`remove_agent`] with an injectable uninstall operation.
24///
25/// The `uninstall` closure is a trait object so its failure arm can be
26/// exercised on every platform: a genuinely installed agent (which
27/// `get_installed` requires - an `agent.leviath` under the agent dir) is an
28/// ordinary removable directory, so `remove_dir_all` only fails on it via a
29/// Unix-only `chmod` on the parent. Production always passes the real
30/// `installer.uninstall`.
31fn remove_agent_with(
32    installer: &leviath_package::AgentInstaller,
33    name: &str,
34    uninstall: &dyn Fn(&str) -> anyhow::Result<()>,
35) -> anyhow::Result<()> {
36    // Verify it's actually installed first
37    let installed = installer.get_installed(name).unwrap();
38    if installed.is_none() {
39        anyhow::bail!(
40            "Agent '{}' is not installed. Use `lev list` to see installed agents.",
41            name
42        );
43    }
44
45    uninstall(name)?;
46    println!("Removed agent '{}'.", name);
47    Ok(())
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn remove_args_stores_name() {
56        let args = RemoveArgs {
57            name: "my-agent".to_string(),
58        };
59        assert_eq!(args.name, "my-agent");
60    }
61
62    #[test]
63    fn remove_args_accepts_various_names() {
64        for name in &[
65            "simple",
66            "with-dash",
67            "with_underscore",
68            "CamelCase",
69            "v1.0.0",
70        ] {
71            let args = RemoveArgs {
72                name: name.to_string(),
73            };
74            assert_eq!(args.name, *name);
75        }
76    }
77
78    // ─── remove_agent ────────────────────────────────────────────────────
79
80    fn install_test_agent(installer: &leviath_package::AgentInstaller, name: &str) {
81        let project_dir = tempfile::tempdir().unwrap();
82        std::fs::write(
83            project_dir.path().join("agent.leviath"),
84            format!(
85                "[agent]\nname = \"{name}\"\nversion = \"1.0.0\"\ndescription = \"test agent\"\n"
86            ),
87        )
88        .unwrap();
89        let bundle = leviath_package::AgentBundler::new()
90            .bundle(project_dir.path())
91            .unwrap();
92        installer.install_from_bytes(name, &bundle).unwrap();
93    }
94
95    #[test]
96    fn remove_agent_not_installed_returns_error() {
97        let dir = tempfile::tempdir().unwrap();
98        let installer = leviath_package::AgentInstaller::with_install_dir(dir.path().to_path_buf());
99
100        let err = remove_agent(&installer, "nonexistent").unwrap_err();
101        assert!(err.to_string().contains("is not installed"));
102        assert!(err.to_string().contains("lev list"));
103    }
104
105    #[test]
106    fn remove_agent_installed_succeeds_and_uninstalls() {
107        let dir = tempfile::tempdir().unwrap();
108        let installer = leviath_package::AgentInstaller::with_install_dir(dir.path().to_path_buf());
109        install_test_agent(&installer, "my-agent");
110        assert!(installer.get_installed("my-agent").unwrap().is_some());
111
112        remove_agent(&installer, "my-agent").unwrap();
113
114        assert!(installer.get_installed("my-agent").unwrap().is_none());
115    }
116
117    // ─── execute() ───────────────────────────────────────────────────────
118    //
119    // `execute()` always constructs a real `AgentInstaller::new()` pointed at
120    // the developer's real `~/.leviath/agents` - there's no env-var seam for
121    // it (unlike `Config`'s `LEVIATH_CONFIG_PATH`), so this can't be driven
122    // through a real install/uninstall round trip without touching that real
123    // directory. It's still safe to exercise the not-installed error path:
124    // `get_installed` only *reads* that directory, and an agent name this
125    // specific is never going to exist there for real.
126    #[tokio::test]
127    async fn execute_with_nonexistent_agent_returns_error() {
128        let args = RemoveArgs {
129            name: "definitely-nonexistent-agent-for-lev-remove-coverage-xyz".to_string(),
130        };
131        let err = execute(args).await.unwrap_err();
132        assert!(err.to_string().contains("is not installed"));
133    }
134
135    #[test]
136    fn remove_agent_uninstall_error_propagates() {
137        let dir = tempfile::tempdir().unwrap();
138        let installer = leviath_package::AgentInstaller::with_install_dir(dir.path().to_path_buf());
139        install_test_agent(&installer, "err-agent");
140
141        // An injected uninstall that fails exercises the `uninstall(name)?`
142        // error arm deterministically on every platform.
143        let result = remove_agent_with(&installer, "err-agent", &|_| {
144            Err(anyhow::anyhow!("simulated uninstall failure"))
145        });
146        assert!(result.is_err());
147    }
148}