#[derive(Debug, Default, Clone)]
pub struct Model {
pub mounts: Vec<Mount>,
pub nodes: Vec<NodeDecl>,
pub refs: Vec<RefDecl>,
pub edges: Vec<EdgeDecl>,
pub defs_text: String,
}
#[derive(Debug, Clone)]
pub struct Mount {
pub name: String,
pub target: String,
}
#[derive(Debug, Clone)]
pub struct NodeDecl {
pub name: String,
pub query: String,
}
#[derive(Debug, Clone)]
pub struct RefDecl {
pub scope: String,
pub field: String,
pub container: String,
}
#[derive(Debug, Clone)]
pub struct EdgeDecl {
pub scope: String,
pub field_a: String,
pub field_b: String,
}
fn statements(text: &str) -> Vec<String> {
let b: Vec<char> = text.chars().collect();
let mut out = Vec::new();
let mut start = 0;
let mut i = 0;
while i < b.len() {
match b[i] {
q @ ('\'' | '"') => {
i += 1;
while i < b.len() && b[i] != q {
if b[i] == '\\' {
i += 1;
}
i += 1;
}
i += 1;
}
';' => {
let mut run = 0;
while i < b.len() && b[i] == ';' {
run += 1;
i += 1;
}
if run == 1 {
out.push(b[start..i - 1].iter().collect());
start = i;
}
}
_ => i += 1,
}
}
if start < b.len() {
let tail: String = b[start..].iter().collect();
if !tail.trim().is_empty() {
out.push(tail);
}
}
out
}
fn strip_comments(text: &str) -> String {
text.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
}
fn split_field(s: &str) -> Result<(String, String), String> {
let s = s.trim();
match s.rfind("::") {
Some(idx) => {
let path = s[..idx].trim().to_string();
let field = s[idx + 2..].trim().to_string();
if path.is_empty() {
return Err(format!(
"ref needs a path scope before '::{field}' \
(bare fields are refused): '{s}'"
));
}
if field.is_empty() || field.contains(|c: char| c.is_whitespace()) {
return Err(format!("ref/edge field is not a bare property: '{s}'"));
}
Ok((path, field))
}
None => Err(format!(
"ref/edge needs a scoped '::field' (bare fields are refused): '{s}'"
)),
}
}
pub fn resolve_mount_target(target: &str, base_dir: Option<&std::path::Path>) -> String {
let has_scheme = target.split_once(':').is_some_and(|(s, _)| {
s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
});
let p = std::path::Path::new(target);
if has_scheme || p.is_absolute() {
return target.to_string();
}
match base_dir {
Some(dir) if !dir.as_os_str().is_empty() => dir.join(target).display().to_string(),
_ => target.to_string(),
}
}
pub fn parse_model(text: &str) -> Result<Model, String> {
let mut model = Model::default();
let mut defs = Vec::new();
for raw in statements(&strip_comments(text)) {
let stmt = raw.trim();
if stmt.is_empty() {
continue;
}
let (head, rest) = match stmt.split_once(char::is_whitespace) {
Some((h, r)) => (h, r.trim()),
None => (stmt, ""),
};
match head {
"node" => {
let (name, query) = rest.split_once(':').ok_or_else(|| {
format!("node needs 'NAME: query': '{stmt}'")
})?;
model.nodes.push(NodeDecl {
name: name.trim().to_string(),
query: query.trim().to_string(),
});
}
"mount" => {
let (name, target) = rest.split_once(':').ok_or_else(|| {
format!("mount needs 'NAME: target': '{stmt}'")
})?;
model.mounts.push(Mount {
name: name.trim().to_string(),
target: target.trim().to_string(),
});
}
"ref" => {
let (left, container) = rest.split_once("-->").ok_or_else(|| {
format!("ref needs 'PATH::field --> container': '{stmt}'")
})?;
let (scope, field) = split_field(left)?;
model.refs.push(RefDecl {
scope,
field,
container: container.trim().to_string(),
});
}
"edge" => {
let (scope_head, pair) = rest.split_once(':').ok_or_else(|| {
format!("edge needs 'PATH: ::a -- ::b': '{stmt}'")
})?;
let (a, b) = pair.split_once("--").ok_or_else(|| {
format!("edge needs two fields 'a -- b': '{stmt}'")
})?;
let field_a = a.trim().trim_start_matches("::").trim().to_string();
let field_b = b.trim().trim_start_matches("::").trim().to_string();
model.edges.push(EdgeDecl {
scope: scope_head.trim().to_string(),
field_a,
field_b,
});
}
"def" | "macro" => {
defs.push(format!("{stmt};"));
}
other => {
return Err(format!(
"unknown model statement '{other}' (expected node/ref/edge/mount/def/macro)"
));
}
}
}
model.defs_text = defs.join("\n");
Ok(model)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_forum_model() {
let m = parse_model(
r#"
# the forum's implied graph
mount forum: posts.csv;
node ips: /forum/posts/*::ip;
node cookies: /forum/posts/*::cookie;
ref /forum/posts/*::ip --> ips;
ref /forum/posts/*::cookie --> cookies;
edge /forum/posts/*: ::cookie -- ::ip;
"#,
)
.unwrap();
assert_eq!(m.mounts.len(), 1);
assert_eq!(m.mounts[0].name, "forum");
assert_eq!(m.mounts[0].target, "posts.csv");
assert_eq!(m.nodes.len(), 2);
assert_eq!(m.nodes[0].name, "ips");
assert_eq!(m.nodes[0].query, "/forum/posts/*::ip");
assert_eq!(m.refs.len(), 2);
assert_eq!(m.refs[1].scope, "/forum/posts/*");
assert_eq!(m.refs[1].field, "cookie");
assert_eq!(m.refs[1].container, "cookies");
assert_eq!(m.edges.len(), 1);
assert_eq!(m.edges[0].scope, "/forum/posts/*");
assert_eq!(m.edges[0].field_a, "cookie");
assert_eq!(m.edges[0].field_b, "ip");
}
#[test]
fn collects_defs_and_comments() {
let m = parse_model(
"def &recent: [::ts > '2026-01-01'];\nnode who: /log/*::user;",
)
.unwrap();
assert_eq!(m.nodes.len(), 1);
assert!(m.defs_text.contains("def &recent"));
}
#[test]
fn a_semicolon_projection_is_not_a_terminator() {
let m = parse_model("node sizes: /files/*;;;size;").unwrap();
assert_eq!(m.nodes.len(), 1);
assert_eq!(m.nodes[0].query, "/files/*;;;size");
}
#[test]
fn bare_field_ref_is_refused() {
assert!(parse_model("ref ::ip --> ips;").is_err());
}
}