Skip to main content

geode_client/
schema.rs

1//! Schema file parsing, port of the Go driver's ParseSchemaFS.
2
3use std::fs;
4use std::path::Path;
5
6use crate::error::{Error, Result};
7use crate::types::Value;
8
9/// A single schema statement to execute. Mirrors the Go driver's `Statement`.
10#[derive(Debug, Clone, Default, PartialEq)]
11pub struct Statement {
12    /// The GQL query text.
13    pub query: String,
14    /// Positional arguments (unused for schema parsing; mirrors Go `Args`).
15    pub args: Vec<Value>,
16}
17
18impl Statement {
19    /// Create a statement with no arguments.
20    pub fn new(query: impl Into<String>) -> Self {
21        Self {
22            query: query.into(),
23            args: Vec::new(),
24        }
25    }
26}
27
28/// GQL keywords that mark a line as a real schema statement (case-insensitive
29/// prefix match on the trimmed line), matching the Go reference exactly.
30const GQL_KEYWORDS: [&str; 4] = ["CREATE", "MERGE", "MATCH", "DELETE"];
31
32/// Parse `.gql` files in `dir` into a list of [`Statement`]s.
33///
34/// Reads direct children only (no recursion), keeping files whose name ends
35/// with `.gql` (case-sensitive). Each file is split on newlines; each line is
36/// trimmed, comment lines (`//`, `--`) and blank lines are skipped, and only
37/// lines starting (case-insensitively) with CREATE/MERGE/MATCH/DELETE are kept.
38/// Kept lines are split on `;` into individual statements.
39///
40/// # Errors
41/// Returns [`Error::Other`] if the directory cannot be read, or [`Error::Io`]
42/// if a directory entry cannot be accessed.
43pub fn parse_schema_fs(dir: impl AsRef<Path>) -> Result<Vec<Statement>> {
44    let dir = dir.as_ref();
45    let entries = fs::read_dir(dir).map_err(|e| {
46        Error::Other(format!(
47            "geode: parse_schema_fs: read dir {}: {}",
48            dir.display(),
49            e
50        ))
51    })?;
52
53    let mut names: Vec<std::path::PathBuf> = Vec::new();
54    for entry in entries {
55        let entry = entry.map_err(Error::Io)?;
56        let path = entry.path();
57        let file_type = entry.file_type().map_err(Error::Io)?;
58        if file_type.is_dir() {
59            continue;
60        }
61        let name = entry.file_name();
62        let name = name.to_string_lossy();
63        if !name.ends_with(".gql") {
64            continue;
65        }
66        names.push(path);
67    }
68    // Deterministic order across platforms.
69    names.sort();
70
71    let mut statements = Vec::new();
72    for path in names {
73        let content = fs::read_to_string(&path).map_err(|e| {
74            Error::Other(format!(
75                "geode: parse_schema_fs: read {}: {}",
76                path.display(),
77                e
78            ))
79        })?;
80
81        for line in content.split('\n') {
82            let trimmed = line.trim();
83            if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("--") {
84                continue;
85            }
86            let upper = trimmed.to_uppercase();
87            if !GQL_KEYWORDS.iter().any(|kw| upper.starts_with(kw)) {
88                continue;
89            }
90            for part in trimmed.split(';') {
91                let part = part.trim();
92                if !part.is_empty() {
93                    statements.push(Statement::new(part));
94                }
95            }
96        }
97    }
98
99    Ok(statements)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_parse_schema_fs_keeps_gql_statements() {
108        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/schema");
109        let stmts = parse_schema_fs(dir).unwrap();
110        let queries: Vec<&str> = stmts.iter().map(|s| s.query.as_str()).collect();
111        // CREATE x2 from 01, MATCH...MERGE x1 + DELETE x1 from 02. USE/SET dropped, .txt ignored.
112        assert!(queries.iter().any(|q| q.starts_with("CREATE (a:Person")));
113        assert!(queries.iter().any(|q| q.starts_with("CREATE (b:Person")));
114        assert!(queries.iter().any(|q| q.contains("MERGE")));
115        assert!(queries.iter().any(|q| q.starts_with("DELETE (x)")));
116        assert!(!queries.iter().any(|q| q.starts_with("USE")));
117        assert!(!queries.iter().any(|q| q.starts_with("SET")));
118        assert_eq!(queries.len(), 4);
119    }
120
121    #[test]
122    fn test_parse_schema_fs_splits_on_semicolon() {
123        let dir = std::env::temp_dir().join("geode_schema_split_test");
124        std::fs::create_dir_all(&dir).unwrap();
125        std::fs::write(dir.join("a.gql"), "CREATE (a); CREATE (b); CREATE (c)\n").unwrap();
126        let stmts = parse_schema_fs(&dir).unwrap();
127        std::fs::remove_dir_all(&dir).ok();
128        assert_eq!(stmts.len(), 3);
129        assert_eq!(stmts[0].query, "CREATE (a)");
130        assert_eq!(stmts[2].query, "CREATE (c)");
131    }
132
133    #[test]
134    fn test_parse_schema_fs_missing_dir_errors() {
135        let err = parse_schema_fs("/nonexistent/geode/schema/dir").unwrap_err();
136        assert!(matches!(err, crate::error::Error::Io(_)) || err.to_string().contains("read dir"));
137    }
138
139    #[test]
140    fn test_parse_schema_fs_empty_dir_ok() {
141        let dir = std::env::temp_dir().join("geode_schema_empty_test");
142        std::fs::create_dir_all(&dir).unwrap();
143        let stmts = parse_schema_fs(&dir).unwrap();
144        std::fs::remove_dir_all(&dir).ok();
145        assert!(stmts.is_empty());
146    }
147}