Skip to main content

systemprompt_loader/extension_loader/
mod.rs

1//! Discovers `extensions/<name>/manifest.yaml` files and resolves the
2//! binaries those manifests reference.
3//!
4//! All operations are infallible at the public API level — failures are
5//! either represented as "not found" (`Option`, empty `Vec`) or surfaced
6//! through the [`ExtensionValidationResult`] returned by
7//! [`ExtensionLoader::validate`].
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12mod manifest;
13mod result;
14
15use std::collections::HashMap;
16use std::fs;
17use std::path::Path;
18
19use systemprompt_models::DiscoveredExtension;
20
21use manifest::{load_manifest, mtime_of};
22
23pub use result::ExtensionValidationResult;
24
25const CARGO_TARGET: &str = "target";
26
27#[derive(Debug, Clone, Copy)]
28pub struct ExtensionLoader;
29
30impl ExtensionLoader {
31    #[must_use]
32    pub fn discover(project_root: &Path) -> Vec<DiscoveredExtension> {
33        let extensions_dir = project_root.join("extensions");
34
35        if !extensions_dir.exists() {
36            return vec![];
37        }
38
39        let mut discovered = vec![];
40
41        Self::scan_directory(&extensions_dir, &mut discovered);
42
43        if let Ok(entries) = fs::read_dir(&extensions_dir) {
44            for entry in entries.flatten() {
45                let path = entry.path();
46                if path.is_dir() {
47                    Self::scan_directory(&path, &mut discovered);
48                }
49            }
50        }
51
52        discovered
53    }
54
55    fn scan_directory(dir: &Path, discovered: &mut Vec<DiscoveredExtension>) {
56        let Ok(entries) = fs::read_dir(dir) else {
57            return;
58        };
59
60        for entry in entries.flatten() {
61            let ext_dir = entry.path();
62            if !ext_dir.is_dir() {
63                continue;
64            }
65
66            let manifest_path = ext_dir.join("manifest.yaml");
67            if manifest_path.exists() {
68                match load_manifest(&manifest_path) {
69                    Ok(manifest) => {
70                        discovered.push(DiscoveredExtension::new(manifest, ext_dir, manifest_path));
71                    },
72                    Err(e) => {
73                        tracing::warn!(
74                            path = %manifest_path.display(),
75                            error = %e,
76                            "Failed to parse extension manifest, skipping"
77                        );
78                    },
79                }
80            }
81        }
82    }
83
84    #[must_use]
85    pub fn get_enabled_mcp_extensions(project_root: &Path) -> Vec<DiscoveredExtension> {
86        Self::discover(project_root)
87            .into_iter()
88            .filter(|e| e.is_mcp() && e.is_enabled())
89            .collect()
90    }
91
92    #[must_use]
93    pub fn get_enabled_cli_extensions(project_root: &Path) -> Vec<DiscoveredExtension> {
94        Self::discover(project_root)
95            .into_iter()
96            .filter(|e| e.is_cli() && e.is_enabled())
97            .collect()
98    }
99
100    #[must_use]
101    pub fn find_cli_extension(project_root: &Path, name: &str) -> Option<DiscoveredExtension> {
102        Self::get_enabled_cli_extensions(project_root)
103            .into_iter()
104            .find(|e| {
105                e.binary_name()
106                    .is_some_and(|b| b == name || e.manifest.extension.name == name)
107            })
108    }
109
110    #[must_use]
111    pub fn get_cli_binary_path(
112        project_root: &Path,
113        binary_name: &str,
114    ) -> Option<std::path::PathBuf> {
115        let release_path = project_root
116            .join(CARGO_TARGET)
117            .join("release")
118            .join(binary_name);
119        if release_path.exists() {
120            return Some(release_path);
121        }
122
123        let debug_path = project_root
124            .join(CARGO_TARGET)
125            .join("debug")
126            .join(binary_name);
127        if debug_path.exists() {
128            return Some(debug_path);
129        }
130
131        None
132    }
133
134    #[must_use]
135    pub fn resolve_bin_directory(
136        project_root: &Path,
137        override_path: Option<&Path>,
138    ) -> std::path::PathBuf {
139        if let Some(path) = override_path {
140            return path.to_path_buf();
141        }
142
143        let release_dir = project_root.join(CARGO_TARGET).join("release");
144        let debug_dir = project_root.join(CARGO_TARGET).join("debug");
145
146        let release_binary = release_dir.join("systemprompt");
147        let debug_binary = debug_dir.join("systemprompt");
148
149        match (release_binary.exists(), debug_binary.exists()) {
150            (true, true) => {
151                let release_mtime = mtime_of(&release_binary);
152                let debug_mtime = mtime_of(&debug_binary);
153
154                match (release_mtime, debug_mtime) {
155                    (Some(r), Some(d)) if d > r => debug_dir,
156                    _ => release_dir,
157                }
158            },
159            (true | false, false) => release_dir,
160            (false, true) => debug_dir,
161        }
162    }
163
164    #[must_use]
165    pub fn validate_mcp_binaries(project_root: &Path) -> Vec<(String, std::path::PathBuf)> {
166        let extensions = Self::get_enabled_mcp_extensions(project_root);
167        let target_dir = Self::resolve_bin_directory(project_root, None);
168
169        extensions
170            .into_iter()
171            .filter_map(|ext| {
172                ext.binary_name().and_then(|binary| {
173                    let binary_path = target_dir.join(binary);
174                    if binary_path.exists() {
175                        None
176                    } else {
177                        Some((binary.to_owned(), ext.path.clone()))
178                    }
179                })
180            })
181            .collect()
182    }
183
184    #[must_use]
185    pub fn get_mcp_binary_names(project_root: &Path) -> Vec<String> {
186        Self::get_enabled_mcp_extensions(project_root)
187            .iter()
188            .filter_map(|e| e.binary_name().map(String::from))
189            .collect()
190    }
191
192    #[must_use]
193    pub fn get_production_mcp_binary_names(
194        project_root: &Path,
195        services_config: &systemprompt_models::ServicesConfig,
196    ) -> Vec<String> {
197        Self::get_enabled_mcp_extensions(project_root)
198            .iter()
199            .filter_map(|e| {
200                let binary = e.binary_name()?;
201                let is_dev_only = services_config
202                    .mcp_servers
203                    .values()
204                    .find(|d| d.binary == binary)
205                    .is_some_and(|d| d.dev_only);
206                (!is_dev_only).then(|| binary.to_owned())
207            })
208            .collect()
209    }
210
211    #[must_use]
212    pub fn build_binary_map(project_root: &Path) -> HashMap<String, DiscoveredExtension> {
213        Self::discover(project_root)
214            .into_iter()
215            .filter_map(|ext| {
216                let name = ext.binary_name()?.to_owned();
217                Some((name, ext))
218            })
219            .collect()
220    }
221
222    #[must_use]
223    pub fn validate(project_root: &Path) -> ExtensionValidationResult {
224        ExtensionValidationResult {
225            discovered: Self::discover(project_root),
226            missing_binaries: Self::validate_mcp_binaries(project_root),
227            missing_manifests: vec![],
228        }
229    }
230}