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