Skip to main content

edgecrab_plugins/
context.rs

1//! # Context Engine Plugin Discovery
2//!
3//! Discovers and loads context engine plugins from `~/.edgecrab/plugins/context_engine/`.
4//! Each plugin directory must contain a `manifest.yaml` with name, description,
5//! command, and args fields.
6//!
7//! ## Design Note
8//!
9//! This module is intentionally kept trait-free: it only handles filesystem
10//! discovery and manifest parsing. The `PluginContextEngine` adapter (which
11//! actually *implements* `ContextEngine`) lives in `edgecrab-core` to avoid
12//! a circular dependency (`edgecrab-plugins` → `edgecrab-core` → `edgecrab-plugins`).
13
14use std::path::PathBuf;
15
16/// Manifest data for a context engine plugin — command + args to spawn.
17#[derive(Debug, Clone)]
18pub struct ContextEngineManifest {
19    pub name: String,
20    pub description: String,
21    pub command: String,
22    pub args: Vec<String>,
23}
24
25/// Scan `~/.edgecrab/plugins/context_engine/` for available engines.
26///
27/// Returns a list of `(name, description, available)` tuples.
28pub fn discover_context_engines() -> Vec<(String, String, bool)> {
29    let dir = context_engine_plugins_dir();
30    let mut engines = Vec::new();
31
32    let entries = match std::fs::read_dir(&dir) {
33        Ok(entries) => entries,
34        Err(_) => return engines,
35    };
36
37    for entry in entries.flatten() {
38        let path = entry.path();
39        if !path.is_dir() {
40            continue;
41        }
42
43        let manifest_path = path.join("manifest.yaml");
44        if !manifest_path.exists() {
45            continue;
46        }
47
48        let name = path
49            .file_name()
50            .and_then(|n| n.to_str())
51            .unwrap_or_default()
52            .to_string();
53
54        let (description, available) = match std::fs::read_to_string(&manifest_path) {
55            Ok(content) => {
56                let desc = extract_yaml_field(&content, "description")
57                    .unwrap_or_else(|| format!("Context engine: {name}"));
58                let command = extract_yaml_field(&content, "command");
59                (desc, command.is_some())
60            }
61            Err(_) => (format!("Context engine: {name}"), false),
62        };
63
64        engines.push((name, description, available));
65    }
66
67    engines
68}
69
70/// Find and parse the manifest for a named context engine plugin.
71///
72/// Returns `None` when the plugin directory or manifest does not exist,
73/// or when the manifest is missing a `command` field.
74pub fn find_context_engine_manifest(name: &str) -> Option<ContextEngineManifest> {
75    let dir = context_engine_plugins_dir().join(name);
76    if !dir.is_dir() {
77        return None;
78    }
79
80    let content = std::fs::read_to_string(dir.join("manifest.yaml")).ok()?;
81    let command = extract_yaml_field(&content, "command")?;
82    let description = extract_yaml_field(&content, "description")
83        .unwrap_or_else(|| format!("Context engine: {name}"));
84
85    // Parse `args:` as a simple inline YAML list: ["-m", "my_engine"]
86    let args = extract_yaml_list(&content, "args");
87
88    Some(ContextEngineManifest {
89        name: name.to_string(),
90        description,
91        command,
92        args,
93    })
94}
95
96/// Base directory for context engine plugins.
97fn context_engine_plugins_dir() -> PathBuf {
98    let home = std::env::var("EDGECRAB_HOME")
99        .map(PathBuf::from)
100        .unwrap_or_else(|_| {
101            dirs::home_dir()
102                .unwrap_or_else(|| PathBuf::from("."))
103                .join(".edgecrab")
104        });
105    home.join("plugins").join("context_engine")
106}
107
108/// Simple YAML field extractor (avoids pulling in a full YAML parser just for this).
109fn extract_yaml_field(content: &str, field: &str) -> Option<String> {
110    let prefix = format!("{field}:");
111    for line in content.lines() {
112        let trimmed = line.trim();
113        if let Some(rest) = trimmed.strip_prefix(&prefix) {
114            let value = rest.trim().trim_matches('"').trim_matches('\'');
115            if !value.is_empty() && !value.starts_with('[') {
116                return Some(value.to_string());
117            }
118        }
119    }
120    None
121}
122
123/// Extract an inline YAML list value: `args: ["-m", "engine"]` → `["-m", "engine"]`.
124fn extract_yaml_list(content: &str, field: &str) -> Vec<String> {
125    let prefix = format!("{field}:");
126    for line in content.lines() {
127        let trimmed = line.trim();
128        if let Some(rest) = trimmed.strip_prefix(&prefix) {
129            let rest = rest.trim();
130            if rest.starts_with('[') && rest.ends_with(']') {
131                let inner = &rest[1..rest.len() - 1];
132                return inner
133                    .split(',')
134                    .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
135                    .filter(|s| !s.is_empty())
136                    .collect();
137            }
138        }
139    }
140    vec![]
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn extract_yaml_field_basic() {
149        let yaml = r#"
150name: my-engine
151description: "A test context engine"
152command: python
153args: ["-m", "my_engine"]
154"#;
155        assert_eq!(
156            extract_yaml_field(yaml, "name"),
157            Some("my-engine".to_string())
158        );
159        assert_eq!(
160            extract_yaml_field(yaml, "description"),
161            Some("A test context engine".to_string())
162        );
163        assert_eq!(
164            extract_yaml_field(yaml, "command"),
165            Some("python".to_string())
166        );
167        assert_eq!(extract_yaml_field(yaml, "nonexistent"), None);
168    }
169
170    #[test]
171    fn extract_yaml_list_inline() {
172        let yaml = r#"args: ["-m", "my_engine"]"#;
173        let args = extract_yaml_list(yaml, "args");
174        assert_eq!(args, vec!["-m", "my_engine"]);
175    }
176
177    #[test]
178    fn extract_yaml_list_missing() {
179        let yaml = r#"command: python"#;
180        assert!(extract_yaml_list(yaml, "args").is_empty());
181    }
182
183    #[test]
184    fn discover_returns_empty_for_missing_dir() {
185        // In test environments, the plugins/context_engine dir won't exist
186        let engines = discover_context_engines();
187        // Should not panic, just return empty or whatever is there
188        let _ = engines;
189    }
190
191    #[test]
192    fn find_manifest_returns_none_for_missing() {
193        let result = find_context_engine_manifest("__nonexistent_engine__");
194        assert!(result.is_none());
195    }
196}