oo-ide 0.0.4

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! [`SchemaRegistry`] — central store for all registered schema providers.

use std::path::Path;
use std::sync::{Arc, RwLock};

use super::{FilePattern, SchemaContent, SchemaProvider};

// ---------------------------------------------------------------------------
// Built-in extension-schema adapter
// ---------------------------------------------------------------------------

/// A schema provided by an extension package or registered by the manager
/// when an extension is enabled.
struct ExtensionSchema {
    id: String,
    name: String,
    patterns: Vec<FilePattern>,
    schema_json: String,
}

impl SchemaProvider for ExtensionSchema {
    fn id(&self) -> &str {
        &self.id
    }

    fn name(&self) -> &str {
        &self.name
    }

    fn schema_for(&self, path: &Path) -> Option<SchemaContent> {
        if self.patterns.iter().any(|p| p.matches(path)) {
            Some(SchemaContent {
                id: self.id.clone(),
                name: self.name.clone(),
                schema_json: self.schema_json.clone(),
            })
        } else {
            None
        }
    }
}

// ---------------------------------------------------------------------------
// SchemaRegistry
// ---------------------------------------------------------------------------

/// Holds all registered [`SchemaProvider`]s and resolves file paths to schemas.
///
/// Shared via [`Arc<RwLock<SchemaRegistry>>`] between the project and each
/// loaded extension instance so extensions can register schemas at runtime.
pub struct SchemaRegistry {
    providers: Vec<Box<dyn SchemaProvider>>,
}

impl SchemaRegistry {
    /// Create an empty registry.
    pub fn new() -> Self {
        Self {
            providers: Vec::new(),
        }
    }

    /// Wrap a new registry in an [`Arc<RwLock<_>>`] ready for sharing.
    pub fn shared() -> Arc<RwLock<Self>> {
        Arc::new(RwLock::new(Self::new()))
    }

    /// Register a built-in [`SchemaProvider`].
    pub fn register(&mut self, provider: impl SchemaProvider + 'static) {
        self.providers.push(Box::new(provider));
    }

    /// Register a schema that was declared by an extension at runtime.
    ///
    /// Each string in `patterns` is classified automatically:
    /// - Contains `*` or `/` → [`FilePattern::Glob`]
    /// - Otherwise → [`FilePattern::Filename`] (exact filename match)
    pub fn register_from_extension(
        &mut self,
        id: String,
        name: String,
        patterns: Vec<String>,
        schema_json: String,
    ) {
        let file_patterns = patterns
            .into_iter()
            .map(|p| {
                if p.contains('*') || p.contains('/') {
                    FilePattern::Glob(p)
                } else {
                    FilePattern::Filename(p)
                }
            })
            .collect();

        self.providers.push(Box::new(ExtensionSchema {
            id,
            name,
            patterns: file_patterns,
            schema_json,
        }));
    }

    /// Resolve the schema for `path`.
    ///
    /// Returns the [`SchemaContent`] from the first provider whose patterns
    /// match `path`, or `None` if no provider covers it.
    pub fn resolve(&self, path: &Path) -> Option<SchemaContent> {
        self.providers.iter().find_map(|p| p.schema_for(path))
    }

    /// Returns `true` if any registered provider covers `path`.
    pub fn has_schema_for(&self, path: &Path) -> bool {
        self.providers.iter().any(|p| p.schema_for(path).is_some())
    }

    /// Number of registered providers (useful for testing).
    pub fn provider_count(&self) -> usize {
        self.providers.len()
    }
}

