1use anyhow::{Context, Result};
34use serde::Deserialize;
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37use std::process::Command;
38
39use crate::config::Config;
40use crate::ctx_dirs;
41
42const COMMUNITY_REGISTRY_URL: &str = "https://github.com/parallax-labs/ctx-registry.git";
43const DEFAULT_BRANCH: &str = "main";
44
45#[derive(Debug, Deserialize, Clone)]
51pub struct RegistryManifest {
52 #[serde(default)]
54 #[allow(dead_code)]
55 pub registry: RegistryMeta,
56 #[serde(default)]
58 pub connectors: HashMap<String, ExtensionEntry>,
59 #[serde(default)]
61 pub tools: HashMap<String, ExtensionEntry>,
62 #[serde(default, alias = "agents")]
65 pub profiles: HashMap<String, ExtensionEntry>,
66}
67
68#[derive(Debug, Deserialize, Clone, Default)]
70#[allow(dead_code)]
71pub struct RegistryMeta {
72 #[serde(default)]
74 pub name: String,
75 #[serde(default)]
77 pub description: String,
78 #[serde(default)]
80 pub url: Option<String>,
81 #[serde(default)]
83 pub min_version: Option<String>,
84}
85
86#[derive(Debug, Deserialize, Clone)]
88pub struct ExtensionEntry {
89 #[serde(default)]
91 pub description: String,
92 pub path: String,
94 #[serde(default)]
96 pub tags: Vec<String>,
97 #[serde(default)]
99 pub required_config: Vec<String>,
100 #[serde(default)]
102 pub host_apis: Vec<String>,
103 #[serde(default)]
105 pub tools: Vec<String>,
106}
107
108#[derive(Debug, Clone)]
110pub struct ResolvedExtension {
111 pub name: String,
113 pub kind: String,
115 pub script_path: PathBuf,
117 pub registry_name: String,
119 pub entry: ExtensionEntry,
121}
122
123pub fn clone_registry(url: &str, branch: Option<&str>, target_dir: &Path) -> Result<()> {
129 if target_dir.exists() {
130 anyhow::bail!("Target directory already exists: {}", target_dir.display());
131 }
132
133 if let Some(parent) = target_dir.parent() {
134 std::fs::create_dir_all(parent)
135 .with_context(|| format!("Failed to create parent directory: {}", parent.display()))?;
136 }
137
138 let branch = branch.unwrap_or(DEFAULT_BRANCH);
139 let output = Command::new("git")
140 .args([
141 "clone",
142 "--depth",
143 "1",
144 "--branch",
145 branch,
146 url,
147 &target_dir.to_string_lossy(),
148 ])
149 .output()
150 .context("Failed to run git clone")?;
151
152 if !output.status.success() {
153 let stderr = String::from_utf8_lossy(&output.stderr);
154 anyhow::bail!("git clone failed: {}", stderr.trim());
155 }
156
157 Ok(())
158}
159
160pub fn pull_registry(registry_dir: &Path) -> Result<()> {
162 if !is_git_repo(registry_dir) {
163 anyhow::bail!("Not a git repository: {}", registry_dir.display());
164 }
165
166 if is_dirty(registry_dir)? {
167 eprintln!(
168 "Warning: registry at {} has uncommitted changes, skipping update",
169 registry_dir.display()
170 );
171 return Ok(());
172 }
173
174 let output = Command::new("git")
175 .args(["pull", "--ff-only"])
176 .current_dir(registry_dir)
177 .output()
178 .context("Failed to run git pull")?;
179
180 if !output.status.success() {
181 let stderr = String::from_utf8_lossy(&output.stderr);
182 anyhow::bail!("git pull failed: {}", stderr.trim());
183 }
184
185 Ok(())
186}
187
188pub fn is_git_repo(dir: &Path) -> bool {
190 dir.join(".git").exists()
191}
192
193fn is_dirty(dir: &Path) -> Result<bool> {
195 let output = Command::new("git")
196 .args(["status", "--porcelain"])
197 .current_dir(dir)
198 .output()
199 .context("Failed to run git status")?;
200
201 Ok(!output.stdout.is_empty())
202}
203
204pub fn load_manifest(registry_dir: &Path) -> Result<RegistryManifest> {
210 let manifest_path = registry_dir.join("registry.toml");
211 let content = std::fs::read_to_string(&manifest_path)
212 .with_context(|| format!("Failed to read manifest: {}", manifest_path.display()))?;
213
214 let manifest: RegistryManifest =
215 toml::from_str(&content).with_context(|| "Failed to parse registry.toml")?;
216
217 Ok(manifest)
218}
219
220fn discover_manifest(registry_dir: &Path) -> RegistryManifest {
223 let mut manifest = RegistryManifest {
224 registry: RegistryMeta::default(),
225 connectors: HashMap::new(),
226 tools: HashMap::new(),
227 profiles: HashMap::new(),
228 };
229
230 let scan_dir = |subdir: &str, script_name: &str| -> HashMap<String, ExtensionEntry> {
231 let mut entries = HashMap::new();
232 let dir = registry_dir.join(subdir);
233 if !dir.is_dir() {
234 return entries;
235 }
236 if let Ok(read) = std::fs::read_dir(&dir) {
237 for entry in read.flatten() {
238 if entry.path().is_dir() {
239 let name = entry.file_name().to_string_lossy().to_string();
240 let script = entry.path().join(script_name);
241 if script.exists() {
242 let rel_path = format!("{}/{}/{}", subdir, name, script_name);
243 entries.insert(
244 name,
245 ExtensionEntry {
246 description: String::new(),
247 path: rel_path,
248 tags: Vec::new(),
249 required_config: Vec::new(),
250 host_apis: Vec::new(),
251 tools: Vec::new(),
252 },
253 );
254 }
255 }
256 }
257 }
258 entries
259 };
260
261 manifest.connectors = scan_dir("connectors", "connector.lua");
262 manifest.tools = scan_dir("tools", "tool.lua");
263
264 let mut profiles = scan_dir("profiles", "profile.lua");
266 for (k, v) in scan_dir("profiles", "profile.toml") {
267 profiles.entry(k).or_insert(v);
268 }
269 for (k, v) in scan_dir("agents", "agent.lua") {
270 profiles.entry(k).or_insert(v);
271 }
272 for (k, v) in scan_dir("agents", "agent.toml") {
273 profiles.entry(k).or_insert(v);
274 }
275 manifest.profiles = profiles;
276
277 manifest
278}
279
280pub struct RegistryManager {
290 registries: Vec<LoadedRegistry>,
292}
293
294struct LoadedRegistry {
295 name: String,
296 path: PathBuf,
297 manifest: RegistryManifest,
298 readonly: bool,
299}
300
301impl RegistryManager {
302 pub fn from_config(config: &Config) -> Self {
308 let mut registries = Vec::new();
309
310 for (name, reg_cfg) in &config.registries {
311 let path = expand_tilde(®_cfg.path);
312 if !path.exists() {
313 eprintln!(
314 "Warning: registry '{}' path does not exist: {}",
315 name,
316 path.display()
317 );
318 continue;
319 }
320
321 let manifest = match load_manifest(&path) {
322 Ok(m) => m,
323 Err(_) => {
324 eprintln!(
325 "Warning: registry '{}' has no valid registry.toml, using directory scan",
326 name
327 );
328 discover_manifest(&path)
329 }
330 };
331
332 registries.push(LoadedRegistry {
333 name: name.clone(),
334 path,
335 manifest,
336 readonly: reg_cfg.readonly,
337 });
338 }
339
340 if let Some(ctx_dir) = find_local_ctx_dir() {
342 let manifest = match load_manifest(&ctx_dir) {
343 Ok(m) => m,
344 Err(_) => discover_manifest(&ctx_dir),
345 };
346 registries.push(LoadedRegistry {
347 name: "project-local".to_string(),
348 path: ctx_dir,
349 manifest,
350 readonly: false,
351 });
352 }
353
354 Self { registries }
355 }
356
357 pub fn list_all(&self) -> Vec<ResolvedExtension> {
361 let mut map: HashMap<String, ResolvedExtension> = HashMap::new();
362
363 for reg in &self.registries {
364 for (name, entry) in ®.manifest.connectors {
365 let key = format!("connectors/{}", name);
366 map.insert(
367 key,
368 ResolvedExtension {
369 name: name.clone(),
370 kind: "connector".to_string(),
371 script_path: reg.path.join(&entry.path),
372 registry_name: reg.name.clone(),
373 entry: entry.clone(),
374 },
375 );
376 }
377 for (name, entry) in ®.manifest.tools {
378 let key = format!("tools/{}", name);
379 map.insert(
380 key,
381 ResolvedExtension {
382 name: name.clone(),
383 kind: "tool".to_string(),
384 script_path: reg.path.join(&entry.path),
385 registry_name: reg.name.clone(),
386 entry: entry.clone(),
387 },
388 );
389 }
390 for (name, entry) in ®.manifest.profiles {
391 let key = format!("profiles/{}", name);
392 map.insert(
393 key,
394 ResolvedExtension {
395 name: name.clone(),
396 kind: "profile".to_string(),
397 script_path: reg.path.join(&entry.path),
398 registry_name: reg.name.clone(),
399 entry: entry.clone(),
400 },
401 );
402 }
403 }
404
405 let mut all: Vec<ResolvedExtension> = map.into_values().collect();
406 all.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.name.cmp(&b.name)));
407 all
408 }
409
410 pub fn resolve(&self, extension_id: &str) -> Option<ResolvedExtension> {
412 let extension_id = extension_id
413 .strip_prefix("agents/")
414 .map(|name| format!("profiles/{name}"))
415 .unwrap_or_else(|| extension_id.to_string());
416 self.list_all()
417 .into_iter()
418 .find(|e| format!("{}s/{}", e.kind, e.name) == extension_id)
419 }
420
421 #[allow(dead_code)]
423 pub fn list_connectors(&self) -> Vec<ResolvedExtension> {
424 self.list_all()
425 .into_iter()
426 .filter(|e| e.kind == "connector")
427 .collect()
428 }
429
430 pub fn list_tools(&self) -> Vec<ResolvedExtension> {
432 self.list_all()
433 .into_iter()
434 .filter(|e| e.kind == "tool")
435 .collect()
436 }
437
438 pub fn list_profiles(&self) -> Vec<ResolvedExtension> {
440 self.list_all()
441 .into_iter()
442 .filter(|e| e.kind == "profile")
443 .collect()
444 }
445
446 #[allow(dead_code)]
448 #[deprecated(note = "use list_profiles")]
449 pub fn list_agents(&self) -> Vec<ResolvedExtension> {
450 self.list_profiles()
451 }
452
453 pub fn writable_path(&self) -> Option<&Path> {
455 self.registries
456 .iter()
457 .rev()
458 .find(|r| !r.readonly)
459 .map(|r| r.path.as_path())
460 }
461
462 pub fn registries(&self) -> Vec<RegistryInfo> {
464 self.registries
465 .iter()
466 .map(|r| RegistryInfo {
467 name: r.name.clone(),
468 path: r.path.clone(),
469 readonly: r.readonly,
470 is_git: is_git_repo(&r.path),
471 connectors: r.manifest.connectors.len(),
472 tools: r.manifest.tools.len(),
473 profiles: r.manifest.profiles.len(),
474 })
475 .collect()
476 }
477}
478
479pub struct RegistryInfo {
481 pub name: String,
482 pub path: PathBuf,
483 pub readonly: bool,
484 pub is_git: bool,
485 pub connectors: usize,
486 pub tools: usize,
487 pub profiles: usize,
488}
489
490pub fn find_local_ctx_dir() -> Option<PathBuf> {
499 find_ctx_dir_from(std::env::current_dir().ok()?)
500}
501
502fn find_ctx_dir_from(start: PathBuf) -> Option<PathBuf> {
504 let mut dir = start;
505 loop {
506 let candidate = dir.join(".ctx");
507 if candidate.is_dir() {
508 return Some(candidate);
509 }
510 if !dir.pop() {
511 return None;
512 }
513 }
514}
515
516pub fn cmd_list(config: &Config) {
522 let mgr = RegistryManager::from_config(config);
523
524 let registries = mgr.registries();
525 if registries.is_empty() {
526 println!("No registries configured.");
527 println!("Add a [registries.<name>] section to ctx.toml, or run `ctx registry install`.");
528 return;
529 }
530
531 println!("Registries:\n");
532 for r in ®istries {
533 let git_tag = if r.is_git { " (git)" } else { "" };
534 let ro_tag = if r.readonly { " [readonly]" } else { "" };
535 println!(
536 " {} — {}{}{}\n {} connectors, {} tools, {} profiles",
537 r.name,
538 r.path.display(),
539 git_tag,
540 ro_tag,
541 r.connectors,
542 r.tools,
543 r.profiles,
544 );
545 }
546
547 let all = mgr.list_all();
548 if all.is_empty() {
549 println!("\nNo extensions found.");
550 return;
551 }
552
553 println!("\nAvailable extensions:\n");
554 let mut current_kind = String::new();
555 for ext in &all {
556 if ext.kind != current_kind {
557 current_kind = ext.kind.clone();
558 println!(" {}s:", current_kind);
559 }
560 let tags = if ext.entry.tags.is_empty() {
561 String::new()
562 } else {
563 format!(" [{}]", ext.entry.tags.join(", "))
564 };
565 println!(
566 " {} — {}{} (from: {})",
567 ext.name, ext.entry.description, tags, ext.registry_name
568 );
569 }
570}
571
572pub fn cmd_install(config: &Config, name: Option<&str>) -> Result<()> {
574 let mut installed = 0;
575
576 for (reg_name, reg_cfg) in &config.registries {
577 if let Some(filter) = name {
578 if reg_name != filter {
579 continue;
580 }
581 }
582
583 let url = match ®_cfg.url {
584 Some(u) => u,
585 None => {
586 if name.is_some() {
587 println!("Registry '{}' is local-only (no url configured).", reg_name);
588 }
589 continue;
590 }
591 };
592
593 let target = expand_tilde(®_cfg.path);
594 if target.exists() {
595 println!(
596 "Registry '{}' already installed at {}",
597 reg_name,
598 target.display()
599 );
600 continue;
601 }
602
603 println!("Cloning registry '{}' from {}...", reg_name, url);
604 clone_registry(url, reg_cfg.branch.as_deref(), &target)?;
605
606 match load_manifest(&target) {
608 Ok(m) => {
609 println!(
610 " Installed: {} connectors, {} tools, {} profiles",
611 m.connectors.len(),
612 m.tools.len(),
613 m.profiles.len()
614 );
615 }
616 Err(_) => {
617 println!(" Installed (no registry.toml found — extensions will be discovered by directory scan).");
618 }
619 }
620 installed += 1;
621 }
622
623 if installed == 0 && name.is_none() {
624 println!(
625 "No registries to install. Add [registries.<name>] entries with `url` to ctx.toml."
626 );
627 }
628
629 Ok(())
630}
631
632pub fn cmd_update(config: &Config, name: Option<&str>) -> Result<()> {
634 let mut updated = 0;
635
636 for (reg_name, reg_cfg) in &config.registries {
637 if let Some(filter) = name {
638 if reg_name != filter {
639 continue;
640 }
641 }
642
643 let path = expand_tilde(®_cfg.path);
644 if !path.exists() {
645 eprintln!(
646 "Registry '{}' not installed at {}. Run `ctx registry install` first.",
647 reg_name,
648 path.display()
649 );
650 continue;
651 }
652
653 if !is_git_repo(&path) {
654 if name.is_some() {
655 println!("Registry '{}' is not a git repository, skipping.", reg_name);
656 }
657 continue;
658 }
659
660 println!("Updating registry '{}'...", reg_name);
661 match pull_registry(&path) {
662 Ok(()) => {
663 println!(" Updated successfully.");
664 updated += 1;
665 }
666 Err(e) => {
667 eprintln!(" Failed to update '{}': {}", reg_name, e);
668 }
669 }
670 }
671
672 if updated == 0 && name.is_none() {
673 println!("No git-backed registries to update.");
674 }
675
676 Ok(())
677}
678
679pub fn cmd_search(config: &Config, query: &str) {
681 let mgr = RegistryManager::from_config(config);
682 let all = mgr.list_all();
683
684 let query_lower = query.to_lowercase();
685 let matches: Vec<&ResolvedExtension> = all
686 .iter()
687 .filter(|e| {
688 e.name.to_lowercase().contains(&query_lower)
689 || e.entry.description.to_lowercase().contains(&query_lower)
690 || e.entry
691 .tags
692 .iter()
693 .any(|t| t.to_lowercase().contains(&query_lower))
694 })
695 .collect();
696
697 if matches.is_empty() {
698 println!("No extensions matching '{}'.", query);
699 return;
700 }
701
702 println!("Found {} extensions matching '{}':\n", matches.len(), query);
703 for ext in matches {
704 let tags = if ext.entry.tags.is_empty() {
705 String::new()
706 } else {
707 format!(" [{}]", ext.entry.tags.join(", "))
708 };
709 println!(
710 " {}s/{} — {}{} (from: {})",
711 ext.kind, ext.name, ext.entry.description, tags, ext.registry_name
712 );
713 }
714}
715
716pub fn cmd_info(config: &Config, extension_id: &str) -> Result<()> {
718 let mgr = RegistryManager::from_config(config);
719
720 let ext = mgr
721 .resolve(extension_id)
722 .ok_or_else(|| anyhow::anyhow!("Extension '{}' not found in any registry", extension_id))?;
723
724 println!("Extension: {}s/{}", ext.kind, ext.name);
725 println!("Registry: {}", ext.registry_name);
726 println!("Script: {}", ext.script_path.display());
727
728 if !ext.entry.description.is_empty() {
729 println!("Description: {}", ext.entry.description);
730 }
731 if !ext.entry.tags.is_empty() {
732 println!("Tags: {}", ext.entry.tags.join(", "));
733 }
734 if !ext.entry.required_config.is_empty() {
735 println!("Required config: {}", ext.entry.required_config.join(", "));
736 }
737 if !ext.entry.host_apis.is_empty() {
738 println!("Host APIs: {}", ext.entry.host_apis.join(", "));
739 }
740 if !ext.entry.tools.is_empty() {
741 println!("Tools: {}", ext.entry.tools.join(", "));
742 }
743
744 let readme_path = ext.script_path.parent().map(|p| p.join("README.md"));
746 if let Some(ref readme) = readme_path {
747 if readme.exists() {
748 if let Ok(content) = std::fs::read_to_string(readme) {
749 println!("\n--- README ---\n\n{}", content);
750 }
751 }
752 }
753
754 Ok(())
755}
756
757pub fn cmd_add(config: &Config, extension_id: &str, config_path: &Path) -> Result<()> {
759 let mgr = RegistryManager::from_config(config);
760
761 let ext = mgr
762 .resolve(extension_id)
763 .ok_or_else(|| anyhow::anyhow!("Extension '{}' not found in any registry", extension_id))?;
764
765 let example_path = ext
767 .script_path
768 .parent()
769 .map(|p| p.join("config.example.toml"));
770 let example_content = example_path
771 .as_ref()
772 .and_then(|p| std::fs::read_to_string(p).ok());
773
774 let section = match ext.kind.as_str() {
775 "connector" => {
776 if let Some(example) = &example_content {
777 format!("\n[connectors.script.{}]\n{}", ext.name, example)
778 } else {
779 let mut section = format!(
780 "\n[connectors.script.{}]\npath = \"{}\"\n",
781 ext.name,
782 ext.script_path.display()
783 );
784 for key in &ext.entry.required_config {
785 section.push_str(&format!("{} = \"\" # TODO: set this\n", key));
786 }
787 section
788 }
789 }
790 "tool" => {
791 if let Some(example) = &example_content {
792 format!("\n[tools.script.{}]\n{}", ext.name, example)
793 } else {
794 format!(
795 "\n[tools.script.{}]\npath = \"{}\"\n",
796 ext.name,
797 ext.script_path.display()
798 )
799 }
800 }
801 "profile" => {
802 if let Some(example) = &example_content {
803 format!("\n[profiles.script.{}]\n{}", ext.name, example)
804 } else {
805 format!(
806 "\n[profiles.script.{}]\npath = \"{}\"\n",
807 ext.name,
808 ext.script_path.display()
809 )
810 }
811 }
812 _ => anyhow::bail!("Unknown extension kind: {}", ext.kind),
813 };
814
815 let mut content = std::fs::read_to_string(config_path)
817 .with_context(|| format!("Failed to read config: {}", config_path.display()))?;
818
819 content.push_str(§ion);
820
821 std::fs::write(config_path, &content)
822 .with_context(|| format!("Failed to write config: {}", config_path.display()))?;
823
824 println!(
825 "Added [{}s.script.{}] to {}",
826 ext.kind,
827 ext.name,
828 config_path.display()
829 );
830
831 if !ext.entry.required_config.is_empty() {
832 println!(
833 "Edit {} to set: {}",
834 config_path.display(),
835 ext.entry.required_config.join(", ")
836 );
837 }
838
839 Ok(())
840}
841
842pub fn cmd_override(config: &Config, extension_id: &str) -> Result<()> {
844 let mgr = RegistryManager::from_config(config);
845
846 let ext = mgr
847 .resolve(extension_id)
848 .ok_or_else(|| anyhow::anyhow!("Extension '{}' not found in any registry", extension_id))?;
849
850 let writable_path = mgr.writable_path().ok_or_else(|| {
851 anyhow::anyhow!("No writable registry found. Add a registry with readonly = false.")
852 })?;
853
854 let dest_dir = writable_path.join(format!("{}s", ext.kind)).join(&ext.name);
855
856 if dest_dir.exists() {
857 anyhow::bail!(
858 "Override already exists at {}. Edit it directly.",
859 dest_dir.display()
860 );
861 }
862
863 let src_dir = ext
865 .script_path
866 .parent()
867 .ok_or_else(|| anyhow::anyhow!("Cannot determine source directory"))?;
868
869 copy_dir_recursive(src_dir, &dest_dir)?;
870
871 println!(
872 "Copied {}s/{} to {}",
873 ext.kind,
874 ext.name,
875 dest_dir.display()
876 );
877 println!(
878 "Your version will take precedence over the {} registry version.",
879 ext.registry_name
880 );
881
882 Ok(())
883}
884
885pub fn cmd_init_community(config_path: &Path) -> Result<()> {
887 let xdg_path = ctx_dirs::registries_dir().join("community");
888 let legacy_path = ctx_dirs::legacy_registries_dir().join("community");
889 let default_path = if !xdg_path.exists() && legacy_path.exists() {
890 legacy_path
891 } else {
892 xdg_path
893 };
894
895 let already_installed = default_path.exists();
896 if already_installed {
897 println!(
898 "Community registry already installed at {}",
899 default_path.display()
900 );
901 } else {
902 println!("Cloning community extension registry...");
903 clone_registry(COMMUNITY_REGISTRY_URL, Some(DEFAULT_BRANCH), &default_path)?;
904
905 match load_manifest(&default_path) {
907 Ok(m) => {
908 println!(
909 "Installed: {} connectors, {} tools, {} profiles",
910 m.connectors.len(),
911 m.tools.len(),
912 m.profiles.len()
913 );
914 }
915 Err(_) => {
916 println!("Installed (directory scan mode — no registry.toml).");
917 }
918 }
919 };
920
921 let registry_section = format!(
923 "\n[registries.community]\nurl = \"{}\"\nbranch = \"{}\"\npath = \"{}\"\nreadonly = true\nauto_update = true\n",
924 COMMUNITY_REGISTRY_URL,
925 DEFAULT_BRANCH,
926 default_path.display()
927 );
928
929 let mut content = std::fs::read_to_string(config_path)
930 .with_context(|| format!("Failed to read config: {}", config_path.display()))?;
931 if content.contains("[registries.community]") {
932 println!(
933 "[registries.community] already exists in {}",
934 config_path.display()
935 );
936 return Ok(());
937 }
938 content.push_str(®istry_section);
939 std::fs::write(config_path, &content)
940 .with_context(|| format!("Failed to write config: {}", config_path.display()))?;
941
942 println!("Added [registries.community] to {}", config_path.display());
943 println!("Run `ctx registry list` to see available extensions.");
944
945 Ok(())
946}
947
948fn expand_tilde(path: &Path) -> PathBuf {
954 let s = path.to_string_lossy();
955 if s.starts_with("~/") || s == "~" {
956 if let Some(home) = home_dir() {
957 return home.join(s.strip_prefix("~/").unwrap_or(""));
958 }
959 }
960 path.to_path_buf()
961}
962
963fn home_dir() -> Option<PathBuf> {
965 std::env::var_os("HOME").map(PathBuf::from)
966}
967
968fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
970 std::fs::create_dir_all(dst)?;
971
972 for entry in std::fs::read_dir(src)? {
973 let entry = entry?;
974 let src_path = entry.path();
975 let dst_path = dst.join(entry.file_name());
976
977 if src_path.is_dir() {
978 if entry.file_name() == ".git" {
979 continue;
980 }
981 copy_dir_recursive(&src_path, &dst_path)?;
982 } else {
983 std::fs::copy(&src_path, &dst_path)?;
984 }
985 }
986
987 Ok(())
988}
989
990#[cfg(test)]
995mod tests {
996 use super::*;
997 use crate::config::RegistryConfig;
998
999 #[test]
1000 fn parse_manifest() {
1001 let toml = r#"
1002[registry]
1003name = "test"
1004description = "Test registry"
1005min_version = "0.3.0"
1006
1007[connectors.jira]
1008description = "Index Jira issues"
1009path = "connectors/jira/connector.lua"
1010tags = ["atlassian", "pm"]
1011required_config = ["url", "api_token"]
1012host_apis = ["http", "json"]
1013
1014[tools.summarize]
1015description = "Summarize a document"
1016path = "tools/summarize/tool.lua"
1017tags = ["llm"]
1018host_apis = ["http", "context"]
1019
1020[agents.runbook]
1021description = "Incident response agent"
1022path = "agents/runbook/agent.lua"
1023tags = ["ops"]
1024tools = ["search", "get"]
1025"#;
1026
1027 let manifest: RegistryManifest = toml::from_str(toml).unwrap();
1028
1029 assert_eq!(manifest.registry.name, "test");
1030 assert_eq!(manifest.registry.min_version, Some("0.3.0".to_string()));
1031
1032 assert_eq!(manifest.connectors.len(), 1);
1033 let jira = &manifest.connectors["jira"];
1034 assert_eq!(jira.description, "Index Jira issues");
1035 assert_eq!(jira.path, "connectors/jira/connector.lua");
1036 assert_eq!(jira.tags, vec!["atlassian", "pm"]);
1037 assert_eq!(jira.required_config, vec!["url", "api_token"]);
1038
1039 assert_eq!(manifest.tools.len(), 1);
1040 assert_eq!(
1041 manifest.tools["summarize"].description,
1042 "Summarize a document"
1043 );
1044
1045 assert_eq!(manifest.profiles.len(), 1);
1046 assert_eq!(manifest.profiles["runbook"].tools, vec!["search", "get"]);
1047 }
1048
1049 #[test]
1050 fn parse_empty_manifest() {
1051 let toml = "[registry]\nname = \"empty\"\n";
1052 let manifest: RegistryManifest = toml::from_str(toml).unwrap();
1053 assert_eq!(manifest.connectors.len(), 0);
1054 assert_eq!(manifest.tools.len(), 0);
1055 assert_eq!(manifest.profiles.len(), 0);
1056 }
1057
1058 #[test]
1059 fn expand_tilde_works() {
1060 let expanded = expand_tilde(Path::new("~/foo/bar"));
1061 assert!(!expanded.to_string_lossy().starts_with("~"));
1062 assert!(expanded.to_string_lossy().ends_with("foo/bar"));
1063 }
1064
1065 #[test]
1066 fn expand_tilde_noop_for_absolute() {
1067 let path = Path::new("/usr/local/bin");
1068 assert_eq!(expand_tilde(path), path.to_path_buf());
1069 }
1070
1071 #[test]
1072 fn discover_manifest_empty_dir() {
1073 let dir = tempfile::tempdir().unwrap();
1074 let manifest = discover_manifest(dir.path());
1075 assert!(manifest.connectors.is_empty());
1076 assert!(manifest.tools.is_empty());
1077 assert!(manifest.profiles.is_empty());
1078 }
1079
1080 #[test]
1081 fn discover_manifest_finds_scripts() {
1082 let dir = tempfile::tempdir().unwrap();
1083
1084 let jira_dir = dir.path().join("connectors").join("jira");
1086 std::fs::create_dir_all(&jira_dir).unwrap();
1087 std::fs::write(jira_dir.join("connector.lua"), "-- jira").unwrap();
1088
1089 let tool_dir = dir.path().join("tools").join("summarize");
1091 std::fs::create_dir_all(&tool_dir).unwrap();
1092 std::fs::write(tool_dir.join("tool.lua"), "-- summarize").unwrap();
1093
1094 let agent_dir = dir.path().join("agents").join("runbook");
1096 std::fs::create_dir_all(&agent_dir).unwrap();
1097 std::fs::write(agent_dir.join("agent.lua"), "-- runbook").unwrap();
1098
1099 let manifest = discover_manifest(dir.path());
1100 assert_eq!(manifest.connectors.len(), 1);
1101 assert!(manifest.connectors.contains_key("jira"));
1102 assert_eq!(manifest.tools.len(), 1);
1103 assert!(manifest.tools.contains_key("summarize"));
1104 assert_eq!(manifest.profiles.len(), 1);
1105 assert!(manifest.profiles.contains_key("runbook"));
1106 }
1107
1108 #[test]
1109 fn resolution_order_later_wins() {
1110 let dir_a = tempfile::tempdir().unwrap();
1111 let dir_b = tempfile::tempdir().unwrap();
1112
1113 for dir in [dir_a.path(), dir_b.path()] {
1115 let jira_dir = dir.join("connectors").join("jira");
1116 std::fs::create_dir_all(&jira_dir).unwrap();
1117 std::fs::write(jira_dir.join("connector.lua"), "-- jira").unwrap();
1118 }
1119
1120 let config = Config {
1121 registries: {
1122 let mut m = HashMap::new();
1123 m.insert(
1124 "first".to_string(),
1125 RegistryConfig {
1126 url: None,
1127 branch: None,
1128 path: dir_a.path().to_path_buf(),
1129 readonly: true,
1130 auto_update: false,
1131 },
1132 );
1133 m.insert(
1134 "second".to_string(),
1135 RegistryConfig {
1136 url: None,
1137 branch: None,
1138 path: dir_b.path().to_path_buf(),
1139 readonly: false,
1140 auto_update: false,
1141 },
1142 );
1143 m
1144 },
1145 ..Config::minimal()
1146 };
1147
1148 let mgr = RegistryManager::from_config(&config);
1149 let connectors = mgr.list_connectors();
1150 assert_eq!(connectors.len(), 1);
1151 assert_eq!(connectors[0].name, "jira");
1155 }
1156
1157 #[test]
1158 fn find_ctx_dir_from_nested() {
1159 let root = tempfile::tempdir().unwrap();
1160 let ctx_dir = root.path().join(".ctx");
1161 std::fs::create_dir_all(&ctx_dir).unwrap();
1162
1163 let nested = root.path().join("src").join("deep");
1164 std::fs::create_dir_all(&nested).unwrap();
1165
1166 let found = find_ctx_dir_from(nested);
1167 assert!(found.is_some());
1168 assert_eq!(found.unwrap(), ctx_dir);
1169 }
1170
1171 #[test]
1172 fn find_ctx_dir_returns_none_when_missing() {
1173 let root = tempfile::tempdir().unwrap();
1174 let nested = root.path().join("src").join("deep");
1175 std::fs::create_dir_all(&nested).unwrap();
1176
1177 let found = find_ctx_dir_from(nested);
1178 assert!(found.is_none());
1179 }
1180}