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::alc_toml::{self, load_alc_toml};
7use super::super::lockfile::{load_lockfile, lockfile_path};
8use super::super::manifest;
9use super::super::project::resolve_project_root;
10use super::super::resolve::is_system_package;
11use super::super::source::{infer_from_legacy_source_string, PackageSource};
12use super::super::AppService;
13
14// ─── Intermediate DTO for pkg_list ───────────────────────────────
15
16#[derive(Debug)]
17enum Scope {
18    Project,
19    Global,
20}
21
22/// Typed intermediate representation of a single package list entry.
23/// Converted to `serde_json::Value` only at the final serialisation step.
24/// Fields that are `None` are omitted from the output JSON.
25#[derive(Debug)]
26struct PackageListEntry {
27    name: String,
28    scope: Scope,
29    /// Absent (`None`) when the package is not recorded in `installed.json`.
30    source_type: Option<String>,
31    /// Absolute path — project-local packages only.
32    path: Option<String>,
33    /// Search-path directory — global packages only.
34    source: Option<String>,
35    active: bool,
36    /// Package version from alc.lock or meta evaluation.
37    version: Option<String>,
38    installed_at: Option<String>,
39    updated_at: Option<String>,
40    /// Legacy source string from `installed.json` (the raw URL/path).
41    install_source: Option<String>,
42    overrides: Option<Vec<String>>,
43    meta: serde_json::Value,
44    error: Option<String>,
45    /// `Some(true)` when this package directory is a symlink (linked package).
46    linked: Option<bool>,
47    /// Resolved symlink target path (only present when `linked` is `Some(true)`).
48    link_target: Option<String>,
49    /// `Some(true)` when the symlink target does not exist (dangling symlink).
50    broken: Option<bool>,
51}
52
53impl PackageListEntry {
54    fn into_json(self) -> serde_json::Value {
55        let scope_str = match self.scope {
56            Scope::Project => "project",
57            Scope::Global => "global",
58        };
59
60        let mut map = serde_json::Map::new();
61        map.insert("name".to_string(), serde_json::Value::String(self.name));
62        map.insert(
63            "scope".to_string(),
64            serde_json::Value::String(scope_str.to_string()),
65        );
66
67        // source_type: only insert when resolved (no fallback to "global")
68        if let Some(st) = self.source_type {
69            map.insert("source_type".to_string(), serde_json::Value::String(st));
70        }
71
72        if let Some(p) = self.path {
73            map.insert("path".to_string(), serde_json::Value::String(p));
74        }
75        if let Some(s) = self.source {
76            map.insert("source".to_string(), serde_json::Value::String(s));
77        }
78
79        map.insert("active".to_string(), serde_json::Value::Bool(self.active));
80
81        if let Some(v) = self.version {
82            map.insert("version".to_string(), serde_json::Value::String(v));
83        }
84        if let Some(ia) = self.installed_at {
85            map.insert("installed_at".to_string(), serde_json::Value::String(ia));
86        }
87        if let Some(ua) = self.updated_at {
88            map.insert("updated_at".to_string(), serde_json::Value::String(ua));
89        }
90        if let Some(is) = self.install_source {
91            map.insert("install_source".to_string(), serde_json::Value::String(is));
92        }
93        if let Some(ov) = self.overrides {
94            map.insert("overrides".to_string(), serde_json::json!(ov));
95        }
96
97        // Merge meta fields (Lua pkg.meta) into the top-level object.
98        if let serde_json::Value::Object(meta_map) = self.meta {
99            for (k, v) in meta_map {
100                // Never let meta overwrite the fields we have already set.
101                map.entry(k).or_insert(v);
102            }
103        }
104
105        if let Some(err) = self.error {
106            map.insert("error".to_string(), serde_json::Value::String(err));
107        }
108
109        if let Some(linked) = self.linked {
110            map.insert("linked".to_string(), serde_json::Value::Bool(linked));
111        }
112        if let Some(target) = self.link_target {
113            map.insert("link_target".to_string(), serde_json::Value::String(target));
114        }
115        if let Some(broken) = self.broken {
116            map.insert("broken".to_string(), serde_json::Value::Bool(broken));
117        }
118
119        serde_json::Value::Object(map)
120    }
121}
122
123impl AppService {
124    /// List installed packages with metadata, showing the full override chain.
125    ///
126    /// When `project_root` is provided (or resolvable), project-local packages
127    /// from `alc.toml` are prepended with `scope: "project"`, merged with
128    /// version/source info from `alc.lock`. Global packages carry `scope: "global"`.
129    /// If a project package and a global package share the same name, the project
130    /// one is `active: true` and the global one `active: false`.
131    pub async fn pkg_list(&self, project_root: Option<String>) -> Result<String, String> {
132        // ── Load manifest once upfront ─────────────────────────────────────
133        let manifest_data = manifest::load_manifest().unwrap_or_default();
134
135        // ── Project-local packages (from alc.toml + alc.lock) ─────────────
136        let resolved_root = resolve_project_root(project_root.as_deref());
137
138        let mut project_names: std::collections::HashSet<String> = std::collections::HashSet::new();
139        let mut entries: Vec<PackageListEntry> = Vec::new();
140        let mut project_root_str: Option<String> = None;
141        let mut lockfile_path_str: Option<String> = None;
142
143        if let Some(ref root) = resolved_root {
144            project_root_str = Some(root.display().to_string());
145            lockfile_path_str = Some(lockfile_path(root).display().to_string());
146
147            // Load alc.lock for version/source lookup (may not exist yet).
148            let lock_map: HashMap<String, (Option<String>, PackageSource)> =
149                match load_lockfile(root) {
150                    Ok(Some(lock)) => lock
151                        .packages
152                        .into_iter()
153                        .map(|p| (p.name, (p.version, p.source)))
154                        .collect(),
155                    Ok(None) => HashMap::new(),
156                    Err(e) => {
157                        tracing::warn!("failed to load alc.lock: {e}");
158                        HashMap::new()
159                    }
160                };
161
162            // Enumerate project packages from alc.toml declarations.
163            match load_alc_toml(root) {
164                Ok(Some(alc_toml)) => {
165                    for (name, dep) in &alc_toml.packages {
166                        let (version, source_type, abs_path) =
167                            resolve_project_pkg_info(name, dep, &lock_map, root);
168                        project_names.insert(name.clone());
169                        entries.push(make_project_entry(
170                            name.clone(),
171                            version,
172                            source_type,
173                            abs_path,
174                        ));
175                    }
176                }
177                Ok(None) => {
178                    // No alc.toml — fall back to alc.lock Path entries for backward compat.
179                    collect_path_entries_from_lock(
180                        &lock_map,
181                        root,
182                        &mut project_names,
183                        &mut entries,
184                    );
185                }
186                Err(e) => {
187                    tracing::warn!("failed to load alc.toml: {e}");
188                }
189            }
190        }
191
192        // ── Global packages (from search paths) ────────────────────────────
193        // Key: package name → list of (search_path_index, source_display)
194        let mut seen: HashMap<String, Vec<(usize, String)>> = HashMap::new();
195        // Separate Vec so overrides pass can reference seen after collection.
196        let global_start_idx = entries.len();
197
198        for (idx, sp) in self.search_paths.iter().enumerate() {
199            if !sp.path.is_dir() {
200                continue;
201            }
202            let read_entries = match std::fs::read_dir(&sp.path) {
203                Ok(e) => e,
204                Err(_) => continue,
205            };
206
207            for dir_entry in read_entries.flatten() {
208                let path = dir_entry.path();
209
210                // Detect symlink status before is_dir() check so dangling symlinks
211                // are also enumerated (dangling symlinks have is_dir() == false).
212                let is_symlink = path
213                    .symlink_metadata()
214                    .map(|m| m.file_type().is_symlink())
215                    .unwrap_or(false);
216
217                let link_target = if is_symlink {
218                    path.read_link().ok().map(|t| t.display().to_string())
219                } else {
220                    None
221                };
222
223                // broken = symlink exists but target does not.
224                let broken = if is_symlink {
225                    Some(!path.exists())
226                } else {
227                    None
228                };
229
230                // For dangling symlinks: init.lua check will fail, so we allow
231                // them through (they show as broken: true without init.lua check).
232                // For non-symlinks and live symlinks: require is_dir().
233                if !is_symlink && !path.is_dir() {
234                    continue;
235                }
236
237                // Skip if no init.lua (only for non-broken entries).
238                if broken != Some(true) && !path.join("init.lua").exists() {
239                    continue;
240                }
241
242                let name = dir_entry.file_name().to_string_lossy().to_string();
243                if is_system_package(&name) {
244                    continue;
245                }
246
247                let source_display = sp.path.display().to_string();
248                seen.entry(name.clone())
249                    .or_default()
250                    .push((idx, source_display.clone()));
251
252                // active among globals: first occurrence wins; also shadowed
253                // by project-local if same name
254                let global_active = seen[&name].len() == 1 && !project_names.contains(&name);
255
256                // Evaluate Lua meta (best-effort; error captured in entry).
257                let (meta, eval_error) = if is_safe_pkg_name(&name) {
258                    let code = format!(
259                        r#"package.loaded["{name}"] = nil
260local pkg = require("{name}")
261return pkg.meta or {{ name = "{name}" }}"#
262                    );
263                    match self.executor.eval_simple(code).await {
264                        Ok(v) => (v, None),
265                        Err(_) => (
266                            serde_json::Value::Object(serde_json::Map::new()),
267                            Some("failed to load meta".to_string()),
268                        ),
269                    }
270                } else {
271                    (
272                        serde_json::Value::Object(serde_json::Map::new()),
273                        Some("invalid package name".to_string()),
274                    )
275                };
276
277                // Look up manifest to determine source_type at collection time.
278                let (source_type, installed_at, updated_at, install_source) =
279                    if let Some(entry) = manifest_data.packages.get(&name) {
280                        let inferred = infer_from_legacy_source_string(&entry.source);
281                        let st = match &inferred {
282                            PackageSource::Git { .. } => "git".to_string(),
283                            PackageSource::Installed => {
284                                // I-6: supplement with original path/URL from installed.json
285                                format!("installed (from: {})", entry.source)
286                            }
287                            PackageSource::Path { .. } => "path".to_string(),
288                            PackageSource::Bundled { .. } => "bundled".to_string(),
289                        };
290                        (
291                            Some(st),
292                            Some(entry.installed_at.clone()),
293                            Some(entry.updated_at.clone()),
294                            Some(entry.source.clone()),
295                        )
296                    } else {
297                        // Not registered in manifest → source_type absent
298                        (None, None, None, None)
299                    };
300
301                entries.push(PackageListEntry {
302                    name,
303                    scope: Scope::Global,
304                    source_type,
305                    path: None,
306                    source: Some(source_display),
307                    active: global_active,
308                    version: None,
309                    installed_at,
310                    updated_at,
311                    install_source,
312                    overrides: None,
313                    meta,
314                    error: eval_error,
315                    linked: if is_symlink { Some(true) } else { None },
316                    link_target,
317                    broken,
318                });
319            }
320        }
321
322        // ── Overrides pass (global packages only) ─────────────────────────
323        // For each active global whose name appears in more than one search path,
324        // record the lower-priority search-path paths as `overrides`.
325        for entry in entries[global_start_idx..].iter_mut() {
326            if !entry.active {
327                continue;
328            }
329            if let Some(occurrences) = seen.get(&entry.name) {
330                if occurrences.len() > 1 {
331                    entry.overrides =
332                        Some(occurrences.iter().skip(1).map(|(_, s)| s.clone()).collect());
333                }
334            }
335        }
336
337        // ── Serialise ─────────────────────────────────────────────────────
338        let all_packages: Vec<serde_json::Value> =
339            entries.into_iter().map(|e| e.into_json()).collect();
340
341        let search_paths_json: Vec<serde_json::Value> = self
342            .search_paths
343            .iter()
344            .map(|sp| {
345                serde_json::json!({
346                    "path": sp.path.display().to_string(),
347                    "source": sp.source.to_string(),
348                })
349            })
350            .collect();
351
352        let mut result = serde_json::json!({
353            "packages": all_packages,
354            "search_paths": search_paths_json,
355        });
356
357        if let Some(root_str) = project_root_str {
358            result["project_root"] = serde_json::Value::String(root_str);
359        }
360        if let Some(lp) = lockfile_path_str {
361            result["lockfile_path"] = serde_json::Value::String(lp);
362        }
363
364        Ok(result.to_string())
365    }
366}
367
368// ─── Project package helpers ─────────────────────────────────────
369
370/// Resolve version, source_type, and absolute path for a project package entry
371/// by merging `alc.toml` dep declaration with `alc.lock` data.
372fn resolve_project_pkg_info(
373    name: &str,
374    dep: &alc_toml::PackageDep,
375    lock_map: &HashMap<String, (Option<String>, PackageSource)>,
376    root: &Path,
377) -> (Option<String>, Option<String>, Option<String>) {
378    if let Some((ver, source)) = lock_map.get(name) {
379        match source {
380            PackageSource::Path { path: raw_path } => {
381                let p = Path::new(raw_path);
382                let abs = if p.is_absolute() {
383                    p.to_path_buf()
384                } else {
385                    root.join(p)
386                };
387                (
388                    ver.clone(),
389                    Some("path".to_string()),
390                    Some(abs.display().to_string()),
391                )
392            }
393            PackageSource::Installed => (ver.clone(), Some("installed".to_string()), None),
394            PackageSource::Git { .. } => (ver.clone(), Some("git".to_string()), None),
395            PackageSource::Bundled { .. } => (ver.clone(), Some("bundled".to_string()), None),
396        }
397    } else {
398        let st = match dep {
399            alc_toml::PackageDep::Version(_) => Some("installed".to_string()),
400            alc_toml::PackageDep::Path { .. } => Some("path".to_string()),
401            alc_toml::PackageDep::Git { .. } => Some("git".to_string()),
402        };
403        (None, st, None)
404    }
405}
406
407/// Create a `PackageListEntry` for a project-scoped package.
408fn make_project_entry(
409    name: String,
410    version: Option<String>,
411    source_type: Option<String>,
412    abs_path: Option<String>,
413) -> PackageListEntry {
414    PackageListEntry {
415        name,
416        scope: Scope::Project,
417        source_type,
418        path: abs_path,
419        source: None,
420        active: true,
421        version,
422        installed_at: None,
423        updated_at: None,
424        install_source: None,
425        overrides: None,
426        meta: serde_json::Value::Object(serde_json::Map::new()),
427        error: None,
428        linked: None,
429        link_target: None,
430        broken: None,
431    }
432}
433
434/// Backward-compat fallback: collect `Path` entries from `alc.lock` when no `alc.toml` exists.
435fn collect_path_entries_from_lock(
436    lock_map: &HashMap<String, (Option<String>, PackageSource)>,
437    root: &Path,
438    project_names: &mut std::collections::HashSet<String>,
439    entries: &mut Vec<PackageListEntry>,
440) {
441    for (name, (version, source)) in lock_map {
442        if let PackageSource::Path { path: raw_path } = source {
443            let p = Path::new(raw_path);
444            let abs = if p.is_absolute() {
445                p.to_path_buf()
446            } else {
447                root.join(p)
448            };
449            project_names.insert(name.clone());
450            entries.push(make_project_entry(
451                name.clone(),
452                version.clone(),
453                Some("path".to_string()),
454                Some(abs.display().to_string()),
455            ));
456        }
457    }
458}
459
460// ─── Name validation ─────────────────────────────────────────────
461
462/// Returns `true` iff `name` is safe to interpolate into a Lua source string.
463///
464/// Accepts ASCII alphanumerics, `_` and `-`. Empty strings are rejected.
465fn is_safe_pkg_name(name: &str) -> bool {
466    !name.is_empty()
467        && name
468            .bytes()
469            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
470}