1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use walkdir::WalkDir;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ExtensionManifest {
9 pub extension: ExtensionMetadata,
11
12 #[serde(default)]
14 pub tools: Vec<ToolDefinition>,
15
16 #[serde(default)]
18 pub events: Vec<EventHandler>,
19
20 #[serde(default)]
22 pub config: HashMap<String, serde_json::Value>,
23
24 #[serde(default)]
26 pub dependencies: HashMap<String, String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ExtensionMetadata {
32 pub id: String,
34
35 pub name: String,
37
38 pub version: String,
40
41 pub description: String,
43
44 #[serde(default)]
46 pub author: Option<String>,
47
48 #[serde(default)]
50 pub main: Option<String>,
51
52 #[serde(default)]
54 pub license: Option<String>,
55
56 #[serde(default)]
58 pub homepage: Option<String>,
59
60 #[serde(default)]
62 pub repository: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ToolDefinition {
68 pub name: String,
70
71 pub description: String,
73
74 #[serde(default)]
76 pub parameters: Vec<ToolParameter>,
77
78 #[serde(default)]
80 pub command: Option<String>,
81
82 #[serde(default)]
84 pub script: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ToolParameter {
90 pub name: String,
92
93 #[serde(rename = "type")]
95 pub param_type: String,
96
97 #[serde(default)]
99 pub description: Option<String>,
100
101 #[serde(default)]
103 pub required: bool,
104
105 #[serde(default)]
107 pub default: Option<serde_json::Value>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct EventHandler {
113 pub event: String,
115
116 #[serde(rename = "type")]
118 pub handler_type: String,
119
120 #[serde(default)]
122 pub command: Option<String>,
123
124 #[serde(default)]
126 pub script: Option<String>,
127
128 #[serde(default)]
130 pub url: Option<String>,
131}
132
133impl ExtensionManifest {
134 pub fn from_file(path: &PathBuf) -> Result<Self, ExtensionError> {
139 let content = std::fs::read_to_string(path)
140 .map_err(|e| ExtensionError::Io(format!("Failed to read {}: {}", path.display(), e)))?;
141
142 Self::from_str(&content, path)
143 }
144
145 pub fn from_str(content: &str, path: &PathBuf) -> Result<Self, ExtensionError> {
149 if let Ok(manifest) = toml::from_str::<ExtensionManifest>(content) {
151 return Ok(manifest);
152 }
153
154 if let Ok(legacy) = toml::from_str::<LegacyAgentToml>(content) {
156 return Ok(legacy.into_extension_manifest(path));
157 }
158
159 Err(ExtensionError::Parse(format!(
161 "Failed to parse {} as extension or legacy agent format",
162 path.display()
163 )))
164 }
165}
166
167#[derive(Debug, Clone, Deserialize)]
185pub struct LegacyAgentToml {
186 pub agent: LegacyAgentSection,
188
189 #[serde(default)]
191 pub model: Option<LegacyModelSection>,
192
193 #[serde(default)]
195 pub prompt: Option<LegacyPromptSection>,
196}
197
198#[derive(Debug, Clone, Deserialize)]
200pub struct LegacyAgentSection {
201 pub name: String,
203
204 pub description: String,
206}
207
208#[derive(Debug, Clone, Deserialize)]
210pub struct LegacyModelSection {
211 #[serde(default)]
213 pub harness: Option<String>,
214
215 #[serde(default)]
217 pub model: Option<String>,
218}
219
220#[derive(Debug, Clone, Deserialize)]
222pub struct LegacyPromptSection {
223 #[serde(default)]
225 pub template: Option<String>,
226}
227
228impl LegacyAgentToml {
229 pub fn into_extension_manifest(self, path: &PathBuf) -> ExtensionManifest {
231 let mut config = HashMap::new();
232
233 if let Some(model) = &self.model {
235 if let Some(harness) = &model.harness {
236 config.insert(
237 "harness".to_string(),
238 serde_json::Value::String(harness.clone()),
239 );
240 }
241 if let Some(model_name) = &model.model {
242 config.insert(
243 "model".to_string(),
244 serde_json::Value::String(model_name.clone()),
245 );
246 }
247 }
248
249 if let Some(prompt) = &self.prompt {
251 if let Some(template) = &prompt.template {
252 config.insert(
253 "prompt_template".to_string(),
254 serde_json::Value::String(template.clone()),
255 );
256 }
257 }
258
259 config.insert("_legacy_format".to_string(), serde_json::Value::Bool(true));
261
262 let filename = path
263 .file_stem()
264 .and_then(|s| s.to_str())
265 .unwrap_or("unknown");
266
267 ExtensionManifest {
268 extension: ExtensionMetadata {
269 id: format!("legacy.agent.{}", self.agent.name),
270 name: self.agent.name,
271 version: "1.0.0".to_string(),
272 description: self.agent.description,
273 author: None,
274 main: Some(format!("{}.toml", filename)),
275 license: None,
276 homepage: None,
277 repository: None,
278 },
279 tools: Vec::new(),
280 events: Vec::new(),
281 config,
282 dependencies: HashMap::new(),
283 }
284 }
285}
286
287#[derive(Debug, thiserror::Error)]
289pub enum ExtensionError {
290 #[error("IO error: {0}")]
291 Io(String),
292
293 #[error("Parse error: {0}")]
294 Parse(String),
295
296 #[error("Validation error: {0}")]
297 Validation(String),
298
299 #[error("Extension not found: {0}")]
300 NotFound(String),
301
302 #[error("Duplicate extension ID: {0}")]
303 DuplicateId(String),
304
305 #[error("Discovery error in {path}: {message}")]
306 Discovery { path: String, message: String },
307}
308
309impl ExtensionManifest {
314 pub fn validate(&self) -> Result<(), ExtensionError> {
318 let mut errors = Vec::new();
319
320 if self.extension.id.is_empty() {
322 errors.push("extension.id cannot be empty".to_string());
323 }
324 if self.extension.name.is_empty() {
325 errors.push("extension.name cannot be empty".to_string());
326 }
327 if self.extension.version.is_empty() {
328 errors.push("extension.version cannot be empty".to_string());
329 }
330 if self.extension.description.is_empty() {
331 errors.push("extension.description cannot be empty".to_string());
332 }
333
334 if !self
336 .extension
337 .id
338 .chars()
339 .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
340 {
341 errors.push(format!(
342 "extension.id '{}' contains invalid characters (allowed: alphanumeric, '.', '-', '_')",
343 self.extension.id
344 ));
345 }
346
347 if !self
349 .extension
350 .version
351 .chars()
352 .all(|c| c.is_ascii_digit() || c == '.')
353 {
354 if !is_valid_semver(&self.extension.version) {
356 errors.push(format!(
357 "extension.version '{}' is not a valid semantic version",
358 self.extension.version
359 ));
360 }
361 }
362
363 for (i, tool) in self.tools.iter().enumerate() {
365 if tool.name.is_empty() {
366 errors.push(format!("tools[{}].name cannot be empty", i));
367 }
368 if tool.description.is_empty() {
369 errors.push(format!("tools[{}].description cannot be empty", i));
370 }
371
372 if tool.command.is_none() && tool.script.is_none() {
374 }
376
377 for (j, param) in tool.parameters.iter().enumerate() {
379 if param.name.is_empty() {
380 errors.push(format!(
381 "tools[{}].parameters[{}].name cannot be empty",
382 i, j
383 ));
384 }
385 if !is_valid_param_type(¶m.param_type) {
386 errors.push(format!(
387 "tools[{}].parameters[{}].type '{}' is not valid (expected: string, number, boolean, array, object)",
388 i, j, param.param_type
389 ));
390 }
391 }
392 }
393
394 for (i, event) in self.events.iter().enumerate() {
396 if event.event.is_empty() {
397 errors.push(format!("events[{}].event cannot be empty", i));
398 }
399
400 match event.handler_type.as_str() {
402 "command" => {
403 if event.command.is_none() {
404 errors.push(format!(
405 "events[{}] has type 'command' but no command specified",
406 i
407 ));
408 }
409 }
410 "script" => {
411 if event.script.is_none() {
412 errors.push(format!(
413 "events[{}] has type 'script' but no script specified",
414 i
415 ));
416 }
417 }
418 "webhook" => {
419 if event.url.is_none() {
420 errors.push(format!(
421 "events[{}] has type 'webhook' but no url specified",
422 i
423 ));
424 }
425 }
426 other => {
427 errors.push(format!(
428 "events[{}].type '{}' is not valid (expected: command, script, webhook)",
429 i, other
430 ));
431 }
432 }
433 }
434
435 if errors.is_empty() {
436 Ok(())
437 } else {
438 Err(ExtensionError::Validation(errors.join("; ")))
439 }
440 }
441}
442
443fn is_valid_semver(version: &str) -> bool {
445 let parts: Vec<&str> = version.split('-').collect();
447 let version_part = parts[0];
448
449 let nums: Vec<&str> = version_part.split('.').collect();
450 if nums.len() < 2 || nums.len() > 3 {
451 return false;
452 }
453
454 nums.iter().all(|n| n.parse::<u32>().is_ok())
455}
456
457fn is_valid_param_type(param_type: &str) -> bool {
459 matches!(
460 param_type,
461 "string" | "number" | "boolean" | "array" | "object" | "integer"
462 )
463}
464
465#[derive(Debug, Clone, Default)]
471pub struct DiscoveryOptions {
472 pub max_depth: Option<usize>,
474
475 pub include_legacy: bool,
477
478 pub follow_symlinks: bool,
480
481 pub validate: bool,
483
484 pub skip_errors: bool,
486}
487
488impl DiscoveryOptions {
489 pub fn standard() -> Self {
491 Self {
492 max_depth: Some(10),
493 include_legacy: true,
494 follow_symlinks: false,
495 validate: true,
496 skip_errors: false,
497 }
498 }
499
500 pub fn lenient() -> Self {
502 Self {
503 max_depth: Some(10),
504 include_legacy: true,
505 follow_symlinks: false,
506 validate: false,
507 skip_errors: true,
508 }
509 }
510}
511
512#[derive(Debug, Clone)]
514pub struct DiscoveredExtension {
515 pub manifest: ExtensionManifest,
517
518 pub path: PathBuf,
520
521 pub directory: PathBuf,
523
524 pub is_legacy: bool,
526}
527
528#[derive(Debug, Default)]
530pub struct DiscoveryResult {
531 pub extensions: Vec<DiscoveredExtension>,
533
534 pub errors: Vec<ExtensionError>,
536
537 pub skipped: Vec<PathBuf>,
539}
540
541impl DiscoveryResult {
542 pub fn is_empty(&self) -> bool {
544 self.extensions.is_empty()
545 }
546
547 pub fn count(&self) -> usize {
549 self.extensions.len()
550 }
551
552 pub fn has_errors(&self) -> bool {
554 !self.errors.is_empty()
555 }
556}
557
558pub fn discover(root: &Path, options: DiscoveryOptions) -> Result<DiscoveryResult, ExtensionError> {
583 let mut result = DiscoveryResult::default();
584
585 if !root.exists() {
587 return Err(ExtensionError::Io(format!(
588 "Discovery root does not exist: {}",
589 root.display()
590 )));
591 }
592
593 if !root.is_dir() {
594 return Err(ExtensionError::Io(format!(
595 "Discovery root is not a directory: {}",
596 root.display()
597 )));
598 }
599
600 let mut walker = WalkDir::new(root).follow_links(options.follow_symlinks);
602
603 if let Some(max_depth) = options.max_depth {
604 walker = walker.max_depth(max_depth);
605 }
606
607 let mut seen_ids: HashMap<String, PathBuf> = HashMap::new();
609
610 for entry in walker.into_iter() {
611 let entry = match entry {
612 Ok(e) => e,
613 Err(e) => {
614 let err = ExtensionError::Discovery {
615 path: e
616 .path()
617 .map(|p| p.display().to_string())
618 .unwrap_or_default(),
619 message: e.to_string(),
620 };
621 if options.skip_errors {
622 result.errors.push(err);
623 continue;
624 } else {
625 return Err(err);
626 }
627 }
628 };
629
630 let path = entry.path();
631
632 if !path.is_file() {
634 continue;
635 }
636
637 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
639 let is_extension_toml = file_name == "extension.toml";
640 let is_legacy_agent = options.include_legacy
641 && file_name.ends_with(".toml")
642 && path
643 .parent()
644 .and_then(|p| p.file_name())
645 .and_then(|n| n.to_str())
646 == Some("spawn-agents");
647
648 if !is_extension_toml && !is_legacy_agent {
649 continue;
650 }
651
652 let manifest = match ExtensionManifest::from_file(&path.to_path_buf()) {
654 Ok(m) => m,
655 Err(e) => {
656 if options.skip_errors {
657 result.errors.push(e);
658 result.skipped.push(path.to_path_buf());
659 continue;
660 } else {
661 return Err(e);
662 }
663 }
664 };
665
666 if options.validate {
668 if let Err(e) = manifest.validate() {
669 if options.skip_errors {
670 result.errors.push(e);
671 result.skipped.push(path.to_path_buf());
672 continue;
673 } else {
674 return Err(e);
675 }
676 }
677 }
678
679 let ext_id = &manifest.extension.id;
681 if let Some(existing_path) = seen_ids.get(ext_id) {
682 let err = ExtensionError::DuplicateId(format!(
683 "'{}' defined in both {} and {}",
684 ext_id,
685 existing_path.display(),
686 path.display()
687 ));
688 if options.skip_errors {
689 result.errors.push(err);
690 result.skipped.push(path.to_path_buf());
691 continue;
692 } else {
693 return Err(err);
694 }
695 }
696 seen_ids.insert(ext_id.clone(), path.to_path_buf());
697
698 let directory = path.parent().unwrap_or(path).to_path_buf();
700 result.extensions.push(DiscoveredExtension {
701 manifest,
702 path: path.to_path_buf(),
703 directory,
704 is_legacy: is_legacy_agent,
705 });
706 }
707
708 Ok(result)
709}
710
711pub fn discover_all(
715 roots: &[&Path],
716 options: DiscoveryOptions,
717) -> Result<DiscoveryResult, ExtensionError> {
718 let mut combined = DiscoveryResult::default();
719 let mut seen_ids: HashMap<String, PathBuf> = HashMap::new();
720
721 for root in roots {
722 if !root.exists() {
723 continue; }
725
726 let result = discover(root, options.clone())?;
727
728 for ext in result.extensions {
729 let ext_id = &ext.manifest.extension.id;
730 if let Some(existing_path) = seen_ids.get(ext_id) {
731 let err = ExtensionError::DuplicateId(format!(
732 "'{}' defined in both {} and {}",
733 ext_id,
734 existing_path.display(),
735 ext.path.display()
736 ));
737 if options.skip_errors {
738 combined.errors.push(err);
739 combined.skipped.push(ext.path.clone());
740 continue;
741 } else {
742 return Err(err);
743 }
744 }
745 seen_ids.insert(ext_id.clone(), ext.path.clone());
746 combined.extensions.push(ext);
747 }
748
749 combined.errors.extend(result.errors);
750 combined.skipped.extend(result.skipped);
751 }
752
753 Ok(combined)
754}
755
756#[derive(Debug, Default)]
762pub struct ExtensionRegistry {
763 extensions: HashMap<String, DiscoveredExtension>,
765}
766
767impl ExtensionRegistry {
768 pub fn new() -> Self {
770 Self::default()
771 }
772
773 pub fn load_from_discovery(&mut self, result: DiscoveryResult) {
775 for ext in result.extensions {
776 self.extensions
777 .insert(ext.manifest.extension.id.clone(), ext);
778 }
779 }
780
781 pub fn discover_and_load(
783 &mut self,
784 root: &Path,
785 options: DiscoveryOptions,
786 ) -> Result<&mut Self, ExtensionError> {
787 let result = discover(root, options)?;
788 self.load_from_discovery(result);
789 Ok(self)
790 }
791
792 pub fn get(&self, id: &str) -> Option<&DiscoveredExtension> {
794 self.extensions.get(id)
795 }
796
797 pub fn get_or_error(&self, id: &str) -> Result<&DiscoveredExtension, ExtensionError> {
799 self.extensions
800 .get(id)
801 .ok_or_else(|| ExtensionError::NotFound(id.to_string()))
802 }
803
804 pub fn has(&self, id: &str) -> bool {
806 self.extensions.contains_key(id)
807 }
808
809 pub fn list_ids(&self) -> Vec<&str> {
811 self.extensions.keys().map(|s| s.as_str()).collect()
812 }
813
814 pub fn list(&self) -> Vec<&DiscoveredExtension> {
816 self.extensions.values().collect()
817 }
818
819 pub fn count(&self) -> usize {
821 self.extensions.len()
822 }
823
824 pub fn is_empty(&self) -> bool {
826 self.extensions.is_empty()
827 }
828
829 pub fn remove(&mut self, id: &str) -> Option<DiscoveredExtension> {
831 self.extensions.remove(id)
832 }
833
834 pub fn clear(&mut self) {
836 self.extensions.clear();
837 }
838
839 pub fn filter<F>(&self, predicate: F) -> Vec<&DiscoveredExtension>
841 where
842 F: Fn(&DiscoveredExtension) -> bool,
843 {
844 self.extensions.values().filter(|e| predicate(e)).collect()
845 }
846
847 pub fn legacy_extensions(&self) -> Vec<&DiscoveredExtension> {
849 self.filter(|e| e.is_legacy)
850 }
851
852 pub fn modern_extensions(&self) -> Vec<&DiscoveredExtension> {
854 self.filter(|e| !e.is_legacy)
855 }
856}
857
858#[cfg(test)]
859mod tests {
860 use super::*;
861 use std::io::Write;
862 use tempfile::NamedTempFile;
863
864 #[test]
865 fn test_parse_extension_manifest() {
866 let content = r#"
867[extension]
868id = "test-extension"
869name = "Test Extension"
870version = "1.0.0"
871description = "A test extension"
872
873[[tools]]
874name = "test_tool"
875description = "A test tool"
876
877[[tools.parameters]]
878name = "input"
879type = "string"
880required = true
881
882[[events]]
883event = "task.start"
884type = "command"
885command = "echo 'Task started'"
886"#;
887
888 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
889 assert_eq!(manifest.extension.id, "test-extension");
890 assert_eq!(manifest.extension.name, "Test Extension");
891 assert_eq!(manifest.tools.len(), 1);
892 assert_eq!(manifest.tools[0].name, "test_tool");
893 assert_eq!(manifest.tools[0].parameters.len(), 1);
894 assert_eq!(manifest.events.len(), 1);
895 assert_eq!(manifest.events[0].event, "task.start");
896 }
897
898 #[test]
899 fn test_parse_legacy_agent_toml() {
900 let content = r#"
901[agent]
902name = "builder"
903description = "Fast code implementation agent"
904
905[model]
906harness = "claude"
907model = "opus"
908
909[prompt]
910template = "You are a code builder."
911"#;
912
913 let manifest =
914 ExtensionManifest::from_str(content, &PathBuf::from("builder.toml")).unwrap();
915 assert_eq!(manifest.extension.id, "legacy.agent.builder");
916 assert_eq!(manifest.extension.name, "builder");
917 assert_eq!(
918 manifest.extension.description,
919 "Fast code implementation agent"
920 );
921
922 assert_eq!(
924 manifest.config.get("harness"),
925 Some(&serde_json::Value::String("claude".to_string()))
926 );
927 assert_eq!(
928 manifest.config.get("model"),
929 Some(&serde_json::Value::String("opus".to_string()))
930 );
931 assert!(manifest.config.get("prompt_template").is_some());
932 assert_eq!(
933 manifest.config.get("_legacy_format"),
934 Some(&serde_json::Value::Bool(true))
935 );
936 }
937
938 #[test]
939 fn test_legacy_minimal() {
940 let content = r#"
941[agent]
942name = "minimal"
943description = "Minimal agent"
944"#;
945
946 let manifest =
947 ExtensionManifest::from_str(content, &PathBuf::from("minimal.toml")).unwrap();
948 assert_eq!(manifest.extension.name, "minimal");
949 assert!(manifest.tools.is_empty());
950 assert!(manifest.events.is_empty());
951 }
952
953 #[test]
958 fn test_parse_invalid_toml_syntax() {
959 let content = r#"
960[extension
961id = "broken"
962"#;
963 let result = ExtensionManifest::from_str(content, &PathBuf::from("broken.toml"));
964 assert!(result.is_err());
965 let err = result.unwrap_err();
966 assert!(matches!(err, ExtensionError::Parse(_)));
967 assert!(err.to_string().contains("Failed to parse"));
968 }
969
970 #[test]
971 fn test_parse_missing_required_extension_fields() {
972 let content = r#"
974[extension]
975id = "test"
976name = "Test"
977version = "1.0.0"
978"#;
979 let result = ExtensionManifest::from_str(content, &PathBuf::from("test.toml"));
980 assert!(result.is_err());
981 }
982
983 #[test]
984 fn test_parse_neither_format_matches() {
985 let content = r#"
987[random]
988key = "value"
989"#;
990 let result = ExtensionManifest::from_str(content, &PathBuf::from("random.toml"));
991 assert!(result.is_err());
992 let err = result.unwrap_err();
993 assert!(matches!(err, ExtensionError::Parse(_)));
994 }
995
996 #[test]
997 fn test_parse_empty_content() {
998 let content = "";
999 let result = ExtensionManifest::from_str(content, &PathBuf::from("empty.toml"));
1000 assert!(result.is_err());
1001 }
1002
1003 #[test]
1004 fn test_parse_whitespace_only_content() {
1005 let content = " \n\t\n ";
1006 let result = ExtensionManifest::from_str(content, &PathBuf::from("whitespace.toml"));
1007 assert!(result.is_err());
1008 }
1009
1010 #[test]
1015 fn test_from_file_missing_file() {
1016 let path = PathBuf::from("/nonexistent/path/to/extension.toml");
1017 let result = ExtensionManifest::from_file(&path);
1018 assert!(result.is_err());
1019 let err = result.unwrap_err();
1020 assert!(matches!(err, ExtensionError::Io(_)));
1021 assert!(err.to_string().contains("Failed to read"));
1022 }
1023
1024 #[test]
1025 fn test_from_file_valid_extension() {
1026 let mut temp = NamedTempFile::new().unwrap();
1027 let content = r#"
1028[extension]
1029id = "file-test"
1030name = "File Test"
1031version = "1.0.0"
1032description = "Test loading from file"
1033"#;
1034 temp.write_all(content.as_bytes()).unwrap();
1035
1036 let manifest = ExtensionManifest::from_file(&temp.path().to_path_buf()).unwrap();
1037 assert_eq!(manifest.extension.id, "file-test");
1038 assert_eq!(manifest.extension.name, "File Test");
1039 }
1040
1041 #[test]
1042 fn test_from_file_valid_legacy() {
1043 let mut temp = NamedTempFile::new().unwrap();
1044 let content = r#"
1045[agent]
1046name = "file-legacy"
1047description = "Legacy from file"
1048"#;
1049 temp.write_all(content.as_bytes()).unwrap();
1050
1051 let manifest = ExtensionManifest::from_file(&temp.path().to_path_buf()).unwrap();
1052 assert_eq!(manifest.extension.name, "file-legacy");
1053 assert!(manifest.config.get("_legacy_format").is_some());
1054 }
1055
1056 #[test]
1057 fn test_from_file_invalid_content() {
1058 let mut temp = NamedTempFile::new().unwrap();
1059 let content = "not valid toml [[[";
1060 temp.write_all(content.as_bytes()).unwrap();
1061
1062 let result = ExtensionManifest::from_file(&temp.path().to_path_buf());
1063 assert!(result.is_err());
1064 }
1065
1066 #[test]
1071 fn test_extension_with_all_optional_fields() {
1072 let content = r#"
1073[extension]
1074id = "full-extension"
1075name = "Full Extension"
1076version = "2.0.0"
1077description = "Extension with all fields"
1078author = "Test Author"
1079main = "main.py"
1080license = "MIT"
1081homepage = "https://example.com"
1082repository = "https://github.com/test/repo"
1083
1084[config]
1085api_key = "secret"
1086timeout = 30
1087debug = true
1088
1089[dependencies]
1090"other-ext" = ">=1.0.0"
1091"another-ext" = "^2.0"
1092"#;
1093
1094 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("full.toml")).unwrap();
1095 assert_eq!(manifest.extension.author, Some("Test Author".to_string()));
1096 assert_eq!(manifest.extension.main, Some("main.py".to_string()));
1097 assert_eq!(manifest.extension.license, Some("MIT".to_string()));
1098 assert_eq!(
1099 manifest.extension.homepage,
1100 Some("https://example.com".to_string())
1101 );
1102 assert_eq!(
1103 manifest.extension.repository,
1104 Some("https://github.com/test/repo".to_string())
1105 );
1106 assert_eq!(manifest.config.len(), 3);
1107 assert_eq!(manifest.dependencies.len(), 2);
1108 }
1109
1110 #[test]
1111 fn test_extension_minimal() {
1112 let content = r#"
1113[extension]
1114id = "min"
1115name = "Minimal"
1116version = "0.1.0"
1117description = "Bare minimum"
1118"#;
1119
1120 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("min.toml")).unwrap();
1121 assert_eq!(manifest.extension.id, "min");
1122 assert!(manifest.extension.author.is_none());
1123 assert!(manifest.extension.main.is_none());
1124 assert!(manifest.tools.is_empty());
1125 assert!(manifest.events.is_empty());
1126 assert!(manifest.config.is_empty());
1127 assert!(manifest.dependencies.is_empty());
1128 }
1129
1130 #[test]
1131 fn test_extension_with_multiple_tools() {
1132 let content = r#"
1133[extension]
1134id = "multi-tool"
1135name = "Multi Tool"
1136version = "1.0.0"
1137description = "Multiple tools"
1138
1139[[tools]]
1140name = "tool1"
1141description = "First tool"
1142command = "echo 1"
1143
1144[[tools]]
1145name = "tool2"
1146description = "Second tool"
1147script = "scripts/tool2.py"
1148
1149[[tools]]
1150name = "tool3"
1151description = "Third tool"
1152
1153[[tools.parameters]]
1154name = "param1"
1155type = "string"
1156required = true
1157
1158[[tools.parameters]]
1159name = "param2"
1160type = "number"
1161required = false
1162default = 42
1163"#;
1164
1165 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("multi.toml")).unwrap();
1166 assert_eq!(manifest.tools.len(), 3);
1167 assert_eq!(manifest.tools[0].command, Some("echo 1".to_string()));
1168 assert_eq!(
1169 manifest.tools[1].script,
1170 Some("scripts/tool2.py".to_string())
1171 );
1172 assert_eq!(manifest.tools[2].parameters.len(), 2);
1173 assert_eq!(
1174 manifest.tools[2].parameters[1].default,
1175 Some(serde_json::json!(42))
1176 );
1177 }
1178
1179 #[test]
1180 fn test_extension_with_multiple_events() {
1181 let content = r#"
1182[extension]
1183id = "multi-event"
1184name = "Multi Event"
1185version = "1.0.0"
1186description = "Multiple events"
1187
1188[[events]]
1189event = "task.start"
1190type = "command"
1191command = "echo start"
1192
1193[[events]]
1194event = "task.complete"
1195type = "script"
1196script = "hooks/complete.sh"
1197
1198[[events]]
1199event = "session.end"
1200type = "webhook"
1201url = "https://hooks.example.com/notify"
1202"#;
1203
1204 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("events.toml")).unwrap();
1205 assert_eq!(manifest.events.len(), 3);
1206 assert_eq!(manifest.events[0].handler_type, "command");
1207 assert_eq!(manifest.events[1].handler_type, "script");
1208 assert_eq!(manifest.events[2].handler_type, "webhook");
1209 assert_eq!(
1210 manifest.events[2].url,
1211 Some("https://hooks.example.com/notify".to_string())
1212 );
1213 }
1214
1215 #[test]
1216 fn test_tool_parameter_types() {
1217 let content = r#"
1218[extension]
1219id = "param-types"
1220name = "Parameter Types"
1221version = "1.0.0"
1222description = "Test parameter types"
1223
1224[[tools]]
1225name = "typed_tool"
1226description = "Tool with various param types"
1227
1228[[tools.parameters]]
1229name = "str_param"
1230type = "string"
1231description = "A string parameter"
1232required = true
1233
1234[[tools.parameters]]
1235name = "num_param"
1236type = "number"
1237required = false
1238default = 0
1239
1240[[tools.parameters]]
1241name = "bool_param"
1242type = "boolean"
1243required = false
1244default = false
1245
1246[[tools.parameters]]
1247name = "arr_param"
1248type = "array"
1249
1250[[tools.parameters]]
1251name = "obj_param"
1252type = "object"
1253"#;
1254
1255 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("params.toml")).unwrap();
1256 let params = &manifest.tools[0].parameters;
1257 assert_eq!(params.len(), 5);
1258 assert_eq!(params[0].param_type, "string");
1259 assert!(params[0].required);
1260 assert_eq!(params[1].param_type, "number");
1261 assert!(!params[1].required);
1262 assert_eq!(params[2].param_type, "boolean");
1263 assert_eq!(params[3].param_type, "array");
1264 assert_eq!(params[4].param_type, "object");
1265 }
1266
1267 #[test]
1272 fn test_legacy_with_model_only() {
1273 let content = r#"
1274[agent]
1275name = "model-only"
1276description = "Agent with model section only"
1277
1278[model]
1279harness = "openai"
1280model = "gpt-4"
1281"#;
1282
1283 let manifest =
1284 ExtensionManifest::from_str(content, &PathBuf::from("model-only.toml")).unwrap();
1285 assert_eq!(manifest.extension.name, "model-only");
1286 assert_eq!(
1287 manifest.config.get("harness"),
1288 Some(&serde_json::Value::String("openai".to_string()))
1289 );
1290 assert!(manifest.config.get("prompt_template").is_none());
1291 }
1292
1293 #[test]
1294 fn test_legacy_with_prompt_only() {
1295 let content = r#"
1296[agent]
1297name = "prompt-only"
1298description = "Agent with prompt section only"
1299
1300[prompt]
1301template = "You are a helpful assistant."
1302"#;
1303
1304 let manifest =
1305 ExtensionManifest::from_str(content, &PathBuf::from("prompt-only.toml")).unwrap();
1306 assert_eq!(manifest.extension.name, "prompt-only");
1307 assert_eq!(
1308 manifest.config.get("prompt_template"),
1309 Some(&serde_json::Value::String(
1310 "You are a helpful assistant.".to_string()
1311 ))
1312 );
1313 assert!(manifest.config.get("harness").is_none());
1314 }
1315
1316 #[test]
1317 fn test_legacy_partial_model_section() {
1318 let content = r#"
1320[agent]
1321name = "partial-model"
1322description = "Agent with partial model"
1323
1324[model]
1325harness = "anthropic"
1326"#;
1327
1328 let manifest =
1329 ExtensionManifest::from_str(content, &PathBuf::from("partial.toml")).unwrap();
1330 assert_eq!(
1331 manifest.config.get("harness"),
1332 Some(&serde_json::Value::String("anthropic".to_string()))
1333 );
1334 assert!(manifest.config.get("model").is_none());
1335 }
1336
1337 #[test]
1338 fn test_legacy_into_manifest_sets_main_from_filename() {
1339 let content = r#"
1340[agent]
1341name = "test-agent"
1342description = "Test"
1343"#;
1344
1345 let manifest1 =
1347 ExtensionManifest::from_str(content, &PathBuf::from("custom-agent.toml")).unwrap();
1348 assert_eq!(
1349 manifest1.extension.main,
1350 Some("custom-agent.toml".to_string())
1351 );
1352
1353 let manifest2 =
1354 ExtensionManifest::from_str(content, &PathBuf::from("/path/to/special.toml")).unwrap();
1355 assert_eq!(manifest2.extension.main, Some("special.toml".to_string()));
1356 }
1357
1358 #[test]
1359 fn test_legacy_id_format() {
1360 let content = r#"
1361[agent]
1362name = "my-special-agent"
1363description = "Test"
1364"#;
1365
1366 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1367 assert_eq!(manifest.extension.id, "legacy.agent.my-special-agent");
1368 assert_eq!(manifest.extension.version, "1.0.0");
1369 }
1370
1371 #[test]
1376 fn test_unicode_in_extension_fields() {
1377 let content = r#"
1378[extension]
1379id = "unicode-ext"
1380name = "拡張機能テスト"
1381version = "1.0.0"
1382description = "Extension with émojis 🚀 and ünïcödé"
1383author = "日本語の著者"
1384"#;
1385
1386 let manifest =
1387 ExtensionManifest::from_str(content, &PathBuf::from("unicode.toml")).unwrap();
1388 assert_eq!(manifest.extension.name, "拡張機能テスト");
1389 assert!(manifest.extension.description.contains("🚀"));
1390 assert_eq!(manifest.extension.author, Some("日本語の著者".to_string()));
1391 }
1392
1393 #[test]
1394 fn test_multiline_strings() {
1395 let content = r#"
1396[extension]
1397id = "multiline"
1398name = "Multiline Test"
1399version = "1.0.0"
1400description = """
1401This is a multiline
1402description that spans
1403multiple lines.
1404"""
1405"#;
1406
1407 let manifest =
1408 ExtensionManifest::from_str(content, &PathBuf::from("multiline.toml")).unwrap();
1409 assert!(manifest.extension.description.contains("multiline"));
1410 assert!(manifest.extension.description.contains("\n"));
1411 }
1412
1413 #[test]
1414 fn test_legacy_unicode() {
1415 let content = r#"
1416[agent]
1417name = "unicode-agent"
1418description = "エージェント説明 with special chars: <>&\""
1419
1420[prompt]
1421template = "你好,我是助手。"
1422"#;
1423
1424 let manifest =
1425 ExtensionManifest::from_str(content, &PathBuf::from("unicode.toml")).unwrap();
1426 assert!(manifest.extension.description.contains("エージェント説明"));
1427 assert_eq!(
1428 manifest.config.get("prompt_template"),
1429 Some(&serde_json::Value::String("你好,我是助手。".to_string()))
1430 );
1431 }
1432
1433 #[test]
1438 fn test_extension_error_display() {
1439 let io_err = ExtensionError::Io("file not found".to_string());
1440 assert_eq!(io_err.to_string(), "IO error: file not found");
1441
1442 let parse_err = ExtensionError::Parse("invalid toml".to_string());
1443 assert_eq!(parse_err.to_string(), "Parse error: invalid toml");
1444
1445 let validation_err = ExtensionError::Validation("missing field".to_string());
1446 assert_eq!(
1447 validation_err.to_string(),
1448 "Validation error: missing field"
1449 );
1450
1451 let not_found_err = ExtensionError::NotFound("my-extension".to_string());
1452 assert_eq!(
1453 not_found_err.to_string(),
1454 "Extension not found: my-extension"
1455 );
1456 }
1457
1458 #[test]
1463 fn test_config_various_json_types() {
1464 let content = r#"
1465[extension]
1466id = "config-types"
1467name = "Config Types"
1468version = "1.0.0"
1469description = "Test various config types"
1470
1471[config]
1472string_val = "hello"
1473int_val = 42
1474float_val = 3.14
1475bool_val = true
1476array_val = [1, 2, 3]
1477nested = { key = "value", num = 10 }
1478"#;
1479
1480 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("config.toml")).unwrap();
1481
1482 assert_eq!(
1483 manifest.config.get("string_val"),
1484 Some(&serde_json::json!("hello"))
1485 );
1486 assert_eq!(manifest.config.get("int_val"), Some(&serde_json::json!(42)));
1487 assert_eq!(
1488 manifest.config.get("float_val"),
1489 Some(&serde_json::json!(3.14))
1490 );
1491 assert_eq!(
1492 manifest.config.get("bool_val"),
1493 Some(&serde_json::json!(true))
1494 );
1495 assert_eq!(
1496 manifest.config.get("array_val"),
1497 Some(&serde_json::json!([1, 2, 3]))
1498 );
1499
1500 let nested = manifest.config.get("nested").unwrap();
1501 assert_eq!(nested.get("key"), Some(&serde_json::json!("value")));
1502 assert_eq!(nested.get("num"), Some(&serde_json::json!(10)));
1503 }
1504
1505 #[test]
1510 fn test_legacy_path_without_extension() {
1511 let content = r#"
1512[agent]
1513name = "no-ext"
1514description = "Test"
1515"#;
1516
1517 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("agentfile")).unwrap();
1518 assert_eq!(manifest.extension.main, Some("agentfile.toml".to_string()));
1519 }
1520
1521 #[test]
1522 fn test_legacy_path_empty_filename() {
1523 let content = r#"
1524[agent]
1525name = "empty-path"
1526description = "Test"
1527"#;
1528
1529 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("")).unwrap();
1531 assert_eq!(manifest.extension.main, Some("unknown.toml".to_string()));
1532 }
1533
1534 #[test]
1539 fn test_validate_valid_manifest() {
1540 let content = r#"
1541[extension]
1542id = "valid-ext"
1543name = "Valid Extension"
1544version = "1.0.0"
1545description = "A valid extension"
1546"#;
1547 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1548 assert!(manifest.validate().is_ok());
1549 }
1550
1551 #[test]
1552 fn test_validate_empty_id() {
1553 let content = r#"
1554[extension]
1555id = ""
1556name = "Test"
1557version = "1.0.0"
1558description = "Test"
1559"#;
1560 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1561 let err = manifest.validate().unwrap_err();
1562 assert!(err.to_string().contains("id cannot be empty"));
1563 }
1564
1565 #[test]
1566 fn test_validate_invalid_id_chars() {
1567 let content = r#"
1568[extension]
1569id = "invalid@id"
1570name = "Test"
1571version = "1.0.0"
1572description = "Test"
1573"#;
1574 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1575 let err = manifest.validate().unwrap_err();
1576 assert!(err.to_string().contains("invalid characters"));
1577 }
1578
1579 #[test]
1580 fn test_validate_valid_id_formats() {
1581 let valid_ids = [
1582 "my-extension",
1583 "my_extension",
1584 "my.extension",
1585 "ext123",
1586 "a.b.c-d_e",
1587 ];
1588 for id in valid_ids {
1589 let content = format!(
1590 r#"
1591[extension]
1592id = "{}"
1593name = "Test"
1594version = "1.0.0"
1595description = "Test"
1596"#,
1597 id
1598 );
1599 let manifest =
1600 ExtensionManifest::from_str(&content, &PathBuf::from("test.toml")).unwrap();
1601 assert!(manifest.validate().is_ok(), "ID '{}' should be valid", id);
1602 }
1603 }
1604
1605 #[test]
1606 fn test_validate_invalid_version() {
1607 let content = r#"
1608[extension]
1609id = "test"
1610name = "Test"
1611version = "not-a-version"
1612description = "Test"
1613"#;
1614 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1615 let err = manifest.validate().unwrap_err();
1616 assert!(err.to_string().contains("not a valid semantic version"));
1617 }
1618
1619 #[test]
1620 fn test_validate_valid_versions() {
1621 let valid_versions = [
1622 "1.0.0",
1623 "0.1.0",
1624 "10.20.30",
1625 "1.0",
1626 "1.0.0-beta",
1627 "2.0.0-rc.1",
1628 ];
1629 for version in valid_versions {
1630 let content = format!(
1631 r#"
1632[extension]
1633id = "test"
1634name = "Test"
1635version = "{}"
1636description = "Test"
1637"#,
1638 version
1639 );
1640 let manifest =
1641 ExtensionManifest::from_str(&content, &PathBuf::from("test.toml")).unwrap();
1642 assert!(
1643 manifest.validate().is_ok(),
1644 "Version '{}' should be valid",
1645 version
1646 );
1647 }
1648 }
1649
1650 #[test]
1651 fn test_validate_tool_empty_name() {
1652 let content = r#"
1653[extension]
1654id = "test"
1655name = "Test"
1656version = "1.0.0"
1657description = "Test"
1658
1659[[tools]]
1660name = ""
1661description = "A tool"
1662"#;
1663 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1664 let err = manifest.validate().unwrap_err();
1665 assert!(err.to_string().contains("tools[0].name cannot be empty"));
1666 }
1667
1668 #[test]
1669 fn test_validate_tool_invalid_param_type() {
1670 let content = r#"
1671[extension]
1672id = "test"
1673name = "Test"
1674version = "1.0.0"
1675description = "Test"
1676
1677[[tools]]
1678name = "my_tool"
1679description = "A tool"
1680
1681[[tools.parameters]]
1682name = "param"
1683type = "invalid_type"
1684"#;
1685 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1686 let err = manifest.validate().unwrap_err();
1687 assert!(err.to_string().contains("is not valid"));
1688 }
1689
1690 #[test]
1691 fn test_validate_event_invalid_type() {
1692 let content = r#"
1693[extension]
1694id = "test"
1695name = "Test"
1696version = "1.0.0"
1697description = "Test"
1698
1699[[events]]
1700event = "task.start"
1701type = "invalid"
1702"#;
1703 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1704 let err = manifest.validate().unwrap_err();
1705 assert!(err.to_string().contains("is not valid"));
1706 }
1707
1708 #[test]
1709 fn test_validate_event_command_missing_command() {
1710 let content = r#"
1711[extension]
1712id = "test"
1713name = "Test"
1714version = "1.0.0"
1715description = "Test"
1716
1717[[events]]
1718event = "task.start"
1719type = "command"
1720"#;
1721 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1722 let err = manifest.validate().unwrap_err();
1723 assert!(err.to_string().contains("no command specified"));
1724 }
1725
1726 #[test]
1727 fn test_validate_event_script_missing_script() {
1728 let content = r#"
1729[extension]
1730id = "test"
1731name = "Test"
1732version = "1.0.0"
1733description = "Test"
1734
1735[[events]]
1736event = "task.start"
1737type = "script"
1738"#;
1739 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1740 let err = manifest.validate().unwrap_err();
1741 assert!(err.to_string().contains("no script specified"));
1742 }
1743
1744 #[test]
1745 fn test_validate_event_webhook_missing_url() {
1746 let content = r#"
1747[extension]
1748id = "test"
1749name = "Test"
1750version = "1.0.0"
1751description = "Test"
1752
1753[[events]]
1754event = "task.start"
1755type = "webhook"
1756"#;
1757 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1758 let err = manifest.validate().unwrap_err();
1759 assert!(err.to_string().contains("no url specified"));
1760 }
1761
1762 #[test]
1763 fn test_validate_multiple_errors() {
1764 let content = r#"
1765[extension]
1766id = ""
1767name = ""
1768version = ""
1769description = ""
1770"#;
1771 let manifest = ExtensionManifest::from_str(content, &PathBuf::from("test.toml")).unwrap();
1772 let err = manifest.validate().unwrap_err();
1773 let msg = err.to_string();
1774 assert!(msg.contains("id cannot be empty"));
1775 assert!(msg.contains("name cannot be empty"));
1776 assert!(msg.contains("version cannot be empty"));
1777 assert!(msg.contains("description cannot be empty"));
1778 }
1779
1780 #[test]
1785 fn test_discovery_options_standard() {
1786 let opts = DiscoveryOptions::standard();
1787 assert_eq!(opts.max_depth, Some(10));
1788 assert!(opts.include_legacy);
1789 assert!(!opts.follow_symlinks);
1790 assert!(opts.validate);
1791 assert!(!opts.skip_errors);
1792 }
1793
1794 #[test]
1795 fn test_discovery_options_lenient() {
1796 let opts = DiscoveryOptions::lenient();
1797 assert!(!opts.validate);
1798 assert!(opts.skip_errors);
1799 }
1800
1801 #[test]
1802 fn test_discover_nonexistent_directory() {
1803 let result = discover(Path::new("/nonexistent/path"), DiscoveryOptions::default());
1804 assert!(result.is_err());
1805 let err = result.unwrap_err();
1806 assert!(matches!(err, ExtensionError::Io(_)));
1807 }
1808
1809 #[test]
1810 fn test_discover_file_not_directory() {
1811 let temp = NamedTempFile::new().unwrap();
1812 let result = discover(temp.path(), DiscoveryOptions::default());
1813 assert!(result.is_err());
1814 let err = result.unwrap_err();
1815 assert!(matches!(err, ExtensionError::Io(_)));
1816 assert!(err.to_string().contains("not a directory"));
1817 }
1818
1819 #[test]
1820 fn test_discover_empty_directory() {
1821 let dir = tempfile::tempdir().unwrap();
1822 let result = discover(dir.path(), DiscoveryOptions::default()).unwrap();
1823 assert!(result.is_empty());
1824 assert_eq!(result.count(), 0);
1825 }
1826
1827 #[test]
1828 fn test_discover_extension_toml() {
1829 let dir = tempfile::tempdir().unwrap();
1830
1831 let ext_content = r#"
1833[extension]
1834id = "discovered-ext"
1835name = "Discovered Extension"
1836version = "1.0.0"
1837description = "Found via discovery"
1838"#;
1839 std::fs::write(dir.path().join("extension.toml"), ext_content).unwrap();
1840
1841 let result = discover(dir.path(), DiscoveryOptions::standard()).unwrap();
1842 assert_eq!(result.count(), 1);
1843 assert_eq!(result.extensions[0].manifest.extension.id, "discovered-ext");
1844 assert!(!result.extensions[0].is_legacy);
1845 }
1846
1847 #[test]
1848 fn test_discover_legacy_agent() {
1849 let dir = tempfile::tempdir().unwrap();
1850
1851 let agents_dir = dir.path().join("spawn-agents");
1853 std::fs::create_dir(&agents_dir).unwrap();
1854
1855 let legacy_content = r#"
1856[agent]
1857name = "legacy-agent"
1858description = "A legacy agent"
1859"#;
1860 std::fs::write(agents_dir.join("agent.toml"), legacy_content).unwrap();
1861
1862 let opts = DiscoveryOptions {
1863 include_legacy: true,
1864 ..DiscoveryOptions::default()
1865 };
1866 let result = discover(dir.path(), opts).unwrap();
1867 assert_eq!(result.count(), 1);
1868 assert!(result.extensions[0].is_legacy);
1869 assert_eq!(result.extensions[0].manifest.extension.name, "legacy-agent");
1870 }
1871
1872 #[test]
1873 fn test_discover_skip_legacy_when_disabled() {
1874 let dir = tempfile::tempdir().unwrap();
1875
1876 let agents_dir = dir.path().join("spawn-agents");
1878 std::fs::create_dir(&agents_dir).unwrap();
1879 std::fs::write(
1880 agents_dir.join("agent.toml"),
1881 r#"[agent]
1882name = "legacy"
1883description = "test""#,
1884 )
1885 .unwrap();
1886
1887 let opts = DiscoveryOptions {
1888 include_legacy: false,
1889 ..DiscoveryOptions::default()
1890 };
1891 let result = discover(dir.path(), opts).unwrap();
1892 assert!(result.is_empty());
1893 }
1894
1895 #[test]
1896 fn test_discover_nested_extensions() {
1897 let dir = tempfile::tempdir().unwrap();
1898
1899 let ext1 = dir.path().join("ext1");
1901 let ext2 = dir.path().join("subdir").join("ext2");
1902 std::fs::create_dir_all(&ext1).unwrap();
1903 std::fs::create_dir_all(&ext2).unwrap();
1904
1905 std::fs::write(
1906 ext1.join("extension.toml"),
1907 r#"[extension]
1908id = "ext1"
1909name = "Extension 1"
1910version = "1.0.0"
1911description = "First"
1912"#,
1913 )
1914 .unwrap();
1915
1916 std::fs::write(
1917 ext2.join("extension.toml"),
1918 r#"[extension]
1919id = "ext2"
1920name = "Extension 2"
1921version = "2.0.0"
1922description = "Second"
1923"#,
1924 )
1925 .unwrap();
1926
1927 let result = discover(dir.path(), DiscoveryOptions::standard()).unwrap();
1928 assert_eq!(result.count(), 2);
1929
1930 let ids: Vec<_> = result
1931 .extensions
1932 .iter()
1933 .map(|e| e.manifest.extension.id.as_str())
1934 .collect();
1935 assert!(ids.contains(&"ext1"));
1936 assert!(ids.contains(&"ext2"));
1937 }
1938
1939 #[test]
1940 fn test_discover_max_depth() {
1941 let dir = tempfile::tempdir().unwrap();
1942
1943 let deep_dir = dir.path().join("a").join("b").join("c").join("d");
1945 std::fs::create_dir_all(&deep_dir).unwrap();
1946 std::fs::write(
1947 deep_dir.join("extension.toml"),
1948 r#"[extension]
1949id = "deep"
1950name = "Deep Extension"
1951version = "1.0.0"
1952description = "Deeply nested"
1953"#,
1954 )
1955 .unwrap();
1956
1957 let opts = DiscoveryOptions {
1959 max_depth: Some(2),
1960 ..DiscoveryOptions::default()
1961 };
1962 let result = discover(dir.path(), opts).unwrap();
1963 assert!(result.is_empty());
1964
1965 let opts = DiscoveryOptions {
1967 max_depth: Some(5),
1968 ..DiscoveryOptions::default()
1969 };
1970 let result = discover(dir.path(), opts).unwrap();
1971 assert_eq!(result.count(), 1);
1972 }
1973
1974 #[test]
1975 fn test_discover_duplicate_ids_error() {
1976 let dir = tempfile::tempdir().unwrap();
1977
1978 let ext1 = dir.path().join("ext1");
1979 let ext2 = dir.path().join("ext2");
1980 std::fs::create_dir_all(&ext1).unwrap();
1981 std::fs::create_dir_all(&ext2).unwrap();
1982
1983 let content = r#"[extension]
1985id = "duplicate-id"
1986name = "Extension"
1987version = "1.0.0"
1988description = "Test"
1989"#;
1990 std::fs::write(ext1.join("extension.toml"), content).unwrap();
1991 std::fs::write(ext2.join("extension.toml"), content).unwrap();
1992
1993 let opts = DiscoveryOptions {
1995 skip_errors: false,
1996 ..DiscoveryOptions::default()
1997 };
1998 let result = discover(dir.path(), opts);
1999 assert!(result.is_err());
2000 let err = result.unwrap_err();
2001 assert!(matches!(err, ExtensionError::DuplicateId(_)));
2002 }
2003
2004 #[test]
2005 fn test_discover_duplicate_ids_skip() {
2006 let dir = tempfile::tempdir().unwrap();
2007
2008 let ext1 = dir.path().join("ext1");
2009 let ext2 = dir.path().join("ext2");
2010 std::fs::create_dir_all(&ext1).unwrap();
2011 std::fs::create_dir_all(&ext2).unwrap();
2012
2013 let content = r#"[extension]
2014id = "duplicate-id"
2015name = "Extension"
2016version = "1.0.0"
2017description = "Test"
2018"#;
2019 std::fs::write(ext1.join("extension.toml"), content).unwrap();
2020 std::fs::write(ext2.join("extension.toml"), content).unwrap();
2021
2022 let opts = DiscoveryOptions {
2024 skip_errors: true,
2025 ..DiscoveryOptions::default()
2026 };
2027 let result = discover(dir.path(), opts).unwrap();
2028 assert_eq!(result.count(), 1); assert!(result.has_errors());
2030 assert_eq!(result.errors.len(), 1);
2031 }
2032
2033 #[test]
2034 fn test_discover_invalid_manifest_skip() {
2035 let dir = tempfile::tempdir().unwrap();
2036
2037 let valid_dir = dir.path().join("valid");
2039 std::fs::create_dir_all(&valid_dir).unwrap();
2040 std::fs::write(
2041 valid_dir.join("extension.toml"),
2042 r#"[extension]
2043id = "valid"
2044name = "Valid"
2045version = "1.0.0"
2046description = "Valid ext"
2047"#,
2048 )
2049 .unwrap();
2050
2051 let invalid_dir = dir.path().join("invalid");
2053 std::fs::create_dir_all(&invalid_dir).unwrap();
2054 std::fs::write(invalid_dir.join("extension.toml"), "invalid [[[ toml").unwrap();
2055
2056 let opts = DiscoveryOptions {
2057 skip_errors: true,
2058 validate: true,
2059 ..DiscoveryOptions::default()
2060 };
2061 let result = discover(dir.path(), opts).unwrap();
2062 assert_eq!(result.count(), 1);
2063 assert!(result.has_errors());
2064 assert_eq!(result.skipped.len(), 1);
2065 }
2066
2067 #[test]
2068 fn test_discover_validation_failure_skip() {
2069 let dir = tempfile::tempdir().unwrap();
2070
2071 std::fs::write(
2073 dir.path().join("extension.toml"),
2074 r#"[extension]
2075id = ""
2076name = "Test"
2077version = "1.0.0"
2078description = "Test"
2079"#,
2080 )
2081 .unwrap();
2082
2083 let opts = DiscoveryOptions {
2084 skip_errors: true,
2085 validate: true,
2086 ..DiscoveryOptions::default()
2087 };
2088 let result = discover(dir.path(), opts).unwrap();
2089 assert!(result.is_empty());
2090 assert!(result.has_errors());
2091 }
2092
2093 #[test]
2094 fn test_discovery_result_methods() {
2095 let mut result = DiscoveryResult::default();
2096 assert!(result.is_empty());
2097 assert!(!result.has_errors());
2098 assert_eq!(result.count(), 0);
2099
2100 result.errors.push(ExtensionError::Io("test".to_string()));
2101 assert!(result.has_errors());
2102 }
2103
2104 #[test]
2109 fn test_discover_all_multiple_roots() {
2110 let dir1 = tempfile::tempdir().unwrap();
2111 let dir2 = tempfile::tempdir().unwrap();
2112
2113 std::fs::write(
2114 dir1.path().join("extension.toml"),
2115 r#"[extension]
2116id = "ext1"
2117name = "Ext 1"
2118version = "1.0.0"
2119description = "First"
2120"#,
2121 )
2122 .unwrap();
2123
2124 std::fs::write(
2125 dir2.path().join("extension.toml"),
2126 r#"[extension]
2127id = "ext2"
2128name = "Ext 2"
2129version = "2.0.0"
2130description = "Second"
2131"#,
2132 )
2133 .unwrap();
2134
2135 let roots = [dir1.path(), dir2.path()];
2136 let result = discover_all(&roots, DiscoveryOptions::standard()).unwrap();
2137 assert_eq!(result.count(), 2);
2138 }
2139
2140 #[test]
2141 fn test_discover_all_skips_nonexistent() {
2142 let dir = tempfile::tempdir().unwrap();
2143 std::fs::write(
2144 dir.path().join("extension.toml"),
2145 r#"[extension]
2146id = "ext"
2147name = "Ext"
2148version = "1.0.0"
2149description = "Test"
2150"#,
2151 )
2152 .unwrap();
2153
2154 let roots = [dir.path(), Path::new("/nonexistent/path")];
2155 let result = discover_all(&roots, DiscoveryOptions::standard()).unwrap();
2156 assert_eq!(result.count(), 1);
2157 }
2158
2159 #[test]
2164 fn test_registry_new() {
2165 let registry = ExtensionRegistry::new();
2166 assert!(registry.is_empty());
2167 assert_eq!(registry.count(), 0);
2168 }
2169
2170 #[test]
2171 fn test_registry_load_from_discovery() {
2172 let dir = tempfile::tempdir().unwrap();
2173 std::fs::write(
2174 dir.path().join("extension.toml"),
2175 r#"[extension]
2176id = "test-ext"
2177name = "Test Extension"
2178version = "1.0.0"
2179description = "For testing"
2180"#,
2181 )
2182 .unwrap();
2183
2184 let result = discover(dir.path(), DiscoveryOptions::standard()).unwrap();
2185 let mut registry = ExtensionRegistry::new();
2186 registry.load_from_discovery(result);
2187
2188 assert!(!registry.is_empty());
2189 assert_eq!(registry.count(), 1);
2190 assert!(registry.has("test-ext"));
2191 }
2192
2193 #[test]
2194 fn test_registry_get() {
2195 let dir = tempfile::tempdir().unwrap();
2196 std::fs::write(
2197 dir.path().join("extension.toml"),
2198 r#"[extension]
2199id = "my-ext"
2200name = "My Extension"
2201version = "1.0.0"
2202description = "Testing"
2203"#,
2204 )
2205 .unwrap();
2206
2207 let mut registry = ExtensionRegistry::new();
2208 registry
2209 .discover_and_load(dir.path(), DiscoveryOptions::standard())
2210 .unwrap();
2211
2212 let ext = registry.get("my-ext").unwrap();
2213 assert_eq!(ext.manifest.extension.name, "My Extension");
2214
2215 assert!(registry.get("nonexistent").is_none());
2216 }
2217
2218 #[test]
2219 fn test_registry_get_or_error() {
2220 let registry = ExtensionRegistry::new();
2221 let result = registry.get_or_error("missing");
2222 assert!(result.is_err());
2223 assert!(matches!(result.unwrap_err(), ExtensionError::NotFound(_)));
2224 }
2225
2226 #[test]
2227 fn test_registry_list_ids() {
2228 let dir = tempfile::tempdir().unwrap();
2229
2230 let ext1 = dir.path().join("ext1");
2231 let ext2 = dir.path().join("ext2");
2232 std::fs::create_dir_all(&ext1).unwrap();
2233 std::fs::create_dir_all(&ext2).unwrap();
2234
2235 std::fs::write(
2236 ext1.join("extension.toml"),
2237 r#"[extension]
2238id = "alpha"
2239name = "Alpha"
2240version = "1.0.0"
2241description = "First"
2242"#,
2243 )
2244 .unwrap();
2245
2246 std::fs::write(
2247 ext2.join("extension.toml"),
2248 r#"[extension]
2249id = "beta"
2250name = "Beta"
2251version = "1.0.0"
2252description = "Second"
2253"#,
2254 )
2255 .unwrap();
2256
2257 let mut registry = ExtensionRegistry::new();
2258 registry
2259 .discover_and_load(dir.path(), DiscoveryOptions::standard())
2260 .unwrap();
2261
2262 let ids = registry.list_ids();
2263 assert_eq!(ids.len(), 2);
2264 assert!(ids.contains(&"alpha"));
2265 assert!(ids.contains(&"beta"));
2266 }
2267
2268 #[test]
2269 fn test_registry_remove() {
2270 let dir = tempfile::tempdir().unwrap();
2271 std::fs::write(
2272 dir.path().join("extension.toml"),
2273 r#"[extension]
2274id = "to-remove"
2275name = "To Remove"
2276version = "1.0.0"
2277description = "Test"
2278"#,
2279 )
2280 .unwrap();
2281
2282 let mut registry = ExtensionRegistry::new();
2283 registry
2284 .discover_and_load(dir.path(), DiscoveryOptions::standard())
2285 .unwrap();
2286
2287 assert!(registry.has("to-remove"));
2288 let removed = registry.remove("to-remove");
2289 assert!(removed.is_some());
2290 assert!(!registry.has("to-remove"));
2291 }
2292
2293 #[test]
2294 fn test_registry_clear() {
2295 let dir = tempfile::tempdir().unwrap();
2296 std::fs::write(
2297 dir.path().join("extension.toml"),
2298 r#"[extension]
2299id = "test"
2300name = "Test"
2301version = "1.0.0"
2302description = "Test"
2303"#,
2304 )
2305 .unwrap();
2306
2307 let mut registry = ExtensionRegistry::new();
2308 registry
2309 .discover_and_load(dir.path(), DiscoveryOptions::standard())
2310 .unwrap();
2311
2312 assert!(!registry.is_empty());
2313 registry.clear();
2314 assert!(registry.is_empty());
2315 }
2316
2317 #[test]
2318 fn test_registry_filter_legacy() {
2319 let dir = tempfile::tempdir().unwrap();
2320
2321 let modern_dir = dir.path().join("modern");
2323 std::fs::create_dir_all(&modern_dir).unwrap();
2324 std::fs::write(
2325 modern_dir.join("extension.toml"),
2326 r#"[extension]
2327id = "modern"
2328name = "Modern"
2329version = "1.0.0"
2330description = "Modern ext"
2331"#,
2332 )
2333 .unwrap();
2334
2335 let legacy_dir = dir.path().join("spawn-agents");
2337 std::fs::create_dir_all(&legacy_dir).unwrap();
2338 std::fs::write(
2339 legacy_dir.join("agent.toml"),
2340 r#"[agent]
2341name = "legacy-agent"
2342description = "Legacy"
2343"#,
2344 )
2345 .unwrap();
2346
2347 let mut registry = ExtensionRegistry::new();
2348 registry
2349 .discover_and_load(dir.path(), DiscoveryOptions::standard())
2350 .unwrap();
2351
2352 let legacy = registry.legacy_extensions();
2353 let modern = registry.modern_extensions();
2354
2355 assert_eq!(legacy.len(), 1);
2356 assert_eq!(modern.len(), 1);
2357 assert!(legacy[0].is_legacy);
2358 assert!(!modern[0].is_legacy);
2359 }
2360
2361 #[test]
2362 fn test_registry_filter_custom() {
2363 let dir = tempfile::tempdir().unwrap();
2364
2365 let ext1 = dir.path().join("ext1");
2366 let ext2 = dir.path().join("ext2");
2367 std::fs::create_dir_all(&ext1).unwrap();
2368 std::fs::create_dir_all(&ext2).unwrap();
2369
2370 std::fs::write(
2371 ext1.join("extension.toml"),
2372 r#"[extension]
2373id = "v1-ext"
2374name = "V1 Extension"
2375version = "1.0.0"
2376description = "Version 1"
2377"#,
2378 )
2379 .unwrap();
2380
2381 std::fs::write(
2382 ext2.join("extension.toml"),
2383 r#"[extension]
2384id = "v2-ext"
2385name = "V2 Extension"
2386version = "2.0.0"
2387description = "Version 2"
2388"#,
2389 )
2390 .unwrap();
2391
2392 let mut registry = ExtensionRegistry::new();
2393 registry
2394 .discover_and_load(dir.path(), DiscoveryOptions::standard())
2395 .unwrap();
2396
2397 let v2_exts = registry.filter(|e| e.manifest.extension.version.starts_with("2"));
2399 assert_eq!(v2_exts.len(), 1);
2400 assert_eq!(v2_exts[0].manifest.extension.id, "v2-ext");
2401 }
2402
2403 #[test]
2404 fn test_discovered_extension_fields() {
2405 let dir = tempfile::tempdir().unwrap();
2406 std::fs::write(
2407 dir.path().join("extension.toml"),
2408 r#"[extension]
2409id = "field-test"
2410name = "Field Test"
2411version = "1.0.0"
2412description = "Testing fields"
2413"#,
2414 )
2415 .unwrap();
2416
2417 let result = discover(dir.path(), DiscoveryOptions::standard()).unwrap();
2418 let ext = &result.extensions[0];
2419
2420 assert_eq!(ext.manifest.extension.id, "field-test");
2421 assert_eq!(ext.path, dir.path().join("extension.toml"));
2422 assert_eq!(ext.directory, dir.path());
2423 assert!(!ext.is_legacy);
2424 }
2425}