1use crate::util::http_client::shared_http_client;
42use anyhow::{bail, Context, Result};
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45use std::collections::{BTreeMap, HashMap, HashSet};
46use std::fs;
47use std::path::{Path, PathBuf};
48use std::sync::LazyLock;
49
50fn run_on_fresh_runtime<F, T>(future: F) -> Result<T>
56where
57 F: std::future::Future<Output = Result<T>> + Send,
58 T: Send,
59{
60 std::thread::scope(|s| {
61 s.spawn(|| {
62 let rt = tokio::runtime::Builder::new_current_thread()
63 .enable_all()
64 .build()
65 .context("failed to build temp runtime")?;
66 rt.block_on(future)
67 })
68 .join()
69 .map_err(|_| anyhow::anyhow!("runtime thread panicked"))?
70 })
71}
72
73static NPM_SPEC_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
75 regex::Regex::new(r"^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$").expect("valid static regex")
76});
77
78const LOCKFILE_NAME: &str = "oxi-lock.json";
81const MANIFEST_NAME: &str = "oxi-package.toml";
82const NPM_MANIFEST_NAME: &str = "package.json";
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum ResourceKind {
90 Extension,
92 Skill,
94 Prompt,
96 Theme,
98}
99
100impl std::fmt::Display for ResourceKind {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 ResourceKind::Extension => write!(f, "extension"),
104 ResourceKind::Skill => write!(f, "skill"),
105 ResourceKind::Prompt => write!(f, "prompt"),
106 ResourceKind::Theme => write!(f, "theme"),
107 }
108 }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct PackageManifest {
116 pub name: String,
118 pub version: String,
120 #[serde(default)]
122 pub extensions: Vec<String>,
123 #[serde(default)]
125 pub skills: Vec<String>,
126 #[serde(default)]
128 pub prompts: Vec<String>,
129 #[serde(default)]
131 pub themes: Vec<String>,
132 #[serde(default)]
134 pub description: Option<String>,
135 #[serde(default)]
137 pub dependencies: BTreeMap<String, String>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct DiscoveredResource {
143 pub kind: ResourceKind,
145 pub path: PathBuf,
147 pub relative_path: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct PathMetadata {
154 pub source: String,
156 pub scope: SourceScope,
158 pub origin: ResourceOrigin,
160 pub base_dir: Option<PathBuf>,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum ResourceOrigin {
168 Package,
170 TopLevel,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum SourceScope {
178 User,
180 Project,
182}
183
184impl std::fmt::Display for SourceScope {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 match self {
187 SourceScope::User => write!(f, "user"),
188 SourceScope::Project => write!(f, "project"),
189 }
190 }
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ResolvedResource {
196 pub path: PathBuf,
198 pub enabled: bool,
200 pub metadata: PathMetadata,
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206pub struct ResolvedPaths {
207 pub extensions: Vec<ResolvedResource>,
209 pub skills: Vec<ResolvedResource>,
211 pub prompts: Vec<ResolvedResource>,
213 pub themes: Vec<ResolvedResource>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct ProgressEvent {
220 pub event_type: ProgressEventType,
222 pub action: ProgressAction,
224 pub source: String,
226 pub message: Option<String>,
228}
229
230#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub enum ProgressEventType {
234 Start,
236 Progress,
238 Complete,
240 Error,
242}
243
244#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247pub enum ProgressAction {
248 Install,
250 Remove,
252 Update,
254 Clone,
256 Pull,
258}
259
260impl std::fmt::Display for ProgressAction {
261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262 match self {
263 ProgressAction::Install => write!(f, "install"),
264 ProgressAction::Remove => write!(f, "remove"),
265 ProgressAction::Update => write!(f, "update"),
266 ProgressAction::Clone => write!(f, "clone"),
267 ProgressAction::Pull => write!(f, "pull"),
268 }
269 }
270}
271
272pub type ProgressCallback = Box<dyn Fn(ProgressEvent) + Send + Sync>;
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
279#[serde(tag = "type", rename_all = "snake_case")]
280pub enum ParsedSource {
281 Npm {
283 spec: String,
285 name: String,
287 pinned: bool,
289 },
290 Git {
292 repo: String,
294 host: String,
296 path: String,
298 ref_: Option<String>,
300 },
301 Local {
303 path: String,
305 },
306 Url {
308 url: String,
310 },
311}
312
313impl ParsedSource {
314 pub fn parse(source: &str) -> Self {
316 if let Some(rest) = source.strip_prefix("npm:") {
317 let spec = rest.trim();
318 let (name, pinned) = parse_npm_spec(spec);
319 return ParsedSource::Npm {
320 spec: spec.to_string(),
321 name,
322 pinned,
323 };
324 }
325
326 if let Some(rest) = source.strip_prefix("github:") {
327 let parts: Vec<&str> = rest.splitn(2, '/').collect();
328 if parts.len() == 2 {
329 let (path, ref_) = split_git_path_ref(rest);
330 return ParsedSource::Git {
331 repo: format!("https://github.com/{}.git", path),
332 host: "github.com".to_string(),
333 path,
334 ref_,
335 };
336 }
337 }
338
339 if source.starts_with("git+") || source.starts_with("git://") || source.starts_with("git@")
340 {
341 return parse_git_source(source);
342 }
343
344 if source.starts_with("https://") || source.starts_with("http://") {
345 if source.ends_with(".git")
347 || source.contains("github.com")
348 || source.contains("gitlab.com")
349 {
350 return parse_git_source(source);
351 }
352 if source.ends_with(".tar.gz")
354 || source.ends_with(".tgz")
355 || source.ends_with(".zip")
356 || source.ends_with(".tar.bz2")
357 {
358 return ParsedSource::Url {
359 url: source.to_string(),
360 };
361 }
362 return parse_git_source(source);
364 }
365
366 ParsedSource::Local {
368 path: source.to_string(),
369 }
370 }
371
372 pub fn identity(&self) -> String {
374 match self {
375 ParsedSource::Npm { name, .. } => format!("npm:{}", name),
376 ParsedSource::Git { host, path, .. } => format!("git:{}/{}", host, path),
377 ParsedSource::Local { path } => format!("local:{}", path),
378 ParsedSource::Url { url } => format!("url:{}", url),
379 }
380 }
381
382 pub fn display_name(&self) -> String {
384 match self {
385 ParsedSource::Npm { name, .. } => name.clone(),
386 ParsedSource::Git { host, path, .. } => format!("{}/{}", host, path),
387 ParsedSource::Local { path } => path.clone(),
388 ParsedSource::Url { url } => url.clone(),
389 }
390 }
391}
392
393fn parse_npm_spec(spec: &str) -> (String, bool) {
395 if let Some(caps) = NPM_SPEC_RE.captures(spec) {
397 let name = caps.get(1).map(|m| m.as_str()).unwrap_or(spec);
398 let has_version = caps.get(2).is_some();
399 return (name.to_string(), has_version);
400 }
401 (spec.to_string(), false)
402}
403
404fn split_git_path_ref(input: &str) -> (String, Option<String>) {
406 if let Some(at_pos) = input.rfind('@') {
407 if input[..at_pos].contains('/') {
409 return (
410 input[..at_pos].to_string(),
411 Some(input[at_pos + 1..].to_string()),
412 );
413 }
414 }
415 (input.to_string(), None)
416}
417
418fn parse_git_source(source: &str) -> ParsedSource {
420 if let Some(rest) = source.strip_prefix("git@") {
422 let colon_pos = rest.find(':').unwrap_or(rest.len());
423 let host = &rest[..colon_pos];
424 let path_part = rest.get(colon_pos + 1..).unwrap_or("");
425 let (path, ref_) = if let Some(hash_pos) = path_part.rfind('#') {
426 (
427 path_part[..hash_pos].to_string(),
428 Some(path_part[hash_pos + 1..].to_string()),
429 )
430 } else {
431 split_git_path_ref(path_part)
432 };
433 let repo = format!("git@{}:{}", host, path_part);
434 let host = host.to_string();
435 return ParsedSource::Git {
436 repo,
437 host,
438 path: path.trim_end_matches(".git").to_string(),
439 ref_,
440 };
441 }
442
443 let url_str = source
445 .strip_prefix("git+")
446 .unwrap_or(source)
447 .strip_prefix("git://")
448 .map(|s| format!("https://{}", s))
449 .unwrap_or_else(|| source.strip_prefix("git+").unwrap_or(source).to_string());
450
451 let url = match url::Url::parse(&url_str) {
453 Ok(u) => u,
454 Err(_) => {
455 return ParsedSource::Local {
456 path: source.to_string(),
457 }
458 }
459 };
460
461 let host = url.host_str().unwrap_or("unknown").to_string();
462 let full_path = url.path().trim_start_matches('/').to_string();
463
464 let fragment = url.fragment().map(|f| f.to_string());
466
467 let (path, ref_) = if let Some(frag) = fragment {
468 (full_path.trim_end_matches(".git").to_string(), Some(frag))
469 } else {
470 let (p, r) = split_git_path_ref(&full_path);
471 (p.trim_end_matches(".git").to_string(), r)
472 };
473
474 let repo = url_str.clone();
475
476 ParsedSource::Git {
477 repo,
478 host,
479 path,
480 ref_,
481 }
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct NpmPackageInfo {
489 pub name: String,
491 pub versions: BTreeMap<String, serde_json::Value>,
493 #[serde(rename = "dist-tags")]
495 pub dist_tags: BTreeMap<String, String>,
496}
497
498impl NpmPackageInfo {
499 pub async fn fetch(name: &str) -> Result<Self> {
501 let url = format!("https://registry.npmjs.org/{}", name);
502 let client = shared_http_client();
503
504 let resp = client
505 .get(&url)
506 .header("Accept", "application/json")
507 .send()
508 .await
509 .with_context(|| format!("Failed to fetch npm info for '{}'", name))?;
510
511 if !resp.status().is_success() {
512 bail!("npm registry returned {} for '{}'", resp.status(), name);
513 }
514
515 let info: NpmPackageInfo = resp
516 .json()
517 .await
518 .with_context(|| format!("Failed to parse npm registry response for '{}'", name))?;
519
520 Ok(info)
521 }
522
523 pub fn latest_version(&self) -> Option<&str> {
525 self.dist_tags.get("latest").map(|s| s.as_str())
526 }
527
528 pub fn resolve_version(&self, constraint: &str) -> Option<String> {
530 if constraint == "latest" || constraint.is_empty() {
531 return self.latest_version().map(|s| s.to_string());
532 }
533
534 if self.versions.contains_key(constraint) {
536 return Some(constraint.to_string());
537 }
538
539 if let Ok(req) = semver::VersionReq::parse(constraint) {
541 let mut best: Option<semver::Version> = None;
542 for ver_str in self.versions.keys() {
543 if let Ok(ver) = semver::Version::parse(ver_str) {
544 if req.matches(&ver) {
545 match &best {
546 Some(b) if ver > *b => best = Some(ver),
547 None => best = Some(ver),
548 _ => {}
549 }
550 }
551 }
552 }
553 if let Some(v) = best {
554 return Some(v.to_string());
555 }
556 }
557
558 None
559 }
560}
561
562pub async fn get_latest_npm_version(name: &str) -> Result<String> {
564 let info = NpmPackageInfo::fetch(name).await?;
565 info.latest_version()
566 .map(|s| s.to_string())
567 .context(format!("No latest version found for '{}'", name))
568}
569
570fn git_command(args: &[&str], cwd: Option<&Path>) -> Result<String> {
574 let mut cmd = std::process::Command::new("git");
575 cmd.args(args)
576 .env("GIT_TERMINAL_PROMPT", "0")
577 .stdout(std::process::Stdio::piped())
578 .stderr(std::process::Stdio::piped());
579
580 if let Some(dir) = cwd {
581 cmd.current_dir(dir);
582 }
583
584 let output = cmd.output().context("Failed to execute git")?;
585
586 if !output.status.success() {
587 let stderr = String::from_utf8_lossy(&output.stderr);
588 bail!(
589 "git {} failed ({}): {}",
590 args.join(" "),
591 output.status,
592 stderr.trim()
593 );
594 }
595
596 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
597}
598
599fn git_command_silent(args: &[&str], cwd: Option<&Path>) -> Result<()> {
601 let mut cmd = std::process::Command::new("git");
602 cmd.args(args)
603 .env("GIT_TERMINAL_PROMPT", "0")
604 .stdout(std::process::Stdio::null())
605 .stderr(std::process::Stdio::null());
606
607 if let Some(dir) = cwd {
608 cmd.current_dir(dir);
609 }
610
611 let status = cmd.status().context("Failed to execute git")?;
612 if !status.success() {
613 bail!("git {} failed ({})", args.join(" "), status);
614 }
615 Ok(())
616}
617
618pub fn git_clone(repo_url: &str, target_dir: &Path, ref_: Option<&str>) -> Result<()> {
620 if target_dir.exists() {
621 bail!("Target directory already exists: {}", target_dir.display());
622 }
623 fs::create_dir_all(target_dir)
624 .with_context(|| format!("Failed to create {}", target_dir.display()))?;
625
626 let target_str = target_dir.to_string_lossy().to_string();
627 let args = vec!["clone", repo_url, &target_str];
628
629 git_command_silent(&args, None)?;
630
631 if let Some(r) = ref_ {
632 git_command_silent(&["checkout", r], Some(target_dir))?;
633 }
634
635 Ok(())
636}
637
638pub fn git_update(repo_dir: &Path, ref_: Option<&str>) -> Result<bool> {
640 if !repo_dir.exists() {
641 bail!(
642 "Repository directory does not exist: {}",
643 repo_dir.display()
644 );
645 }
646
647 let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
649
650 let fetch_ref = if let Some(r) = ref_ {
652 r.to_string()
653 } else {
654 match git_command(
656 &["rev-parse", "--abbrev-ref", "@{upstream}"],
657 Some(repo_dir),
658 ) {
659 Ok(upstream) => {
660 if let Some(branch) = upstream.strip_prefix("origin/") {
661 format!("+refs/heads/{branch}:refs/remotes/origin/{branch}")
662 } else {
663 "+HEAD:refs/remotes/origin/HEAD".to_string()
664 }
665 }
666 Err(_) => "+HEAD:refs/remotes/origin/HEAD".to_string(),
667 }
668 };
669
670 git_command_silent(
671 &["fetch", "--prune", "--no-tags", "origin", &fetch_ref],
672 Some(repo_dir),
673 )?;
674
675 let target_ref = ref_.unwrap_or("origin/HEAD");
677 let remote_head = git_command(&["rev-parse", target_ref], Some(repo_dir))?;
678
679 if local_head == remote_head {
680 return Ok(false); }
682
683 git_command_silent(&["reset", "--hard", target_ref], Some(repo_dir))?;
684 git_command_silent(&["clean", "-fdx"], Some(repo_dir))?;
685
686 Ok(true) }
688
689pub fn git_has_update(repo_dir: &Path) -> Result<bool> {
691 let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
692
693 let upstream_ref = match git_command(
695 &["rev-parse", "--abbrev-ref", "@{upstream}"],
696 Some(repo_dir),
697 ) {
698 Ok(u) if u.starts_with("origin/") => {
699 let branch = &u["origin/".len()..];
700 format!("refs/heads/{branch}")
701 }
702 _ => "HEAD".to_string(),
703 };
704
705 let _ = git_command_silent(&["fetch", "--prune", "--no-tags", "origin"], Some(repo_dir));
707
708 let remote_head = git_command(&["ls-remote", "origin", &upstream_ref], None)?;
709
710 let remote_hash = remote_head
712 .lines()
713 .next()
714 .and_then(|line| line.split_whitespace().next())
715 .unwrap_or("");
716
717 Ok(local_head != remote_hash)
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize)]
724pub struct LockEntry {
725 pub source: String,
727 pub name: String,
729 pub version: String,
731 pub integrity: Option<String>,
733 pub scope: SourceScope,
735 pub source_type: String,
737 #[serde(default)]
739 pub dependencies: BTreeMap<String, String>,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize)]
744pub struct Lockfile {
745 pub version: u32,
747 pub packages: BTreeMap<String, LockEntry>,
749}
750
751impl Lockfile {
752 pub fn new() -> Self {
754 Self {
755 version: 1,
756 packages: BTreeMap::new(),
757 }
758 }
759
760 pub fn read(path: &Path) -> Result<Option<Self>> {
762 if !path.exists() {
763 return Ok(None);
764 }
765 let content = fs::read_to_string(path)
766 .with_context(|| format!("Failed to read lockfile {}", path.display()))?;
767 let lock: Lockfile = serde_json::from_str(&content)
768 .with_context(|| format!("Failed to parse lockfile {}", path.display()))?;
769 Ok(Some(lock))
770 }
771
772 pub fn write(&self, path: &Path) -> Result<()> {
774 let content = serde_json::to_string_pretty(self).context("Failed to serialize lockfile")?;
775 fs::write(path, content)
776 .with_context(|| format!("Failed to write lockfile {}", path.display()))?;
777 Ok(())
778 }
779
780 pub fn insert(&mut self, entry: LockEntry) {
782 self.packages.insert(entry.name.clone(), entry);
783 }
784
785 pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
787 self.packages.remove(name)
788 }
789
790 pub fn contains(&self, name: &str) -> bool {
792 self.packages.contains_key(name)
793 }
794
795 pub fn get(&self, name: &str) -> Option<&LockEntry> {
797 self.packages.get(name)
798 }
799}
800
801impl Default for Lockfile {
802 fn default() -> Self {
803 Self::new()
804 }
805}
806
807#[derive(Debug, Clone, Default, Serialize, Deserialize)]
811pub struct ResourceCounts {
812 pub extensions: usize,
814 pub skills: usize,
816 pub prompts: usize,
818 pub themes: usize,
820}
821
822impl std::fmt::Display for ResourceCounts {
823 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
824 let mut parts = Vec::new();
825 if self.extensions > 0 {
826 parts.push(format!("{} ext", self.extensions));
827 }
828 if self.skills > 0 {
829 parts.push(format!("{} skill", self.skills));
830 }
831 if self.prompts > 0 {
832 parts.push(format!("{} prompt", self.prompts));
833 }
834 if self.themes > 0 {
835 parts.push(format!("{} theme", self.themes));
836 }
837 if parts.is_empty() {
838 write!(f, "-")?;
839 } else {
840 write!(f, "{}", parts.join(", "))?;
841 }
842 Ok(())
843 }
844}
845
846#[derive(Debug, Clone, Serialize, Deserialize)]
850pub struct PackageUpdateInfo {
851 pub source: String,
853 pub display_name: String,
855 pub source_type: String, pub scope: SourceScope,
859}
860
861#[derive(Debug, Clone, Serialize, Deserialize)]
863pub struct ConfiguredPackage {
864 pub source: String,
866 pub scope: SourceScope,
868 pub filtered: bool,
870 pub installed_path: Option<PathBuf>,
872}
873
874pub struct PackageManager {
876 packages_dir: PathBuf,
877 project_dir: PathBuf,
879 installed: HashMap<String, PackageManifest>,
880 lockfile: Lockfile,
881 progress_callback: Option<Box<dyn Fn(ProgressEvent) + Send + Sync>>,
882}
883
884impl PackageManager {
885 pub fn new() -> Result<Self> {
887 let base = dirs::home_dir().context("Cannot determine home directory")?;
888 let packages_dir = base.join(".oxi").join("packages");
889 let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
890 let mut mgr = Self {
891 packages_dir,
892 project_dir,
893 installed: HashMap::new(),
894 lockfile: Lockfile::new(),
895 progress_callback: None,
896 };
897 mgr.load_installed()?;
898 mgr.load_lockfile()?;
899 Ok(mgr)
900 }
901
902 pub fn with_dir(packages_dir: PathBuf) -> Result<Self> {
904 let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
905 let mut mgr = Self {
906 packages_dir,
907 project_dir,
908 installed: HashMap::new(),
909 lockfile: Lockfile::new(),
910 progress_callback: None,
911 };
912 mgr.load_installed()?;
913 mgr.load_lockfile()?;
914 Ok(mgr)
915 }
916
917 pub fn set_project_dir(&mut self, dir: PathBuf) {
919 self.project_dir = dir;
920 }
921
922 pub fn set_progress_callback(&mut self, callback: Box<dyn Fn(ProgressEvent) + Send + Sync>) {
924 self.progress_callback = Some(callback);
925 }
926
927 fn emit_progress(&self, event: ProgressEvent) {
928 if let Some(ref cb) = self.progress_callback {
929 cb(event);
930 }
931 }
932
933 fn load_installed(&mut self) -> Result<()> {
937 if !self.packages_dir.exists() {
938 return Ok(());
939 }
940 for entry in fs::read_dir(&self.packages_dir)? {
941 let entry = entry?;
942 let manifest_path = entry.path().join(MANIFEST_NAME);
943 if manifest_path.exists() {
944 match Self::read_manifest(&manifest_path) {
945 Ok(manifest) => {
946 self.installed.insert(manifest.name.clone(), manifest);
947 }
948 Err(e) => {
949 tracing::warn!(
950 "Failed to load manifest {}: {}",
951 manifest_path.display(),
952 e
953 );
954 }
955 }
956 }
957 }
958 Ok(())
959 }
960
961 fn load_lockfile(&mut self) -> Result<()> {
963 let lock_path = self.packages_dir.join(LOCKFILE_NAME);
964 if let Some(lock) = Lockfile::read(&lock_path)? {
965 self.lockfile = lock;
966 }
967 Ok(())
968 }
969
970 fn save_lockfile(&self) -> Result<()> {
972 let lock_path = self.packages_dir.join(LOCKFILE_NAME);
973 self.lockfile.write(&lock_path)
974 }
975
976 fn read_manifest(path: &Path) -> Result<PackageManifest> {
980 let content = fs::read_to_string(path)
981 .with_context(|| format!("Failed to read manifest {}", path.display()))?;
982 let manifest: PackageManifest = toml::from_str(&content)
983 .with_context(|| format!("Failed to parse manifest {}", path.display()))?;
984 Ok(manifest)
985 }
986
987 fn read_package_json(dir: &Path) -> Option<serde_json::Value> {
989 let path = dir.join(NPM_MANIFEST_NAME);
990 let content = fs::read_to_string(path).ok()?;
991 serde_json::from_str(&content).ok()
992 }
993
994 fn pkg_install_dir(&self, name: &str) -> PathBuf {
998 let safe_name = name.replace('@', "").replace('/', "-");
999 self.packages_dir.join(safe_name)
1000 }
1001
1002 pub fn packages_dir(&self) -> &Path {
1004 &self.packages_dir
1005 }
1006
1007 fn git_install_path(&self, host: &str, path: &str, scope: SourceScope) -> PathBuf {
1009 match scope {
1010 SourceScope::Project => self
1011 .project_dir
1012 .join(".oxi")
1013 .join("git")
1014 .join(host)
1015 .join(path),
1016 SourceScope::User => self.packages_dir.join("git").join(host).join(path),
1017 }
1018 }
1019
1020 fn npm_install_path(&self, name: &str, scope: SourceScope) -> PathBuf {
1022 let safe_name = name.replace('@', "").replace('/', "-");
1023 match scope {
1024 SourceScope::Project => self.project_dir.join(".oxi").join("npm").join(safe_name),
1025 SourceScope::User => self.packages_dir.join("npm").join(safe_name),
1026 }
1027 }
1028
1029 fn ensure_packages_dir(&self) -> Result<()> {
1033 fs::create_dir_all(&self.packages_dir).with_context(|| {
1034 format!(
1035 "Failed to create packages directory {}",
1036 self.packages_dir.display()
1037 )
1038 })
1039 }
1040
1041 pub fn install(&mut self, source: &str) -> Result<PackageManifest> {
1043 let parsed = ParsedSource::parse(source);
1044 match parsed {
1045 ParsedSource::Local { path } => self.install_local(&path),
1046 _ => bail!("Use install_from_source() for non-local packages"),
1047 }
1048 }
1049
1050 fn install_local(&mut self, path: &str) -> Result<PackageManifest> {
1052 let source_path = Path::new(path);
1053 let manifest_path = source_path.join(MANIFEST_NAME);
1054
1055 let manifest = if manifest_path.exists() {
1056 Self::read_manifest(&manifest_path)
1057 .with_context(|| format!("No valid {} found in {}", MANIFEST_NAME, path))?
1058 } else {
1059 let name = source_path
1061 .file_name()
1062 .map(|n| n.to_string_lossy().to_string())
1063 .unwrap_or_else(|| "unknown".to_string());
1064 PackageManifest {
1065 name,
1066 version: "0.0.0".to_string(),
1067 extensions: Vec::new(),
1068 skills: Vec::new(),
1069 prompts: Vec::new(),
1070 themes: Vec::new(),
1071 description: None,
1072 dependencies: BTreeMap::new(),
1073 }
1074 };
1075
1076 let dest = self.pkg_install_dir(&manifest.name);
1077 self.ensure_packages_dir()?;
1078
1079 if dest.exists() {
1080 fs::remove_dir_all(&dest).with_context(|| {
1081 format!("Failed to remove existing package at {}", dest.display())
1082 })?;
1083 }
1084
1085 copy_dir_recursive(source_path, &dest).with_context(|| {
1086 format!("Failed to copy package from {} to {}", path, dest.display())
1087 })?;
1088
1089 let integrity = compute_dir_hash(&dest);
1090
1091 self.lockfile.insert(LockEntry {
1092 source: path.to_string(),
1093 name: manifest.name.clone(),
1094 version: manifest.version.clone(),
1095 integrity,
1096 scope: SourceScope::User,
1097 source_type: "local".to_string(),
1098 dependencies: manifest.dependencies.clone(),
1099 });
1100
1101 self.installed
1102 .insert(manifest.name.clone(), manifest.clone());
1103 let _ = self.save_lockfile();
1104 Ok(manifest)
1105 }
1106
1107 pub fn install_from_source(
1109 &mut self,
1110 source: &str,
1111 scope: SourceScope,
1112 ) -> Result<PackageManifest> {
1113 let parsed = ParsedSource::parse(source);
1114 self.emit_progress(ProgressEvent {
1115 event_type: ProgressEventType::Start,
1116 action: ProgressAction::Install,
1117 source: source.to_string(),
1118 message: Some(format!("Installing {}...", source)),
1119 });
1120 let result = match &parsed {
1121 ParsedSource::Npm { .. } => {
1122 run_on_fresh_runtime(self.install_npm_async(source, scope))
1123 }
1124 ParsedSource::Git { repo, ref_, .. } => {
1125 self.install_git_sync(source, repo, ref_.as_deref(), scope)
1126 }
1127 ParsedSource::Local { path } => self.install_local(path),
1128 ParsedSource::Url { url } => {
1129 run_on_fresh_runtime(self.install_url(url, scope))
1130 }
1131 };
1132 match &result {
1133 Ok(_) => self.emit_progress(ProgressEvent {
1134 event_type: ProgressEventType::Complete,
1135 action: ProgressAction::Install,
1136 source: source.to_string(),
1137 message: None,
1138 }),
1139 Err(e) => self.emit_progress(ProgressEvent {
1140 event_type: ProgressEventType::Error,
1141 action: ProgressAction::Install,
1142 source: source.to_string(),
1143 message: Some(e.to_string()),
1144 }),
1145 }
1146 result
1147 }
1148
1149 async fn install_npm_async(
1151 &mut self,
1152 source: &str,
1153 scope: SourceScope,
1154 ) -> Result<PackageManifest> {
1155 let parsed = ParsedSource::parse(source);
1156 let (spec, name, pinned) = match &parsed {
1157 ParsedSource::Npm { spec, name, pinned } => (spec.clone(), name.clone(), *pinned),
1158 _ => bail!("Expected npm source"),
1159 };
1160
1161 let _version = if pinned {
1163 let (_, ver) = parse_npm_spec(&spec);
1165 if ver {
1166 spec.rsplit('@').next().unwrap_or("latest").to_string()
1167 } else {
1168 "latest".to_string()
1169 }
1170 } else {
1171 get_latest_npm_version(&name)
1172 .await
1173 .unwrap_or_else(|_| "latest".to_string())
1174 };
1175
1176 self.install_npm_pack(&spec, scope)
1178 }
1179
1180 fn install_npm_pack(&mut self, spec: &str, scope: SourceScope) -> Result<PackageManifest> {
1182 let tmp_dir =
1183 tempfile::tempdir().context("Failed to create temp directory for npm install")?;
1184
1185 let output = std::process::Command::new("npm")
1186 .args(["pack", spec, "--pack-destination"])
1187 .arg(tmp_dir.path())
1188 .current_dir(tmp_dir.path())
1189 .output()
1190 .context("Failed to run npm pack")?;
1191
1192 if !output.status.success() {
1193 let stderr = String::from_utf8_lossy(&output.stderr);
1194 bail!("npm pack failed for '{}': {}", spec, stderr);
1195 }
1196
1197 let tarball = fs::read_dir(tmp_dir.path())?
1199 .filter_map(|e| e.ok())
1200 .find(|e| {
1201 e.path()
1202 .extension()
1203 .map(|ext| ext == "tgz")
1204 .unwrap_or(false)
1205 })
1206 .map(|e| e.path())
1207 .context("No .tgz file found after npm pack")?;
1208
1209 let extract_dir = tmp_dir.path().join("extracted");
1211 fs::create_dir_all(&extract_dir)?;
1212
1213 let tar_status = std::process::Command::new("tar")
1214 .args(["-xzf", &tarball.to_string_lossy(), "-C"])
1215 .arg(&extract_dir)
1216 .output()
1217 .context("Failed to run tar")?;
1218
1219 if !tar_status.status.success() {
1220 let stderr = String::from_utf8_lossy(&tar_status.stderr);
1221 bail!("tar extraction failed: {}", stderr);
1222 }
1223
1224 let pkg_source = extract_dir.join("package");
1226 let source_for_copy = if pkg_source.exists() {
1227 &pkg_source
1228 } else {
1229 extract_dir.as_path()
1231 };
1232
1233 self.ensure_packages_dir()?;
1234
1235 let manifest = if source_for_copy.join(MANIFEST_NAME).exists() {
1237 Self::read_manifest(&source_for_copy.join(MANIFEST_NAME))?
1238 } else if source_for_copy.join(NPM_MANIFEST_NAME).exists() {
1239 let pj = Self::read_package_json(source_for_copy);
1240 let (pkg_name, pkg_version) = pj
1241 .as_ref()
1242 .map(|v| {
1243 (
1244 v.get("name")
1245 .and_then(|n| n.as_str())
1246 .unwrap_or(spec)
1247 .to_string(),
1248 v.get("version")
1249 .and_then(|v| v.as_str())
1250 .unwrap_or("0.0.0")
1251 .to_string(),
1252 )
1253 })
1254 .unwrap_or((spec.to_string(), "0.0.0".to_string()));
1255
1256 PackageManifest {
1257 name: pkg_name,
1258 version: pkg_version,
1259 extensions: Vec::new(),
1260 skills: Vec::new(),
1261 prompts: Vec::new(),
1262 themes: Vec::new(),
1263 description: None,
1264 dependencies: BTreeMap::new(),
1265 }
1266 } else {
1267 PackageManifest {
1268 name: spec.to_string(),
1269 version: "0.0.0".to_string(),
1270 extensions: Vec::new(),
1271 skills: Vec::new(),
1272 prompts: Vec::new(),
1273 themes: Vec::new(),
1274 description: None,
1275 dependencies: BTreeMap::new(),
1276 }
1277 };
1278
1279 let dest = self.pkg_install_dir(&manifest.name);
1280 if dest.exists() {
1281 fs::remove_dir_all(&dest).with_context(|| {
1282 format!("Failed to remove existing package at {}", dest.display())
1283 })?;
1284 }
1285
1286 copy_dir_recursive(source_for_copy, &dest)
1287 .with_context(|| format!("Failed to copy npm package for '{}'", spec))?;
1288
1289 let integrity = compute_dir_hash(&dest);
1290
1291 self.lockfile.insert(LockEntry {
1292 source: format!("npm:{}", spec),
1293 name: manifest.name.clone(),
1294 version: manifest.version.clone(),
1295 integrity,
1296 scope,
1297 source_type: "npm".to_string(),
1298 dependencies: manifest.dependencies.clone(),
1299 });
1300
1301 self.installed
1302 .insert(manifest.name.clone(), manifest.clone());
1303 let _ = self.save_lockfile();
1304 Ok(manifest)
1305 }
1306
1307 fn install_git_sync(
1309 &mut self,
1310 source: &str,
1311 repo: &str,
1312 ref_: Option<&str>,
1313 scope: SourceScope,
1314 ) -> Result<PackageManifest> {
1315 let parsed = ParsedSource::parse(source);
1316 let (host, path) = match &parsed {
1317 ParsedSource::Git { host, path, .. } => (host.clone(), path.clone()),
1318 _ => bail!("Expected git source"),
1319 };
1320
1321 let target_dir = self.git_install_path(&host, &path, scope);
1322
1323 if target_dir.exists() {
1324 return self.load_manifest_from_dir(&target_dir, source, scope);
1326 }
1327
1328 let Some(parent) = target_dir.parent() else {
1329 bail!(
1330 "Invalid install path: no parent directory for {}",
1331 target_dir.display()
1332 );
1333 };
1334 fs::create_dir_all(parent)
1335 .with_context(|| format!("Failed to create parent dir for {}", target_dir.display()))?;
1336
1337 git_clone(repo, &target_dir, ref_)?;
1338
1339 if target_dir.join(NPM_MANIFEST_NAME).exists() {
1341 let _ = std::process::Command::new("npm")
1342 .args(["install", "--omit=dev"])
1343 .current_dir(&target_dir)
1344 .output();
1345 }
1346
1347 self.load_manifest_from_dir(&target_dir, source, scope)
1348 }
1349
1350 fn load_manifest_from_dir(
1352 &mut self,
1353 dir: &Path,
1354 source: &str,
1355 scope: SourceScope,
1356 ) -> Result<PackageManifest> {
1357 let manifest = if dir.join(MANIFEST_NAME).exists() {
1358 Self::read_manifest(&dir.join(MANIFEST_NAME))?
1359 } else {
1360 let name = dir
1361 .file_name()
1362 .map(|n| n.to_string_lossy().to_string())
1363 .unwrap_or_else(|| "unknown".to_string());
1364 PackageManifest {
1365 name,
1366 version: "0.0.0".to_string(),
1367 extensions: Vec::new(),
1368 skills: Vec::new(),
1369 prompts: Vec::new(),
1370 themes: Vec::new(),
1371 description: None,
1372 dependencies: BTreeMap::new(),
1373 }
1374 };
1375
1376 let integrity = compute_dir_hash(dir);
1377
1378 self.lockfile.insert(LockEntry {
1379 source: source.to_string(),
1380 name: manifest.name.clone(),
1381 version: manifest.version.clone(),
1382 integrity,
1383 scope,
1384 source_type: "git".to_string(),
1385 dependencies: manifest.dependencies.clone(),
1386 });
1387
1388 self.installed
1389 .insert(manifest.name.clone(), manifest.clone());
1390 let _ = self.save_lockfile();
1391 Ok(manifest)
1392 }
1393
1394 async fn install_url(&mut self, url: &str, scope: SourceScope) -> Result<PackageManifest> {
1396 let client = shared_http_client();
1397
1398 let resp = client.get(url).send().await?;
1399 if !resp.status().is_success() {
1400 bail!("Failed to download {}: {}", url, resp.status());
1401 }
1402
1403 let bytes = resp.bytes().await?;
1404
1405 let tmp_dir = tempfile::tempdir()?;
1406 let archive_name = url.split('/').next_back().unwrap_or("archive");
1407 let archive_path = tmp_dir.path().join(archive_name);
1408 fs::write(&archive_path, &bytes)?;
1409
1410 let extract_dir = tmp_dir.path().join("extracted");
1411 fs::create_dir_all(&extract_dir)?;
1412
1413 if archive_name.ends_with(".tar.gz") || archive_name.ends_with(".tgz") {
1414 let status = std::process::Command::new("tar")
1415 .args(["-xzf", &archive_path.to_string_lossy(), "-C"])
1416 .arg(&extract_dir)
1417 .output()?;
1418 if !status.status.success() {
1419 bail!("Failed to extract archive");
1420 }
1421 } else if archive_name.ends_with(".zip") {
1422 let status = std::process::Command::new("unzip")
1424 .arg("-o")
1425 .arg(&archive_path)
1426 .arg("-d")
1427 .arg(&extract_dir)
1428 .output()?;
1429 if !status.status.success() {
1430 bail!("Failed to extract zip archive");
1431 }
1432 } else {
1433 bail!("Unsupported archive format: {}", archive_name);
1434 }
1435
1436 let pkg_dir = find_single_subdir(&extract_dir).unwrap_or_else(|| extract_dir.to_path_buf());
1438
1439 self.ensure_packages_dir()?;
1440
1441 let manifest = if pkg_dir.join(MANIFEST_NAME).exists() {
1442 Self::read_manifest(&pkg_dir.join(MANIFEST_NAME))?
1443 } else {
1444 let name = url
1445 .split('/')
1446 .next_back()
1447 .unwrap_or("url-package")
1448 .trim_end_matches(".tar.gz")
1449 .trim_end_matches(".tgz")
1450 .trim_end_matches(".zip")
1451 .to_string();
1452 PackageManifest {
1453 name,
1454 version: "0.0.0".to_string(),
1455 extensions: Vec::new(),
1456 skills: Vec::new(),
1457 prompts: Vec::new(),
1458 themes: Vec::new(),
1459 description: None,
1460 dependencies: BTreeMap::new(),
1461 }
1462 };
1463
1464 let dest = self.pkg_install_dir(&manifest.name);
1465 if dest.exists() {
1466 fs::remove_dir_all(&dest)?;
1467 }
1468
1469 copy_dir_recursive(&pkg_dir, &dest)?;
1470
1471 let integrity = compute_dir_hash(&dest);
1472
1473 self.lockfile.insert(LockEntry {
1474 source: url.to_string(),
1475 name: manifest.name.clone(),
1476 version: manifest.version.clone(),
1477 integrity,
1478 scope,
1479 source_type: "url".to_string(),
1480 dependencies: manifest.dependencies.clone(),
1481 });
1482
1483 self.installed
1484 .insert(manifest.name.clone(), manifest.clone());
1485 let _ = self.save_lockfile();
1486 Ok(manifest)
1487 }
1488
1489 pub fn install_npm(&mut self, name: &str) -> Result<PackageManifest> {
1491 self.install_npm_pack(name, SourceScope::User)
1492 }
1493
1494 pub fn uninstall(&mut self, name: &str) -> Result<()> {
1498 if !self.installed.contains_key(name) {
1499 bail!("Package '{}' is not installed", name);
1500 }
1501
1502 let dest = self.pkg_install_dir(name);
1503 if dest.exists() {
1504 fs::remove_dir_all(&dest).with_context(|| {
1505 format!("Failed to remove package directory {}", dest.display())
1506 })?;
1507 }
1508
1509 let _ = self.lockfile.remove(name);
1512 let _ = self.save_lockfile();
1513
1514 self.installed.remove(name);
1515 Ok(())
1516 }
1517
1518 pub fn uninstall_from_source(&mut self, source: &str, scope: SourceScope) -> Result<()> {
1520 let parsed = ParsedSource::parse(source);
1521 self.emit_progress(ProgressEvent {
1522 event_type: ProgressEventType::Start,
1523 action: ProgressAction::Remove,
1524 source: source.to_string(),
1525 message: Some(format!("Removing {}...", source)),
1526 });
1527 let result = self.do_uninstall_from_source(&parsed, scope);
1528 match &result {
1529 Ok(_) => self.emit_progress(ProgressEvent {
1530 event_type: ProgressEventType::Complete,
1531 action: ProgressAction::Remove,
1532 source: source.to_string(),
1533 message: None,
1534 }),
1535 Err(e) => self.emit_progress(ProgressEvent {
1536 event_type: ProgressEventType::Error,
1537 action: ProgressAction::Remove,
1538 source: source.to_string(),
1539 message: Some(e.to_string()),
1540 }),
1541 }
1542 result
1543 }
1544
1545 fn do_uninstall_from_source(
1546 &mut self,
1547 parsed: &ParsedSource,
1548 scope: SourceScope,
1549 ) -> Result<()> {
1550 match parsed {
1551 ParsedSource::Npm { name, .. } => {
1552 let dest = self.npm_install_path(name, scope);
1553 if dest.exists() {
1554 fs::remove_dir_all(&dest)?;
1555 }
1556 self.installed.remove(name);
1557 self.lockfile.remove(name);
1558 let _ = self.save_lockfile();
1559 Ok(())
1560 }
1561 ParsedSource::Git { host, path, .. } => {
1562 let dest = self.git_install_path(host, path, scope);
1563 if dest.exists() {
1564 fs::remove_dir_all(&dest)?;
1565 prune_empty_parents(&dest, &self.packages_dir);
1566 }
1567 self.installed.retain(|_, m| {
1568 let parsed_m = ParsedSource::parse(m.name.as_str());
1569 parsed_m.identity() != parsed.identity()
1570 });
1571 self.lockfile.packages.retain(|_, entry| {
1572 let parsed_e = ParsedSource::parse(&entry.source);
1573 parsed_e.identity() != parsed.identity()
1574 });
1575 let _ = self.save_lockfile();
1576 Ok(())
1577 }
1578 ParsedSource::Local { .. } => Ok(()),
1579 ParsedSource::Url { .. } => {
1580 let identity = parsed.identity();
1581 self.lockfile
1582 .packages
1583 .retain(|_, e| ParsedSource::parse(&e.source).identity() != identity);
1584 let _ = self.save_lockfile();
1585 Ok(())
1586 }
1587 }
1588 }
1589
1590 pub fn update(&mut self, name: &str) -> Result<PackageManifest> {
1597 let lock_entry = self.lockfile.get(name).cloned();
1598
1599 if let Some(entry) = lock_entry {
1600 let parsed = ParsedSource::parse(&entry.source);
1601 return match &parsed {
1602 ParsedSource::Npm { spec, .. } => {
1603 self.emit_progress(ProgressEvent {
1604 event_type: ProgressEventType::Start,
1605 action: ProgressAction::Update,
1606 source: entry.source.clone(),
1607 message: Some(format!("Updating {}...", name)),
1608 });
1609 let result = self.install_npm_pack(spec, entry.scope);
1610 match &result {
1611 Ok(_) => self.emit_progress(ProgressEvent {
1612 event_type: ProgressEventType::Complete,
1613 action: ProgressAction::Update,
1614 source: entry.source.clone(),
1615 message: None,
1616 }),
1617 Err(e) => self.emit_progress(ProgressEvent {
1618 event_type: ProgressEventType::Error,
1619 action: ProgressAction::Update,
1620 source: entry.source.clone(),
1621 message: Some(e.to_string()),
1622 }),
1623 }
1624 result
1625 }
1626 ParsedSource::Git { repo, ref_, .. } => {
1627 let target_dir = match &parsed {
1628 ParsedSource::Git { host, path, .. } => {
1629 self.git_install_path(host, path, entry.scope)
1630 }
1631 _ => unreachable!(),
1632 };
1633 if target_dir.exists() {
1634 let updated = git_update(&target_dir, ref_.as_deref())?;
1635 if updated && target_dir.join(NPM_MANIFEST_NAME).exists() {
1636 let _ = std::process::Command::new("npm")
1637 .args(["install", "--omit=dev"])
1638 .current_dir(&target_dir)
1639 .output();
1640 }
1641 self.load_manifest_from_dir(&target_dir, &entry.source, entry.scope)
1642 } else {
1643 self.install_git_sync(&entry.source, repo, ref_.as_deref(), entry.scope)
1644 }
1645 }
1646 ParsedSource::Local { path } => self.install_local(path),
1647 ParsedSource::Url { url } => {
1648 run_on_fresh_runtime(self.install_url(url, entry.scope))
1649 }
1650 };
1651 }
1652
1653 if self.installed.contains_key(name) {
1655 self.install_npm_pack(name, SourceScope::User)
1656 } else {
1657 bail!("Package '{}' is not installed", name);
1658 }
1659 }
1660
1661 pub fn update_all(&mut self) -> Vec<(String, Result<PackageManifest>)> {
1663 let names: Vec<String> = self.installed.keys().cloned().collect();
1664 let mut results = Vec::new();
1665 for name in names {
1666 let result = self.update(&name);
1667 results.push((name, result));
1668 }
1669 results
1670 }
1671
1672 pub async fn check_for_updates(&self) -> Vec<PackageUpdateInfo> {
1674 let mut updates = Vec::new();
1675
1676 for lock_entry in self.lockfile.packages.values() {
1677 let parsed = ParsedSource::parse(&lock_entry.source);
1678
1679 match &parsed {
1680 ParsedSource::Npm { name: pkg_name, .. } => {
1681 match NpmPackageInfo::fetch(pkg_name).await {
1683 Ok(info) => {
1684 if let Some(latest) = info.latest_version() {
1685 if latest != lock_entry.version {
1686 updates.push(PackageUpdateInfo {
1687 source: lock_entry.source.clone(),
1688 display_name: pkg_name.clone(),
1689 source_type: "npm".to_string(),
1690 scope: lock_entry.scope,
1691 });
1692 }
1693 }
1694 }
1695 Err(_) => continue,
1696 }
1697 }
1698 ParsedSource::Git { host, path, .. } => {
1699 let install_path = self.git_install_path(host, path, lock_entry.scope);
1700 if install_path.exists() {
1701 match git_has_update(&install_path) {
1702 Ok(true) => {
1703 updates.push(PackageUpdateInfo {
1704 source: lock_entry.source.clone(),
1705 display_name: format!("{}/{}", host, path),
1706 source_type: "git".to_string(),
1707 scope: lock_entry.scope,
1708 });
1709 }
1710 _ => continue,
1711 }
1712 }
1713 }
1714 _ => continue,
1715 }
1716 }
1717
1718 updates
1719 }
1720
1721 pub fn list(&self) -> Vec<&PackageManifest> {
1725 self.installed.values().collect()
1726 }
1727
1728 pub fn list_configured(&self) -> Vec<ConfiguredPackage> {
1730 let mut result = Vec::new();
1731 for name in self.installed.keys() {
1732 let installed_path = self.get_install_dir(name);
1733 let lock_entry = self.lockfile.get(name);
1734 result.push(ConfiguredPackage {
1735 source: lock_entry
1736 .map(|e| e.source.clone())
1737 .unwrap_or_else(|| name.clone()),
1738 scope: lock_entry.map(|e| e.scope).unwrap_or(SourceScope::User),
1739 filtered: false,
1740 installed_path,
1741 });
1742 }
1743 result
1744 }
1745
1746 pub fn is_installed(&self, name: &str) -> bool {
1748 self.installed.contains_key(name)
1749 }
1750
1751 pub fn get_install_dir(&self, name: &str) -> Option<PathBuf> {
1753 let dir = self.pkg_install_dir(name);
1754 if dir.exists() {
1755 Some(dir)
1756 } else {
1757 None
1758 }
1759 }
1760
1761 pub fn get_installed_path_for_source(
1763 &self,
1764 source: &str,
1765 scope: SourceScope,
1766 ) -> Option<PathBuf> {
1767 let parsed = ParsedSource::parse(source);
1768 match &parsed {
1769 ParsedSource::Npm { name, .. } => {
1770 let path = self.npm_install_path(name, scope);
1771 if path.exists() {
1772 Some(path)
1773 } else {
1774 None
1775 }
1776 }
1777 ParsedSource::Git { host, path, .. } => {
1778 let path = self.git_install_path(host, path, scope);
1779 if path.exists() {
1780 Some(path)
1781 } else {
1782 None
1783 }
1784 }
1785 ParsedSource::Local { path } => {
1786 let p = PathBuf::from(path);
1787 if p.exists() {
1788 Some(p)
1789 } else {
1790 None
1791 }
1792 }
1793 ParsedSource::Url { .. } => None,
1794 }
1795 }
1796
1797 pub fn discover_resources(&self, name: &str) -> Result<Vec<DiscoveredResource>> {
1801 let manifest = self
1802 .installed
1803 .get(name)
1804 .with_context(|| format!("Package '{}' not found", name))?;
1805
1806 let install_dir = self.pkg_install_dir(name);
1807 if !install_dir.exists() {
1808 bail!("Install directory for '{}' does not exist", name);
1809 }
1810
1811 let mut resources = Vec::new();
1812
1813 let has_explicit = !manifest.extensions.is_empty()
1814 || !manifest.skills.is_empty()
1815 || !manifest.prompts.is_empty()
1816 || !manifest.themes.is_empty();
1817
1818 if has_explicit {
1819 for ext in &manifest.extensions {
1820 let path = install_dir.join(ext);
1821 if path.exists() {
1822 resources.push(DiscoveredResource {
1823 kind: ResourceKind::Extension,
1824 path,
1825 relative_path: ext.clone(),
1826 });
1827 }
1828 }
1829 for skill in &manifest.skills {
1830 let path = install_dir.join(skill);
1831 if path.exists() {
1832 resources.push(DiscoveredResource {
1833 kind: ResourceKind::Skill,
1834 path,
1835 relative_path: skill.clone(),
1836 });
1837 }
1838 }
1839 for prompt in &manifest.prompts {
1840 let path = install_dir.join(prompt);
1841 if path.exists() {
1842 resources.push(DiscoveredResource {
1843 kind: ResourceKind::Prompt,
1844 path,
1845 relative_path: prompt.clone(),
1846 });
1847 }
1848 }
1849 for theme in &manifest.themes {
1850 let path = install_dir.join(theme);
1851 if path.exists() {
1852 resources.push(DiscoveredResource {
1853 kind: ResourceKind::Theme,
1854 path,
1855 relative_path: theme.clone(),
1856 });
1857 }
1858 }
1859 } else {
1860 resources.extend(discover_extensions(&install_dir));
1861 resources.extend(discover_skills(&install_dir));
1862 resources.extend(discover_prompts(&install_dir));
1863 resources.extend(discover_themes(&install_dir));
1864 }
1865
1866 Ok(resources)
1867 }
1868
1869 pub fn resource_counts(&self, name: &str) -> Result<ResourceCounts> {
1871 let resources = self.discover_resources(name)?;
1872 let mut counts = ResourceCounts::default();
1873 for r in &resources {
1874 match r.kind {
1875 ResourceKind::Extension => counts.extensions += 1,
1876 ResourceKind::Skill => counts.skills += 1,
1877 ResourceKind::Prompt => counts.prompts += 1,
1878 ResourceKind::Theme => counts.themes += 1,
1879 }
1880 }
1881 Ok(counts)
1882 }
1883
1884 pub fn resolve(&self) -> ResolvedPaths {
1886 let mut extensions = Vec::new();
1887 let mut skills = Vec::new();
1888 let mut prompts = Vec::new();
1889 let mut themes = Vec::new();
1890
1891 for name in self.installed.keys() {
1892 let install_dir = self.pkg_install_dir(name);
1893 if !install_dir.exists() {
1894 continue;
1895 }
1896
1897 let metadata = PathMetadata {
1898 source: name.clone(),
1899 scope: SourceScope::User,
1900 origin: ResourceOrigin::Package,
1901 base_dir: Some(install_dir.clone()),
1902 };
1903
1904 if let Ok(resources) = self.discover_resources(name) {
1906 for r in resources {
1907 match r.kind {
1908 ResourceKind::Extension => extensions.push(ResolvedResource {
1909 path: r.path,
1910 enabled: true,
1911 metadata: metadata.clone(),
1912 }),
1913 ResourceKind::Skill => skills.push(ResolvedResource {
1914 path: r.path,
1915 enabled: true,
1916 metadata: metadata.clone(),
1917 }),
1918 ResourceKind::Prompt => prompts.push(ResolvedResource {
1919 path: r.path,
1920 enabled: true,
1921 metadata: metadata.clone(),
1922 }),
1923 ResourceKind::Theme => themes.push(ResolvedResource {
1924 path: r.path,
1925 enabled: true,
1926 metadata: metadata.clone(),
1927 }),
1928 }
1929 }
1930 }
1931 }
1932
1933 ResolvedPaths {
1934 extensions,
1935 skills,
1936 prompts,
1937 themes,
1938 }
1939 }
1940
1941 pub fn resolve_dependencies(&self) -> Vec<(String, Vec<String>)> {
1946 let mut result = Vec::new();
1947 let installed_names: HashSet<&str> = self.installed.keys().map(|s| s.as_str()).collect();
1948
1949 for (name, manifest) in &self.installed {
1950 let missing: Vec<String> = manifest
1951 .dependencies
1952 .keys()
1953 .filter(|dep| !installed_names.contains(dep.as_str()))
1954 .cloned()
1955 .collect();
1956
1957 if !missing.is_empty() {
1958 result.push((name.clone(), missing));
1959 }
1960 }
1961
1962 result
1963 }
1964
1965 pub fn validate_package(dir: &Path) -> Result<Vec<String>> {
1967 let mut warnings = Vec::new();
1968
1969 if !dir.join(MANIFEST_NAME).exists() && !dir.join(NPM_MANIFEST_NAME).exists() {
1971 warnings.push(format!(
1972 "No {} or {} found",
1973 MANIFEST_NAME, NPM_MANIFEST_NAME
1974 ));
1975 }
1976
1977 if dir.join(MANIFEST_NAME).exists() {
1979 match Self::read_manifest(&dir.join(MANIFEST_NAME)) {
1980 Ok(m) => {
1981 if m.name.is_empty() {
1982 warnings.push("Package name is empty".to_string());
1983 }
1984 if m.version.is_empty() {
1985 warnings.push("Package version is empty".to_string());
1986 }
1987 if semver::Version::parse(&m.version).is_err() {
1988 warnings.push(format!("Version '{}' is not valid semver", m.version));
1989 }
1990 let has_resources = !m.extensions.is_empty()
1991 || !m.skills.is_empty()
1992 || !m.prompts.is_empty()
1993 || !m.themes.is_empty();
1994 if !has_resources {
1995 let discovered = discover_extensions(dir)
1997 .into_iter()
1998 .chain(discover_skills(dir))
1999 .chain(discover_prompts(dir))
2000 .chain(discover_themes(dir))
2001 .count();
2002 if discovered == 0 {
2003 warnings.push(
2004 "Package has no explicit resources and auto-discovery found nothing"
2005 .to_string(),
2006 );
2007 }
2008 }
2009
2010 for ext in &m.extensions {
2012 if !dir.join(ext).exists() {
2013 warnings.push(format!("Extension path '{}' does not exist", ext));
2014 }
2015 }
2016 for skill in &m.skills {
2017 if !dir.join(skill).exists() {
2018 warnings.push(format!("Skill path '{}' does not exist", skill));
2019 }
2020 }
2021 for prompt in &m.prompts {
2022 if !dir.join(prompt).exists() {
2023 warnings.push(format!("Prompt path '{}' does not exist", prompt));
2024 }
2025 }
2026 for theme in &m.themes {
2027 if !dir.join(theme).exists() {
2028 warnings.push(format!("Theme path '{}' does not exist", theme));
2029 }
2030 }
2031 }
2032 Err(e) => {
2033 warnings.push(format!("Failed to parse {}: {}", MANIFEST_NAME, e));
2034 }
2035 }
2036 }
2037
2038 if !dir.join(".gitignore").exists() && !dir.join(".ignore").exists() {
2040 warnings.push("No .gitignore or .ignore file found".to_string());
2041 }
2042
2043 Ok(warnings)
2044 }
2045
2046 pub fn get_installed_version(&self, name: &str) -> Option<&str> {
2050 self.installed.get(name).map(|m| m.version.as_str())
2051 }
2052
2053 pub fn version_satisfies(&self, name: &str, requirement: &str) -> bool {
2055 if let Some(version) = self.get_installed_version(name) {
2056 if let Ok(v) = semver::Version::parse(version) {
2057 if let Ok(req) = semver::VersionReq::parse(requirement) {
2058 return req.matches(&v);
2059 }
2060 }
2061 }
2062 false
2063 }
2064
2065 pub fn lockfile(&self) -> &Lockfile {
2067 &self.lockfile
2068 }
2069}
2070
2071fn discover_extensions(dir: &Path) -> Vec<DiscoveredResource> {
2075 let mut results = Vec::new();
2076 discover_extensions_recursive(dir, dir, &mut results);
2077 results
2078}
2079
2080fn discover_extensions_recursive(
2081 base: &Path,
2082 current: &Path,
2083 results: &mut Vec<DiscoveredResource>,
2084) {
2085 if !current.exists() {
2086 return;
2087 }
2088
2089 let entries = match fs::read_dir(current) {
2090 Ok(e) => e,
2091 Err(_) => return,
2092 };
2093
2094 for entry in entries.flatten() {
2095 let path = entry.path();
2096 let name = entry.file_name();
2097 let name_str = name.to_string_lossy();
2098
2099 if name_str.starts_with('.') || name_str == "node_modules" {
2100 continue;
2101 }
2102
2103 if path.is_dir() {
2104 for index in &["index.ts", "index.js"] {
2106 let index_path = path.join(index);
2107 if index_path.exists() {
2108 let rel = path.strip_prefix(base).unwrap_or(&path);
2109 results.push(DiscoveredResource {
2110 kind: ResourceKind::Extension,
2111 path: index_path,
2112 relative_path: rel.join(index).to_string_lossy().to_string(),
2113 });
2114 }
2115 }
2116 } else {
2117 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
2118 if matches!(ext, "so" | "dylib" | "dll" | "ts" | "js") {
2119 let rel = path.strip_prefix(base).unwrap_or(&path);
2120 results.push(DiscoveredResource {
2121 kind: ResourceKind::Extension,
2122 path: path.clone(),
2123 relative_path: rel.to_string_lossy().to_string(),
2124 });
2125 }
2126 }
2127 }
2128}
2129
2130fn discover_skills(dir: &Path) -> Vec<DiscoveredResource> {
2132 let mut results = Vec::new();
2133 discover_skills_recursive(dir, dir, &mut results);
2134 results
2135}
2136
2137fn discover_skills_recursive(base: &Path, current: &Path, results: &mut Vec<DiscoveredResource>) {
2138 if !current.exists() {
2139 return;
2140 }
2141
2142 let entries = match fs::read_dir(current) {
2143 Ok(e) => e,
2144 Err(_) => return,
2145 };
2146
2147 for entry in entries.flatten() {
2148 let path = entry.path();
2149 let name = entry.file_name();
2150 let name_str = name.to_string_lossy();
2151
2152 if name_str.starts_with('.') || name_str == "node_modules" {
2153 continue;
2154 }
2155
2156 if path.is_dir() {
2157 let skill_file = path.join("SKILL.md");
2158 if skill_file.exists() {
2159 let rel = path.strip_prefix(base).unwrap_or(&path);
2160 results.push(DiscoveredResource {
2161 kind: ResourceKind::Skill,
2162 path: skill_file,
2163 relative_path: rel.join("SKILL.md").to_string_lossy().to_string(),
2164 });
2165 }
2166 discover_skills_recursive(base, &path, results);
2167 }
2168 }
2169}
2170
2171fn discover_prompts(dir: &Path) -> Vec<DiscoveredResource> {
2173 let prompts_dir = dir.join("prompts");
2174 discover_files_by_ext(
2175 if prompts_dir.exists() {
2176 &prompts_dir
2177 } else {
2178 dir
2179 },
2180 "md",
2181 ResourceKind::Prompt,
2182 )
2183}
2184
2185fn discover_themes(dir: &Path) -> Vec<DiscoveredResource> {
2187 let themes_dir = dir.join("themes");
2188 discover_files_by_ext(
2189 if themes_dir.exists() {
2190 &themes_dir
2191 } else {
2192 dir
2193 },
2194 "json",
2195 ResourceKind::Theme,
2196 )
2197}
2198
2199fn discover_files_by_ext(dir: &Path, ext: &str, kind: ResourceKind) -> Vec<DiscoveredResource> {
2201 let mut results = Vec::new();
2202 discover_files_recursive(dir, dir, ext, kind, &mut results);
2203 results
2204}
2205
2206fn discover_files_recursive(
2207 base: &Path,
2208 current: &Path,
2209 ext: &str,
2210 kind: ResourceKind,
2211 results: &mut Vec<DiscoveredResource>,
2212) {
2213 if !current.exists() {
2214 return;
2215 }
2216
2217 let entries = match fs::read_dir(current) {
2218 Ok(e) => e,
2219 Err(_) => return,
2220 };
2221
2222 for entry in entries.flatten() {
2223 let path = entry.path();
2224 let name = entry.file_name();
2225 let name_str = name.to_string_lossy();
2226
2227 if name_str.starts_with('.') || name_str == "node_modules" {
2228 continue;
2229 }
2230
2231 if path.is_dir() {
2232 discover_files_recursive(base, &path, ext, kind, results);
2233 } else if path.extension().and_then(|e| e.to_str()) == Some(ext) {
2234 let rel = path.strip_prefix(base).unwrap_or(&path);
2235 results.push(DiscoveredResource {
2236 kind,
2237 path: path.clone(),
2238 relative_path: rel.to_string_lossy().to_string(),
2239 });
2240 }
2241 }
2242}
2243
2244fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
2248 if !dst.exists() {
2249 fs::create_dir_all(dst)?;
2250 }
2251
2252 for entry in fs::read_dir(src)? {
2253 let entry = entry?;
2254 let src_path = entry.path();
2255 let dst_path = dst.join(entry.file_name());
2256
2257 if src_path.is_dir() {
2258 copy_dir_recursive(&src_path, &dst_path)?;
2259 } else {
2260 fs::copy(&src_path, &dst_path)?;
2261 }
2262 }
2263
2264 Ok(())
2265}
2266
2267fn compute_dir_hash(dir: &Path) -> Option<String> {
2269 let mut hasher = Sha256::new();
2270 let mut files = collect_file_paths(dir);
2271 files.sort();
2272
2273 for file_path in &files {
2274 if let Ok(content) = fs::read(file_path) {
2275 hasher.update(&content);
2276 }
2277 }
2278
2279 let result = hasher.finalize();
2280 Some(format!("sha256-{:x}", result))
2281}
2282
2283fn collect_file_paths(dir: &Path) -> Vec<PathBuf> {
2285 let mut paths = Vec::new();
2286 if !dir.exists() {
2287 return paths;
2288 }
2289
2290 let entries = match fs::read_dir(dir) {
2291 Ok(e) => e,
2292 Err(_) => return paths,
2293 };
2294
2295 for entry in entries.flatten() {
2296 let path = entry.path();
2297 if path.is_dir() {
2298 paths.extend(collect_file_paths(&path));
2299 } else {
2300 paths.push(path);
2301 }
2302 }
2303
2304 paths
2305}
2306
2307fn find_single_subdir(dir: &Path) -> Option<PathBuf> {
2309 let entries: Vec<_> = fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).collect();
2310 if entries.len() == 1 && entries[0].path().is_dir() {
2311 Some(entries[0].path())
2312 } else {
2313 None
2314 }
2315}
2316
2317fn prune_empty_parents(target: &Path, root: &Path) {
2319 let mut current = target.parent();
2320 while let Some(dir) = current {
2321 if dir == root || !dir.starts_with(root) {
2322 break;
2323 }
2324 if dir.exists() {
2325 let is_empty = fs::read_dir(dir)
2326 .map(|mut rd| rd.next().is_none())
2327 .unwrap_or(false);
2328 if is_empty {
2329 let _ = fs::remove_dir(dir);
2330 } else {
2331 break;
2332 }
2333 }
2334 current = dir.parent();
2335 }
2336}
2337
2338#[cfg(test)]
2339mod tests {
2340 use super::*;
2341
2342 fn setup_temp_packages_dir() -> (tempfile::TempDir, PathBuf) {
2343 let tmp = tempfile::tempdir().unwrap();
2344 let packages_dir = tmp.path().join("packages");
2345 fs::create_dir_all(&packages_dir).unwrap();
2346 (tmp, packages_dir)
2347 }
2348
2349 fn create_test_package(base: &Path, name: &str, version: &str) -> PathBuf {
2350 let pkg_dir = base.join("source-pkg");
2351 fs::create_dir_all(&pkg_dir).unwrap();
2352
2353 let manifest = PackageManifest {
2354 name: name.to_string(),
2355 version: version.to_string(),
2356 extensions: vec!["ext1.so".to_string()],
2357 skills: vec!["skill-a".to_string()],
2358 prompts: vec![],
2359 themes: vec![],
2360 description: None,
2361 dependencies: BTreeMap::new(),
2362 };
2363
2364 let toml_content = toml::to_string_pretty(&manifest).unwrap();
2365 fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
2366 fs::write(pkg_dir.join("ext1.so"), "fake extension").unwrap();
2367 fs::create_dir_all(pkg_dir.join("skill-a")).unwrap();
2368 fs::write(pkg_dir.join("skill-a").join("SKILL.md"), "# Skill A").unwrap();
2369
2370 pkg_dir
2371 }
2372
2373 fn create_test_package_with_auto_discovery(base: &Path, name: &str, version: &str) -> PathBuf {
2374 let pkg_dir = base.join("source-pkg-auto");
2375 fs::create_dir_all(&pkg_dir).unwrap();
2376
2377 let manifest = PackageManifest {
2378 name: name.to_string(),
2379 version: version.to_string(),
2380 extensions: vec![],
2381 skills: vec![],
2382 prompts: vec![],
2383 themes: vec![],
2384 description: None,
2385 dependencies: BTreeMap::new(),
2386 };
2387 let toml_content = toml::to_string_pretty(&manifest).unwrap();
2388 fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
2389
2390 fs::write(pkg_dir.join("myext.so"), "extension").unwrap();
2391 fs::create_dir_all(pkg_dir.join("my-skill")).unwrap();
2392 fs::write(pkg_dir.join("my-skill").join("SKILL.md"), "# My Skill").unwrap();
2393 fs::create_dir_all(pkg_dir.join("prompts")).unwrap();
2394 fs::write(pkg_dir.join("prompts").join("review.md"), "# Review").unwrap();
2395 fs::create_dir_all(pkg_dir.join("themes")).unwrap();
2396 fs::write(pkg_dir.join("themes").join("dark.json"), "{}").unwrap();
2397
2398 pkg_dir
2399 }
2400
2401 #[test]
2402 fn test_install_and_list() {
2403 let (tmp, packages_dir) = setup_temp_packages_dir();
2404
2405 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2406 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2407
2408 let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2409 assert_eq!(manifest.name, "test-pkg");
2410 assert_eq!(manifest.version, "1.0.0");
2411
2412 let installed = mgr.list();
2413 assert_eq!(installed.len(), 1);
2414 assert_eq!(installed[0].name, "test-pkg");
2415 }
2416
2417 #[test]
2418 fn test_uninstall() {
2419 let (tmp, packages_dir) = setup_temp_packages_dir();
2420
2421 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2422 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2423
2424 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2425 assert!(mgr.is_installed("test-pkg"));
2426
2427 mgr.uninstall("test-pkg").unwrap();
2428 assert!(!mgr.is_installed("test-pkg"));
2429 assert!(mgr.list().is_empty());
2430 }
2431
2432 #[test]
2433 fn test_uninstall_not_installed() {
2434 let (_tmp, packages_dir) = setup_temp_packages_dir();
2435 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2436
2437 let result = mgr.uninstall("nonexistent");
2438 assert!(result.is_err());
2439 }
2440
2441 #[test]
2442 fn test_install_scoped_package() {
2443 let (tmp, packages_dir) = setup_temp_packages_dir();
2444
2445 let pkg_dir = create_test_package(tmp.path(), "@foo/oxi-tools", "2.0.0");
2446 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
2447
2448 let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2449 assert_eq!(manifest.name, "@foo/oxi-tools");
2450
2451 let expected_dir = packages_dir.join("foo-oxi-tools");
2452 assert!(expected_dir.exists());
2453 }
2454
2455 #[test]
2456 fn test_reinstall_overwrites() {
2457 let (tmp, packages_dir) = setup_temp_packages_dir();
2458
2459 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2460 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2461
2462 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2463
2464 let pkg_dir_v2 = tmp.path().join("source-pkg-v2");
2465 fs::create_dir_all(&pkg_dir_v2).unwrap();
2466 let manifest_v2 = PackageManifest {
2467 name: "test-pkg".to_string(),
2468 version: "2.0.0".to_string(),
2469 extensions: vec![],
2470 skills: vec![],
2471 prompts: vec![],
2472 themes: vec![],
2473 description: None,
2474 dependencies: BTreeMap::new(),
2475 };
2476 fs::write(
2477 pkg_dir_v2.join(MANIFEST_NAME),
2478 toml::to_string_pretty(&manifest_v2).unwrap(),
2479 )
2480 .unwrap();
2481
2482 mgr.install(pkg_dir_v2.to_str().unwrap()).unwrap();
2483
2484 let installed = mgr.list();
2485 assert_eq!(installed.len(), 1);
2486 assert_eq!(installed[0].version, "2.0.0");
2487 }
2488
2489 #[test]
2490 fn test_empty_packages_dir() {
2491 let (_tmp, packages_dir) = setup_temp_packages_dir();
2492 let mgr = PackageManager::with_dir(packages_dir).unwrap();
2493 assert!(mgr.list().is_empty());
2494 }
2495
2496 #[test]
2497 fn test_packages_dir_not_exists() {
2498 let tmp = tempfile::tempdir().unwrap();
2499 let nonexistent = tmp.path().join("does-not-exist");
2500 let mgr = PackageManager::with_dir(nonexistent).unwrap();
2501 assert!(mgr.list().is_empty());
2502 }
2503
2504 #[test]
2505 fn test_discover_resources_explicit() {
2506 let (tmp, packages_dir) = setup_temp_packages_dir();
2507
2508 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2509 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2510 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2511
2512 let resources = mgr.discover_resources("test-pkg").unwrap();
2513 assert_eq!(resources.len(), 2);
2514
2515 let extensions: Vec<_> = resources
2516 .iter()
2517 .filter(|r| r.kind == ResourceKind::Extension)
2518 .collect();
2519 let skills: Vec<_> = resources
2520 .iter()
2521 .filter(|r| r.kind == ResourceKind::Skill)
2522 .collect();
2523 assert_eq!(extensions.len(), 1);
2524 assert_eq!(skills.len(), 1);
2525 }
2526
2527 #[test]
2528 fn test_discover_resources_auto() {
2529 let (tmp, packages_dir) = setup_temp_packages_dir();
2530
2531 let pkg_dir = create_test_package_with_auto_discovery(tmp.path(), "auto-pkg", "1.0.0");
2532 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2533 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2534
2535 let resources = mgr.discover_resources("auto-pkg").unwrap();
2536
2537 let ext_count = resources
2538 .iter()
2539 .filter(|r| r.kind == ResourceKind::Extension)
2540 .count();
2541 let skill_count = resources
2542 .iter()
2543 .filter(|r| r.kind == ResourceKind::Skill)
2544 .count();
2545 let prompt_count = resources
2546 .iter()
2547 .filter(|r| r.kind == ResourceKind::Prompt)
2548 .count();
2549 let theme_count = resources
2550 .iter()
2551 .filter(|r| r.kind == ResourceKind::Theme)
2552 .count();
2553
2554 assert!(
2555 ext_count >= 1,
2556 "Expected at least 1 extension, got {}",
2557 ext_count
2558 );
2559 assert!(
2560 skill_count >= 1,
2561 "Expected at least 1 skill, got {}",
2562 skill_count
2563 );
2564 assert!(
2565 prompt_count >= 1,
2566 "Expected at least 1 prompt, got {}",
2567 prompt_count
2568 );
2569 assert!(
2570 theme_count >= 1,
2571 "Expected at least 1 theme, got {}",
2572 theme_count
2573 );
2574 }
2575
2576 #[test]
2577 fn test_resource_counts() {
2578 let (tmp, packages_dir) = setup_temp_packages_dir();
2579
2580 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2581 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2582 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2583
2584 let counts = mgr.resource_counts("test-pkg").unwrap();
2585 assert_eq!(counts.extensions, 1);
2586 assert_eq!(counts.skills, 1);
2587 assert_eq!(counts.prompts, 0);
2588 assert_eq!(counts.themes, 0);
2589 }
2590
2591 #[test]
2592 fn test_resource_counts_display() {
2593 let counts = ResourceCounts {
2594 extensions: 2,
2595 skills: 1,
2596 prompts: 0,
2597 themes: 3,
2598 };
2599 assert_eq!(counts.to_string(), "2 ext, 1 skill, 3 theme");
2600
2601 let empty = ResourceCounts::default();
2602 assert_eq!(empty.to_string(), "-");
2603 }
2604
2605 #[test]
2606 fn test_resource_kind_display() {
2607 assert_eq!(ResourceKind::Extension.to_string(), "extension");
2608 assert_eq!(ResourceKind::Skill.to_string(), "skill");
2609 assert_eq!(ResourceKind::Prompt.to_string(), "prompt");
2610 assert_eq!(ResourceKind::Theme.to_string(), "theme");
2611 }
2612
2613 #[test]
2614 fn test_get_install_dir() {
2615 let (tmp, packages_dir) = setup_temp_packages_dir();
2616
2617 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2618 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
2619 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2620
2621 let dir = mgr.get_install_dir("test-pkg").unwrap();
2622 assert!(dir.exists());
2623 assert!(dir.join(MANIFEST_NAME).exists());
2624
2625 assert!(mgr.get_install_dir("nonexistent").is_none());
2626 }
2627
2628 #[test]
2629 fn test_discover_resources_not_installed() {
2630 let (_tmp, packages_dir) = setup_temp_packages_dir();
2631 let mgr = PackageManager::with_dir(packages_dir).unwrap();
2632
2633 let result = mgr.discover_resources("nonexistent");
2634 assert!(result.is_err());
2635 }
2636
2637 #[test]
2638 fn test_update_not_installed() {
2639 let (_tmp, packages_dir) = setup_temp_packages_dir();
2640 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2641
2642 let result = mgr.update("nonexistent");
2643 assert!(result.is_err());
2644 }
2645
2646 #[test]
2649 fn test_parse_npm_source() {
2650 let parsed = ParsedSource::parse("npm:express@4.18.0");
2651 match parsed {
2652 ParsedSource::Npm { spec, name, pinned } => {
2653 assert_eq!(spec, "express@4.18.0");
2654 assert_eq!(name, "express");
2655 assert!(pinned);
2656 }
2657 _ => panic!("Expected Npm source"),
2658 }
2659
2660 let parsed = ParsedSource::parse("npm:lodash");
2661 match parsed {
2662 ParsedSource::Npm { name, pinned, .. } => {
2663 assert_eq!(name, "lodash");
2664 assert!(!pinned);
2665 }
2666 _ => panic!("Expected Npm source"),
2667 }
2668 }
2669
2670 #[test]
2671 fn test_parse_git_source() {
2672 let parsed = ParsedSource::parse("https://github.com/org/repo.git");
2673 match parsed {
2674 ParsedSource::Git {
2675 host, path, ref_, ..
2676 } => {
2677 assert_eq!(host, "github.com");
2678 assert_eq!(path, "org/repo");
2679 assert!(ref_.is_none());
2680 }
2681 _ => panic!("Expected Git source"),
2682 }
2683
2684 let parsed = ParsedSource::parse("https://github.com/org/repo.git@v1.0.0");
2685 match parsed {
2686 ParsedSource::Git { path, ref_, .. } => {
2687 assert_eq!(path, "org/repo");
2688 assert_eq!(ref_.as_deref(), Some("v1.0.0"));
2689 }
2690 _ => panic!("Expected Git source"),
2691 }
2692 }
2693
2694 #[test]
2695 fn test_parse_github_shorthand() {
2696 let parsed = ParsedSource::parse("github:org/repo@main");
2697 match parsed {
2698 ParsedSource::Git {
2699 host, path, ref_, ..
2700 } => {
2701 assert_eq!(host, "github.com");
2702 assert_eq!(path, "org/repo");
2703 assert_eq!(ref_.as_deref(), Some("main"));
2704 }
2705 _ => panic!("Expected Git source"),
2706 }
2707 }
2708
2709 #[test]
2710 fn test_parse_local_source() {
2711 let parsed = ParsedSource::parse("/path/to/package");
2712 match parsed {
2713 ParsedSource::Local { path } => {
2714 assert_eq!(path, "/path/to/package");
2715 }
2716 _ => panic!("Expected Local source"),
2717 }
2718
2719 let parsed = ParsedSource::parse("./relative/path");
2720 match parsed {
2721 ParsedSource::Local { path } => {
2722 assert_eq!(path, "./relative/path");
2723 }
2724 _ => panic!("Expected Local source"),
2725 }
2726 }
2727
2728 #[test]
2729 fn test_parse_url_source() {
2730 let parsed = ParsedSource::parse("https://example.com/pkg.tar.gz");
2731 match parsed {
2732 ParsedSource::Url { url } => {
2733 assert_eq!(url, "https://example.com/pkg.tar.gz");
2734 }
2735 _ => panic!("Expected Url source"),
2736 }
2737 }
2738
2739 #[test]
2740 fn test_source_identity() {
2741 let npm = ParsedSource::parse("npm:express@4.18.0");
2742 assert_eq!(npm.identity(), "npm:express");
2743
2744 let git = ParsedSource::parse("https://github.com/org/repo.git");
2745 assert_eq!(git.identity(), "git:github.com/org/repo");
2746
2747 let local = ParsedSource::parse("/path/to/pkg");
2748 assert_eq!(local.identity(), "local:/path/to/pkg");
2749 }
2750
2751 #[test]
2752 fn test_parse_npm_spec() {
2753 let (name, pinned) = parse_npm_spec("express@4.18.0");
2754 assert_eq!(name, "express");
2755 assert!(pinned);
2756
2757 let (name, pinned) = parse_npm_spec("express");
2758 assert_eq!(name, "express");
2759 assert!(!pinned);
2760
2761 let (name, pinned) = parse_npm_spec("@scope/pkg@1.0.0");
2762 assert_eq!(name, "@scope/pkg");
2763 assert!(pinned);
2764 }
2765
2766 #[test]
2769 fn test_lockfile_roundtrip() {
2770 let (tmp, _) = setup_temp_packages_dir();
2771 let lock_path = tmp.path().join(LOCKFILE_NAME);
2772
2773 let mut lock = Lockfile::new();
2774 lock.insert(LockEntry {
2775 source: "npm:express@4.18.0".to_string(),
2776 name: "express".to_string(),
2777 version: "4.18.0".to_string(),
2778 integrity: Some("sha256-abc123".to_string()),
2779 scope: SourceScope::User,
2780 source_type: "npm".to_string(),
2781 dependencies: BTreeMap::new(),
2782 });
2783
2784 lock.write(&lock_path).unwrap();
2785
2786 let loaded = Lockfile::read(&lock_path).unwrap().unwrap();
2787 assert_eq!(loaded.packages.len(), 1);
2788 assert_eq!(loaded.packages["express"].version, "4.18.0");
2789 assert_eq!(
2790 loaded.packages["express"].integrity.as_deref(),
2791 Some("sha256-abc123")
2792 );
2793 }
2794
2795 #[test]
2796 fn test_lockfile_install_roundtrip() {
2797 let (tmp, packages_dir) = setup_temp_packages_dir();
2798 let pkg_dir = create_test_package(tmp.path(), "locked-pkg", "1.0.0");
2799
2800 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2801 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2802
2803 let lock_path = mgr.packages_dir().join(LOCKFILE_NAME);
2805 assert!(lock_path.exists());
2806
2807 let lock = Lockfile::read(&lock_path).unwrap().unwrap();
2808 assert!(lock.contains("locked-pkg"));
2809 let entry = lock.get("locked-pkg").unwrap();
2810 assert_eq!(entry.version, "1.0.0");
2811 }
2812
2813 #[test]
2816 fn test_validate_valid_package() {
2817 let (tmp, _) = setup_temp_packages_dir();
2818 let pkg_dir = create_test_package(tmp.path(), "valid-pkg", "1.0.0");
2819 let warnings = PackageManager::validate_package(&pkg_dir).unwrap();
2820 assert!(
2822 warnings.len() <= 1,
2823 "Expected <= 1 warning, got {:?}",
2824 warnings
2825 );
2826 }
2827
2828 #[test]
2829 fn test_validate_empty_dir() {
2830 let tmp = tempfile::tempdir().unwrap();
2831 let empty_dir = tmp.path().join("empty-pkg");
2832 fs::create_dir_all(&empty_dir).unwrap();
2833 let warnings = PackageManager::validate_package(&empty_dir).unwrap();
2834 assert!(!warnings.is_empty());
2835 }
2836
2837 #[test]
2840 fn test_resolve_dependencies() {
2841 let (tmp, packages_dir) = setup_temp_packages_dir();
2842
2843 let pkg_dir = tmp.path().join("dep-pkg");
2845 fs::create_dir_all(&pkg_dir).unwrap();
2846 let mut deps = BTreeMap::new();
2847 deps.insert("lodash".to_string(), "^4.0.0".to_string());
2848 deps.insert("nonexistent-pkg".to_string(), "^1.0.0".to_string());
2849
2850 let manifest = PackageManifest {
2851 name: "dep-pkg".to_string(),
2852 version: "1.0.0".to_string(),
2853 extensions: vec![],
2854 skills: vec![],
2855 prompts: vec![],
2856 themes: vec![],
2857 description: None,
2858 dependencies: deps,
2859 };
2860 fs::write(
2861 pkg_dir.join(MANIFEST_NAME),
2862 toml::to_string_pretty(&manifest).unwrap(),
2863 )
2864 .unwrap();
2865
2866 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2867 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2868
2869 let missing = mgr.resolve_dependencies();
2870 assert_eq!(missing.len(), 1);
2871 assert_eq!(missing[0].0, "dep-pkg");
2872 assert!(
2873 missing[0].1.contains(&"lodash".to_string())
2874 || missing[0].1.contains(&"nonexistent-pkg".to_string())
2875 );
2876 }
2877
2878 #[test]
2881 fn test_version_satisfies() {
2882 let (tmp, packages_dir) = setup_temp_packages_dir();
2883 let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "1.2.3");
2884 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2885 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2886
2887 assert!(mgr.version_satisfies("ver-pkg", "^1.0.0"));
2888 assert!(mgr.version_satisfies("ver-pkg", ">=1.0.0"));
2889 assert!(!mgr.version_satisfies("ver-pkg", "^2.0.0"));
2890 assert!(!mgr.version_satisfies("ver-pkg", "<1.0.0"));
2891 }
2892
2893 #[test]
2894 fn test_get_installed_version() {
2895 let (tmp, packages_dir) = setup_temp_packages_dir();
2896 let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "3.1.4");
2897 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2898 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2899
2900 assert_eq!(mgr.get_installed_version("ver-pkg"), Some("3.1.4"));
2901 assert_eq!(mgr.get_installed_version("nonexistent"), None);
2902 }
2903
2904 #[test]
2907 fn test_resolve() {
2908 let (tmp, packages_dir) = setup_temp_packages_dir();
2909 let pkg_dir = create_test_package(tmp.path(), "resolve-pkg", "1.0.0");
2910 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2911 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2912
2913 let resolved = mgr.resolve();
2914 assert!(!resolved.extensions.is_empty() || !resolved.skills.is_empty());
2915 }
2916
2917 #[test]
2920 fn test_progress_callback() {
2921 use std::sync::{Arc, Mutex};
2922
2923 let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2924 let events_clone = events.clone();
2925
2926 let (tmp, packages_dir) = setup_temp_packages_dir();
2927 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2928
2929 mgr.set_progress_callback(Box::new(move |event| {
2930 let mut e = events_clone.lock().unwrap();
2931 e.push(format!("{:?}:{:?}", event.event_type, event.action));
2932 }));
2933
2934 let pkg_dir = create_test_package(tmp.path(), "progress-pkg", "1.0.0");
2935 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2936
2937 let _event_count = events.lock().unwrap().len();
2940 }
2941
2942 #[test]
2943 fn test_list_configured() {
2944 let (tmp, packages_dir) = setup_temp_packages_dir();
2945 let pkg_dir = create_test_package(tmp.path(), "cfg-pkg", "1.0.0");
2946 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2947 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2948
2949 let configured = mgr.list_configured();
2950 assert_eq!(configured.len(), 1);
2951 assert!(configured[0].source.contains("source-pkg"));
2952 }
2954}