systemprompt-loader 0.2.0

File loading infrastructure for systemprompt.io - separates I/O from shared models
Documentation
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::fs;
use std::path::Path;

use systemprompt_models::{DiscoveredExtension, ExtensionManifest};

const CARGO_TARGET: &str = "target";

#[derive(Debug, Clone, Copy)]
pub struct ExtensionLoader;

impl ExtensionLoader {
    pub fn discover(project_root: &Path) -> Vec<DiscoveredExtension> {
        let extensions_dir = project_root.join("extensions");

        if !extensions_dir.exists() {
            return vec![];
        }

        let mut discovered = vec![];

        Self::scan_directory(&extensions_dir, &mut discovered);

        if let Ok(entries) = fs::read_dir(&extensions_dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.is_dir() {
                    Self::scan_directory(&path, &mut discovered);
                }
            }
        }

        discovered
    }

    fn scan_directory(dir: &Path, discovered: &mut Vec<DiscoveredExtension>) {
        let Ok(entries) = fs::read_dir(dir) else {
            return;
        };

        for entry in entries.flatten() {
            let ext_dir = entry.path();
            if !ext_dir.is_dir() {
                continue;
            }

            let manifest_path = ext_dir.join("manifest.yaml");
            if manifest_path.exists() {
                match Self::load_manifest(&manifest_path) {
                    Ok(manifest) => {
                        discovered.push(DiscoveredExtension::new(manifest, ext_dir, manifest_path));
                    },
                    Err(e) => {
                        tracing::warn!(
                            path = %manifest_path.display(),
                            error = %e,
                            "Failed to parse extension manifest, skipping"
                        );
                    },
                }
            }
        }
    }

    fn load_manifest(path: &Path) -> Result<ExtensionManifest> {
        let content = fs::read_to_string(path)
            .with_context(|| format!("Failed to read manifest: {}", path.display()))?;

        serde_yaml::from_str(&content)
            .with_context(|| format!("Failed to parse manifest: {}", path.display()))
    }

    pub fn get_enabled_mcp_extensions(project_root: &Path) -> Vec<DiscoveredExtension> {
        Self::discover(project_root)
            .into_iter()
            .filter(|e| e.is_mcp() && e.is_enabled())
            .collect()
    }

    pub fn get_enabled_cli_extensions(project_root: &Path) -> Vec<DiscoveredExtension> {
        Self::discover(project_root)
            .into_iter()
            .filter(|e| e.is_cli() && e.is_enabled())
            .collect()
    }

    pub fn find_cli_extension(project_root: &Path, name: &str) -> Option<DiscoveredExtension> {
        Self::get_enabled_cli_extensions(project_root)
            .into_iter()
            .find(|e| {
                e.binary_name()
                    .is_some_and(|b| b == name || e.manifest.extension.name == name)
            })
    }

    pub fn get_cli_binary_path(
        project_root: &Path,
        binary_name: &str,
    ) -> Option<std::path::PathBuf> {
        let release_path = project_root
            .join(CARGO_TARGET)
            .join("release")
            .join(binary_name);
        if release_path.exists() {
            return Some(release_path);
        }

        let debug_path = project_root
            .join(CARGO_TARGET)
            .join("debug")
            .join(binary_name);
        if debug_path.exists() {
            return Some(debug_path);
        }

        None
    }

    pub fn resolve_bin_directory(
        project_root: &Path,
        override_path: Option<&Path>,
    ) -> std::path::PathBuf {
        if let Some(path) = override_path {
            return path.to_path_buf();
        }

        let release_dir = project_root.join(CARGO_TARGET).join("release");
        let debug_dir = project_root.join(CARGO_TARGET).join("debug");

        let release_binary = release_dir.join("systemprompt");
        let debug_binary = debug_dir.join("systemprompt");

        match (release_binary.exists(), debug_binary.exists()) {
            (true, true) => {
                let release_mtime = fs::metadata(&release_binary)
                    .and_then(|m| m.modified())
                    .ok();
                let debug_mtime = fs::metadata(&debug_binary).and_then(|m| m.modified()).ok();

                match (release_mtime, debug_mtime) {
                    (Some(r), Some(d)) if d > r => debug_dir,
                    _ => release_dir,
                }
            },
            (true | false, false) => release_dir,
            (false, true) => debug_dir,
        }
    }

    pub fn validate_mcp_binaries(project_root: &Path) -> Vec<(String, std::path::PathBuf)> {
        let extensions = Self::get_enabled_mcp_extensions(project_root);
        let target_dir = Self::resolve_bin_directory(project_root, None);

        extensions
            .into_iter()
            .filter_map(|ext| {
                ext.binary_name().and_then(|binary| {
                    let binary_path = target_dir.join(binary);
                    if binary_path.exists() {
                        None
                    } else {
                        Some((binary.to_string(), ext.path.clone()))
                    }
                })
            })
            .collect()
    }

    pub fn get_mcp_binary_names(project_root: &Path) -> Vec<String> {
        Self::get_enabled_mcp_extensions(project_root)
            .iter()
            .filter_map(|e| e.binary_name().map(String::from))
            .collect()
    }

    pub fn get_production_mcp_binary_names(
        project_root: &Path,
        services_config: &systemprompt_models::ServicesConfig,
    ) -> Vec<String> {
        Self::get_enabled_mcp_extensions(project_root)
            .iter()
            .filter_map(|e| {
                let binary = e.binary_name()?;
                let is_dev_only = services_config
                    .mcp_servers
                    .values()
                    .find(|d| d.binary == binary)
                    .is_some_and(|d| d.dev_only);
                (!is_dev_only).then(|| binary.to_string())
            })
            .collect()
    }

    pub fn build_binary_map(project_root: &Path) -> HashMap<String, DiscoveredExtension> {
        Self::discover(project_root)
            .into_iter()
            .filter_map(|ext| {
                let name = ext.binary_name()?.to_string();
                Some((name, ext))
            })
            .collect()
    }

    pub fn validate(project_root: &Path) -> ExtensionValidationResult {
        ExtensionValidationResult {
            discovered: Self::discover(project_root),
            missing_binaries: Self::validate_mcp_binaries(project_root),
            missing_manifests: vec![],
        }
    }
}

#[derive(Debug)]
pub struct ExtensionValidationResult {
    pub discovered: Vec<DiscoveredExtension>,
    pub missing_binaries: Vec<(String, std::path::PathBuf)>,
    pub missing_manifests: Vec<std::path::PathBuf>,
}

impl ExtensionValidationResult {
    pub fn is_valid(&self) -> bool {
        self.missing_binaries.is_empty()
    }

    pub fn format_missing_binaries(&self) -> String {
        self.missing_binaries
            .iter()
            .map(|(binary, path)| format!("{} ({})", binary, path.display()))
            .collect::<Vec<_>>()
            .join("\n")
    }
}