impl Default for SchemaRegistry {
    fn default() -> Self {
        Self::new()
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::schema::{FilePattern, SchemaContent, SchemaProvider};
    use std::path::Path;

    /// Helper: a minimal static SchemaProvider for tests.
    struct StaticProvider {
        id: &'static str,
        name: &'static str,
        pattern: FilePattern,
        schema: &'static str,
    }

    impl SchemaProvider for StaticProvider {
        fn id(&self) -> &str {
            self.id
        }

        fn name(&self) -> &str {
            self.name
        }

        fn schema_for(&self, path: &Path) -> Option<SchemaContent> {
            if self.pattern.matches(path) {
                Some(SchemaContent {
                    id: self.id.to_string(),
                    name: self.name.to_string(),
                    schema_json: self.schema.to_string(),
                })
            } else {
                None
            }
        }
    }

    #[test]
    fn empty_registry_resolves_none() {
        let reg = SchemaRegistry::new();
        assert!(reg.resolve(Path::new("Cargo.toml")).is_none());
        assert!(!reg.has_schema_for(Path::new("Cargo.toml")));
        assert_eq!(reg.provider_count(), 0);
    }

    #[test]
    fn register_custom_provider_and_resolve() {
        let mut reg = SchemaRegistry::new();
        reg.register(StaticProvider {
            id: "cargo-toml",
            name: "Cargo.toml Schema",
            pattern: FilePattern::Filename("Cargo.toml".into()),
            schema: r#"{"type":"object"}"#,
        });

        let content = reg.resolve(Path::new("/project/Cargo.toml"));
        assert!(content.is_some());
        let c = content.unwrap();
        assert_eq!(c.id, "cargo-toml");
        assert_eq!(c.name, "Cargo.toml Schema");
        assert_eq!(c.schema_json, r#"{"type":"object"}"#);
    }

    #[test]
    fn custom_provider_no_match() {
        let mut reg = SchemaRegistry::new();
        reg.register(StaticProvider {
            id: "cargo-toml",
            name: "Cargo.toml Schema",
            pattern: FilePattern::Filename("Cargo.toml".into()),
            schema: "{}",
        });

        assert!(reg.resolve(Path::new("/project/package.json")).is_none());
        assert!(reg.has_schema_for(Path::new("Cargo.toml")));
        assert!(!reg.has_schema_for(Path::new("package.json")));
    }

    #[test]
    fn register_from_extension_filename_pattern() {
        let mut reg = SchemaRegistry::new();
        reg.register_from_extension(
            "ext-schema".into(),
            "Ext Schema".into(),
            vec!["myconfig.yaml".into()],
            r#"{"type":"object"}"#.into(),
        );

        assert!(reg.resolve(Path::new("/project/myconfig.yaml")).is_some());
        assert!(reg.resolve(Path::new("myconfig.yaml")).is_some());
        assert!(reg.resolve(Path::new("/project/other.yaml")).is_none());
    }

    #[test]
    fn register_from_extension_glob_pattern() {
        let mut reg = SchemaRegistry::new();
        reg.register_from_extension(
            "yaml-schema".into(),
            "YAML Schema".into(),
            vec!["*.yaml".into(), "*.yml".into()],
            r#"{"type":"object"}"#.into(),
        );

        assert!(reg.resolve(Path::new("config.yaml")).is_some());
        assert!(reg.resolve(Path::new("/project/config.yaml")).is_some());
        assert!(reg.resolve(Path::new("ci.yml")).is_some());
        assert!(reg.resolve(Path::new("config.json")).is_none());
    }

    #[test]
    fn register_from_extension_deep_glob_pattern() {
        let mut reg = SchemaRegistry::new();
        reg.register_from_extension(
            "workflows".into(),
            "GitHub Actions".into(),
            vec![".github/workflows/*.yml".into()],
            "{}".into(),
        );

        assert!(reg.resolve(Path::new(".github/workflows/ci.yml")).is_some());
        assert!(reg
            .resolve(Path::new("/project/.github/workflows/release.yml"))
            .is_some());
        // Must NOT match a file one level deeper (sub-directory).
        assert!(reg
            .resolve(Path::new(".github/workflows/sub/ci.yml"))
            .is_none());
    }

    #[test]
    fn first_matching_provider_wins() {
        let mut reg = SchemaRegistry::new();
        reg.register(StaticProvider {
            id: "first",
            name: "First",
            pattern: FilePattern::Filename("config.yaml".into()),
            schema: r#"{"title":"first"}"#,
        });
        reg.register(StaticProvider {
            id: "second",
            name: "Second",
            pattern: FilePattern::Filename("config.yaml".into()),
            schema: r#"{"title":"second"}"#,
        });

        let c = reg.resolve(Path::new("config.yaml")).unwrap();
        assert_eq!(c.id, "first");
    }

    #[test]
    fn provider_count_reflects_registrations() {
        let mut reg = SchemaRegistry::new();
        assert_eq!(reg.provider_count(), 0);
        reg.register_from_extension("a".into(), "A".into(), vec![], "{}".into());
        reg.register_from_extension("b".into(), "B".into(), vec![], "{}".into());
        assert_eq!(reg.provider_count(), 2);
    }

    #[test]
    fn shared_creates_arc_rwlock() {
        let shared = SchemaRegistry::shared();
        {
            let mut reg = shared.write().unwrap();
            reg.register_from_extension(
                "test".into(),
                "Test".into(),
                vec!["test.yaml".into()],
                "{}".into(),
            );
        }
        let reg = shared.read().unwrap();
        assert!(reg.resolve(Path::new("test.yaml")).is_some());
    }
}