Skip to main content

cargo_tangle/command/dev/
down.rs

1//! `cargo-tangle dev down` — stop the local devnet started by `dev up`.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use clap::Args;
7use color_eyre::eyre::{Result, bail};
8
9use crate::workspace::WORKSPACE_FILE;
10
11const DEV_DIR: &str = ".tangle/dev";
12
13#[derive(Args, Debug)]
14pub struct DownArgs {
15    /// Keep `.tangle.toml` in place; only stop processes and clear `.tangle/dev/`.
16    #[arg(long)]
17    pub keep_workspace: bool,
18}
19
20pub fn execute(args: DownArgs) -> Result<()> {
21    let dev_dir = PathBuf::from(DEV_DIR);
22
23    if !dev_dir.exists() && !Path::new(WORKSPACE_FILE).exists() {
24        println!("No devnet found in this directory.");
25        return Ok(());
26    }
27
28    let mut stopped = 0usize;
29    // `dev up` only writes anvil.pid today. manager.pid is intentionally
30    // tolerated here so an external orchestrator (or a future `dev up
31    // --with-manager`) can drop one alongside and we'll clean it up too.
32    for (label, file) in [("anvil", "anvil.pid"), ("manager", "manager.pid")] {
33        let path = dev_dir.join(file);
34        if let Some(pid) = read_pid(&path) {
35            if pid_alive(pid) {
36                if let Err(e) = kill_pid(pid) {
37                    eprintln!("⚠ failed to stop {label} ({pid}): {e}");
38                } else {
39                    println!("✓ stopped {label} (PID {pid})");
40                    stopped += 1;
41                }
42            }
43        }
44    }
45
46    if dev_dir.exists() {
47        fs::remove_dir_all(&dev_dir)?;
48        println!("✓ removed {}", dev_dir.display());
49        // If `.tangle/` was empty after removing dev/, drop it too.
50        if let Some(parent) = dev_dir.parent() {
51            if parent.as_os_str() != "" && parent.is_dir() {
52                if let Ok(mut iter) = fs::read_dir(parent) {
53                    if iter.next().is_none() {
54                        let _ = fs::remove_dir(parent);
55                    }
56                }
57            }
58        }
59    }
60
61    if !args.keep_workspace && Path::new(WORKSPACE_FILE).exists() {
62        // Only remove workspaces we can prove we created (active = "local").
63        if workspace_is_dev() {
64            fs::remove_file(WORKSPACE_FILE)?;
65            println!("✓ removed {WORKSPACE_FILE}");
66        } else {
67            println!(
68                "  leaving {WORKSPACE_FILE} (active network is not 'local'; use --keep-workspace to silence this)"
69            );
70        }
71    }
72
73    if stopped == 0 && dev_dir.exists() {
74        bail!("no running anvil/manager found (already stopped?)");
75    }
76
77    println!("Devnet is down.");
78    Ok(())
79}
80
81fn workspace_is_dev() -> bool {
82    let Ok(s) = fs::read_to_string(WORKSPACE_FILE) else {
83        return false;
84    };
85    // Positive identification only: the marker header is written by
86    // `dev up` and nothing else writes it. A user-authored workspace that
87    // happens to match some pattern is NEVER deleted.
88    s.contains(crate::command::dev::up::MANAGED_MARKER)
89}
90
91fn read_pid(path: &Path) -> Option<u32> {
92    fs::read_to_string(path)
93        .ok()
94        .and_then(|s| s.trim().parse().ok())
95}
96
97fn pid_alive(pid: u32) -> bool {
98    use nix::sys::signal::kill;
99    use nix::unistd::Pid;
100    kill(Pid::from_raw(pid as i32), None).is_ok()
101}
102
103fn kill_pid(pid: u32) -> Result<()> {
104    use nix::errno::Errno;
105    use nix::sys::signal::{Signal, kill};
106    use nix::unistd::Pid;
107    match kill(Pid::from_raw(pid as i32), Signal::SIGTERM) {
108        Ok(()) | Err(Errno::ESRCH) => Ok(()),
109        Err(e) => Err(color_eyre::eyre::eyre!("SIGTERM to {pid} failed: {e}")),
110    }
111}