edgecrab_plugins/
context.rs1use std::path::PathBuf;
15
16#[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
25pub 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
70pub 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 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
96fn 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
108fn 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
123fn 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 let engines = discover_context_engines();
187 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}