Skip to main content

what_core/
datasource.rs

1//! Named datasources — connect to multiple backends via `dsn:name` in fetch directives.
2//!
3//! Datasources are configured in `[datasources.*]` sections of `what.toml` and accessed
4//! in templates using the `dsn:` prefix:
5//!
6//! - `dsn:name.collection` — DB types (D1, Supabase, SQLite): query a collection
7//! - `dsn:name/path` — API type: append path to base URL with configured headers
8//! - `dsn:name` — Root: DB types return all data; API types hit the base URL
9
10use crate::database::DatabaseAdapter;
11
12/// A resolved datasource instance, ready for queries.
13#[derive(Clone)]
14pub enum Datasource {
15    /// Database backend (D1, Supabase, or SQLite) — supports collection queries
16    Database(DatabaseAdapter),
17    /// REST API backend — base URL with pre-configured headers
18    Api {
19        base_url: String,
20        headers: Vec<(String, String)>,
21    },
22}
23
24/// Target parsed from a `dsn:` URL — determines what to fetch from the datasource.
25#[derive(Debug, PartialEq)]
26pub enum DsnTarget<'a> {
27    /// `dsn:name.collection` — query a specific collection (DB types)
28    Collection(&'a str),
29    /// `dsn:name/path` — append path to base URL (API types)
30    Path(&'a str),
31    /// `dsn:name` — root/default (all collections for DB, base URL for API)
32    Root,
33}
34
35/// Parse a `dsn:` URL into a datasource name and target.
36///
37/// Returns `None` if the URL doesn't start with `dsn:` or has no name.
38///
39/// # Examples
40/// ```
41/// use what_core::datasource::{parse_dsn, DsnTarget};
42///
43/// let (name, target) = parse_dsn("dsn:users.profiles").unwrap();
44/// assert_eq!(name, "users");
45/// assert_eq!(target, DsnTarget::Collection("profiles"));
46///
47/// let (name, target) = parse_dsn("dsn:inventory/products").unwrap();
48/// assert_eq!(name, "inventory");
49/// assert_eq!(target, DsnTarget::Path("/products"));
50///
51/// let (name, target) = parse_dsn("dsn:mydb").unwrap();
52/// assert_eq!(name, "mydb");
53/// assert_eq!(target, DsnTarget::Root);
54/// ```
55pub fn parse_dsn(url: &str) -> Option<(&str, DsnTarget<'_>)> {
56    let rest = url.strip_prefix("dsn:")?;
57    if rest.is_empty() {
58        return None;
59    }
60
61    // Check for path separator first (API-style: dsn:name/path)
62    if let Some(slash_pos) = rest.find('/') {
63        let name = &rest[..slash_pos];
64        if name.is_empty() {
65            return None;
66        }
67        let path = &rest[slash_pos..]; // includes leading /
68        return Some((name, DsnTarget::Path(path)));
69    }
70
71    // Check for collection separator (DB-style: dsn:name.collection)
72    if let Some(dot_pos) = rest.find('.') {
73        let name = &rest[..dot_pos];
74        let collection = &rest[dot_pos + 1..];
75        if name.is_empty() || collection.is_empty() {
76            return None;
77        }
78        return Some((name, DsnTarget::Collection(collection)));
79    }
80
81    // Root: just the datasource name
82    Some((rest, DsnTarget::Root))
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn test_parse_dsn_collection() {
91        let (name, target) = parse_dsn("dsn:users.profiles").unwrap();
92        assert_eq!(name, "users");
93        assert_eq!(target, DsnTarget::Collection("profiles"));
94    }
95
96    #[test]
97    fn test_parse_dsn_path() {
98        let (name, target) = parse_dsn("dsn:inventory/products").unwrap();
99        assert_eq!(name, "inventory");
100        assert_eq!(target, DsnTarget::Path("/products"));
101    }
102
103    #[test]
104    fn test_parse_dsn_nested_path() {
105        let (name, target) = parse_dsn("dsn:api/v2/users/active").unwrap();
106        assert_eq!(name, "api");
107        assert_eq!(target, DsnTarget::Path("/v2/users/active"));
108    }
109
110    #[test]
111    fn test_parse_dsn_root() {
112        let (name, target) = parse_dsn("dsn:mydb").unwrap();
113        assert_eq!(name, "mydb");
114        assert_eq!(target, DsnTarget::Root);
115    }
116
117    #[test]
118    fn test_parse_dsn_not_dsn_prefix() {
119        assert!(parse_dsn("local:posts").is_none());
120        assert!(parse_dsn("https://example.com").is_none());
121    }
122
123    #[test]
124    fn test_parse_dsn_empty_name() {
125        assert!(parse_dsn("dsn:").is_none());
126        assert!(parse_dsn("dsn:/path").is_none());
127        assert!(parse_dsn("dsn:.collection").is_none());
128    }
129
130    #[test]
131    fn test_parse_dsn_empty_collection() {
132        assert!(parse_dsn("dsn:name.").is_none());
133    }
134
135    #[test]
136    fn test_parse_dsn_path_takes_priority_over_dot() {
137        // If both / and . exist, / wins (it's checked first)
138        let (name, target) = parse_dsn("dsn:api/users.json").unwrap();
139        assert_eq!(name, "api");
140        assert_eq!(target, DsnTarget::Path("/users.json"));
141    }
142
143    #[test]
144    fn test_parse_dsn_trailing_slash() {
145        let (name, target) = parse_dsn("dsn:api/").unwrap();
146        assert_eq!(name, "api");
147        assert_eq!(target, DsnTarget::Path("/"));
148    }
149
150    #[test]
151    fn test_parse_dsn_just_prefix() {
152        assert!(parse_dsn("dsn:").is_none());
153    }
154
155    #[test]
156    fn test_parse_dsn_non_dsn_url() {
157        assert!(parse_dsn("http://example.com").is_none());
158        assert!(parse_dsn("").is_none());
159    }
160
161    #[test]
162    fn test_parse_dsn_deep_collection_name() {
163        // Only first dot splits — rest is part of collection name
164        let (name, target) = parse_dsn("dsn:db.schema.table").unwrap();
165        assert_eq!(name, "db");
166        assert_eq!(target, DsnTarget::Collection("schema.table"));
167    }
168}