Skip to main content

rbnx/cmd/
clean.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// `rbnx clean` — drop build artifacts.
3//
4//   rbnx clean [-p <pkg>]      remove <pkg>/rbnx-build/. Defaults to the
5//                              package containing the current directory.
6//   rbnx clean -f <manifest>   recursively clean every package the manifest
7//                              references (path: + url: + system/*) plus the
8//                              deploy's rbnx-boot/{logs,state.json}.
9//   rbnx clean -f <m> --cache  also wipe rbnx-boot/cache/ (force re-clone).
10
11use anyhow::{Context, Result};
12use robonix_cli::config::Config;
13use robonix_cli::output;
14use serde_yaml::Value;
15use std::io::{self, BufRead, Write};
16use std::path::{Path, PathBuf};
17
18use super::run_package;
19
20/// Ask the user (y/N) whether to retry the failed paths with `sudo rm -rf`.
21/// Returns Ok(()) if the retry succeeded or the user opted out cleanly.
22/// Returns Err if sudo failed or the user declined and we want to surface
23/// that as a non-zero exit.
24fn prompt_sudo_retry(failed: Vec<(PathBuf, std::io::Error)>) -> Result<()> {
25    if failed.is_empty() {
26        return Ok(());
27    }
28    eprintln!();
29    eprintln!("These paths could not be removed (likely docker-build root-owned files):");
30    for (p, e) in &failed {
31        eprintln!("  ✗ {} : {}", p.display(), e);
32    }
33    eprintln!();
34    eprint!(
35        "Retry with `sudo rm -rf` for the {} path(s) above? [y/N] ",
36        failed.len()
37    );
38    io::stderr().flush().ok();
39    let mut line = String::new();
40    io::stdin().lock().read_line(&mut line)?;
41    let yes = matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes");
42    if !yes {
43        anyhow::bail!(
44            "{} path(s) skipped (run `sudo rm -rf <path>` manually if needed)",
45            failed.len()
46        );
47    }
48    let mut cmd = std::process::Command::new("sudo");
49    cmd.arg("rm").arg("-rf");
50    for (p, _) in &failed {
51        cmd.arg(p);
52    }
53    let status = cmd
54        .status()
55        .context("failed to spawn sudo (is it installed?)")?;
56    if !status.success() {
57        anyhow::bail!("sudo rm exited with {:?}", status.code());
58    }
59    Ok(())
60}
61
62pub async fn execute(
63    config: Config,
64    package: Option<PathBuf>,
65    file: Option<PathBuf>,
66    cache: bool,
67) -> Result<()> {
68    match (package, file) {
69        (Some(_), Some(_)) => {
70            anyhow::bail!("pass one of -p / -f, not both")
71        }
72        (Some(pkg), None) => clean_package(&pkg),
73        (None, Some(f)) => clean_deploy(&config, &f, cache),
74        (None, None) => {
75            // Default-mode resolution. cwd-local hints in priority order:
76            //   1. ./robonix_manifest.yaml   → deploy clean (sibling of `rbnx boot -f`)
77            //   2. ./package_manifest.yaml or ancestor → package clean
78            //   3. neither → bail with both options surfaced
79            let cwd = std::env::current_dir().context("get cwd")?;
80            let deploy = cwd.join("robonix_manifest.yaml");
81            if deploy.is_file() {
82                return clean_deploy(&config, &deploy, cache);
83            }
84            match run_package::find_package_from_cwd() {
85                Ok(pkg) => clean_package(&pkg),
86                Err(_) => anyhow::bail!(
87                    "no robonix_manifest.yaml in {} and no package_manifest.yaml in any parent. \
88                     Pass `-f <manifest>` (deploy) or `-p <pkg>` (package).",
89                    cwd.display()
90                ),
91            }
92        }
93    }
94}
95
96fn clean_package(pkg: &Path) -> Result<()> {
97    let pkg = pkg
98        .canonicalize()
99        .with_context(|| format!("not a directory: {}", pkg.display()))?;
100    let build = pkg.join("rbnx-build");
101    if !build.exists() {
102        output::sub_step(&format!(
103            "nothing to clean: {} (no rbnx-build/)",
104            pkg.display()
105        ));
106        return Ok(());
107    }
108    output::action("Cleaning", &build.display().to_string());
109    if let Err(e) = std::fs::remove_dir_all(&build) {
110        // Common case: docker-build left root-owned files. Ask the user
111        // whether to retry with sudo rather than bailing or silently
112        // running sudo on their behalf.
113        return prompt_sudo_retry(vec![(build, e)]);
114    }
115    Ok(())
116}
117
118fn clean_deploy(config: &Config, manifest_path: &Path, also_cache: bool) -> Result<()> {
119    let manifest_path = manifest_path
120        .canonicalize()
121        .with_context(|| format!("manifest not found: {}", manifest_path.display()))?;
122    let manifest_dir = manifest_path
123        .parent()
124        .context("deploy manifest has no parent directory")?
125        .to_path_buf();
126    let raw = std::fs::read_to_string(&manifest_path)
127        .with_context(|| format!("read {}", manifest_path.display()))?;
128    let root: Value =
129        serde_yaml::from_str(&raw).with_context(|| format!("parse {}", manifest_path.display()))?;
130    let cache_root = manifest_dir.join("rbnx-boot").join("cache");
131
132    output::action("Cleaning deploy", &manifest_path.display().to_string());
133
134    // Collect every package directory referenced by the manifest.
135    let mut pkgs: Vec<PathBuf> = Vec::new();
136    for section in &["primitive", "service", "skill"] {
137        let Some(seq) = root.get(*section).and_then(|v| v.as_sequence()) else {
138            continue;
139        };
140        for entry in seq {
141            let name = entry
142                .get("name")
143                .and_then(|v| v.as_str())
144                .unwrap_or("(unnamed)")
145                .to_string();
146            let local = entry.get("path").and_then(|v| v.as_str());
147            let url = entry.get("url").and_then(|v| v.as_str());
148            match (local, url) {
149                (Some(p), _) => pkgs.push(manifest_dir.join(p)),
150                (None, Some(_)) => pkgs.push(cache_root.join(&name)),
151                _ => {}
152            }
153        }
154    }
155    // system: section — non-builtin entries are real packages under
156    // `<robonix_source>/system/<key>/` (memory/scene/speech/…).
157    const SYSTEM_BUILTINS: &[&str] = &["atlas", "executor", "pilot", "liaison"];
158    if let Some(map) = root.get("system").and_then(|v| v.as_mapping())
159        && let Some(source_root) = config.robonix_source_path.as_ref()
160    {
161        for (key, _) in map {
162            let Some(k) = key.as_str() else { continue };
163            if SYSTEM_BUILTINS.contains(&k) {
164                continue;
165            }
166            let pkg = source_root.join("system").join(k);
167            if pkg.exists() {
168                pkgs.push(pkg);
169            }
170        }
171    }
172
173    // Per-package cleanup. Tolerate per-package failures (docker-build
174    // packages often leave root-owned rbnx-build/ that requires sudo).
175    output::step("packages", &format!("{} to inspect", pkgs.len()));
176    let mut failed: Vec<(PathBuf, std::io::Error)> = Vec::new();
177    for p in &pkgs {
178        if !p.exists() {
179            continue;
180        }
181        let build = p.join("rbnx-build");
182        if build.exists() {
183            output::sub_step(&format!("rm -rf {}", build.display()));
184            if let Err(e) = std::fs::remove_dir_all(&build) {
185                failed.push((build, e));
186            }
187        }
188    }
189
190    // Deploy-level cleanup. Walk every entry under <manifest>/rbnx-boot/
191    // and remove it. Skip cache/ unless `--cache` was given (re-cloning
192    // url-fetched packages is expensive).
193    let rbnx_boot = manifest_dir.join("rbnx-boot");
194    if rbnx_boot.exists() {
195        if let Ok(entries) = std::fs::read_dir(&rbnx_boot) {
196            for entry in entries.flatten() {
197                let path = entry.path();
198                let name = entry.file_name();
199                let is_cache = name == "cache";
200                if is_cache && !also_cache {
201                    output::sub_step(&format!("keep {} (use --cache to wipe)", path.display()));
202                    continue;
203                }
204                let label = if is_cache { " (cache)" } else { "" };
205                let kind = entry.file_type().ok();
206                if kind.map(|k| k.is_dir()).unwrap_or(false) {
207                    output::sub_step(&format!("rm -rf {}{}", path.display(), label));
208                    if let Err(e) = std::fs::remove_dir_all(&path) {
209                        failed.push((path, e));
210                    }
211                } else {
212                    output::sub_step(&format!("rm {}{}", path.display(), label));
213                    if let Err(e) = std::fs::remove_file(&path) {
214                        failed.push((path, e));
215                    }
216                }
217            }
218        }
219        // If rbnx-boot/ is empty (or only cache/ left when !also_cache),
220        // try to rmdir it too. Best-effort — no failure surface.
221        let _ = std::fs::remove_dir(&rbnx_boot);
222    }
223
224    prompt_sudo_retry(failed)
225}