Skip to main content

algocline_app/service/pkg/
list.rs

1//! `pkg_list` — enumerate installed packages (project-local + global).
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use super::super::lockfile::{load_lockfile, lockfile_path};
7use super::super::manifest;
8use super::super::project::resolve_project_root;
9use super::super::resolve::is_system_package;
10use super::super::source::{infer_from_legacy_source_string, PackageSource};
11use super::super::AppService;
12
13// ─── Intermediate DTO for pkg_list ───────────────────────────────
14
15#[derive(Debug)]
16enum Scope {
17    Project,
18    Global,
19}
20
21/// Typed intermediate representation of a single package list entry.
22/// Converted to `serde_json::Value` only at the final serialisation step.
23/// Fields that are `None` are omitted from the output JSON.
24#[derive(Debug)]
25struct PackageListEntry {
26    name: String,
27    scope: Scope,
28    /// Absent (`None`) when the package is not recorded in `installed.json`.
29    source_type: Option<String>,
30    /// Absolute path — project-local packages only.
31    path: Option<String>,
32    /// Search-path directory — global packages only.
33    source: Option<String>,
34    active: bool,
35    linked_at: Option<String>,
36    installed_at: Option<String>,
37    updated_at: Option<String>,
38    /// Legacy source string from `installed.json` (the raw URL/path).
39    install_source: Option<String>,
40    overrides: Option<Vec<String>>,
41    meta: serde_json::Value,
42    error: Option<String>,
43}
44
45impl PackageListEntry {
46    fn into_json(self) -> serde_json::Value {
47        let scope_str = match self.scope {
48            Scope::Project => "project",
49            Scope::Global => "global",
50        };
51
52        let mut map = serde_json::Map::new();
53        map.insert("name".to_string(), serde_json::Value::String(self.name));
54        map.insert(
55            "scope".to_string(),
56            serde_json::Value::String(scope_str.to_string()),
57        );
58
59        // source_type: only insert when resolved (no fallback to "global")
60        if let Some(st) = self.source_type {
61            map.insert("source_type".to_string(), serde_json::Value::String(st));
62        }
63
64        if let Some(p) = self.path {
65            map.insert("path".to_string(), serde_json::Value::String(p));
66        }
67        if let Some(s) = self.source {
68            map.insert("source".to_string(), serde_json::Value::String(s));
69        }
70
71        map.insert("active".to_string(), serde_json::Value::Bool(self.active));
72
73        if let Some(la) = self.linked_at {
74            map.insert("linked_at".to_string(), serde_json::Value::String(la));
75        }
76        if let Some(ia) = self.installed_at {
77            map.insert("installed_at".to_string(), serde_json::Value::String(ia));
78        }
79        if let Some(ua) = self.updated_at {
80            map.insert("updated_at".to_string(), serde_json::Value::String(ua));
81        }
82        if let Some(is) = self.install_source {
83            map.insert("install_source".to_string(), serde_json::Value::String(is));
84        }
85        if let Some(ov) = self.overrides {
86            map.insert("overrides".to_string(), serde_json::json!(ov));
87        }
88
89        // Merge meta fields (Lua pkg.meta) into the top-level object.
90        if let serde_json::Value::Object(meta_map) = self.meta {
91            for (k, v) in meta_map {
92                // Never let meta overwrite the fields we have already set.
93                map.entry(k).or_insert(v);
94            }
95        }
96
97        if let Some(err) = self.error {
98            map.insert("error".to_string(), serde_json::Value::String(err));
99        }
100
101        serde_json::Value::Object(map)
102    }
103}
104
105impl AppService {
106    /// List installed packages with metadata, showing the full override chain.
107    ///
108    /// When `project_root` is provided (or resolvable), project-local packages
109    /// from `alc.lock` are prepended with `scope: "project"`. Global packages
110    /// carry `scope: "global"`. If a project package and a global package share
111    /// the same name, the project one is `active: true` and the global one
112    /// `active: false`.
113    pub async fn pkg_list(&self, project_root: Option<String>) -> Result<String, String> {
114        // ── Load manifest once upfront ─────────────────────────────────────
115        let manifest_data = manifest::load_manifest().unwrap_or_default();
116
117        // ── Project-local packages (from alc.lock) ─────────────────────────
118        let resolved_root = resolve_project_root(project_root.as_deref());
119
120        let mut project_names: std::collections::HashSet<String> = std::collections::HashSet::new();
121        let mut entries: Vec<PackageListEntry> = Vec::new();
122        let mut project_root_str: Option<String> = None;
123        let mut lockfile_path_str: Option<String> = None;
124
125        if let Some(ref root) = resolved_root {
126            project_root_str = Some(root.display().to_string());
127            lockfile_path_str = Some(lockfile_path(root).display().to_string());
128
129            match load_lockfile(root) {
130                Ok(Some(lock)) => {
131                    for pkg in &lock.packages {
132                        let PackageSource::LocalDir { path: ref raw_path } = pkg.source else {
133                            continue;
134                        };
135                        let abs_path = {
136                            let p = Path::new(raw_path);
137                            if p.is_absolute() {
138                                p.to_path_buf()
139                            } else {
140                                root.join(p)
141                            }
142                        };
143
144                        project_names.insert(pkg.name.clone());
145                        entries.push(PackageListEntry {
146                            name: pkg.name.clone(),
147                            scope: Scope::Project,
148                            source_type: Some("local_dir".to_string()),
149                            path: Some(abs_path.display().to_string()),
150                            source: None,
151                            active: true,
152                            linked_at: Some(pkg.linked_at.clone()),
153                            installed_at: None,
154                            updated_at: None,
155                            install_source: None,
156                            overrides: None,
157                            meta: serde_json::Value::Object(serde_json::Map::new()),
158                            error: None,
159                        });
160                    }
161                }
162                Ok(None) => {}
163                Err(e) => {
164                    tracing::warn!("failed to load alc.lock: {e}");
165                }
166            }
167        }
168
169        // ── Global packages (from search paths) ────────────────────────────
170        // Key: package name → list of (search_path_index, source_display)
171        let mut seen: HashMap<String, Vec<(usize, String)>> = HashMap::new();
172        // Separate Vec so overrides pass can reference seen after collection.
173        let global_start_idx = entries.len();
174
175        for (idx, sp) in self.search_paths.iter().enumerate() {
176            if !sp.path.is_dir() {
177                continue;
178            }
179            let read_entries = match std::fs::read_dir(&sp.path) {
180                Ok(e) => e,
181                Err(_) => continue,
182            };
183
184            for dir_entry in read_entries.flatten() {
185                let path = dir_entry.path();
186                if !path.is_dir() {
187                    continue;
188                }
189                if !path.join("init.lua").exists() {
190                    continue;
191                }
192                let name = dir_entry.file_name().to_string_lossy().to_string();
193                if is_system_package(&name) {
194                    continue;
195                }
196
197                let source_display = sp.path.display().to_string();
198                seen.entry(name.clone())
199                    .or_default()
200                    .push((idx, source_display.clone()));
201
202                // active among globals: first occurrence wins; also shadowed
203                // by project-local if same name
204                let global_active = seen[&name].len() == 1 && !project_names.contains(&name);
205
206                // Evaluate Lua meta (best-effort; error captured in entry).
207                // Only interpolate the name into Lua source when it matches a
208                // strict whitelist (alnum / `_` / `-`). Names outside this set
209                // cannot be `require`d by Lua anyway; refusing them here also
210                // forecloses any Lua string-injection via crafted directory
211                // names under search paths.
212                let (meta, eval_error) = if is_safe_pkg_name(&name) {
213                    let code = format!(
214                        r#"package.loaded["{name}"] = nil
215local pkg = require("{name}")
216return pkg.meta or {{ name = "{name}" }}"#
217                    );
218                    match self.executor.eval_simple(code).await {
219                        Ok(v) => (v, None),
220                        Err(_) => (
221                            serde_json::Value::Object(serde_json::Map::new()),
222                            Some("failed to load meta".to_string()),
223                        ),
224                    }
225                } else {
226                    (
227                        serde_json::Value::Object(serde_json::Map::new()),
228                        Some("invalid package name".to_string()),
229                    )
230                };
231
232                // Look up manifest to determine source_type at collection time.
233                let (source_type, installed_at, updated_at, install_source) =
234                    if let Some(entry) = manifest_data.packages.get(&name) {
235                        let st = match infer_from_legacy_source_string(&entry.source) {
236                            PackageSource::Git { .. } => "git",
237                            PackageSource::LocalCopy { .. } => "local_copy",
238                            PackageSource::LocalDir { .. } => "local_dir",
239                            PackageSource::Bundled { .. } => "bundled",
240                        };
241                        (
242                            Some(st.to_string()),
243                            Some(entry.installed_at.clone()),
244                            Some(entry.updated_at.clone()),
245                            Some(entry.source.clone()),
246                        )
247                    } else {
248                        // Not registered in manifest → source_type absent
249                        (None, None, None, None)
250                    };
251
252                entries.push(PackageListEntry {
253                    name,
254                    scope: Scope::Global,
255                    source_type,
256                    path: None,
257                    source: Some(source_display),
258                    active: global_active,
259                    linked_at: None,
260                    installed_at,
261                    updated_at,
262                    install_source,
263                    overrides: None,
264                    meta,
265                    error: eval_error,
266                });
267            }
268        }
269
270        // ── Overrides pass (global packages only) ─────────────────────────
271        // For each active global whose name appears in more than one search path,
272        // record the lower-priority search-path paths as `overrides`.
273        for entry in entries[global_start_idx..].iter_mut() {
274            if !entry.active {
275                continue;
276            }
277            if let Some(occurrences) = seen.get(&entry.name) {
278                if occurrences.len() > 1 {
279                    entry.overrides =
280                        Some(occurrences.iter().skip(1).map(|(_, s)| s.clone()).collect());
281                }
282            }
283        }
284
285        // ── Serialise ─────────────────────────────────────────────────────
286        let all_packages: Vec<serde_json::Value> =
287            entries.into_iter().map(|e| e.into_json()).collect();
288
289        let search_paths_json: Vec<serde_json::Value> = self
290            .search_paths
291            .iter()
292            .map(|sp| {
293                serde_json::json!({
294                    "path": sp.path.display().to_string(),
295                    "source": sp.source.to_string(),
296                })
297            })
298            .collect();
299
300        let mut result = serde_json::json!({
301            "packages": all_packages,
302            "search_paths": search_paths_json,
303        });
304
305        if let Some(root_str) = project_root_str {
306            result["project_root"] = serde_json::Value::String(root_str);
307        }
308        if let Some(lp) = lockfile_path_str {
309            result["lockfile_path"] = serde_json::Value::String(lp);
310        }
311
312        Ok(result.to_string())
313    }
314}
315
316// ─── Name validation ─────────────────────────────────────────────
317
318/// Returns `true` iff `name` is safe to interpolate into a Lua source string.
319///
320/// Accepts ASCII alphanumerics, `_` and `-`. Empty strings are rejected.
321/// This matches the set of names that Lua's `require` can actually resolve
322/// against `FsResolver`, so nothing legitimate is excluded.
323fn is_safe_pkg_name(name: &str) -> bool {
324    !name.is_empty()
325        && name
326            .bytes()
327            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
328}