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