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