scud/extensions/
loader.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use walkdir::WalkDir;
5
6/// Extension manifest structure for parsing extension.toml files
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ExtensionManifest {
9    /// Basic extension metadata
10    pub extension: ExtensionMetadata,
11
12    /// Tool definitions provided by the extension
13    #[serde(default)]
14    pub tools: Vec<ToolDefinition>,
15
16    /// Event handlers (lifecycle hooks)
17    #[serde(default)]
18    pub events: Vec<EventHandler>,
19
20    /// Configuration options for the extension
21    #[serde(default)]
22    pub config: HashMap<String, serde_json::Value>,
23
24    /// Extension dependencies
25    #[serde(default)]
26    pub dependencies: HashMap<String, String>,
27}
28
29/// Metadata section of an extension manifest
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ExtensionMetadata {
32    /// Unique identifier for the extension
33    pub id: String,
34
35    /// Human-readable name
36    pub name: String,
37
38    /// Version string (semver)
39    pub version: String,
40
41    /// Description of what the extension does
42    pub description: String,
43
44    /// Author or organization name
45    #[serde(default)]
46    pub author: Option<String>,
47
48    /// Main entry point file (relative to extension directory)
49    #[serde(default)]
50    pub main: Option<String>,
51
52    /// License identifier
53    #[serde(default)]
54    pub license: Option<String>,
55
56    /// Homepage URL
57    #[serde(default)]
58    pub homepage: Option<String>,
59
60    /// Repository URL
61    #[serde(default)]
62    pub repository: Option<String>,
63}
64
65/// Tool definition within an extension
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ToolDefinition {
68    /// Tool name (used as command identifier)
69    pub name: String,
70
71    /// Human-readable description
72    pub description: String,
73
74    /// Input parameters schema
75    #[serde(default)]
76    pub parameters: Vec<ToolParameter>,
77
78    /// Command to execute (for script-based tools)
79    #[serde(default)]
80    pub command: Option<String>,
81
82    /// Script file to run (relative to extension directory)
83    #[serde(default)]
84    pub script: Option<String>,
85}
86
87/// Parameter definition for a tool
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ToolParameter {
90    /// Parameter name
91    pub name: String,
92
93    /// Parameter type (string, number, boolean, array, object)
94    #[serde(rename = "type")]
95    pub param_type: String,
96
97    /// Human-readable description
98    #[serde(default)]
99    pub description: Option<String>,
100
101    /// Whether this parameter is required
102    #[serde(default)]
103    pub required: bool,
104
105    /// Default value if not provided
106    #[serde(default)]
107    pub default: Option<serde_json::Value>,
108}
109
110/// Event handler definition
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct EventHandler {
113    /// Event name to listen for (e.g., "task.start", "task.complete", "session.init")
114    pub event: String,
115
116    /// Handler type: "script", "command", or "webhook"
117    #[serde(rename = "type")]
118    pub handler_type: String,
119
120    /// Command to execute (for "command" type)
121    #[serde(default)]
122    pub command: Option<String>,
123
124    /// Script file to run (for "script" type)
125    #[serde(default)]
126    pub script: Option<String>,
127
128    /// Webhook URL (for "webhook" type)
129    #[serde(default)]
130    pub url: Option<String>,
131}
132
133impl ExtensionManifest {
134    /// Load and parse an extension manifest from a TOML file
135    ///
136    /// Supports both the new extension.toml format and legacy agent TOML format
137    /// via automatic detection and conversion.
138    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    /// Parse an extension manifest from a string
146    ///
147    /// Automatically detects and handles legacy agent TOML format.
148    pub fn from_str(content: &str, path: &PathBuf) -> Result<Self, ExtensionError> {
149        // Try parsing as new extension format first
150        if let Ok(manifest) = toml::from_str::<ExtensionManifest>(content) {
151            return Ok(manifest);
152        }
153
154        // Try parsing as legacy agent TOML format and convert via shim
155        if let Ok(legacy) = toml::from_str::<LegacyAgentToml>(content) {
156            return Ok(legacy.into_extension_manifest(path));
157        }
158
159        // If both fail, return the error from the primary format
160        Err(ExtensionError::Parse(format!(
161            "Failed to parse {} as extension or legacy agent format",
162            path.display()
163        )))
164    }
165}
166
167// =============================================================================
168// Legacy Agent TOML Shim
169// =============================================================================
170// Provides compatibility with the existing spawn-agents/*.toml format:
171//
172// [agent]
173// name = "builder"
174// description = "..."
175//
176// [model]
177// harness = "claude"
178// model = "opus"
179//
180// [prompt]
181// template = "..."
182
183/// Legacy agent TOML format for backward compatibility
184#[derive(Debug, Clone, Deserialize)]
185pub struct LegacyAgentToml {
186    /// Agent metadata section
187    pub agent: LegacyAgentSection,
188
189    /// Model configuration section
190    #[serde(default)]
191    pub model: Option<LegacyModelSection>,
192
193    /// Prompt template section
194    #[serde(default)]
195    pub prompt: Option<LegacyPromptSection>,
196}
197
198/// Agent section in legacy format
199#[derive(Debug, Clone, Deserialize)]
200pub struct LegacyAgentSection {
201    /// Agent name
202    pub name: String,
203
204    /// Agent description
205    pub description: String,
206}
207
208/// Model section in legacy format
209#[derive(Debug, Clone, Deserialize)]
210pub struct LegacyModelSection {
211    /// Harness type (e.g., "claude", "openai")
212    #[serde(default)]
213    pub harness: Option<String>,
214
215    /// Model name (e.g., "opus", "sonnet")
216    #[serde(default)]
217    pub model: Option<String>,
218}
219
220/// Prompt section in legacy format
221#[derive(Debug, Clone, Deserialize)]
222pub struct LegacyPromptSection {
223    /// Prompt template with variable placeholders
224    #[serde(default)]
225    pub template: Option<String>,
226}
227
228impl LegacyAgentToml {
229    /// Convert legacy agent TOML to new extension manifest format
230    pub fn into_extension_manifest(self, path: &PathBuf) -> ExtensionManifest {
231        let mut config = HashMap::new();
232
233        // Preserve model configuration
234        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        // Preserve prompt template
250        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        // Store original format indicator
260        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/// Error type for extension operations
288#[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
309// =============================================================================
310// Extension Validation
311// =============================================================================
312
313impl ExtensionManifest {
314    /// Validate the extension manifest for completeness and correctness
315    ///
316    /// Returns Ok(()) if valid, or Err with descriptive validation errors.
317    pub fn validate(&self) -> Result<(), ExtensionError> {
318        let mut errors = Vec::new();
319
320        // Validate extension metadata
321        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        // Validate ID format (alphanumeric, dots, hyphens, underscores)
335        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        // Validate version format (basic semver check)
348        if !self
349            .extension
350            .version
351            .chars()
352            .all(|c| c.is_ascii_digit() || c == '.')
353        {
354            // Allow pre-release versions like 1.0.0-beta.1
355            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        // Validate tools
364        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            // Tool must have either command or script
373            if tool.command.is_none() && tool.script.is_none() {
374                // This is allowed for tools that might be implemented natively
375            }
376
377            // Validate parameters
378            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(&param.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        // Validate events
395        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            // Validate handler type
401            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
443/// Check if a string is a valid semantic version
444fn is_valid_semver(version: &str) -> bool {
445    // Basic semver: X.Y.Z or X.Y.Z-prerelease+build
446    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
457/// Check if a parameter type is valid
458fn is_valid_param_type(param_type: &str) -> bool {
459    matches!(
460        param_type,
461        "string" | "number" | "boolean" | "array" | "object" | "integer"
462    )
463}
464
465// =============================================================================
466// Extension Discovery
467// =============================================================================
468
469/// Options for extension discovery
470#[derive(Debug, Clone, Default)]
471pub struct DiscoveryOptions {
472    /// Maximum depth to traverse (None = unlimited)
473    pub max_depth: Option<usize>,
474
475    /// Whether to include legacy agent TOML files (in spawn-agents/ directories)
476    pub include_legacy: bool,
477
478    /// Whether to follow symbolic links
479    pub follow_symlinks: bool,
480
481    /// Whether to validate manifests during discovery
482    pub validate: bool,
483
484    /// Whether to skip extensions that fail to load (vs returning errors)
485    pub skip_errors: bool,
486}
487
488impl DiscoveryOptions {
489    /// Create options for discovering all extensions with validation
490    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    /// Create lenient options that skip errors and include legacy
501    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/// Result of discovering a single extension
513#[derive(Debug, Clone)]
514pub struct DiscoveredExtension {
515    /// The parsed extension manifest
516    pub manifest: ExtensionManifest,
517
518    /// Path to the manifest file
519    pub path: PathBuf,
520
521    /// Directory containing the extension
522    pub directory: PathBuf,
523
524    /// Whether this was loaded from legacy format
525    pub is_legacy: bool,
526}
527
528/// Result of extension discovery operation
529#[derive(Debug, Default)]
530pub struct DiscoveryResult {
531    /// Successfully discovered extensions
532    pub extensions: Vec<DiscoveredExtension>,
533
534    /// Errors encountered during discovery (when skip_errors is true)
535    pub errors: Vec<ExtensionError>,
536
537    /// Paths that were skipped
538    pub skipped: Vec<PathBuf>,
539}
540
541impl DiscoveryResult {
542    /// Check if discovery found any extensions
543    pub fn is_empty(&self) -> bool {
544        self.extensions.is_empty()
545    }
546
547    /// Get count of discovered extensions
548    pub fn count(&self) -> usize {
549        self.extensions.len()
550    }
551
552    /// Check if there were any errors
553    pub fn has_errors(&self) -> bool {
554        !self.errors.is_empty()
555    }
556}
557
558/// Discover extensions in a directory tree
559///
560/// Scans the given directory for `extension.toml` files and optionally
561/// legacy agent TOML files in `spawn-agents/` directories.
562///
563/// # Arguments
564/// * `root` - The root directory to scan
565/// * `options` - Discovery options controlling behavior
566///
567/// # Returns
568/// * `Ok(DiscoveryResult)` - The discovery results with extensions and any errors
569/// * `Err(ExtensionError)` - If a fatal error occurs during discovery
570///
571/// # Example
572/// ```no_run
573/// use std::path::PathBuf;
574/// use scud::extensions::loader::{discover, DiscoveryOptions};
575///
576/// let root = PathBuf::from("./extensions");
577/// let result = discover(&root, DiscoveryOptions::standard()).unwrap();
578/// for ext in result.extensions {
579///     println!("Found: {} v{}", ext.manifest.extension.name, ext.manifest.extension.version);
580/// }
581/// ```
582pub fn discover(root: &Path, options: DiscoveryOptions) -> Result<DiscoveryResult, ExtensionError> {
583    let mut result = DiscoveryResult::default();
584
585    // Verify root directory exists
586    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    // Build walker with options
601    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    // Track seen IDs to detect duplicates
608    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        // Skip non-files
633        if !path.is_file() {
634            continue;
635        }
636
637        // Check if this is an extension manifest
638        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        // Try to load the manifest
653        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        // Validate if requested
667        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        // Check for duplicate IDs
680        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        // Add to results
699        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
711/// Discover extensions from multiple root directories
712///
713/// Convenience function that scans multiple directories and combines results.
714pub 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; // Skip non-existent directories
724        }
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// =============================================================================
757// Extension Registry
758// =============================================================================
759
760/// Registry for managing discovered extensions
761#[derive(Debug, Default)]
762pub struct ExtensionRegistry {
763    /// Discovered extensions indexed by ID
764    extensions: HashMap<String, DiscoveredExtension>,
765}
766
767impl ExtensionRegistry {
768    /// Create a new empty registry
769    pub fn new() -> Self {
770        Self::default()
771    }
772
773    /// Load extensions from a discovery result
774    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    /// Discover and load extensions from a directory
782    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    /// Get an extension by ID
793    pub fn get(&self, id: &str) -> Option<&DiscoveredExtension> {
794        self.extensions.get(id)
795    }
796
797    /// Get an extension by ID (returns error if not found)
798    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    /// Check if an extension is registered
805    pub fn has(&self, id: &str) -> bool {
806        self.extensions.contains_key(id)
807    }
808
809    /// List all extension IDs
810    pub fn list_ids(&self) -> Vec<&str> {
811        self.extensions.keys().map(|s| s.as_str()).collect()
812    }
813
814    /// List all extensions
815    pub fn list(&self) -> Vec<&DiscoveredExtension> {
816        self.extensions.values().collect()
817    }
818
819    /// Get count of registered extensions
820    pub fn count(&self) -> usize {
821        self.extensions.len()
822    }
823
824    /// Check if registry is empty
825    pub fn is_empty(&self) -> bool {
826        self.extensions.is_empty()
827    }
828
829    /// Remove an extension by ID
830    pub fn remove(&mut self, id: &str) -> Option<DiscoveredExtension> {
831        self.extensions.remove(id)
832    }
833
834    /// Clear all extensions
835    pub fn clear(&mut self) {
836        self.extensions.clear();
837    }
838
839    /// Filter extensions by predicate
840    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    /// Get all legacy extensions
848    pub fn legacy_extensions(&self) -> Vec<&DiscoveredExtension> {
849        self.filter(|e| e.is_legacy)
850    }
851
852    /// Get all non-legacy extensions
853    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        // Check config preservation
923        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    // =========================================================================
954    // Error handling tests
955    // =========================================================================
956
957    #[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        // Missing 'description' field
973        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        // Content that doesn't match either new or legacy format
986        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    // =========================================================================
1011    // from_file tests
1012    // =========================================================================
1013
1014    #[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    // =========================================================================
1067    // Extension manifest edge cases
1068    // =========================================================================
1069
1070    #[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    // =========================================================================
1268    // Legacy format edge cases
1269    // =========================================================================
1270
1271    #[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        // Model section with only harness, no model name
1319        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        // Test with different file stems
1346        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    // =========================================================================
1372    // Unicode and special characters
1373    // =========================================================================
1374
1375    #[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    // =========================================================================
1434    // Error variant coverage
1435    // =========================================================================
1436
1437    #[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    // =========================================================================
1459    // Config value types
1460    // =========================================================================
1461
1462    #[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    // =========================================================================
1506    // Path edge cases for legacy conversion
1507    // =========================================================================
1508
1509    #[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        // Path with no file stem should use "unknown"
1530        let manifest = ExtensionManifest::from_str(content, &PathBuf::from("")).unwrap();
1531        assert_eq!(manifest.extension.main, Some("unknown.toml".to_string()));
1532    }
1533
1534    // =========================================================================
1535    // Validation tests
1536    // =========================================================================
1537
1538    #[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    // =========================================================================
1781    // Discovery tests
1782    // =========================================================================
1783
1784    #[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        // Create extension.toml
1832        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        // Create spawn-agents directory with legacy agent
1852        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        // Create spawn-agents directory
1877        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        // Create nested structure
1900        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        // Create deeply nested extension
1944        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        // With max_depth=2, should not find it
1958        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        // With max_depth=5, should find it
1966        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        // Same ID in both
1984        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        // Without skip_errors, should error
1994        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        // With skip_errors, should succeed but record error
2023        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); // Only first one loaded
2029        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        // Valid extension
2038        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        // Invalid extension (bad TOML)
2052        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        // Invalid extension (empty ID)
2072        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    // =========================================================================
2105    // discover_all tests
2106    // =========================================================================
2107
2108    #[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    // =========================================================================
2160    // Registry tests
2161    // =========================================================================
2162
2163    #[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        // Modern extension
2322        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        // Legacy extension
2336        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        // Filter by version starting with "2"
2398        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}