use std::collections::HashMap;
use std::path::Path;
use super::super::alc_toml::{self, load_alc_toml};
use super::super::eval_store::splice_response_warnings;
use super::super::list_opts::{
apply_sort_by_value, matches_filter, parse_sort, project_fields, resolve_fields, ListOpts,
PKG_LIST_FULL, PKG_LIST_SUMMARY,
};
use super::super::lockfile::{load_lockfile, lockfile_path};
use super::super::manifest;
use super::super::resolve::{is_system_package, packages_dir, LUA_TYPE_AUTODETECT};
use super::super::source::PackageSource;
use super::super::AppService;
use super::super::{PkgListError, ServiceError};
const UNMARKED_LIBRARY_SUGGESTION: &str =
"This package has no M.run function and is auto-classified as a library \
by VM eval (LUA_TYPE_AUTODETECT). No explicit M.meta.type declaration is needed.";
#[derive(Debug)]
enum Scope {
Variant,
Project,
Global,
}
#[derive(Debug, Clone, Copy)]
enum ResolvedSourceKind {
Installed,
Linked,
LocalPath,
Bundled,
Variant,
}
impl ResolvedSourceKind {
fn as_str(self) -> &'static str {
match self {
ResolvedSourceKind::Installed => "installed",
ResolvedSourceKind::Linked => "linked",
ResolvedSourceKind::LocalPath => "local_path",
ResolvedSourceKind::Bundled => "bundled",
ResolvedSourceKind::Variant => "variant",
}
}
}
#[derive(Debug)]
struct PackageListEntry {
name: String,
scope: Scope,
source_type: Option<String>,
path: Option<String>,
source: Option<String>,
active: bool,
version: Option<String>,
installed_at: Option<String>,
updated_at: Option<String>,
install_source: Option<String>,
overrides: Option<Vec<String>>,
meta: serde_json::Value,
error: Option<String>,
linked: Option<bool>,
link_target: Option<String>,
broken: Option<bool>,
resolved_source_path: Option<String>,
resolved_source_kind: Option<ResolvedSourceKind>,
override_paths: Option<Vec<String>>,
warnings: Option<Vec<String>>,
}
impl PackageListEntry {
fn into_json(self) -> serde_json::Value {
let scope_str = match self.scope {
Scope::Variant => "variant",
Scope::Project => "project",
Scope::Global => "global",
};
let mut map = serde_json::Map::new();
map.insert("name".to_string(), serde_json::Value::String(self.name));
map.insert(
"scope".to_string(),
serde_json::Value::String(scope_str.to_string()),
);
if let Some(st) = self.source_type {
map.insert("source_type".to_string(), serde_json::Value::String(st));
}
if let Some(p) = self.path {
map.insert("path".to_string(), serde_json::Value::String(p));
}
if let Some(s) = self.source {
map.insert("source".to_string(), serde_json::Value::String(s));
}
map.insert("active".to_string(), serde_json::Value::Bool(self.active));
if let Some(v) = self.version {
map.insert("version".to_string(), serde_json::Value::String(v));
}
if let Some(ia) = self.installed_at {
map.insert("installed_at".to_string(), serde_json::Value::String(ia));
}
if let Some(ua) = self.updated_at {
map.insert("updated_at".to_string(), serde_json::Value::String(ua));
}
if let Some(is) = self.install_source {
map.insert("install_source".to_string(), serde_json::Value::String(is));
}
if let Some(ov) = self.overrides {
map.insert("overrides".to_string(), serde_json::json!(ov));
}
if let Some(rsp) = self.resolved_source_path {
map.insert(
"resolved_source_path".to_string(),
serde_json::Value::String(rsp),
);
}
if let Some(rsk) = self.resolved_source_kind {
map.insert(
"resolved_source_kind".to_string(),
serde_json::Value::String(rsk.as_str().to_string()),
);
}
if let Some(op) = self.override_paths {
map.insert("override_paths".to_string(), serde_json::json!(op));
}
if let Some(err) = self.error {
map.insert("error".to_string(), serde_json::Value::String(err));
}
if let Some(linked) = self.linked {
map.insert("linked".to_string(), serde_json::Value::Bool(linked));
}
if let Some(target) = self.link_target {
map.insert("link_target".to_string(), serde_json::Value::String(target));
}
if let Some(broken) = self.broken {
map.insert("broken".to_string(), serde_json::Value::Bool(broken));
}
if let Some(warns) = self.warnings {
if !warns.is_empty() {
map.insert("warnings".to_string(), serde_json::json!(warns));
}
}
if let serde_json::Value::Object(meta_map) = self.meta {
for (k, v) in meta_map {
map.entry(k).or_insert(v);
}
}
serde_json::Value::Object(map)
}
}
impl AppService {
pub(crate) async fn pkg_list(
&self,
project_root: Option<String>,
opts: ListOpts,
) -> Result<String, ServiceError> {
let sort_str = opts.sort.as_deref().unwrap_or("-active,-installed_at");
let sort_keys = parse_sort(sort_str).map_err(ServiceError::InvalidInput)?;
let fields = resolve_fields(
opts.verbose.as_deref(),
opts.fields.as_deref(),
PKG_LIST_SUMMARY,
PKG_LIST_FULL,
)
.map_err(ServiceError::InvalidInput)?;
let app_dir = self.log_config.app_dir();
let manifest_data = manifest::load_manifest(&app_dir)
.map_err(|e| ServiceError::InvalidInput(e.to_string()))?;
let resolved_root = self.resolve_root(project_root.as_deref());
let mut project_names: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut variant_names: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut entries: Vec<PackageListEntry> = Vec::new();
let mut project_root_str: Option<String> = None;
let mut lockfile_path_str: Option<String> = None;
let mut pkg_list_warnings: Vec<PkgListError> = Vec::new();
if let Some(ref root) = resolved_root {
project_root_str = Some(root.display().to_string());
lockfile_path_str = Some(lockfile_path(root).display().to_string());
let variant_warnings = collect_variant_entries(root, &mut variant_names, &mut entries);
pkg_list_warnings.extend(variant_warnings);
let lock_map: HashMap<String, (Option<String>, PackageSource)> =
match load_lockfile(root) {
Ok(Some(lock)) => lock
.packages
.into_iter()
.map(|p| (p.name, (p.version, p.source)))
.collect(),
Ok(None) => HashMap::new(),
Err(e) => {
pkg_list_warnings.push(PkgListError::LockfileParse(e));
HashMap::new()
}
};
match load_alc_toml(root) {
Ok(Some(alc_toml)) => {
for (name, dep) in &alc_toml.packages {
let (version, source_type, abs_path) =
resolve_project_pkg_info(name, dep, &lock_map, root);
project_names.insert(name.clone());
let (rsp, rsk, resolve_err): (
Option<String>,
Option<ResolvedSourceKind>,
Option<String>,
) = match source_type.as_deref() {
Some("path") => {
let rsp = abs_path
.as_ref()
.and_then(|p| resolve_source_path(std::path::Path::new(p)));
(rsp, Some(ResolvedSourceKind::LocalPath), None)
}
Some(st) => {
let kind = if st == "bundled" {
ResolvedSourceKind::Bundled
} else {
ResolvedSourceKind::Installed
};
{
let dir = packages_dir(&app_dir);
(resolve_source_path(&dir.join(name)), Some(kind), None)
}
}
None => (None, None, None),
};
let mut entry = make_project_entry(
name.clone(),
version,
source_type,
abs_path,
rsp,
rsk,
resolve_err,
);
if variant_names.contains(name) {
entry.active = false;
}
entries.push(entry);
}
}
Ok(None) => {
collect_path_entries_from_lock(
&lock_map,
root,
&variant_names,
&mut project_names,
&mut entries,
);
}
Err(e) => {
pkg_list_warnings.push(PkgListError::AlcTomlParse(e));
}
}
}
let mut seen: HashMap<String, Vec<(usize, String)>> = HashMap::new();
let global_start_idx = entries.len();
for (idx, sp) in self.search_paths.iter().enumerate() {
if !sp.path.is_dir() {
continue;
}
let read_entries = match std::fs::read_dir(&sp.path) {
Ok(e) => e,
Err(_) => continue,
};
for dir_entry in read_entries.flatten() {
let path = dir_entry.path();
let is_symlink = path
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
let link_target = if is_symlink {
path.read_link().ok().map(|t| t.display().to_string())
} else {
None
};
let broken = if is_symlink {
Some(!path.try_exists().unwrap_or(false))
} else {
None
};
if !is_symlink && !path.is_dir() {
continue;
}
if broken != Some(true) && !path.join("init.lua").exists() {
continue;
}
let name = dir_entry.file_name().to_string_lossy().to_string();
if is_system_package(&name) {
continue;
}
let source_display = sp.path.display().to_string();
seen.entry(name.clone())
.or_default()
.push((idx, source_display.clone()));
let global_active = seen[&name].len() == 1
&& !project_names.contains(&name)
&& !variant_names.contains(&name);
let (meta, eval_error) = if is_safe_pkg_name(&name) {
let code = format!(
r#"package.loaded["{name}"] = nil
local pkg = require("{name}")
local meta = pkg.meta or {{ name = "{name}" }}
{LUA_TYPE_AUTODETECT}
return meta"#,
name = name,
LUA_TYPE_AUTODETECT = LUA_TYPE_AUTODETECT,
);
match self.executor.eval_simple(code).await {
Ok(v) => (v, None),
Err(_) => (
serde_json::Value::Object(serde_json::Map::new()),
Some("failed to load meta".to_string()),
),
}
} else {
(
serde_json::Value::Object(serde_json::Map::new()),
Some("invalid package name".to_string()),
)
};
let (source_type, installed_at, updated_at, install_source) =
if let Some(entry) = manifest_data.packages.get(&name) {
let st = match &entry.source {
PackageSource::Git { .. } => "git".to_string(),
PackageSource::Installed => {
"installed".to_string()
}
PackageSource::Path { path } => {
format!("path (from: {path})")
}
PackageSource::Bundled { .. } => "bundled".to_string(),
PackageSource::Unknown => "unknown".to_string(),
};
let display = entry.source.display_string();
let install_source = if display.is_empty() {
None
} else {
Some(display)
};
(
Some(st),
Some(entry.installed_at.clone()),
Some(entry.updated_at.clone()),
install_source,
)
} else {
(None, None, None, None)
};
let (resolved_source_path, resolved_source_kind): (
Option<String>,
Option<ResolvedSourceKind>,
) = if is_symlink {
let kind = Some(ResolvedSourceKind::Linked);
if broken == Some(true) {
(None, kind)
} else {
let candidate = path.read_link().ok().map(|target| {
if target.is_absolute() {
target
} else {
sp.path.join(target)
}
});
let rsp = candidate.as_deref().and_then(resolve_source_path);
(rsp, kind)
}
} else {
let candidate = sp.path.join(&name);
let rsp = resolve_source_path(&candidate);
let kind = match source_type.as_deref() {
Some("bundled") => ResolvedSourceKind::Bundled,
_ => ResolvedSourceKind::Installed,
};
(rsp, Some(kind))
};
let warnings = derive_warnings_from_meta(&meta);
entries.push(PackageListEntry {
name,
scope: Scope::Global,
source_type,
path: None,
source: Some(source_display),
active: global_active,
version: None,
installed_at,
updated_at,
install_source,
overrides: None,
meta,
error: eval_error,
linked: if is_symlink { Some(true) } else { None },
link_target,
broken,
resolved_source_path,
resolved_source_kind,
override_paths: None,
warnings,
});
}
}
for entry in entries[global_start_idx..].iter_mut() {
if !entry.active {
continue;
}
if let Some(occurrences) = seen.get(&entry.name) {
if occurrences.len() > 1 {
entry.overrides =
Some(occurrences.iter().skip(1).map(|(_, s)| s.clone()).collect());
let override_ps: Vec<String> = occurrences
.iter()
.skip(1)
.filter_map(|(idx, _)| {
let candidate = self.search_paths[*idx].path.join(&entry.name);
resolve_source_path(&candidate)
})
.collect();
if !override_ps.is_empty() {
entry.override_paths = Some(override_ps);
}
}
}
}
for entry in entries[..global_start_idx].iter_mut() {
let self_path = entry.resolved_source_path.as_deref();
if let Some(occurrences) = seen.get(&entry.name) {
let ps: Vec<String> = occurrences
.iter()
.filter_map(|(idx, _)| {
let candidate = self.search_paths[*idx].path.join(&entry.name);
resolve_source_path(&candidate)
})
.filter(|p| Some(p.as_str()) != self_path)
.collect();
if !ps.is_empty() {
entry.override_paths = Some(ps);
}
}
}
let mut all_packages: Vec<serde_json::Value> =
entries.into_iter().map(|e| e.into_json()).collect();
if let Some(ref filter_map) = opts.filter {
if !filter_map.is_empty() {
all_packages.retain(|v| matches_filter(v, filter_map));
}
}
apply_sort_by_value(&mut all_packages, &sort_keys);
let limit = opts.limit.unwrap_or(50);
if limit > 0 {
all_packages.truncate(limit);
}
let projected: Vec<serde_json::Value> = all_packages
.into_iter()
.map(|v| project_fields(v, &fields))
.collect();
let search_paths_json: Vec<serde_json::Value> = self
.search_paths
.iter()
.map(|sp| {
serde_json::json!({
"path": sp.path.display().to_string(),
"source": sp.source.to_string(),
})
})
.collect();
let mut result = serde_json::json!({
"packages": projected,
"search_paths": search_paths_json,
});
if let Some(root_str) = project_root_str {
result["project_root"] = serde_json::Value::String(root_str);
}
if let Some(lp) = lockfile_path_str {
result["lockfile_path"] = serde_json::Value::String(lp);
}
let json = result.to_string();
let wire_warnings: Vec<String> = pkg_list_warnings.iter().map(|e| e.to_string()).collect();
Ok(splice_response_warnings(&json, "warnings", &wire_warnings))
}
}
fn resolve_project_pkg_info(
name: &str,
dep: &alc_toml::PackageDep,
lock_map: &HashMap<String, (Option<String>, PackageSource)>,
root: &Path,
) -> (Option<String>, Option<String>, Option<String>) {
if let Some((ver, source)) = lock_map.get(name) {
match source {
PackageSource::Path { path: raw_path } => {
let p = Path::new(raw_path);
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
root.join(p)
};
(
ver.clone(),
Some("path".to_string()),
Some(abs.display().to_string()),
)
}
PackageSource::Installed => (ver.clone(), Some("installed".to_string()), None),
PackageSource::Git { .. } => (ver.clone(), Some("git".to_string()), None),
PackageSource::Bundled { .. } => (ver.clone(), Some("bundled".to_string()), None),
PackageSource::Unknown => (ver.clone(), Some("unknown".to_string()), None),
}
} else {
let st = match dep {
alc_toml::PackageDep::Version(_) => Some("installed".to_string()),
alc_toml::PackageDep::Path { .. } => Some("path".to_string()),
alc_toml::PackageDep::Git { .. } => Some("git".to_string()),
};
(None, st, None)
}
}
fn collect_variant_entries(
root: &Path,
variant_names: &mut std::collections::HashSet<String>,
entries: &mut Vec<PackageListEntry>,
) -> Vec<PkgListError> {
let local = match alc_toml::load_alc_local_toml(root) {
Ok(Some(l)) => l,
Ok(None) => return vec![],
Err(e) => {
return vec![PkgListError::AlcLocalTomlParse(format!(
"failed to load alc.local.toml at {}: {e}",
root.display()
))];
}
};
for vp in alc_toml::resolve_local_variant_pkgs(root, &local) {
variant_names.insert(vp.name.clone());
let abs_path = vp.pkg_dir.display().to_string();
let rsp = resolve_source_path(&vp.pkg_dir);
entries.push(PackageListEntry {
name: vp.name,
scope: Scope::Variant,
source_type: Some("path".to_string()),
path: Some(abs_path),
source: None,
active: true,
version: None,
installed_at: None,
updated_at: None,
install_source: None,
overrides: None,
meta: serde_json::Value::Object(serde_json::Map::new()),
error: None,
linked: None,
link_target: None,
broken: None,
resolved_source_path: rsp,
resolved_source_kind: Some(ResolvedSourceKind::Variant),
override_paths: None,
warnings: None,
});
}
vec![]
}
fn make_project_entry(
name: String,
version: Option<String>,
source_type: Option<String>,
abs_path: Option<String>,
resolved_source_path: Option<String>,
resolved_source_kind: Option<ResolvedSourceKind>,
error: Option<String>,
) -> PackageListEntry {
PackageListEntry {
name,
scope: Scope::Project,
source_type,
path: abs_path,
source: None,
active: true,
version,
installed_at: None,
updated_at: None,
install_source: None,
overrides: None,
meta: serde_json::Value::Object(serde_json::Map::new()),
error,
linked: None,
link_target: None,
broken: None,
resolved_source_path,
resolved_source_kind,
override_paths: None,
warnings: None,
}
}
fn collect_path_entries_from_lock(
lock_map: &HashMap<String, (Option<String>, PackageSource)>,
root: &Path,
variant_names: &std::collections::HashSet<String>,
project_names: &mut std::collections::HashSet<String>,
entries: &mut Vec<PackageListEntry>,
) {
for (name, (version, source)) in lock_map {
if let PackageSource::Path { path: raw_path } = source {
let p = Path::new(raw_path);
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
root.join(p)
};
project_names.insert(name.clone());
let rsp = resolve_source_path(&abs);
let mut entry = make_project_entry(
name.clone(),
version.clone(),
Some("path".to_string()),
Some(abs.display().to_string()),
rsp,
Some(ResolvedSourceKind::LocalPath),
None,
);
if variant_names.contains(name) {
entry.active = false;
}
entries.push(entry);
}
}
}
fn derive_warnings_from_meta(meta: &serde_json::Value) -> Option<Vec<String>> {
let ts = meta.get("type_source").and_then(|v| v.as_str())?;
if ts == "auto_detected_library" {
Some(vec![UNMARKED_LIBRARY_SUGGESTION.to_string()])
} else {
None
}
}
fn resolve_source_path(candidate: &std::path::Path) -> Option<String> {
std::fs::canonicalize(candidate)
.ok()
.map(|p| p.display().to_string())
}
fn is_safe_pkg_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_emits_warnings_for_auto_detected_library() {
let meta = serde_json::json!({
"name": "mylib",
"type": "library",
"type_source": "auto_detected_library",
});
let result = derive_warnings_from_meta(&meta);
let warns = result.expect("expected Some(warnings) for auto_detected_library");
assert_eq!(warns.len(), 1, "must have exactly one warning: {warns:?}");
assert_eq!(
warns[0], UNMARKED_LIBRARY_SUGGESTION,
"warning must match canonical UNMARKED_LIBRARY_SUGGESTION"
);
assert!(
warns[0].contains("M.meta.type"),
"warning must mention M.meta.type: {}",
warns[0]
);
}
#[test]
fn entry_no_warnings_for_unknown_or_runnable() {
let meta_explicit = serde_json::json!({
"name": "legacypkg",
"type": "library",
"type_source": "unknown_value",
});
assert!(
derive_warnings_from_meta(&meta_explicit).is_none(),
"unknown type_source must produce no warnings"
);
let meta_runnable = serde_json::json!({
"name": "runnablepkg",
"type": "runnable",
"type_source": "auto_detected_runnable",
});
assert!(
derive_warnings_from_meta(&meta_runnable).is_none(),
"auto_detected_runnable must produce no warnings"
);
}
#[test]
fn entry_no_warnings_for_missing_type_source() {
let meta_legacy = serde_json::json!({
"name": "legacypkg",
"version": "0.1.0",
});
assert!(
derive_warnings_from_meta(&meta_legacy).is_none(),
"absent type_source must produce no warnings (legacy compat)"
);
let meta_null = serde_json::json!({
"name": "nullpkg",
"type_source": null,
});
assert!(
derive_warnings_from_meta(&meta_null).is_none(),
"null type_source must produce no warnings"
);
}
#[test]
fn into_json_emits_warnings_field_when_present() {
let entry = PackageListEntry {
name: "testpkg".to_string(),
scope: Scope::Global,
source_type: None,
path: None,
source: None,
active: true,
version: None,
installed_at: None,
updated_at: None,
install_source: None,
overrides: None,
meta: serde_json::json!({"type_source": "auto_detected_library"}),
error: None,
linked: None,
link_target: None,
broken: None,
resolved_source_path: None,
resolved_source_kind: None,
override_paths: None,
warnings: Some(vec!["my suggestion".to_string()]),
};
let json = entry.into_json();
let obj = json.as_object().expect("expected JSON object");
let warns = obj.get("warnings").expect("warnings key must be present");
assert!(warns.is_array(), "warnings must be a JSON array: {warns}");
assert_eq!(warns[0], "my suggestion");
}
#[test]
fn into_json_omits_warnings_field_when_none() {
let entry = PackageListEntry {
name: "testpkg2".to_string(),
scope: Scope::Global,
source_type: None,
path: None,
source: None,
active: true,
version: None,
installed_at: None,
updated_at: None,
install_source: None,
overrides: None,
meta: serde_json::json!({"type_source": "auto_detected_runnable"}),
error: None,
linked: None,
link_target: None,
broken: None,
resolved_source_path: None,
resolved_source_kind: None,
override_paths: None,
warnings: None,
};
let json = entry.into_json();
let obj = json.as_object().expect("expected JSON object");
assert!(
!obj.contains_key("warnings"),
"warnings key must not be present when warnings is None"
);
}
#[test]
fn into_json_host_warnings_not_overridden_by_meta() {
let entry = PackageListEntry {
name: "testpkg3".to_string(),
scope: Scope::Global,
source_type: None,
path: None,
source: None,
active: true,
version: None,
installed_at: None,
updated_at: None,
install_source: None,
overrides: None,
meta: serde_json::json!({
"type_source": "auto_detected_library",
"warnings": ["lua-side warning that must not win"],
}),
error: None,
linked: None,
link_target: None,
broken: None,
resolved_source_path: None,
resolved_source_kind: None,
override_paths: None,
warnings: Some(vec![UNMARKED_LIBRARY_SUGGESTION.to_string()]),
};
let json = entry.into_json();
let obj = json.as_object().expect("expected JSON object");
let warns = obj.get("warnings").expect("warnings key must be present");
let warns_arr = warns.as_array().expect("warnings must be array");
assert_eq!(
warns_arr.len(),
1,
"must have exactly one warning: {warns_arr:?}"
);
assert_eq!(
warns_arr[0], UNMARKED_LIBRARY_SUGGESTION,
"host warnings must win over Lua meta.warnings"
);
}
}