Skip to main content

algocline_app/service/pkg/
remove.rs

1//! `pkg_remove` — remove a package entry, scoped by `scope`:
2//! `"project"` (default) removes from `alc.toml` + `alc.lock`;
3//! `"global"` removes from `~/.algocline/installed.json`;
4//! `"all"` removes from both.
5//!
6//! Physical files in `~/.algocline/packages/{name}/` are never deleted by any
7//! scope. See `PkgRemoveScope` in the MCP layer for the enum definition and
8//! CHANGELOG for the semantic difference from the historical 0.14.0 `scope`.
9
10use algocline_core::AppDir;
11
12use super::super::alc_toml::{load_alc_toml_document, remove_package_entry, save_alc_toml};
13use super::super::lockfile::{load_lockfile, lockfile_path, save_lockfile};
14use super::super::manifest::{load_manifest, record_remove};
15use super::super::project::resolve_project_root;
16use super::super::AppService;
17
18impl AppService {
19    /// Remove a package entry scoped by `scope`. See module-level docs.
20    ///
21    /// Parameters:
22    /// - `name`: package name to remove.
23    /// - `project_root`: optional explicit project root. Required for
24    ///   `"project"` / `"all"`; ignored for `"global"`.
25    /// - `version`: optional version constraint (only affects `alc.lock`
26    ///   removal in project scope; the global manifest is version-agnostic).
27    /// - `scope`: `"project"` (default, back-compat), `"global"`, or `"all"`.
28    ///   Any other value errors.
29    pub async fn pkg_remove(
30        &self,
31        name: &str,
32        project_root: Option<String>,
33        version: Option<String>,
34        scope: Option<String>,
35    ) -> Result<String, String> {
36        let scope = scope.as_deref().unwrap_or("project");
37        let app_dir = self.log_config.app_dir();
38        match scope {
39            "project" => remove_from_project(name, project_root, version),
40            "global" => remove_from_global(&app_dir, name),
41            "all" => remove_from_all(&app_dir, name, project_root, version),
42            other => Err(format!(
43                "invalid scope '{other}': expected one of project, global, all"
44            )),
45        }
46    }
47}
48
49/// Remove from `alc.toml` + `alc.lock`. Existing 0.15.0+ behavior.
50fn remove_from_project(
51    name: &str,
52    project_root: Option<String>,
53    version: Option<String>,
54) -> Result<String, String> {
55    let root = resolve_project_root(project_root.as_deref()).ok_or_else(|| {
56        format!(
57            "alc.toml not found: cannot remove '{name}' without a project root. \
58             Provide project_root or run from a project directory."
59        )
60    })?;
61
62    // alc.toml (best-effort: entry may already be gone).
63    match load_alc_toml_document(&root)? {
64        Some(mut doc) => {
65            remove_package_entry(&mut doc, name);
66            save_alc_toml(&root, &doc)?;
67        }
68        None => {
69            return Err(format!("alc.toml not found at {}", root.display()));
70        }
71    }
72
73    // alc.lock (authoritative: absence is an error so callers can't silently
74    // no-op on a typo'd name).
75    let alc_lock_path = lockfile_path(&root);
76    match load_lockfile(&root)? {
77        Some(mut lock) => {
78            let before = lock.packages.len();
79            lock.packages.retain(|p| {
80                if p.name != name {
81                    return true;
82                }
83                match &version {
84                    Some(v) => p.version.as_deref() != Some(v.as_str()),
85                    None => false,
86                }
87            });
88
89            if lock.packages.len() == before {
90                return Err(format!(
91                    "Package '{name}' not found in alc.lock at {}",
92                    alc_lock_path.display()
93                ));
94            }
95
96            save_lockfile(&root, &lock)?;
97        }
98        None => {
99            return Err(format!(
100                "Package '{name}' not found in alc.lock at {}",
101                alc_lock_path.display()
102            ));
103        }
104    }
105
106    Ok(serde_json::json!({
107        "removed": name,
108        "scope": "project",
109        "alc_toml": root.join("alc.toml").display().to_string(),
110        "alc_lock": alc_lock_path.display().to_string(),
111    })
112    .to_string())
113}
114
115/// Remove from `{app_dir}/installed.json`. Physical `packages/{name}/` is
116/// untouched — symmetric with the project scope's no-delete policy.
117fn remove_from_global(app_dir: &AppDir, name: &str) -> Result<String, String> {
118    let manifest = load_manifest(app_dir)?;
119    if !manifest.packages.contains_key(name) {
120        return Err(format!(
121            "Package '{name}' not found in global manifest ({})",
122            app_dir.installed_json().display()
123        ));
124    }
125
126    record_remove(app_dir, name)?;
127
128    Ok(serde_json::json!({
129        "removed": name,
130        "scope": "global",
131        "installed_json": manifest_path_display(app_dir),
132    })
133    .to_string())
134}
135
136/// Remove from both project and global. Lenient: success if either scope
137/// had the entry; only errors when neither did.
138fn remove_from_all(
139    app_dir: &AppDir,
140    name: &str,
141    project_root: Option<String>,
142    version: Option<String>,
143) -> Result<String, String> {
144    let project_res = remove_from_project(name, project_root, version);
145    let global_res = remove_from_global(app_dir, name);
146
147    let (project_ok, project_err) = match project_res {
148        Ok(_) => (true, None),
149        Err(e) => (false, Some(e)),
150    };
151    let (global_ok, global_err) = match global_res {
152        Ok(_) => (true, None),
153        Err(e) => (false, Some(e)),
154    };
155
156    if !project_ok && !global_ok {
157        return Err(format!(
158            "Package '{name}' not found in any scope:\n  project: {}\n  global: {}",
159            project_err.unwrap_or_default(),
160            global_err.unwrap_or_default()
161        ));
162    }
163
164    Ok(serde_json::json!({
165        "removed": name,
166        "scope": "all",
167        "project_removed": project_ok,
168        "global_removed": global_ok,
169        "project_note": project_err,
170        "global_note": global_err,
171    })
172    .to_string())
173}
174
175/// Display string for `{app_dir}/installed.json`, used only in the
176/// informational JSON response — never for correctness.
177fn manifest_path_display(app_dir: &AppDir) -> String {
178    app_dir.installed_json().display().to_string()
179}