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