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, packages_dir};
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/// Origin of a package's `resolved_source_path`.
23///
24/// Stringified form is part of the MCP wire contract (`resolved_source_kind`
25/// field of `alc_pkg_list` entries). Adding a new variant is a backward-
26/// compatible extension; renaming an existing one is a breaking change.
27#[derive(Debug, Clone, Copy)]
28enum ResolvedSourceKind {
29    /// Package materialised under `packages_dir()` via git clone / copy.
30    Installed,
31    /// Symlink under `packages_dir()` or a search path (dev workflow).
32    Linked,
33    /// Project vendor directory referenced by `path = "..."` in alc.toml.
34    LocalPath,
35    /// Package shipped with algocline via `BUNDLED_SOURCES`.
36    Bundled,
37}
38
39impl ResolvedSourceKind {
40    fn as_str(self) -> &'static str {
41        match self {
42            ResolvedSourceKind::Installed => "installed",
43            ResolvedSourceKind::Linked => "linked",
44            ResolvedSourceKind::LocalPath => "local_path",
45            ResolvedSourceKind::Bundled => "bundled",
46        }
47    }
48}
49
50/// Typed intermediate representation of a single package list entry.
51/// Converted to `serde_json::Value` only at the final serialisation step.
52/// Fields that are `None` are omitted from the output JSON.
53#[derive(Debug)]
54struct PackageListEntry {
55    name: String,
56    scope: Scope,
57    /// Absent (`None`) when the package is not recorded in `installed.json`.
58    source_type: Option<String>,
59    /// Absolute path — project-local packages only.
60    path: Option<String>,
61    /// Search-path directory — global packages only.
62    source: Option<String>,
63    active: bool,
64    /// Package version from alc.lock or meta evaluation.
65    version: Option<String>,
66    installed_at: Option<String>,
67    updated_at: Option<String>,
68    /// Legacy source string from `installed.json` (the raw URL/path).
69    install_source: Option<String>,
70    overrides: Option<Vec<String>>,
71    meta: serde_json::Value,
72    error: Option<String>,
73    /// `Some(true)` when this package directory is a symlink (linked package).
74    linked: Option<bool>,
75    /// Resolved symlink target path (only present when `linked` is `Some(true)`).
76    link_target: Option<String>,
77    /// `Some(true)` when the symlink target does not exist (dangling symlink).
78    broken: Option<bool>,
79    /// Canonical absolute path of the Lua source directory for this package.
80    /// Absent for broken entries or when canonicalization fails.
81    resolved_source_path: Option<String>,
82    /// Origin of `resolved_source_path`. Serialised as the variant string
83    /// (`"installed"` / `"linked"` / `"local_path"` / `"bundled"`).
84    resolved_source_kind: Option<ResolvedSourceKind>,
85    /// Canonical absolute paths of same-name packages that are shadowed by
86    /// this (active) entry. Only present when overrides exist.
87    override_paths: Option<Vec<String>>,
88}
89
90impl PackageListEntry {
91    fn into_json(self) -> serde_json::Value {
92        let scope_str = match self.scope {
93            Scope::Project => "project",
94            Scope::Global => "global",
95        };
96
97        let mut map = serde_json::Map::new();
98        map.insert("name".to_string(), serde_json::Value::String(self.name));
99        map.insert(
100            "scope".to_string(),
101            serde_json::Value::String(scope_str.to_string()),
102        );
103
104        // source_type: only insert when resolved (no fallback to "global")
105        if let Some(st) = self.source_type {
106            map.insert("source_type".to_string(), serde_json::Value::String(st));
107        }
108
109        if let Some(p) = self.path {
110            map.insert("path".to_string(), serde_json::Value::String(p));
111        }
112        if let Some(s) = self.source {
113            map.insert("source".to_string(), serde_json::Value::String(s));
114        }
115
116        map.insert("active".to_string(), serde_json::Value::Bool(self.active));
117
118        if let Some(v) = self.version {
119            map.insert("version".to_string(), serde_json::Value::String(v));
120        }
121        if let Some(ia) = self.installed_at {
122            map.insert("installed_at".to_string(), serde_json::Value::String(ia));
123        }
124        if let Some(ua) = self.updated_at {
125            map.insert("updated_at".to_string(), serde_json::Value::String(ua));
126        }
127        if let Some(is) = self.install_source {
128            map.insert("install_source".to_string(), serde_json::Value::String(is));
129        }
130        if let Some(ov) = self.overrides {
131            map.insert("overrides".to_string(), serde_json::json!(ov));
132        }
133        if let Some(rsp) = self.resolved_source_path {
134            map.insert(
135                "resolved_source_path".to_string(),
136                serde_json::Value::String(rsp),
137            );
138        }
139        if let Some(rsk) = self.resolved_source_kind {
140            map.insert(
141                "resolved_source_kind".to_string(),
142                serde_json::Value::String(rsk.as_str().to_string()),
143            );
144        }
145        if let Some(op) = self.override_paths {
146            map.insert("override_paths".to_string(), serde_json::json!(op));
147        }
148
149        // All host-authoritative fields must be inserted BEFORE the meta
150        // merge so `map.entry().or_insert` skips them — otherwise Lua meta
151        // can masquerade as host-authoritative state (e.g. meta.linked
152        // silently overriding the real symlink status).
153        if let Some(err) = self.error {
154            map.insert("error".to_string(), serde_json::Value::String(err));
155        }
156        if let Some(linked) = self.linked {
157            map.insert("linked".to_string(), serde_json::Value::Bool(linked));
158        }
159        if let Some(target) = self.link_target {
160            map.insert("link_target".to_string(), serde_json::Value::String(target));
161        }
162        if let Some(broken) = self.broken {
163            map.insert("broken".to_string(), serde_json::Value::Bool(broken));
164        }
165
166        // Merge meta fields (Lua pkg.meta) into the top-level object.
167        if let serde_json::Value::Object(meta_map) = self.meta {
168            for (k, v) in meta_map {
169                // Never let meta overwrite the fields we have already set.
170                map.entry(k).or_insert(v);
171            }
172        }
173
174        serde_json::Value::Object(map)
175    }
176}
177
178impl AppService {
179    /// List installed packages with metadata, showing the full override chain.
180    ///
181    /// When `project_root` is provided (or resolvable), project-local packages
182    /// from `alc.toml` are prepended with `scope: "project"`, merged with
183    /// version/source info from `alc.lock`. Global packages carry `scope: "global"`.
184    /// If a project package and a global package share the same name, the project
185    /// one is `active: true` and the global one `active: false`.
186    pub async fn pkg_list(&self, project_root: Option<String>) -> Result<String, String> {
187        // ── Load manifest once upfront ─────────────────────────────────────
188        let manifest_data = manifest::load_manifest().unwrap_or_default();
189
190        // ── Project-local packages (from alc.toml + alc.lock) ─────────────
191        let resolved_root = resolve_project_root(project_root.as_deref());
192
193        let mut project_names: std::collections::HashSet<String> = std::collections::HashSet::new();
194        let mut entries: Vec<PackageListEntry> = Vec::new();
195        let mut project_root_str: Option<String> = None;
196        let mut lockfile_path_str: Option<String> = None;
197
198        if let Some(ref root) = resolved_root {
199            project_root_str = Some(root.display().to_string());
200            lockfile_path_str = Some(lockfile_path(root).display().to_string());
201
202            // Load alc.lock for version/source lookup (may not exist yet).
203            let lock_map: HashMap<String, (Option<String>, PackageSource)> =
204                match load_lockfile(root) {
205                    Ok(Some(lock)) => lock
206                        .packages
207                        .into_iter()
208                        .map(|p| (p.name, (p.version, p.source)))
209                        .collect(),
210                    Ok(None) => HashMap::new(),
211                    Err(e) => {
212                        tracing::warn!("failed to load alc.lock: {e}");
213                        HashMap::new()
214                    }
215                };
216
217            // Enumerate project packages from alc.toml declarations.
218            match load_alc_toml(root) {
219                Ok(Some(alc_toml)) => {
220                    for (name, dep) in &alc_toml.packages {
221                        let (version, source_type, abs_path) =
222                            resolve_project_pkg_info(name, dep, &lock_map, root);
223                        project_names.insert(name.clone());
224
225                        // Resolve canonical source path depending on source_type.
226                        // `path` → vendor dir from alc.toml; everything else
227                        // (`installed` / `git` / `bundled`) resolves under
228                        // `packages_dir()/{name}` and differs only in `kind`.
229                        let (rsp, rsk, resolve_err): (
230                            Option<String>,
231                            Option<ResolvedSourceKind>,
232                            Option<String>,
233                        ) = match source_type.as_deref() {
234                            Some("path") => {
235                                let rsp = abs_path
236                                    .as_ref()
237                                    .and_then(|p| resolve_source_path(std::path::Path::new(p)));
238                                (rsp, Some(ResolvedSourceKind::LocalPath), None)
239                            }
240                            Some(st) => {
241                                let kind = if st == "bundled" {
242                                    ResolvedSourceKind::Bundled
243                                } else {
244                                    ResolvedSourceKind::Installed
245                                };
246                                match packages_dir() {
247                                    Ok(dir) => {
248                                        (resolve_source_path(&dir.join(name)), Some(kind), None)
249                                    }
250                                    Err(e) => (
251                                        None,
252                                        Some(kind),
253                                        Some(format!("cannot resolve packages_dir: {e}")),
254                                    ),
255                                }
256                            }
257                            None => (None, None, None),
258                        };
259
260                        entries.push(make_project_entry(
261                            name.clone(),
262                            version,
263                            source_type,
264                            abs_path,
265                            rsp,
266                            rsk,
267                            resolve_err,
268                        ));
269                    }
270                }
271                Ok(None) => {
272                    // No alc.toml — fall back to alc.lock Path entries for backward compat.
273                    collect_path_entries_from_lock(
274                        &lock_map,
275                        root,
276                        &mut project_names,
277                        &mut entries,
278                    );
279                }
280                Err(e) => {
281                    tracing::warn!("failed to load alc.toml: {e}");
282                }
283            }
284        }
285
286        // ── Global packages (from search paths) ────────────────────────────
287        // Key: package name → list of (search_path_index, source_display)
288        let mut seen: HashMap<String, Vec<(usize, String)>> = HashMap::new();
289        // Separate Vec so overrides pass can reference seen after collection.
290        let global_start_idx = entries.len();
291
292        for (idx, sp) in self.search_paths.iter().enumerate() {
293            if !sp.path.is_dir() {
294                continue;
295            }
296            let read_entries = match std::fs::read_dir(&sp.path) {
297                Ok(e) => e,
298                Err(_) => continue,
299            };
300
301            for dir_entry in read_entries.flatten() {
302                let path = dir_entry.path();
303
304                // Detect symlink status before is_dir() check so dangling symlinks
305                // are also enumerated (dangling symlinks have is_dir() == false).
306                let is_symlink = path
307                    .symlink_metadata()
308                    .map(|m| m.file_type().is_symlink())
309                    .unwrap_or(false);
310
311                let link_target = if is_symlink {
312                    path.read_link().ok().map(|t| t.display().to_string())
313                } else {
314                    None
315                };
316
317                // broken = symlink exists but target does not.
318                //
319                // `try_exists()` distinguishes Err (IO / permission failure)
320                // from Ok(false) (confirmed non-existent). On Err we cannot
321                // prove the target is intact, so we conservatively report
322                // `broken: true` — the user cannot use the target either
323                // way, so the signal is more useful than silently hiding
324                // the symlink. `path.exists()` collapsed these cases.
325                let broken = if is_symlink {
326                    Some(!path.try_exists().unwrap_or(false))
327                } else {
328                    None
329                };
330
331                // For dangling symlinks: init.lua check will fail, so we allow
332                // them through (they show as broken: true without init.lua check).
333                // For non-symlinks and live symlinks: require is_dir().
334                if !is_symlink && !path.is_dir() {
335                    continue;
336                }
337
338                // Skip if no init.lua (only for non-broken entries).
339                if broken != Some(true) && !path.join("init.lua").exists() {
340                    continue;
341                }
342
343                let name = dir_entry.file_name().to_string_lossy().to_string();
344                if is_system_package(&name) {
345                    continue;
346                }
347
348                let source_display = sp.path.display().to_string();
349                seen.entry(name.clone())
350                    .or_default()
351                    .push((idx, source_display.clone()));
352
353                // active among globals: first occurrence wins; also shadowed
354                // by project-local if same name
355                let global_active = seen[&name].len() == 1 && !project_names.contains(&name);
356
357                // Evaluate Lua meta (best-effort; error captured in entry).
358                let (meta, eval_error) = if is_safe_pkg_name(&name) {
359                    let code = format!(
360                        r#"package.loaded["{name}"] = nil
361local pkg = require("{name}")
362return pkg.meta or {{ name = "{name}" }}"#
363                    );
364                    match self.executor.eval_simple(code).await {
365                        Ok(v) => (v, None),
366                        Err(_) => (
367                            serde_json::Value::Object(serde_json::Map::new()),
368                            Some("failed to load meta".to_string()),
369                        ),
370                    }
371                } else {
372                    (
373                        serde_json::Value::Object(serde_json::Map::new()),
374                        Some("invalid package name".to_string()),
375                    )
376                };
377
378                // Look up manifest to determine source_type at collection time.
379                let (source_type, installed_at, updated_at, install_source) =
380                    if let Some(entry) = manifest_data.packages.get(&name) {
381                        let inferred = infer_from_legacy_source_string(&entry.source);
382                        let st = match &inferred {
383                            PackageSource::Git { .. } => "git".to_string(),
384                            PackageSource::Installed => {
385                                // I-6: supplement with original path/URL from installed.json
386                                format!("installed (from: {})", entry.source)
387                            }
388                            PackageSource::Path { .. } => "path".to_string(),
389                            PackageSource::Bundled { .. } => "bundled".to_string(),
390                        };
391                        (
392                            Some(st),
393                            Some(entry.installed_at.clone()),
394                            Some(entry.updated_at.clone()),
395                            Some(entry.source.clone()),
396                        )
397                    } else {
398                        // Not registered in manifest → source_type absent
399                        (None, None, None, None)
400                    };
401
402                // Resolve canonical source path for this global entry.
403                let (resolved_source_path, resolved_source_kind): (
404                    Option<String>,
405                    Option<ResolvedSourceKind>,
406                ) = if is_symlink {
407                    let kind = Some(ResolvedSourceKind::Linked);
408                    if broken == Some(true) {
409                        // dangling symlink — omit path, keep kind
410                        (None, kind)
411                    } else {
412                        // resolve symlink target; make absolute if relative
413                        let candidate = path.read_link().ok().map(|target| {
414                            if target.is_absolute() {
415                                target
416                            } else {
417                                sp.path.join(target)
418                            }
419                        });
420                        let rsp = candidate.as_deref().and_then(resolve_source_path);
421                        (rsp, kind)
422                    }
423                } else {
424                    // normal (non-symlink) entry
425                    let candidate = sp.path.join(&name);
426                    let rsp = resolve_source_path(&candidate);
427                    let kind = match source_type.as_deref() {
428                        Some("bundled") => ResolvedSourceKind::Bundled,
429                        _ => ResolvedSourceKind::Installed,
430                    };
431                    (rsp, Some(kind))
432                };
433
434                entries.push(PackageListEntry {
435                    name,
436                    scope: Scope::Global,
437                    source_type,
438                    path: None,
439                    source: Some(source_display),
440                    active: global_active,
441                    version: None,
442                    installed_at,
443                    updated_at,
444                    install_source,
445                    overrides: None,
446                    meta,
447                    error: eval_error,
448                    linked: if is_symlink { Some(true) } else { None },
449                    link_target,
450                    broken,
451                    resolved_source_path,
452                    resolved_source_kind,
453                    override_paths: None,
454                });
455            }
456        }
457
458        // ── Overrides pass (global packages only) ─────────────────────────
459        // For each active global whose name appears in more than one search path,
460        // record the lower-priority search-path paths as `overrides` (existing
461        // behaviour) and the canonicalized pkg directories as `override_paths`
462        // (new, §3.2-a).
463        for entry in entries[global_start_idx..].iter_mut() {
464            if !entry.active {
465                continue;
466            }
467            if let Some(occurrences) = seen.get(&entry.name) {
468                if occurrences.len() > 1 {
469                    entry.overrides =
470                        Some(occurrences.iter().skip(1).map(|(_, s)| s.clone()).collect());
471
472                    // §3.2-a: canonicalized pkg directories for shadowed global entries.
473                    let override_ps: Vec<String> = occurrences
474                        .iter()
475                        .skip(1)
476                        .filter_map(|(idx, _)| {
477                            let candidate = self.search_paths[*idx].path.join(&entry.name);
478                            resolve_source_path(&candidate)
479                        })
480                        .collect();
481                    if !override_ps.is_empty() {
482                        entry.override_paths = Some(override_ps);
483                    }
484                }
485            }
486        }
487
488        // ── Project-shadows-global pass (§3.2-b) ──────────────────────────
489        // For each active project entry whose name also appears in global seen map,
490        // expose all global occurrences as override_paths on the project entry.
491        //
492        // A project `installed` / `git` / `bundled` entry's own `resolved_source_path`
493        // typically resolves to `packages_dir()/{name}`, which is itself one of the
494        // search paths. Filter those occurrences out so an entry never lists itself
495        // as a shadow target — `override_paths` should only contain genuinely distinct
496        // same-name packages.
497        for entry in entries[..global_start_idx].iter_mut() {
498            let self_path = entry.resolved_source_path.as_deref();
499            if let Some(occurrences) = seen.get(&entry.name) {
500                let ps: Vec<String> = occurrences
501                    .iter()
502                    .filter_map(|(idx, _)| {
503                        let candidate = self.search_paths[*idx].path.join(&entry.name);
504                        resolve_source_path(&candidate)
505                    })
506                    .filter(|p| Some(p.as_str()) != self_path)
507                    .collect();
508                if !ps.is_empty() {
509                    entry.override_paths = Some(ps);
510                }
511            }
512        }
513
514        // ── Serialise ─────────────────────────────────────────────────────
515        let all_packages: Vec<serde_json::Value> =
516            entries.into_iter().map(|e| e.into_json()).collect();
517
518        let search_paths_json: Vec<serde_json::Value> = self
519            .search_paths
520            .iter()
521            .map(|sp| {
522                serde_json::json!({
523                    "path": sp.path.display().to_string(),
524                    "source": sp.source.to_string(),
525                })
526            })
527            .collect();
528
529        let mut result = serde_json::json!({
530            "packages": all_packages,
531            "search_paths": search_paths_json,
532        });
533
534        if let Some(root_str) = project_root_str {
535            result["project_root"] = serde_json::Value::String(root_str);
536        }
537        if let Some(lp) = lockfile_path_str {
538            result["lockfile_path"] = serde_json::Value::String(lp);
539        }
540
541        Ok(result.to_string())
542    }
543}
544
545// ─── Project package helpers ─────────────────────────────────────
546
547/// Resolve version, source_type, and absolute path for a project package entry
548/// by merging `alc.toml` dep declaration with `alc.lock` data.
549fn resolve_project_pkg_info(
550    name: &str,
551    dep: &alc_toml::PackageDep,
552    lock_map: &HashMap<String, (Option<String>, PackageSource)>,
553    root: &Path,
554) -> (Option<String>, Option<String>, Option<String>) {
555    if let Some((ver, source)) = lock_map.get(name) {
556        match source {
557            PackageSource::Path { path: raw_path } => {
558                let p = Path::new(raw_path);
559                let abs = if p.is_absolute() {
560                    p.to_path_buf()
561                } else {
562                    root.join(p)
563                };
564                (
565                    ver.clone(),
566                    Some("path".to_string()),
567                    Some(abs.display().to_string()),
568                )
569            }
570            PackageSource::Installed => (ver.clone(), Some("installed".to_string()), None),
571            PackageSource::Git { .. } => (ver.clone(), Some("git".to_string()), None),
572            PackageSource::Bundled { .. } => (ver.clone(), Some("bundled".to_string()), None),
573        }
574    } else {
575        let st = match dep {
576            alc_toml::PackageDep::Version(_) => Some("installed".to_string()),
577            alc_toml::PackageDep::Path { .. } => Some("path".to_string()),
578            alc_toml::PackageDep::Git { .. } => Some("git".to_string()),
579        };
580        (None, st, None)
581    }
582}
583
584/// Create a `PackageListEntry` for a project-scoped package.
585fn make_project_entry(
586    name: String,
587    version: Option<String>,
588    source_type: Option<String>,
589    abs_path: Option<String>,
590    resolved_source_path: Option<String>,
591    resolved_source_kind: Option<ResolvedSourceKind>,
592    error: Option<String>,
593) -> PackageListEntry {
594    PackageListEntry {
595        name,
596        scope: Scope::Project,
597        source_type,
598        path: abs_path,
599        source: None,
600        active: true,
601        version,
602        installed_at: None,
603        updated_at: None,
604        install_source: None,
605        overrides: None,
606        meta: serde_json::Value::Object(serde_json::Map::new()),
607        error,
608        linked: None,
609        link_target: None,
610        broken: None,
611        resolved_source_path,
612        resolved_source_kind,
613        override_paths: None,
614    }
615}
616
617/// Backward-compat fallback: collect `Path` entries from `alc.lock` when no `alc.toml` exists.
618fn collect_path_entries_from_lock(
619    lock_map: &HashMap<String, (Option<String>, PackageSource)>,
620    root: &Path,
621    project_names: &mut std::collections::HashSet<String>,
622    entries: &mut Vec<PackageListEntry>,
623) {
624    for (name, (version, source)) in lock_map {
625        if let PackageSource::Path { path: raw_path } = source {
626            let p = Path::new(raw_path);
627            let abs = if p.is_absolute() {
628                p.to_path_buf()
629            } else {
630                root.join(p)
631            };
632            project_names.insert(name.clone());
633            let rsp = resolve_source_path(&abs);
634            entries.push(make_project_entry(
635                name.clone(),
636                version.clone(),
637                Some("path".to_string()),
638                Some(abs.display().to_string()),
639                rsp,
640                Some(ResolvedSourceKind::LocalPath),
641                None,
642            ));
643        }
644    }
645}
646
647// ─── Path resolution ─────────────────────────────────────────────
648
649/// Canonicalize `candidate` and return the canonical absolute path string,
650/// or `None` on failure (broken symlink, race condition, missing dir, etc.).
651/// The `kind` decision is left to the caller; this helper focuses solely on
652/// the canonicalize step.
653fn resolve_source_path(candidate: &std::path::Path) -> Option<String> {
654    std::fs::canonicalize(candidate)
655        .ok()
656        .map(|p| p.display().to_string())
657}
658
659// ─── Name validation ─────────────────────────────────────────────
660
661/// Returns `true` iff `name` is safe to interpolate into a Lua source string.
662///
663/// Accepts ASCII alphanumerics, `_` and `-`. Empty strings are rejected.
664fn is_safe_pkg_name(name: &str) -> bool {
665    !name.is_empty()
666        && name
667            .bytes()
668            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
669}