what-core 1.7.0

Core framework for What - an HTML-first web framework powered by Rust
Documentation
//! Named datasources — connect to multiple backends via `dsn:name` in fetch directives.
//!
//! Datasources are configured in `[datasources.*]` sections of `what.toml` and accessed
//! in templates using the `dsn:` prefix:
//!
//! - `dsn:name.collection` — DB types (D1, Supabase, SQLite): query a collection
//! - `dsn:name/path` — API type: append path to base URL with configured headers
//! - `dsn:name` — Root: DB types return all data; API types hit the base URL

use crate::database::DatabaseAdapter;

/// A resolved datasource instance, ready for queries.
#[derive(Clone)]
pub enum Datasource {
    /// Database backend (D1, Supabase, or SQLite) — supports collection queries
    Database(DatabaseAdapter),
    /// REST API backend — base URL with pre-configured headers
    Api {
        base_url: String,
        headers: Vec<(String, String)>,
    },
}

/// Target parsed from a `dsn:` URL — determines what to fetch from the datasource.
#[derive(Debug, PartialEq)]
pub enum DsnTarget<'a> {
    /// `dsn:name.collection` — query a specific collection (DB types)
    Collection(&'a str),
    /// `dsn:name/path` — append path to base URL (API types)
    Path(&'a str),
    /// `dsn:name` — root/default (all collections for DB, base URL for API)
    Root,
}

/// Parse a `dsn:` URL into a datasource name and target.
///
/// Returns `None` if the URL doesn't start with `dsn:` or has no name.
///
/// # Examples
/// ```
/// use what_core::datasource::{parse_dsn, DsnTarget};
///
/// let (name, target) = parse_dsn("dsn:users.profiles").unwrap();
/// assert_eq!(name, "users");
/// assert_eq!(target, DsnTarget::Collection("profiles"));
///
/// let (name, target) = parse_dsn("dsn:inventory/products").unwrap();
/// assert_eq!(name, "inventory");
/// assert_eq!(target, DsnTarget::Path("/products"));
///
/// let (name, target) = parse_dsn("dsn:mydb").unwrap();
/// assert_eq!(name, "mydb");
/// assert_eq!(target, DsnTarget::Root);
/// ```
pub fn parse_dsn(url: &str) -> Option<(&str, DsnTarget<'_>)> {
    let rest = url.strip_prefix("dsn:")?;
    if rest.is_empty() {
        return None;
    }

    // Check for path separator first (API-style: dsn:name/path)
    if let Some(slash_pos) = rest.find('/') {
        let name = &rest[..slash_pos];
        if name.is_empty() {
            return None;
        }
        let path = &rest[slash_pos..]; // includes leading /
        return Some((name, DsnTarget::Path(path)));
    }

    // Check for collection separator (DB-style: dsn:name.collection)
    if let Some(dot_pos) = rest.find('.') {
        let name = &rest[..dot_pos];
        let collection = &rest[dot_pos + 1..];
        if name.is_empty() || collection.is_empty() {
            return None;
        }
        return Some((name, DsnTarget::Collection(collection)));
    }

    // Root: just the datasource name
    Some((rest, DsnTarget::Root))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_dsn_collection() {
        let (name, target) = parse_dsn("dsn:users.profiles").unwrap();
        assert_eq!(name, "users");
        assert_eq!(target, DsnTarget::Collection("profiles"));
    }

    #[test]
    fn test_parse_dsn_path() {
        let (name, target) = parse_dsn("dsn:inventory/products").unwrap();
        assert_eq!(name, "inventory");
        assert_eq!(target, DsnTarget::Path("/products"));
    }

    #[test]
    fn test_parse_dsn_nested_path() {
        let (name, target) = parse_dsn("dsn:api/v2/users/active").unwrap();
        assert_eq!(name, "api");
        assert_eq!(target, DsnTarget::Path("/v2/users/active"));
    }

    #[test]
    fn test_parse_dsn_root() {
        let (name, target) = parse_dsn("dsn:mydb").unwrap();
        assert_eq!(name, "mydb");
        assert_eq!(target, DsnTarget::Root);
    }

    #[test]
    fn test_parse_dsn_not_dsn_prefix() {
        assert!(parse_dsn("local:posts").is_none());
        assert!(parse_dsn("https://example.com").is_none());
    }

    #[test]
    fn test_parse_dsn_empty_name() {
        assert!(parse_dsn("dsn:").is_none());
        assert!(parse_dsn("dsn:/path").is_none());
        assert!(parse_dsn("dsn:.collection").is_none());
    }

    #[test]
    fn test_parse_dsn_empty_collection() {
        assert!(parse_dsn("dsn:name.").is_none());
    }

    #[test]
    fn test_parse_dsn_path_takes_priority_over_dot() {
        // If both / and . exist, / wins (it's checked first)
        let (name, target) = parse_dsn("dsn:api/users.json").unwrap();
        assert_eq!(name, "api");
        assert_eq!(target, DsnTarget::Path("/users.json"));
    }

    #[test]
    fn test_parse_dsn_trailing_slash() {
        let (name, target) = parse_dsn("dsn:api/").unwrap();
        assert_eq!(name, "api");
        assert_eq!(target, DsnTarget::Path("/"));
    }

    #[test]
    fn test_parse_dsn_just_prefix() {
        assert!(parse_dsn("dsn:").is_none());
    }

    #[test]
    fn test_parse_dsn_non_dsn_url() {
        assert!(parse_dsn("http://example.com").is_none());
        assert!(parse_dsn("").is_none());
    }

    #[test]
    fn test_parse_dsn_deep_collection_name() {
        // Only first dot splits — rest is part of collection name
        let (name, target) = parse_dsn("dsn:db.schema.table").unwrap();
        assert_eq!(name, "db");
        assert_eq!(target, DsnTarget::Collection("schema.table"));
    }
}