use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
const NETWORK_TIMEOUT_SECS: u64 = 10;
#[allow(dead_code)]
const UPDATE_CHECK_CONCURRENCY: usize = 4;
#[allow(dead_code)]
const GIT_UPDATE_CONCURRENCY: usize = 4;
const LOCKFILE_NAME: &str = "oxi-lock.json";
const MANIFEST_NAME: &str = "oxi-package.toml";
const NPM_MANIFEST_NAME: &str = "package.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResourceKind {
Extension,
Skill,
Prompt,
Theme,
}
impl std::fmt::Display for ResourceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ResourceKind::Extension => write!(f, "extension"),
ResourceKind::Skill => write!(f, "skill"),
ResourceKind::Prompt => write!(f, "prompt"),
ResourceKind::Theme => write!(f, "theme"),
}
}
}
#[allow(dead_code)]
const RESOURCE_KINDS: [ResourceKind; 4] = [
ResourceKind::Extension,
ResourceKind::Skill,
ResourceKind::Prompt,
ResourceKind::Theme,
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageManifest {
pub name: String,
pub version: String,
#[serde(default)]
pub extensions: Vec<String>,
#[serde(default)]
pub skills: Vec<String>,
#[serde(default)]
pub prompts: Vec<String>,
#[serde(default)]
pub themes: Vec<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub dependencies: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredResource {
pub kind: ResourceKind,
pub path: PathBuf,
pub relative_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathMetadata {
pub source: String,
pub scope: SourceScope,
pub origin: ResourceOrigin,
pub base_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResourceOrigin {
Package,
TopLevel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceScope {
User,
Project,
}
impl std::fmt::Display for SourceScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SourceScope::User => write!(f, "user"),
SourceScope::Project => write!(f, "project"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedResource {
pub path: PathBuf,
pub enabled: bool,
pub metadata: PathMetadata,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResolvedPaths {
pub extensions: Vec<ResolvedResource>,
pub skills: Vec<ResolvedResource>,
pub prompts: Vec<ResolvedResource>,
pub themes: Vec<ResolvedResource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressEvent {
pub event_type: ProgressEventType,
pub action: ProgressAction,
pub source: String,
pub message: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProgressEventType {
Start,
Progress,
Complete,
Error,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProgressAction {
Install,
Remove,
Update,
Clone,
Pull,
}
impl std::fmt::Display for ProgressAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProgressAction::Install => write!(f, "install"),
ProgressAction::Remove => write!(f, "remove"),
ProgressAction::Update => write!(f, "update"),
ProgressAction::Clone => write!(f, "clone"),
ProgressAction::Pull => write!(f, "pull"),
}
}
}
pub type ProgressCallback = Box<dyn Fn(ProgressEvent) + Send + Sync>;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ParsedSource {
Npm {
spec: String,
name: String,
pinned: bool,
},
Git {
repo: String,
host: String,
path: String,
ref_: Option<String>,
},
Local {
path: String,
},
Url {
url: String,
},
}
impl ParsedSource {
pub fn parse(source: &str) -> Self {
if let Some(rest) = source.strip_prefix("npm:") {
let spec = rest.trim();
let (name, pinned) = parse_npm_spec(spec);
return ParsedSource::Npm {
spec: spec.to_string(),
name,
pinned,
};
}
if let Some(rest) = source.strip_prefix("github:") {
let parts: Vec<&str> = rest.splitn(2, '/').collect();
if parts.len() == 2 {
let (path, ref_) = split_git_path_ref(rest);
return ParsedSource::Git {
repo: format!("https://github.com/{}.git", path),
host: "github.com".to_string(),
path,
ref_,
};
}
}
if source.starts_with("git+") || source.starts_with("git://") || source.starts_with("git@") {
return parse_git_source(source);
}
if source.starts_with("https://") || source.starts_with("http://") {
if source.ends_with(".git") || source.contains("github.com") || source.contains("gitlab.com") {
return parse_git_source(source);
}
if source.ends_with(".tar.gz")
|| source.ends_with(".tgz")
|| source.ends_with(".zip")
|| source.ends_with(".tar.bz2")
{
return ParsedSource::Url {
url: source.to_string(),
};
}
return parse_git_source(source);
}
ParsedSource::Local {
path: source.to_string(),
}
}
pub fn identity(&self) -> String {
match self {
ParsedSource::Npm { name, .. } => format!("npm:{}", name),
ParsedSource::Git { host, path, .. } => format!("git:{}/{}", host, path),
ParsedSource::Local { path } => format!("local:{}", path),
ParsedSource::Url { url } => format!("url:{}", url),
}
}
pub fn display_name(&self) -> String {
match self {
ParsedSource::Npm { name, .. } => name.clone(),
ParsedSource::Git { host, path, .. } => format!("{}/{}", host, path),
ParsedSource::Local { path } => path.clone(),
ParsedSource::Url { url } => url.clone(),
}
}
}
fn parse_npm_spec(spec: &str) -> (String, bool) {
let re = regex::Regex::new(r"^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$").unwrap();
if let Some(caps) = re.captures(spec) {
let name = caps.get(1).map(|m| m.as_str()).unwrap_or(spec);
let has_version = caps.get(2).is_some();
return (name.to_string(), has_version);
}
(spec.to_string(), false)
}
fn split_git_path_ref(input: &str) -> (String, Option<String>) {
if let Some(at_pos) = input.rfind('@') {
if input[..at_pos].contains('/') {
return (
input[..at_pos].to_string(),
Some(input[at_pos + 1..].to_string()),
);
}
}
(input.to_string(), None)
}
fn parse_git_source(source: &str) -> ParsedSource {
if let Some(rest) = source.strip_prefix("git@") {
let colon_pos = rest.find(':').unwrap_or(rest.len());
let host = &rest[..colon_pos];
let path_part = rest.get(colon_pos + 1..).unwrap_or("");
let (path, ref_) = if let Some(hash_pos) = path_part.rfind('#') {
(
path_part[..hash_pos].to_string(),
Some(path_part[hash_pos + 1..].to_string()),
)
} else {
split_git_path_ref(path_part)
};
let repo = if path_part.ends_with(".git") {
format!("git@{}:{}", host, path_part)
} else {
format!("git@{}:{}", host, path_part)
};
let host = host.to_string();
return ParsedSource::Git {
repo,
host,
path: path.trim_end_matches(".git").to_string(),
ref_,
};
}
let url_str = source
.strip_prefix("git+")
.unwrap_or(source)
.strip_prefix("git://")
.map(|s| format!("https://{}", s))
.unwrap_or_else(|| {
source
.strip_prefix("git+")
.unwrap_or(source)
.to_string()
});
let url = match url::Url::parse(&url_str) {
Ok(u) => u,
Err(_) => {
return ParsedSource::Local {
path: source.to_string(),
}
}
};
let host = url.host_str().unwrap_or("unknown").to_string();
let full_path = url.path().trim_start_matches('/').to_string();
let fragment = url.fragment().map(|f| f.to_string());
let (path, ref_) = if let Some(frag) = fragment {
(full_path.trim_end_matches(".git").to_string(), Some(frag))
} else {
let (p, r) = split_git_path_ref(&full_path);
(p.trim_end_matches(".git").to_string(), r)
};
let repo = url_str.clone();
ParsedSource::Git {
repo,
host,
path,
ref_,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpmPackageInfo {
pub name: String,
pub versions: BTreeMap<String, serde_json::Value>,
#[serde(rename = "dist-tags")]
pub dist_tags: BTreeMap<String, String>,
}
impl NpmPackageInfo {
pub async fn fetch(name: &str) -> Result<Self> {
let url = format!("https://registry.npmjs.org/{}", name);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(NETWORK_TIMEOUT_SECS))
.build()
.context("Failed to create HTTP client")?;
let resp = client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.with_context(|| format!("Failed to fetch npm info for '{}'", name))?;
if !resp.status().is_success() {
bail!(
"npm registry returned {} for '{}'",
resp.status(),
name
);
}
let info: NpmPackageInfo = resp
.json()
.await
.with_context(|| format!("Failed to parse npm registry response for '{}'", name))?;
Ok(info)
}
pub fn latest_version(&self) -> Option<&str> {
self.dist_tags.get("latest").map(|s| s.as_str())
}
pub fn resolve_version(&self, constraint: &str) -> Option<String> {
if constraint == "latest" || constraint.is_empty() {
return self.latest_version().map(|s| s.to_string());
}
if self.versions.contains_key(constraint) {
return Some(constraint.to_string());
}
if let Ok(req) = semver::VersionReq::parse(constraint) {
let mut best: Option<semver::Version> = None;
for ver_str in self.versions.keys() {
if let Ok(ver) = semver::Version::parse(ver_str) {
if req.matches(&ver) {
match &best {
Some(b) if ver > *b => best = Some(ver),
None => best = Some(ver),
_ => {}
}
}
}
}
if let Some(v) = best {
return Some(v.to_string());
}
}
None
}
}
pub async fn get_latest_npm_version(name: &str) -> Result<String> {
let info = NpmPackageInfo::fetch(name).await?;
info.latest_version()
.map(|s| s.to_string())
.context(format!("No latest version found for '{}'", name))
}
fn git_command(args: &[&str], cwd: Option<&Path>) -> Result<String> {
let mut cmd = std::process::Command::new("git");
cmd.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let output = cmd.output().context("Failed to execute git")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"git {} failed ({}): {}",
args.join(" "),
output.status,
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn git_command_silent(args: &[&str], cwd: Option<&Path>) -> Result<()> {
let mut cmd = std::process::Command::new("git");
cmd.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let status = cmd.status().context("Failed to execute git")?;
if !status.success() {
bail!("git {} failed ({})", args.join(" "), status);
}
Ok(())
}
pub fn git_clone(repo_url: &str, target_dir: &Path, ref_: Option<&str>) -> Result<()> {
if target_dir.exists() {
bail!("Target directory already exists: {}", target_dir.display());
}
fs::create_dir_all(target_dir)
.with_context(|| format!("Failed to create {}", target_dir.display()))?;
let target_str = target_dir.to_string_lossy().to_string();
let args = vec!["clone", repo_url, &target_str];
git_command_silent(&args, None)?;
if let Some(r) = ref_ {
git_command_silent(&["checkout", r], Some(target_dir))?;
}
Ok(())
}
pub fn git_update(repo_dir: &Path, ref_: Option<&str>) -> Result<bool> {
if !repo_dir.exists() {
bail!("Repository directory does not exist: {}", repo_dir.display());
}
let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
let fetch_ref = if let Some(r) = ref_ {
r.to_string()
} else {
match git_command(
&["rev-parse", "--abbrev-ref", "@{upstream}"],
Some(repo_dir),
) {
Ok(upstream) => {
if upstream.starts_with("origin/") {
let branch = &upstream["origin/".len()..];
format!("+refs/heads/{branch}:refs/remotes/origin/{branch}")
} else {
"+HEAD:refs/remotes/origin/HEAD".to_string()
}
}
Err(_) => "+HEAD:refs/remotes/origin/HEAD".to_string(),
}
};
git_command_silent(&["fetch", "--prune", "--no-tags", "origin", &fetch_ref], Some(repo_dir))?;
let target_ref = ref_.unwrap_or("origin/HEAD");
let remote_head = git_command(&["rev-parse", target_ref], Some(repo_dir))?;
if local_head == remote_head {
return Ok(false); }
git_command_silent(&["reset", "--hard", target_ref], Some(repo_dir))?;
git_command_silent(&["clean", "-fdx"], Some(repo_dir))?;
Ok(true) }
pub fn git_has_update(repo_dir: &Path) -> Result<bool> {
let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
let upstream_ref = match git_command(
&["rev-parse", "--abbrev-ref", "@{upstream}"],
Some(repo_dir),
) {
Ok(u) if u.starts_with("origin/") => {
let branch = &u["origin/".len()..];
format!("refs/heads/{branch}")
}
_ => "HEAD".to_string(),
};
let _ = git_command_silent(
&["fetch", "--prune", "--no-tags", "origin"],
Some(repo_dir),
);
let remote_head = git_command(&["ls-remote", "origin", &upstream_ref], None)?;
let remote_hash = remote_head
.lines()
.next()
.and_then(|line| line.split_whitespace().next())
.unwrap_or("");
Ok(local_head != remote_hash)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockEntry {
pub source: String,
pub name: String,
pub version: String,
pub integrity: Option<String>,
pub scope: SourceScope,
pub source_type: String,
#[serde(default)]
pub dependencies: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lockfile {
pub version: u32,
pub packages: BTreeMap<String, LockEntry>,
}
impl Lockfile {
pub fn new() -> Self {
Self {
version: 1,
packages: BTreeMap::new(),
}
}
pub fn read(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read lockfile {}", path.display()))?;
let lock: Lockfile = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse lockfile {}", path.display()))?;
Ok(Some(lock))
}
pub fn write(&self, path: &Path) -> Result<()> {
let content = serde_json::to_string_pretty(self).context("Failed to serialize lockfile")?;
fs::write(path, content)
.with_context(|| format!("Failed to write lockfile {}", path.display()))?;
Ok(())
}
pub fn insert(&mut self, entry: LockEntry) {
self.packages.insert(entry.name.clone(), entry);
}
pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
self.packages.remove(name)
}
pub fn contains(&self, name: &str) -> bool {
self.packages.contains_key(name)
}
pub fn get(&self, name: &str) -> Option<&LockEntry> {
self.packages.get(name)
}
}
impl Default for Lockfile {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResourceCounts {
pub extensions: usize,
pub skills: usize,
pub prompts: usize,
pub themes: usize,
}
impl std::fmt::Display for ResourceCounts {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut parts = Vec::new();
if self.extensions > 0 {
parts.push(format!("{} ext", self.extensions));
}
if self.skills > 0 {
parts.push(format!("{} skill", self.skills));
}
if self.prompts > 0 {
parts.push(format!("{} prompt", self.prompts));
}
if self.themes > 0 {
parts.push(format!("{} theme", self.themes));
}
if parts.is_empty() {
write!(f, "-")?;
} else {
write!(f, "{}", parts.join(", "))?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageUpdateInfo {
pub source: String,
pub display_name: String,
pub source_type: String, pub scope: SourceScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfiguredPackage {
pub source: String,
pub scope: SourceScope,
pub filtered: bool,
pub installed_path: Option<PathBuf>,
}
pub struct PackageManager {
packages_dir: PathBuf,
project_dir: PathBuf,
installed: HashMap<String, PackageManifest>,
lockfile: Lockfile,
progress_callback: Option<Box<dyn Fn(ProgressEvent) + Send + Sync>>,
}
impl PackageManager {
pub fn new() -> Result<Self> {
let base = dirs::home_dir().context("Cannot determine home directory")?;
let packages_dir = base.join(".oxi").join("packages");
let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut mgr = Self {
packages_dir,
project_dir,
installed: HashMap::new(),
lockfile: Lockfile::new(),
progress_callback: None,
};
mgr.load_installed()?;
mgr.load_lockfile()?;
Ok(mgr)
}
pub fn with_dir(packages_dir: PathBuf) -> Result<Self> {
let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut mgr = Self {
packages_dir,
project_dir,
installed: HashMap::new(),
lockfile: Lockfile::new(),
progress_callback: None,
};
mgr.load_installed()?;
mgr.load_lockfile()?;
Ok(mgr)
}
pub fn set_project_dir(&mut self, dir: PathBuf) {
self.project_dir = dir;
}
pub fn set_progress_callback(&mut self, callback: Box<dyn Fn(ProgressEvent) + Send + Sync>) {
self.progress_callback = Some(callback);
}
fn emit_progress(&self, event: ProgressEvent) {
if let Some(ref cb) = self.progress_callback {
cb(event);
}
}
#[allow(dead_code)]
fn with_progress<F>(
&self,
action: ProgressAction,
source: &str,
message: &str,
op: F,
) -> Result<()>
where
F: FnOnce() -> Result<()>,
{
self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Start,
action,
source: source.to_string(),
message: Some(message.to_string()),
});
match op() {
Ok(()) => {
self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Complete,
action,
source: source.to_string(),
message: None,
});
Ok(())
}
Err(e) => {
self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Error,
action,
source: source.to_string(),
message: Some(e.to_string()),
});
Err(e)
}
}
}
fn load_installed(&mut self) -> Result<()> {
if !self.packages_dir.exists() {
return Ok(());
}
for entry in fs::read_dir(&self.packages_dir)? {
let entry = entry?;
let manifest_path = entry.path().join(MANIFEST_NAME);
if manifest_path.exists() {
match Self::read_manifest(&manifest_path) {
Ok(manifest) => {
self.installed.insert(manifest.name.clone(), manifest);
}
Err(e) => {
tracing::warn!(
"Failed to load manifest {}: {}",
manifest_path.display(),
e
);
}
}
}
}
Ok(())
}
fn load_lockfile(&mut self) -> Result<()> {
let lock_path = self.packages_dir.join(LOCKFILE_NAME);
if let Some(lock) = Lockfile::read(&lock_path)? {
self.lockfile = lock;
}
Ok(())
}
fn save_lockfile(&self) -> Result<()> {
let lock_path = self.packages_dir.join(LOCKFILE_NAME);
self.lockfile.write(&lock_path)
}
fn read_manifest(path: &Path) -> Result<PackageManifest> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read manifest {}", path.display()))?;
let manifest: PackageManifest = toml::from_str(&content)
.with_context(|| format!("Failed to parse manifest {}", path.display()))?;
Ok(manifest)
}
fn read_package_json(dir: &Path) -> Option<serde_json::Value> {
let path = dir.join(NPM_MANIFEST_NAME);
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn pkg_install_dir(&self, name: &str) -> PathBuf {
let safe_name = name.replace('@', "").replace('/', "-");
self.packages_dir.join(safe_name)
}
pub fn packages_dir(&self) -> &Path {
&self.packages_dir
}
fn git_install_path(&self, host: &str, path: &str, scope: SourceScope) -> PathBuf {
match scope {
SourceScope::Project => self.project_dir.join(".oxi").join("git").join(host).join(path),
SourceScope::User => self.packages_dir.join("git").join(host).join(path),
}
}
fn npm_install_path(&self, name: &str, scope: SourceScope) -> PathBuf {
let safe_name = name.replace('@', "").replace('/', "-");
match scope {
SourceScope::Project => {
self.project_dir.join(".oxi").join("npm").join(safe_name)
}
SourceScope::User => self.packages_dir.join("npm").join(safe_name),
}
}
fn ensure_packages_dir(&self) -> Result<()> {
fs::create_dir_all(&self.packages_dir).with_context(|| {
format!(
"Failed to create packages directory {}",
self.packages_dir.display()
)
})
}
pub fn install(&mut self, source: &str) -> Result<PackageManifest> {
let parsed = ParsedSource::parse(source);
match parsed {
ParsedSource::Local { path } => self.install_local(&path),
_ => bail!("Use install_from_source() for non-local packages"),
}
}
fn install_local(&mut self, path: &str) -> Result<PackageManifest> {
let source_path = Path::new(path);
let manifest_path = source_path.join(MANIFEST_NAME);
let manifest = if manifest_path.exists() {
Self::read_manifest(&manifest_path)
.with_context(|| format!("No valid {} found in {}", MANIFEST_NAME, path))?
} else {
let name = source_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
PackageManifest {
name,
version: "0.0.0".to_string(),
extensions: Vec::new(),
skills: Vec::new(),
prompts: Vec::new(),
themes: Vec::new(),
description: None,
dependencies: BTreeMap::new(),
}
};
let dest = self.pkg_install_dir(&manifest.name);
self.ensure_packages_dir()?;
if dest.exists() {
fs::remove_dir_all(&dest).with_context(|| {
format!("Failed to remove existing package at {}", dest.display())
})?;
}
copy_dir_recursive(source_path, &dest).with_context(|| {
format!(
"Failed to copy package from {} to {}",
path,
dest.display()
)
})?;
let integrity = compute_dir_hash(&dest);
self.lockfile.insert(LockEntry {
source: path.to_string(),
name: manifest.name.clone(),
version: manifest.version.clone(),
integrity,
scope: SourceScope::User,
source_type: "local".to_string(),
dependencies: manifest.dependencies.clone(),
});
self.installed
.insert(manifest.name.clone(), manifest.clone());
let _ = self.save_lockfile();
Ok(manifest)
}
pub fn install_from_source(
&mut self,
source: &str,
scope: SourceScope,
) -> Result<PackageManifest> {
let parsed = ParsedSource::parse(source);
self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Start,
action: ProgressAction::Install,
source: source.to_string(),
message: Some(format!("Installing {}...", source)),
});
let result = match &parsed {
ParsedSource::Npm { .. } => {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(self.install_npm_async(source, scope))
}
ParsedSource::Git { repo, ref_, .. } => {
self.install_git_sync(source, repo, ref_.as_deref(), scope)
}
ParsedSource::Local { path } => self.install_local(path),
ParsedSource::Url { url } => {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(self.install_url(url, scope))
}
};
match &result {
Ok(_) => self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Complete,
action: ProgressAction::Install,
source: source.to_string(),
message: None,
}),
Err(e) => self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Error,
action: ProgressAction::Install,
source: source.to_string(),
message: Some(e.to_string()),
}),
}
result
}
async fn install_npm_async(
&mut self,
source: &str,
scope: SourceScope,
) -> Result<PackageManifest> {
let parsed = ParsedSource::parse(source);
let (spec, name, pinned) = match &parsed {
ParsedSource::Npm { spec, name, pinned } => (spec.clone(), name.clone(), *pinned),
_ => bail!("Expected npm source"),
};
let _version = if pinned {
let (_, ver) = parse_npm_spec(&spec);
if ver {
spec.rsplit('@')
.next()
.unwrap_or("latest")
.to_string()
} else {
"latest".to_string()
}
} else {
get_latest_npm_version(&name).await.unwrap_or_else(|_| "latest".to_string())
};
self.install_npm_pack(&spec, scope)
}
fn install_npm_pack(
&mut self,
spec: &str,
scope: SourceScope,
) -> Result<PackageManifest> {
let tmp_dir = tempfile::tempdir().context("Failed to create temp directory for npm install")?;
let output = std::process::Command::new("npm")
.args(["pack", spec, "--pack-destination"])
.arg(tmp_dir.path())
.current_dir(tmp_dir.path())
.output()
.context("Failed to run npm pack")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("npm pack failed for '{}': {}", spec, stderr);
}
let tarball = fs::read_dir(tmp_dir.path())?
.filter_map(|e| e.ok())
.find(|e| {
e.path()
.extension()
.map(|ext| ext == "tgz")
.unwrap_or(false)
})
.map(|e| e.path())
.context("No .tgz file found after npm pack")?;
let extract_dir = tmp_dir.path().join("extracted");
fs::create_dir_all(&extract_dir)?;
let tar_status = std::process::Command::new("tar")
.args(["-xzf", &tarball.to_string_lossy(), "-C"])
.arg(&extract_dir)
.output()
.context("Failed to run tar")?;
if !tar_status.status.success() {
let stderr = String::from_utf8_lossy(&tar_status.stderr);
bail!("tar extraction failed: {}", stderr);
}
let pkg_source = extract_dir.join("package");
let source_for_copy = if pkg_source.exists() {
&pkg_source
} else {
extract_dir.as_path()
};
self.ensure_packages_dir()?;
let manifest = if source_for_copy.join(MANIFEST_NAME).exists() {
Self::read_manifest(&source_for_copy.join(MANIFEST_NAME))?
} else if source_for_copy.join(NPM_MANIFEST_NAME).exists() {
let pj = Self::read_package_json(source_for_copy);
let (pkg_name, pkg_version) = pj
.as_ref()
.map(|v| {
(
v.get("name")
.and_then(|n| n.as_str())
.unwrap_or(spec)
.to_string(),
v.get("version")
.and_then(|v| v.as_str())
.unwrap_or("0.0.0")
.to_string(),
)
})
.unwrap_or((spec.to_string(), "0.0.0".to_string()));
PackageManifest {
name: pkg_name,
version: pkg_version,
extensions: Vec::new(),
skills: Vec::new(),
prompts: Vec::new(),
themes: Vec::new(),
description: None,
dependencies: BTreeMap::new(),
}
} else {
PackageManifest {
name: spec.to_string(),
version: "0.0.0".to_string(),
extensions: Vec::new(),
skills: Vec::new(),
prompts: Vec::new(),
themes: Vec::new(),
description: None,
dependencies: BTreeMap::new(),
}
};
let dest = self.pkg_install_dir(&manifest.name);
if dest.exists() {
fs::remove_dir_all(&dest).with_context(|| {
format!("Failed to remove existing package at {}", dest.display())
})?;
}
copy_dir_recursive(source_for_copy, &dest)
.with_context(|| format!("Failed to copy npm package for '{}'", spec))?;
let integrity = compute_dir_hash(&dest);
self.lockfile.insert(LockEntry {
source: format!("npm:{}", spec),
name: manifest.name.clone(),
version: manifest.version.clone(),
integrity,
scope,
source_type: "npm".to_string(),
dependencies: manifest.dependencies.clone(),
});
self.installed
.insert(manifest.name.clone(), manifest.clone());
let _ = self.save_lockfile();
Ok(manifest)
}
fn install_git_sync(
&mut self,
source: &str,
repo: &str,
ref_: Option<&str>,
scope: SourceScope,
) -> Result<PackageManifest> {
let parsed = ParsedSource::parse(source);
let (host, path) = match &parsed {
ParsedSource::Git { host, path, .. } => (host.clone(), path.clone()),
_ => bail!("Expected git source"),
};
let target_dir = self.git_install_path(&host, &path, scope);
if target_dir.exists() {
return self.load_manifest_from_dir(&target_dir, source, scope);
}
fs::create_dir_all(target_dir.parent().unwrap())
.with_context(|| format!("Failed to create parent dir for {}", target_dir.display()))?;
git_clone(repo, &target_dir, ref_)?;
if target_dir.join(NPM_MANIFEST_NAME).exists() {
let _ = std::process::Command::new("npm")
.args(["install", "--omit=dev"])
.current_dir(&target_dir)
.output();
}
self.load_manifest_from_dir(&target_dir, source, scope)
}
fn load_manifest_from_dir(
&mut self,
dir: &Path,
source: &str,
scope: SourceScope,
) -> Result<PackageManifest> {
let manifest = if dir.join(MANIFEST_NAME).exists() {
Self::read_manifest(&dir.join(MANIFEST_NAME))?
} else {
let name = dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
PackageManifest {
name,
version: "0.0.0".to_string(),
extensions: Vec::new(),
skills: Vec::new(),
prompts: Vec::new(),
themes: Vec::new(),
description: None,
dependencies: BTreeMap::new(),
}
};
let integrity = compute_dir_hash(dir);
self.lockfile.insert(LockEntry {
source: source.to_string(),
name: manifest.name.clone(),
version: manifest.version.clone(),
integrity,
scope,
source_type: "git".to_string(),
dependencies: manifest.dependencies.clone(),
});
self.installed
.insert(manifest.name.clone(), manifest.clone());
let _ = self.save_lockfile();
Ok(manifest)
}
async fn install_url(
&mut self,
url: &str,
scope: SourceScope,
) -> Result<PackageManifest> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(NETWORK_TIMEOUT_SECS))
.build()?;
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
bail!("Failed to download {}: {}", url, resp.status());
}
let bytes = resp.bytes().await?;
let tmp_dir = tempfile::tempdir()?;
let archive_name = url.split('/').last().unwrap_or("archive");
let archive_path = tmp_dir.path().join(archive_name);
fs::write(&archive_path, &bytes)?;
let extract_dir = tmp_dir.path().join("extracted");
fs::create_dir_all(&extract_dir)?;
if archive_name.ends_with(".tar.gz") || archive_name.ends_with(".tgz") {
let status = std::process::Command::new("tar")
.args(["-xzf", &archive_path.to_string_lossy(), "-C"])
.arg(&extract_dir)
.output()?;
if !status.status.success() {
bail!("Failed to extract archive");
}
} else if archive_name.ends_with(".zip") {
let status = std::process::Command::new("unzip")
.arg("-o")
.arg(&archive_path)
.arg("-d")
.arg(&extract_dir)
.output()?;
if !status.status.success() {
bail!("Failed to extract zip archive");
}
} else {
bail!("Unsupported archive format: {}", archive_name);
}
let pkg_dir = find_single_subdir(&extract_dir).unwrap_or_else(|| extract_dir.to_path_buf());
self.ensure_packages_dir()?;
let manifest = if pkg_dir.join(MANIFEST_NAME).exists() {
Self::read_manifest(&pkg_dir.join(MANIFEST_NAME))?
} else {
let name = url
.split('/')
.last()
.unwrap_or("url-package")
.trim_end_matches(".tar.gz")
.trim_end_matches(".tgz")
.trim_end_matches(".zip")
.to_string();
PackageManifest {
name,
version: "0.0.0".to_string(),
extensions: Vec::new(),
skills: Vec::new(),
prompts: Vec::new(),
themes: Vec::new(),
description: None,
dependencies: BTreeMap::new(),
}
};
let dest = self.pkg_install_dir(&manifest.name);
if dest.exists() {
fs::remove_dir_all(&dest)?;
}
copy_dir_recursive(&pkg_dir, &dest)?;
let integrity = compute_dir_hash(&dest);
self.lockfile.insert(LockEntry {
source: url.to_string(),
name: manifest.name.clone(),
version: manifest.version.clone(),
integrity,
scope,
source_type: "url".to_string(),
dependencies: manifest.dependencies.clone(),
});
self.installed
.insert(manifest.name.clone(), manifest.clone());
let _ = self.save_lockfile();
Ok(manifest)
}
pub fn install_npm(&mut self, name: &str) -> Result<PackageManifest> {
self.install_npm_pack(name, SourceScope::User)
}
pub fn uninstall(&mut self, name: &str) -> Result<()> {
if !self.installed.contains_key(name) {
bail!("Package '{}' is not installed", name);
}
let dest = self.pkg_install_dir(name);
if dest.exists() {
fs::remove_dir_all(&dest).with_context(|| {
format!("Failed to remove package directory {}", dest.display())
})?;
}
let _ = self.lockfile.remove(name);
let _ = self.save_lockfile();
self.installed.remove(name);
Ok(())
}
pub fn uninstall_from_source(&mut self, source: &str, scope: SourceScope) -> Result<()> {
let parsed = ParsedSource::parse(source);
self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Start,
action: ProgressAction::Remove,
source: source.to_string(),
message: Some(format!("Removing {}...", source)),
});
let result = self.do_uninstall_from_source(&parsed, scope);
match &result {
Ok(_) => self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Complete,
action: ProgressAction::Remove,
source: source.to_string(),
message: None,
}),
Err(e) => self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Error,
action: ProgressAction::Remove,
source: source.to_string(),
message: Some(e.to_string()),
}),
}
result
}
fn do_uninstall_from_source(&mut self, parsed: &ParsedSource, scope: SourceScope) -> Result<()> {
match parsed {
ParsedSource::Npm { name, .. } => {
let dest = self.npm_install_path(name, scope);
if dest.exists() {
fs::remove_dir_all(&dest)?;
}
self.installed.remove(name);
self.lockfile.remove(name);
let _ = self.save_lockfile();
Ok(())
}
ParsedSource::Git { host, path, .. } => {
let dest = self.git_install_path(host, path, scope);
if dest.exists() {
fs::remove_dir_all(&dest)?;
prune_empty_parents(&dest, &self.packages_dir);
}
self.installed.retain(|_, m| {
let parsed_m = ParsedSource::parse(m.name.as_str());
parsed_m.identity() != parsed.identity()
});
self.lockfile.packages.retain(|_, entry| {
let parsed_e = ParsedSource::parse(&entry.source);
parsed_e.identity() != parsed.identity()
});
let _ = self.save_lockfile();
Ok(())
}
ParsedSource::Local { .. } => Ok(()),
ParsedSource::Url { .. } => {
let identity = parsed.identity();
self.lockfile.packages.retain(|_, e| {
ParsedSource::parse(&e.source).identity() != identity
});
let _ = self.save_lockfile();
Ok(())
}
}
}
pub fn update(&mut self, name: &str) -> Result<PackageManifest> {
let lock_entry = self.lockfile.get(name).cloned();
if let Some(entry) = lock_entry {
let parsed = ParsedSource::parse(&entry.source);
return match &parsed {
ParsedSource::Npm { spec, .. } => {
self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Start,
action: ProgressAction::Update,
source: entry.source.clone(),
message: Some(format!("Updating {}...", name)),
});
let result = self.install_npm_pack(spec, entry.scope);
match &result {
Ok(_) => self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Complete,
action: ProgressAction::Update,
source: entry.source.clone(),
message: None,
}),
Err(e) => self.emit_progress(ProgressEvent {
event_type: ProgressEventType::Error,
action: ProgressAction::Update,
source: entry.source.clone(),
message: Some(e.to_string()),
}),
}
result
}
ParsedSource::Git { repo, ref_, .. } => {
let target_dir = match &parsed {
ParsedSource::Git { host, path, .. } => {
self.git_install_path(host, path, entry.scope)
}
_ => unreachable!(),
};
if target_dir.exists() {
let updated = git_update(&target_dir, ref_.as_deref())?;
if updated {
if target_dir.join(NPM_MANIFEST_NAME).exists() {
let _ = std::process::Command::new("npm")
.args(["install", "--omit=dev"])
.current_dir(&target_dir)
.output();
}
}
self.load_manifest_from_dir(&target_dir, &entry.source, entry.scope)
} else {
self.install_git_sync(
&entry.source,
repo,
ref_.as_deref(),
entry.scope,
)
}
}
ParsedSource::Local { path } => self.install_local(path),
ParsedSource::Url { url } => {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(self.install_url(url, entry.scope))
}
};
}
if self.installed.contains_key(name) {
self.install_npm_pack(name, SourceScope::User)
} else {
bail!("Package '{}' is not installed", name);
}
}
pub fn update_all(&mut self) -> Vec<(String, Result<PackageManifest>)> {
let names: Vec<String> = self.installed.keys().cloned().collect();
let mut results = Vec::new();
for name in names {
let result = self.update(&name);
results.push((name, result));
}
results
}
pub async fn check_for_updates(&self) -> Vec<PackageUpdateInfo> {
let mut updates = Vec::new();
for (_name, lock_entry) in &self.lockfile.packages {
let parsed = ParsedSource::parse(&lock_entry.source);
match &parsed {
ParsedSource::Npm { name: pkg_name, .. } => {
match NpmPackageInfo::fetch(pkg_name).await {
Ok(info) => {
if let Some(latest) = info.latest_version() {
if latest != lock_entry.version {
updates.push(PackageUpdateInfo {
source: lock_entry.source.clone(),
display_name: pkg_name.clone(),
source_type: "npm".to_string(),
scope: lock_entry.scope,
});
}
}
}
Err(_) => continue,
}
}
ParsedSource::Git { host, path, .. } => {
let install_path = self.git_install_path(host, path, lock_entry.scope);
if install_path.exists() {
match git_has_update(&install_path) {
Ok(true) => {
updates.push(PackageUpdateInfo {
source: lock_entry.source.clone(),
display_name: format!("{}/{}", host, path),
source_type: "git".to_string(),
scope: lock_entry.scope,
});
}
_ => continue,
}
}
}
_ => continue,
}
}
updates
}
#[allow(dead_code)]
fn is_offline() -> bool {
std::env::var("OXI_OFFLINE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes"))
.unwrap_or(false)
}
pub fn list(&self) -> Vec<&PackageManifest> {
self.installed.values().collect()
}
pub fn list_configured(&self) -> Vec<ConfiguredPackage> {
let mut result = Vec::new();
for (name, _manifest) in &self.installed {
let installed_path = self.get_install_dir(name);
let lock_entry = self.lockfile.get(name);
result.push(ConfiguredPackage {
source: lock_entry
.map(|e| e.source.clone())
.unwrap_or_else(|| name.clone()),
scope: lock_entry
.map(|e| e.scope)
.unwrap_or(SourceScope::User),
filtered: false,
installed_path,
});
}
result
}
pub fn is_installed(&self, name: &str) -> bool {
self.installed.contains_key(name)
}
pub fn get_install_dir(&self, name: &str) -> Option<PathBuf> {
let dir = self.pkg_install_dir(name);
if dir.exists() {
Some(dir)
} else {
None
}
}
pub fn get_installed_path_for_source(
&self,
source: &str,
scope: SourceScope,
) -> Option<PathBuf> {
let parsed = ParsedSource::parse(source);
match &parsed {
ParsedSource::Npm { name, .. } => {
let path = self.npm_install_path(name, scope);
if path.exists() {
Some(path)
} else {
None
}
}
ParsedSource::Git { host, path, .. } => {
let path = self.git_install_path(host, path, scope);
if path.exists() {
Some(path)
} else {
None
}
}
ParsedSource::Local { path } => {
let p = PathBuf::from(path);
if p.exists() {
Some(p)
} else {
None
}
}
ParsedSource::Url { .. } => None,
}
}
pub fn discover_resources(&self, name: &str) -> Result<Vec<DiscoveredResource>> {
let manifest = self
.installed
.get(name)
.with_context(|| format!("Package '{}' not found", name))?;
let install_dir = self.pkg_install_dir(name);
if !install_dir.exists() {
bail!("Install directory for '{}' does not exist", name);
}
let mut resources = Vec::new();
let has_explicit = !manifest.extensions.is_empty()
|| !manifest.skills.is_empty()
|| !manifest.prompts.is_empty()
|| !manifest.themes.is_empty();
if has_explicit {
for ext in &manifest.extensions {
let path = install_dir.join(ext);
if path.exists() {
resources.push(DiscoveredResource {
kind: ResourceKind::Extension,
path,
relative_path: ext.clone(),
});
}
}
for skill in &manifest.skills {
let path = install_dir.join(skill);
if path.exists() {
resources.push(DiscoveredResource {
kind: ResourceKind::Skill,
path,
relative_path: skill.clone(),
});
}
}
for prompt in &manifest.prompts {
let path = install_dir.join(prompt);
if path.exists() {
resources.push(DiscoveredResource {
kind: ResourceKind::Prompt,
path,
relative_path: prompt.clone(),
});
}
}
for theme in &manifest.themes {
let path = install_dir.join(theme);
if path.exists() {
resources.push(DiscoveredResource {
kind: ResourceKind::Theme,
path,
relative_path: theme.clone(),
});
}
}
} else {
resources.extend(discover_extensions(&install_dir));
resources.extend(discover_skills(&install_dir));
resources.extend(discover_prompts(&install_dir));
resources.extend(discover_themes(&install_dir));
}
Ok(resources)
}
pub fn resource_counts(&self, name: &str) -> Result<ResourceCounts> {
let resources = self.discover_resources(name)?;
let mut counts = ResourceCounts::default();
for r in &resources {
match r.kind {
ResourceKind::Extension => counts.extensions += 1,
ResourceKind::Skill => counts.skills += 1,
ResourceKind::Prompt => counts.prompts += 1,
ResourceKind::Theme => counts.themes += 1,
}
}
Ok(counts)
}
pub fn resolve(&self) -> ResolvedPaths {
let mut extensions = Vec::new();
let mut skills = Vec::new();
let mut prompts = Vec::new();
let mut themes = Vec::new();
for (name, _manifest) in &self.installed {
let install_dir = self.pkg_install_dir(name);
if !install_dir.exists() {
continue;
}
let metadata = PathMetadata {
source: name.clone(),
scope: SourceScope::User,
origin: ResourceOrigin::Package,
base_dir: Some(install_dir.clone()),
};
if let Ok(resources) = self.discover_resources(name) {
for r in resources {
match r.kind {
ResourceKind::Extension => extensions.push(ResolvedResource {
path: r.path,
enabled: true,
metadata: metadata.clone(),
}),
ResourceKind::Skill => skills.push(ResolvedResource {
path: r.path,
enabled: true,
metadata: metadata.clone(),
}),
ResourceKind::Prompt => prompts.push(ResolvedResource {
path: r.path,
enabled: true,
metadata: metadata.clone(),
}),
ResourceKind::Theme => themes.push(ResolvedResource {
path: r.path,
enabled: true,
metadata: metadata.clone(),
}),
}
}
}
}
ResolvedPaths {
extensions,
skills,
prompts,
themes,
}
}
pub fn resolve_dependencies(&self) -> Vec<(String, Vec<String>)> {
let mut result = Vec::new();
let installed_names: HashSet<&str> =
self.installed.keys().map(|s| s.as_str()).collect();
for (name, manifest) in &self.installed {
let missing: Vec<String> = manifest
.dependencies
.keys()
.filter(|dep| !installed_names.contains(dep.as_str()))
.cloned()
.collect();
if !missing.is_empty() {
result.push((name.clone(), missing));
}
}
result
}
pub fn validate_package(dir: &Path) -> Result<Vec<String>> {
let mut warnings = Vec::new();
if !dir.join(MANIFEST_NAME).exists() && !dir.join(NPM_MANIFEST_NAME).exists() {
warnings.push(format!(
"No {} or {} found",
MANIFEST_NAME, NPM_MANIFEST_NAME
));
}
if dir.join(MANIFEST_NAME).exists() {
match Self::read_manifest(&dir.join(MANIFEST_NAME)) {
Ok(m) => {
if m.name.is_empty() {
warnings.push("Package name is empty".to_string());
}
if m.version.is_empty() {
warnings.push("Package version is empty".to_string());
}
if semver::Version::parse(&m.version).is_err() {
warnings.push(format!(
"Version '{}' is not valid semver",
m.version
));
}
let has_resources = !m.extensions.is_empty()
|| !m.skills.is_empty()
|| !m.prompts.is_empty()
|| !m.themes.is_empty();
if !has_resources {
let discovered = discover_extensions(dir)
.into_iter()
.chain(discover_skills(dir))
.chain(discover_prompts(dir))
.chain(discover_themes(dir))
.count();
if discovered == 0 {
warnings.push(
"Package has no explicit resources and auto-discovery found nothing"
.to_string(),
);
}
}
for ext in &m.extensions {
if !dir.join(ext).exists() {
warnings.push(format!("Extension path '{}' does not exist", ext));
}
}
for skill in &m.skills {
if !dir.join(skill).exists() {
warnings.push(format!("Skill path '{}' does not exist", skill));
}
}
for prompt in &m.prompts {
if !dir.join(prompt).exists() {
warnings.push(format!("Prompt path '{}' does not exist", prompt));
}
}
for theme in &m.themes {
if !dir.join(theme).exists() {
warnings.push(format!("Theme path '{}' does not exist", theme));
}
}
}
Err(e) => {
warnings.push(format!("Failed to parse {}: {}", MANIFEST_NAME, e));
}
}
}
if !dir.join(".gitignore").exists() && !dir.join(".ignore").exists() {
warnings.push("No .gitignore or .ignore file found".to_string());
}
Ok(warnings)
}
pub fn get_installed_version(&self, name: &str) -> Option<&str> {
self.installed.get(name).map(|m| m.version.as_str())
}
pub fn version_satisfies(&self, name: &str, requirement: &str) -> bool {
if let Some(version) = self.get_installed_version(name) {
if let Ok(v) = semver::Version::parse(version) {
if let Ok(req) = semver::VersionReq::parse(requirement) {
return req.matches(&v);
}
}
}
false
}
pub fn lockfile(&self) -> &Lockfile {
&self.lockfile
}
}
fn discover_extensions(dir: &Path) -> Vec<DiscoveredResource> {
let mut results = Vec::new();
discover_extensions_recursive(dir, dir, &mut results);
results
}
fn discover_extensions_recursive(
base: &Path,
current: &Path,
results: &mut Vec<DiscoveredResource>,
) {
if !current.exists() {
return;
}
let entries = match fs::read_dir(current) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') || name_str == "node_modules" {
continue;
}
if path.is_dir() {
for index in &["index.ts", "index.js"] {
let index_path = path.join(index);
if index_path.exists() {
let rel = path.strip_prefix(base).unwrap_or(&path);
results.push(DiscoveredResource {
kind: ResourceKind::Extension,
path: index_path,
relative_path: rel.join(index).to_string_lossy().to_string(),
});
}
}
} else {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if matches!(ext, "so" | "dylib" | "dll" | "ts" | "js") {
let rel = path.strip_prefix(base).unwrap_or(&path);
results.push(DiscoveredResource {
kind: ResourceKind::Extension,
path: path.clone(),
relative_path: rel.to_string_lossy().to_string(),
});
}
}
}
}
fn discover_skills(dir: &Path) -> Vec<DiscoveredResource> {
let mut results = Vec::new();
discover_skills_recursive(dir, dir, &mut results);
results
}
fn discover_skills_recursive(base: &Path, current: &Path, results: &mut Vec<DiscoveredResource>) {
if !current.exists() {
return;
}
let entries = match fs::read_dir(current) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') || name_str == "node_modules" {
continue;
}
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.exists() {
let rel = path.strip_prefix(base).unwrap_or(&path);
results.push(DiscoveredResource {
kind: ResourceKind::Skill,
path: skill_file,
relative_path: rel.join("SKILL.md").to_string_lossy().to_string(),
});
}
discover_skills_recursive(base, &path, results);
}
}
}
fn discover_prompts(dir: &Path) -> Vec<DiscoveredResource> {
let prompts_dir = dir.join("prompts");
discover_files_by_ext(
if prompts_dir.exists() {
&prompts_dir
} else {
dir
},
"md",
ResourceKind::Prompt,
)
}
fn discover_themes(dir: &Path) -> Vec<DiscoveredResource> {
let themes_dir = dir.join("themes");
discover_files_by_ext(
if themes_dir.exists() {
&themes_dir
} else {
dir
},
"json",
ResourceKind::Theme,
)
}
fn discover_files_by_ext(dir: &Path, ext: &str, kind: ResourceKind) -> Vec<DiscoveredResource> {
let mut results = Vec::new();
discover_files_recursive(dir, dir, ext, kind, &mut results);
results
}
fn discover_files_recursive(
base: &Path,
current: &Path,
ext: &str,
kind: ResourceKind,
results: &mut Vec<DiscoveredResource>,
) {
if !current.exists() {
return;
}
let entries = match fs::read_dir(current) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') || name_str == "node_modules" {
continue;
}
if path.is_dir() {
discover_files_recursive(base, &path, ext, kind, results);
} else if path.extension().and_then(|e| e.to_str()) == Some(ext) {
let rel = path.strip_prefix(base).unwrap_or(&path);
results.push(DiscoveredResource {
kind,
path: path.clone(),
relative_path: rel.to_string_lossy().to_string(),
});
}
}
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
if !dst.exists() {
fs::create_dir_all(dst)?;
}
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn compute_dir_hash(dir: &Path) -> Option<String> {
let mut hasher = Sha256::new();
let mut files = collect_file_paths(dir);
files.sort();
for file_path in &files {
if let Ok(content) = fs::read(file_path) {
hasher.update(&content);
}
}
let result = hasher.finalize();
Some(format!("sha256-{:x}", result))
}
fn collect_file_paths(dir: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
if !dir.exists() {
return paths;
}
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return paths,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
paths.extend(collect_file_paths(&path));
} else {
paths.push(path);
}
}
paths
}
fn find_single_subdir(dir: &Path) -> Option<PathBuf> {
let entries: Vec<_> = fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).collect();
if entries.len() == 1 && entries[0].path().is_dir() {
Some(entries[0].path())
} else {
None
}
}
fn prune_empty_parents(target: &Path, root: &Path) {
let mut current = target.parent();
while let Some(dir) = current {
if dir == root || !dir.starts_with(root) {
break;
}
if dir.exists() {
let is_empty = fs::read_dir(dir)
.map(|mut rd| rd.next().is_none())
.unwrap_or(false);
if is_empty {
let _ = fs::remove_dir(dir);
} else {
break;
}
}
current = dir.parent();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_temp_packages_dir() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let packages_dir = tmp.path().join("packages");
fs::create_dir_all(&packages_dir).unwrap();
(tmp, packages_dir)
}
fn create_test_package(base: &Path, name: &str, version: &str) -> PathBuf {
let pkg_dir = base.join("source-pkg");
fs::create_dir_all(&pkg_dir).unwrap();
let manifest = PackageManifest {
name: name.to_string(),
version: version.to_string(),
extensions: vec!["ext1.so".to_string()],
skills: vec!["skill-a".to_string()],
prompts: vec![],
themes: vec![],
description: None,
dependencies: BTreeMap::new(),
};
let toml_content = toml::to_string_pretty(&manifest).unwrap();
fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
fs::write(pkg_dir.join("ext1.so"), "fake extension").unwrap();
fs::create_dir_all(pkg_dir.join("skill-a")).unwrap();
fs::write(pkg_dir.join("skill-a").join("SKILL.md"), "# Skill A").unwrap();
pkg_dir
}
fn create_test_package_with_auto_discovery(base: &Path, name: &str, version: &str) -> PathBuf {
let pkg_dir = base.join("source-pkg-auto");
fs::create_dir_all(&pkg_dir).unwrap();
let manifest = PackageManifest {
name: name.to_string(),
version: version.to_string(),
extensions: vec![],
skills: vec![],
prompts: vec![],
themes: vec![],
description: None,
dependencies: BTreeMap::new(),
};
let toml_content = toml::to_string_pretty(&manifest).unwrap();
fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
fs::write(pkg_dir.join("myext.so"), "extension").unwrap();
fs::create_dir_all(pkg_dir.join("my-skill")).unwrap();
fs::write(pkg_dir.join("my-skill").join("SKILL.md"), "# My Skill").unwrap();
fs::create_dir_all(pkg_dir.join("prompts")).unwrap();
fs::write(pkg_dir.join("prompts").join("review.md"), "# Review").unwrap();
fs::create_dir_all(pkg_dir.join("themes")).unwrap();
fs::write(pkg_dir.join("themes").join("dark.json"), "{}").unwrap();
pkg_dir
}
#[test]
fn test_install_and_list() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
assert_eq!(manifest.name, "test-pkg");
assert_eq!(manifest.version, "1.0.0");
let installed = mgr.list();
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].name, "test-pkg");
}
#[test]
fn test_uninstall() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
assert!(mgr.is_installed("test-pkg"));
mgr.uninstall("test-pkg").unwrap();
assert!(!mgr.is_installed("test-pkg"));
assert!(mgr.list().is_empty());
}
#[test]
fn test_uninstall_not_installed() {
let (_tmp, packages_dir) = setup_temp_packages_dir();
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
let result = mgr.uninstall("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_install_scoped_package() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "@foo/oxi-tools", "2.0.0");
let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
assert_eq!(manifest.name, "@foo/oxi-tools");
let expected_dir = packages_dir.join("foo-oxi-tools");
assert!(expected_dir.exists());
}
#[test]
fn test_reinstall_overwrites() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
let pkg_dir_v2 = tmp.path().join("source-pkg-v2");
fs::create_dir_all(&pkg_dir_v2).unwrap();
let manifest_v2 = PackageManifest {
name: "test-pkg".to_string(),
version: "2.0.0".to_string(),
extensions: vec![],
skills: vec![],
prompts: vec![],
themes: vec![],
description: None,
dependencies: BTreeMap::new(),
};
fs::write(
pkg_dir_v2.join(MANIFEST_NAME),
toml::to_string_pretty(&manifest_v2).unwrap(),
)
.unwrap();
mgr.install(pkg_dir_v2.to_str().unwrap()).unwrap();
let installed = mgr.list();
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].version, "2.0.0");
}
#[test]
fn test_empty_packages_dir() {
let (_tmp, packages_dir) = setup_temp_packages_dir();
let mgr = PackageManager::with_dir(packages_dir).unwrap();
assert!(mgr.list().is_empty());
}
#[test]
fn test_packages_dir_not_exists() {
let tmp = tempfile::tempdir().unwrap();
let nonexistent = tmp.path().join("does-not-exist");
let mgr = PackageManager::with_dir(nonexistent).unwrap();
assert!(mgr.list().is_empty());
}
#[test]
fn test_discover_resources_explicit() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
let resources = mgr.discover_resources("test-pkg").unwrap();
assert_eq!(resources.len(), 2);
let extensions: Vec<_> = resources
.iter()
.filter(|r| r.kind == ResourceKind::Extension)
.collect();
let skills: Vec<_> = resources
.iter()
.filter(|r| r.kind == ResourceKind::Skill)
.collect();
assert_eq!(extensions.len(), 1);
assert_eq!(skills.len(), 1);
}
#[test]
fn test_discover_resources_auto() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package_with_auto_discovery(tmp.path(), "auto-pkg", "1.0.0");
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
let resources = mgr.discover_resources("auto-pkg").unwrap();
let ext_count = resources.iter().filter(|r| r.kind == ResourceKind::Extension).count();
let skill_count = resources.iter().filter(|r| r.kind == ResourceKind::Skill).count();
let prompt_count = resources.iter().filter(|r| r.kind == ResourceKind::Prompt).count();
let theme_count = resources.iter().filter(|r| r.kind == ResourceKind::Theme).count();
assert!(ext_count >= 1, "Expected at least 1 extension, got {}", ext_count);
assert!(skill_count >= 1, "Expected at least 1 skill, got {}", skill_count);
assert!(prompt_count >= 1, "Expected at least 1 prompt, got {}", prompt_count);
assert!(theme_count >= 1, "Expected at least 1 theme, got {}", theme_count);
}
#[test]
fn test_resource_counts() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
let counts = mgr.resource_counts("test-pkg").unwrap();
assert_eq!(counts.extensions, 1);
assert_eq!(counts.skills, 1);
assert_eq!(counts.prompts, 0);
assert_eq!(counts.themes, 0);
}
#[test]
fn test_resource_counts_display() {
let counts = ResourceCounts {
extensions: 2,
skills: 1,
prompts: 0,
themes: 3,
};
assert_eq!(counts.to_string(), "2 ext, 1 skill, 3 theme");
let empty = ResourceCounts::default();
assert_eq!(empty.to_string(), "-");
}
#[test]
fn test_resource_kind_display() {
assert_eq!(ResourceKind::Extension.to_string(), "extension");
assert_eq!(ResourceKind::Skill.to_string(), "skill");
assert_eq!(ResourceKind::Prompt.to_string(), "prompt");
assert_eq!(ResourceKind::Theme.to_string(), "theme");
}
#[test]
fn test_get_install_dir() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
let dir = mgr.get_install_dir("test-pkg").unwrap();
assert!(dir.exists());
assert!(dir.join(MANIFEST_NAME).exists());
assert!(mgr.get_install_dir("nonexistent").is_none());
}
#[test]
fn test_discover_resources_not_installed() {
let (_tmp, packages_dir) = setup_temp_packages_dir();
let mgr = PackageManager::with_dir(packages_dir).unwrap();
let result = mgr.discover_resources("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_update_not_installed() {
let (_tmp, packages_dir) = setup_temp_packages_dir();
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
let result = mgr.update("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_parse_npm_source() {
let parsed = ParsedSource::parse("npm:express@4.18.0");
match parsed {
ParsedSource::Npm { spec, name, pinned } => {
assert_eq!(spec, "express@4.18.0");
assert_eq!(name, "express");
assert!(pinned);
}
_ => panic!("Expected Npm source"),
}
let parsed = ParsedSource::parse("npm:lodash");
match parsed {
ParsedSource::Npm { name, pinned, .. } => {
assert_eq!(name, "lodash");
assert!(!pinned);
}
_ => panic!("Expected Npm source"),
}
}
#[test]
fn test_parse_git_source() {
let parsed = ParsedSource::parse("https://github.com/org/repo.git");
match parsed {
ParsedSource::Git { host, path, ref_, .. } => {
assert_eq!(host, "github.com");
assert_eq!(path, "org/repo");
assert!(ref_.is_none());
}
_ => panic!("Expected Git source"),
}
let parsed = ParsedSource::parse("https://github.com/org/repo.git@v1.0.0");
match parsed {
ParsedSource::Git { path, ref_, .. } => {
assert_eq!(path, "org/repo");
assert_eq!(ref_.as_deref(), Some("v1.0.0"));
}
_ => panic!("Expected Git source"),
}
}
#[test]
fn test_parse_github_shorthand() {
let parsed = ParsedSource::parse("github:org/repo@main");
match parsed {
ParsedSource::Git { host, path, ref_, .. } => {
assert_eq!(host, "github.com");
assert_eq!(path, "org/repo");
assert_eq!(ref_.as_deref(), Some("main"));
}
_ => panic!("Expected Git source"),
}
}
#[test]
fn test_parse_local_source() {
let parsed = ParsedSource::parse("/path/to/package");
match parsed {
ParsedSource::Local { path } => {
assert_eq!(path, "/path/to/package");
}
_ => panic!("Expected Local source"),
}
let parsed = ParsedSource::parse("./relative/path");
match parsed {
ParsedSource::Local { path } => {
assert_eq!(path, "./relative/path");
}
_ => panic!("Expected Local source"),
}
}
#[test]
fn test_parse_url_source() {
let parsed = ParsedSource::parse("https://example.com/pkg.tar.gz");
match parsed {
ParsedSource::Url { url } => {
assert_eq!(url, "https://example.com/pkg.tar.gz");
}
_ => panic!("Expected Url source"),
}
}
#[test]
fn test_source_identity() {
let npm = ParsedSource::parse("npm:express@4.18.0");
assert_eq!(npm.identity(), "npm:express");
let git = ParsedSource::parse("https://github.com/org/repo.git");
assert_eq!(git.identity(), "git:github.com/org/repo");
let local = ParsedSource::parse("/path/to/pkg");
assert_eq!(local.identity(), "local:/path/to/pkg");
}
#[test]
fn test_parse_npm_spec() {
let (name, pinned) = parse_npm_spec("express@4.18.0");
assert_eq!(name, "express");
assert!(pinned);
let (name, pinned) = parse_npm_spec("express");
assert_eq!(name, "express");
assert!(!pinned);
let (name, pinned) = parse_npm_spec("@scope/pkg@1.0.0");
assert_eq!(name, "@scope/pkg");
assert!(pinned);
}
#[test]
fn test_lockfile_roundtrip() {
let (tmp, _) = setup_temp_packages_dir();
let lock_path = tmp.path().join(LOCKFILE_NAME);
let mut lock = Lockfile::new();
lock.insert(LockEntry {
source: "npm:express@4.18.0".to_string(),
name: "express".to_string(),
version: "4.18.0".to_string(),
integrity: Some("sha256-abc123".to_string()),
scope: SourceScope::User,
source_type: "npm".to_string(),
dependencies: BTreeMap::new(),
});
lock.write(&lock_path).unwrap();
let loaded = Lockfile::read(&lock_path).unwrap().unwrap();
assert_eq!(loaded.packages.len(), 1);
assert_eq!(loaded.packages["express"].version, "4.18.0");
assert_eq!(loaded.packages["express"].integrity.as_deref(), Some("sha256-abc123"));
}
#[test]
fn test_lockfile_install_roundtrip() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "locked-pkg", "1.0.0");
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
let lock_path = mgr.packages_dir().join(LOCKFILE_NAME);
assert!(lock_path.exists());
let lock = Lockfile::read(&lock_path).unwrap().unwrap();
assert!(lock.contains("locked-pkg"));
let entry = lock.get("locked-pkg").unwrap();
assert_eq!(entry.version, "1.0.0");
}
#[test]
fn test_validate_valid_package() {
let (tmp, _) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "valid-pkg", "1.0.0");
let warnings = PackageManager::validate_package(&pkg_dir).unwrap();
assert!(warnings.len() <= 1, "Expected <= 1 warning, got {:?}", warnings);
}
#[test]
fn test_validate_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
let empty_dir = tmp.path().join("empty-pkg");
fs::create_dir_all(&empty_dir).unwrap();
let warnings = PackageManager::validate_package(&empty_dir).unwrap();
assert!(!warnings.is_empty());
}
#[test]
fn test_resolve_dependencies() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = tmp.path().join("dep-pkg");
fs::create_dir_all(&pkg_dir).unwrap();
let mut deps = BTreeMap::new();
deps.insert("lodash".to_string(), "^4.0.0".to_string());
deps.insert("nonexistent-pkg".to_string(), "^1.0.0".to_string());
let manifest = PackageManifest {
name: "dep-pkg".to_string(),
version: "1.0.0".to_string(),
extensions: vec![],
skills: vec![],
prompts: vec![],
themes: vec![],
description: None,
dependencies: deps,
};
fs::write(
pkg_dir.join(MANIFEST_NAME),
toml::to_string_pretty(&manifest).unwrap(),
)
.unwrap();
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
let missing = mgr.resolve_dependencies();
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].0, "dep-pkg");
assert!(missing[0].1.contains(&"lodash".to_string())
|| missing[0].1.contains(&"nonexistent-pkg".to_string()));
}
#[test]
fn test_version_satisfies() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "1.2.3");
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
assert!(mgr.version_satisfies("ver-pkg", "^1.0.0"));
assert!(mgr.version_satisfies("ver-pkg", ">=1.0.0"));
assert!(!mgr.version_satisfies("ver-pkg", "^2.0.0"));
assert!(!mgr.version_satisfies("ver-pkg", "<1.0.0"));
}
#[test]
fn test_get_installed_version() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "3.1.4");
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
assert_eq!(mgr.get_installed_version("ver-pkg"), Some("3.1.4"));
assert_eq!(mgr.get_installed_version("nonexistent"), None);
}
#[test]
fn test_resolve() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "resolve-pkg", "1.0.0");
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
let resolved = mgr.resolve();
assert!(!resolved.extensions.is_empty() || !resolved.skills.is_empty());
}
#[test]
fn test_progress_callback() {
use std::sync::{Arc, Mutex};
let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
let (tmp, packages_dir) = setup_temp_packages_dir();
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.set_progress_callback(Box::new(move |event| {
let mut e = events_clone.lock().unwrap();
e.push(format!("{:?}:{:?}", event.event_type, event.action));
}));
let pkg_dir = create_test_package(tmp.path(), "progress-pkg", "1.0.0");
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
assert!(events.lock().unwrap().is_empty() || events.lock().unwrap().len() >= 0);
}
#[test]
fn test_list_configured() {
let (tmp, packages_dir) = setup_temp_packages_dir();
let pkg_dir = create_test_package(tmp.path(), "cfg-pkg", "1.0.0");
let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
mgr.install(pkg_dir.to_str().unwrap()).unwrap();
let configured = mgr.list_configured();
assert_eq!(configured.len(), 1);
assert!(configured[0].source.contains("source-pkg"));
}
}