use crate::error::{GroundDbError, Result};
use chrono::NaiveDate;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub struct PathTemplate {
pub raw: String,
pub segments: Vec<PathSegment>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PathSegment {
Literal(String),
Field { name: String, format: Option<String> },
NestedField { parent: String, child: String },
}
impl PathTemplate {
pub fn parse(template: &str) -> Result<Self> {
let mut segments = Vec::new();
let mut remaining = template;
while !remaining.is_empty() {
if let Some(start) = remaining.find('{') {
if start > 0 {
segments.push(PathSegment::Literal(remaining[..start].to_string()));
}
let end = remaining[start..]
.find('}')
.ok_or_else(|| {
GroundDbError::Schema(format!(
"Unclosed '{{' in path template: {template}"
))
})?
+ start;
let field_expr = &remaining[start + 1..end];
if field_expr.is_empty() {
return Err(GroundDbError::Schema(format!(
"Empty field reference '{{}}' in path template: {template}"
)));
}
let segment = parse_field_expr(field_expr);
segments.push(segment);
remaining = &remaining[end + 1..];
} else {
segments.push(PathSegment::Literal(remaining.to_string()));
break;
}
}
Ok(PathTemplate {
raw: template.to_string(),
segments,
})
}
pub fn render(&self, fields: &serde_yaml::Value, id: Option<&str>) -> Result<String> {
let mut result = String::new();
for segment in &self.segments {
match segment {
PathSegment::Literal(s) => result.push_str(s),
PathSegment::Field { name, format } => {
let raw_value = if name == "id" {
if let Some(id) = id {
serde_yaml::Value::String(id.to_string())
} else {
get_yaml_field(fields, name)?
}
} else if name == "created_at" || name == "modified_at" {
get_yaml_field(fields, name)?
} else {
get_yaml_field(fields, name)?
};
let rendered = format_value(&raw_value, format.as_deref())?;
result.push_str(&slugify(&rendered));
}
PathSegment::NestedField { parent, child } => {
let raw_value = get_nested_yaml_field(fields, parent, child)?;
let rendered = value_to_string(&raw_value)?;
result.push_str(&slugify(&rendered));
}
}
}
Ok(result)
}
pub fn referenced_fields(&self) -> HashSet<String> {
let mut fields = HashSet::new();
for segment in &self.segments {
match segment {
PathSegment::Field { name, .. } => {
fields.insert(name.clone());
}
PathSegment::NestedField { parent, .. } => {
fields.insert(parent.clone());
}
PathSegment::Literal(_) => {}
}
}
fields
}
pub fn references_field(&self, field_name: &str) -> bool {
self.segments.iter().any(|s| match s {
PathSegment::Field { name, .. } => name == field_name,
PathSegment::NestedField { parent, .. } => parent == field_name,
PathSegment::Literal(_) => false,
})
}
pub fn base_directory(&self) -> String {
let mut base = String::new();
for segment in &self.segments {
match segment {
PathSegment::Literal(s) => base.push_str(s),
_ => break,
}
}
if let Some(pos) = base.rfind('/') {
base[..=pos].to_string()
} else {
String::new()
}
}
pub fn extract(&self, path: &str) -> Option<HashMap<String, String>> {
let mut fields = HashMap::new();
let mut remaining = path;
for (i, segment) in self.segments.iter().enumerate() {
match segment {
PathSegment::Literal(lit) => {
if remaining.starts_with(lit.as_str()) {
remaining = &remaining[lit.len()..];
} else {
return None;
}
}
PathSegment::Field { name, format } => {
let value = self.extract_field_value(remaining, i, format.as_deref())?;
remaining = &remaining[value.len()..];
fields.insert(name.clone(), value);
}
PathSegment::NestedField { .. } => {
let value = self.extract_field_value(remaining, i, None)?;
remaining = &remaining[value.len()..];
}
}
}
if remaining.is_empty() {
Some(fields)
} else {
None
}
}
fn extract_field_value(&self, remaining: &str, idx: usize, format: Option<&str>) -> Option<String> {
if let Some(fmt) = format {
let len = fmt.len();
if remaining.len() >= len {
return Some(remaining[..len].to_string());
} else {
return None;
}
}
let delimiter = self.segments[idx + 1..]
.iter()
.find_map(|s| match s {
PathSegment::Literal(lit) => Some(lit.as_str()),
_ => None,
});
if let Some(delim) = delimiter {
if let Some(pos) = remaining.find(delim) {
Some(remaining[..pos].to_string())
} else {
None
}
} else {
Some(remaining.to_string())
}
}
}
fn parse_field_expr(expr: &str) -> PathSegment {
if let Some(colon_pos) = expr.find(':') {
let left = &expr[..colon_pos];
let right = &expr[colon_pos + 1..];
if is_date_format(right) {
PathSegment::Field {
name: left.to_string(),
format: Some(right.to_string()),
}
} else {
PathSegment::NestedField {
parent: left.to_string(),
child: right.to_string(),
}
}
} else {
PathSegment::Field {
name: expr.to_string(),
format: None,
}
}
}
fn is_date_format(s: &str) -> bool {
let format_chars = ['Y', 'M', 'D', 'H', 'T', 'S'];
s.chars().any(|c| format_chars.contains(&c))
}
fn get_yaml_field(value: &serde_yaml::Value, field: &str) -> Result<serde_yaml::Value> {
match value {
serde_yaml::Value::Mapping(map) => {
map.get(serde_yaml::Value::String(field.to_string()))
.cloned()
.ok_or_else(|| {
GroundDbError::Validation(format!(
"Field '{field}' required by path template but not found in document"
))
})
}
_ => Err(GroundDbError::Validation(
"Document data is not a YAML mapping".into(),
)),
}
}
fn get_nested_yaml_field(
value: &serde_yaml::Value,
parent: &str,
child: &str,
) -> Result<serde_yaml::Value> {
let parent_val = get_yaml_field(value, parent)?;
match &parent_val {
serde_yaml::Value::Mapping(map) => {
map.get(serde_yaml::Value::String(child.to_string()))
.cloned()
.ok_or_else(|| {
GroundDbError::Validation(format!(
"Nested field '{parent}:{child}' not found in ref value"
))
})
}
serde_yaml::Value::String(s) => {
if child == "id" {
Ok(serde_yaml::Value::String(s.clone()))
} else {
Err(GroundDbError::Validation(format!(
"Cannot access '{child}' on a simple ref value (string). \
Use a polymorphic ref (mapping with type/id) for '{parent}'"
)))
}
}
_ => Err(GroundDbError::Validation(format!(
"Ref field '{parent}' is not a string or mapping"
))),
}
}
fn format_value(value: &serde_yaml::Value, format: Option<&str>) -> Result<String> {
match format {
Some(fmt) => {
let date_str = value_to_string(value)?;
format_date(&date_str, fmt)
}
None => value_to_string(value),
}
}
fn value_to_string(value: &serde_yaml::Value) -> Result<String> {
match value {
serde_yaml::Value::String(s) => Ok(s.clone()),
serde_yaml::Value::Number(n) => Ok(n.to_string()),
serde_yaml::Value::Bool(b) => Ok(b.to_string()),
serde_yaml::Value::Null => Ok(String::new()),
_ => Err(GroundDbError::Validation(format!(
"Cannot convert value to path string: {value:?}"
))),
}
}
fn format_date(date_str: &str, format: &str) -> Result<String> {
if let Ok(date) = date_str.parse::<NaiveDate>() {
let mut result = format.to_string();
result = result.replace("YYYY", &format!("{:04}", date.format("%Y")));
result = result.replace("MM", &format!("{:02}", date.format("%m")));
result = result.replace("DD", &format!("{:02}", date.format("%d")));
return Ok(result);
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M:%S") {
return Ok(format_datetime(dt, format));
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M") {
return Ok(format_datetime(dt, format));
}
if let Ok(dt) = date_str.parse::<chrono::DateTime<chrono::Utc>>() {
return Ok(format_datetime(dt.naive_utc(), format));
}
Ok(date_str.to_string())
}
fn format_datetime(dt: chrono::NaiveDateTime, format: &str) -> String {
let mut result = format.to_string();
result = result.replace("YYYY", &format!("{}", dt.format("%Y")));
result = result.replace("HH", &format!("{}", dt.format("%H")));
result = replace_date_tokens(result, dt);
result
}
fn replace_date_tokens(format: String, dt: chrono::NaiveDateTime) -> String {
let mut result = format;
result = result.replace("DD", &format!("{}", dt.format("%d")));
result = result.replace("SS", &format!("{}", dt.format("%S")));
let month = format!("{}", dt.format("%m"));
let minute = format!("{}", dt.format("%M"));
if let Some(first_pos) = result.find("MM") {
let after_first = first_pos + 2;
if result[after_first..].contains("MM") {
result = result.replacen("MM", &month, 1);
result = result.replacen("MM", &minute, 1);
} else {
let before = &result[..first_pos];
if before.ends_with(|c: char| c.is_ascii_digit()) && before.len() >= 2 {
let last_two = &before[before.len() - 2..];
if last_two.chars().all(|c| c.is_ascii_digit()) {
result = result.replacen("MM", &minute, 1);
} else {
result = result.replacen("MM", &month, 1);
}
} else {
result = result.replacen("MM", &month, 1);
}
}
}
result
}
pub fn slugify(input: &str) -> String {
slug::slugify(input)
}
pub fn resolve_suffix(base_path: &str, exists_fn: impl Fn(&str) -> bool) -> String {
if !exists_fn(base_path) {
return base_path.to_string();
}
let (stem, ext) = if let Some(dot_pos) = base_path.rfind('.') {
(&base_path[..dot_pos], &base_path[dot_pos..])
} else {
(base_path, "")
};
let mut counter = 2;
loop {
let candidate = format!("{stem}-{counter}{ext}");
if !exists_fn(&candidate) {
return candidate;
}
counter += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_yaml::Value;
#[test]
fn test_parse_simple_template() {
let t = PathTemplate::parse("users/{name}.md").unwrap();
assert_eq!(t.segments.len(), 3);
assert_eq!(t.segments[0], PathSegment::Literal("users/".to_string()));
assert_eq!(
t.segments[1],
PathSegment::Field {
name: "name".to_string(),
format: None,
}
);
assert_eq!(t.segments[2], PathSegment::Literal(".md".to_string()));
}
#[test]
fn test_parse_template_with_date_format() {
let t = PathTemplate::parse("posts/{status}/{date:YYYY-MM-DD}-{title}.md").unwrap();
assert_eq!(t.segments.len(), 7);
assert_eq!(t.segments[0], PathSegment::Literal("posts/".to_string()));
assert_eq!(
t.segments[1],
PathSegment::Field {
name: "status".to_string(),
format: None,
}
);
assert_eq!(t.segments[2], PathSegment::Literal("/".to_string()));
assert_eq!(
t.segments[3],
PathSegment::Field {
name: "date".to_string(),
format: Some("YYYY-MM-DD".to_string()),
}
);
assert_eq!(t.segments[4], PathSegment::Literal("-".to_string()));
assert_eq!(
t.segments[5],
PathSegment::Field {
name: "title".to_string(),
format: None,
}
);
assert_eq!(t.segments[6], PathSegment::Literal(".md".to_string()));
}
#[test]
fn test_parse_template_with_nested_refs() {
let t = PathTemplate::parse(
"comments/{parent:type}/{parent:id}/{user:id}-{created_at:YYYY-MM-DDTHHMM}.md",
)
.unwrap();
let nested_fields: Vec<_> = t
.segments
.iter()
.filter(|s| matches!(s, PathSegment::NestedField { .. }))
.collect();
assert_eq!(nested_fields.len(), 3);
assert!(t.segments.contains(&PathSegment::NestedField {
parent: "parent".to_string(),
child: "type".to_string(),
}));
assert!(t.segments.contains(&PathSegment::NestedField {
parent: "parent".to_string(),
child: "id".to_string(),
}));
assert!(t.segments.contains(&PathSegment::NestedField {
parent: "user".to_string(),
child: "id".to_string(),
}));
}
#[test]
fn test_parse_id_template() {
let t = PathTemplate::parse("events/{id}.md").unwrap();
assert_eq!(t.segments.len(), 3);
assert_eq!(
t.segments[1],
PathSegment::Field {
name: "id".to_string(),
format: None,
}
);
}
#[test]
fn test_render_simple() {
let t = PathTemplate::parse("users/{name}.md").unwrap();
let data: Value = serde_yaml::from_str("name: Alice Chen").unwrap();
let result = t.render(&data, None).unwrap();
assert_eq!(result, "users/alice-chen.md");
}
#[test]
fn test_render_with_date() {
let t = PathTemplate::parse("posts/{status}/{date:YYYY-MM-DD}-{title}.md").unwrap();
let data: Value = serde_yaml::from_str(
"title: Quarterly Review\nstatus: published\ndate: '2026-02-13'",
)
.unwrap();
let result = t.render(&data, None).unwrap();
assert_eq!(result, "posts/published/2026-02-13-quarterly-review.md");
}
#[test]
fn test_render_with_id() {
let t = PathTemplate::parse("events/{id}.md").unwrap();
let data: Value = serde_yaml::from_str("type: test").unwrap();
let result = t.render(&data, Some("01JMCX7K9A")).unwrap();
assert_eq!(result, "events/01jmcx7k9a.md");
}
#[test]
fn test_render_nested_ref() {
let t = PathTemplate::parse("comments/{parent:type}/{parent:id}.md").unwrap();
let data: Value =
serde_yaml::from_str("parent:\n type: posts\n id: my-post").unwrap();
let result = t.render(&data, None).unwrap();
assert_eq!(result, "comments/posts/my-post.md");
}
#[test]
fn test_referenced_fields() {
let t = PathTemplate::parse("posts/{status}/{date:YYYY-MM-DD}-{title}.md").unwrap();
let fields = t.referenced_fields();
assert!(fields.contains("status"));
assert!(fields.contains("date"));
assert!(fields.contains("title"));
assert_eq!(fields.len(), 3);
}
#[test]
fn test_referenced_fields_with_nested() {
let t = PathTemplate::parse(
"comments/{parent:type}/{parent:id}/{user:id}.md",
)
.unwrap();
let fields = t.referenced_fields();
assert!(fields.contains("parent"));
assert!(fields.contains("user"));
}
#[test]
fn test_references_field() {
let t = PathTemplate::parse("posts/{status}/{date:YYYY-MM-DD}-{title}.md").unwrap();
assert!(t.references_field("status"));
assert!(t.references_field("title"));
assert!(!t.references_field("author_id"));
}
#[test]
fn test_slugify() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("My Draft Post"), "my-draft-post");
assert_eq!(slugify("UPPERCASE"), "uppercase");
assert_eq!(slugify("special!@#chars"), "special-chars");
}
#[test]
fn test_resolve_suffix() {
let existing = vec!["test.md".to_string(), "test-2.md".to_string()];
let result = resolve_suffix("test.md", |p| existing.contains(&p.to_string()));
assert_eq!(result, "test-3.md");
}
#[test]
fn test_resolve_suffix_no_conflict() {
let result = resolve_suffix("test.md", |_| false);
assert_eq!(result, "test.md");
}
#[test]
fn test_unclosed_brace() {
let result = PathTemplate::parse("posts/{title");
assert!(result.is_err());
}
#[test]
fn test_empty_field_ref() {
let result = PathTemplate::parse("posts/{}.md");
assert!(result.is_err());
}
#[test]
fn test_base_directory() {
let t = PathTemplate::parse("posts/{status}/{date:YYYY-MM-DD}-{title}.md").unwrap();
assert_eq!(t.base_directory(), "posts/");
let t2 = PathTemplate::parse("users/{name}.md").unwrap();
assert_eq!(t2.base_directory(), "users/");
let t3 = PathTemplate::parse("{id}.md").unwrap();
assert_eq!(t3.base_directory(), "");
}
#[test]
fn test_extract_simple() {
let t = PathTemplate::parse("users/{name}.md").unwrap();
let fields = t.extract("users/alice-chen.md").unwrap();
assert_eq!(fields.get("name").unwrap(), "alice-chen");
assert_eq!(fields.len(), 1);
}
#[test]
fn test_extract_with_date_and_status() {
let t = PathTemplate::parse("posts/{status}/{date:YYYY-MM-DD}-{title}.md").unwrap();
let fields = t
.extract("posts/published/2026-02-13-quarterly-review.md")
.unwrap();
assert_eq!(fields.get("status").unwrap(), "published");
assert_eq!(fields.get("date").unwrap(), "2026-02-13");
assert_eq!(fields.get("title").unwrap(), "quarterly-review");
assert_eq!(fields.len(), 3);
}
#[test]
fn test_extract_wrong_prefix() {
let t = PathTemplate::parse("posts/{status}/{date:YYYY-MM-DD}-{title}.md").unwrap();
assert!(t.extract("users/alice-chen.md").is_none());
}
#[test]
fn test_extract_id_only() {
let t = PathTemplate::parse("events/{id}.md").unwrap();
let fields = t.extract("events/01jmcx7k9a.md").unwrap();
assert_eq!(fields.get("id").unwrap(), "01jmcx7k9a");
}
#[test]
fn test_extract_nested_ref_skipped() {
let t = PathTemplate::parse("comments/{parent:type}/{parent:id}/{user:id}-{created_at:YYYY-MM-DDTHHMM}.md").unwrap();
let fields = t
.extract("comments/posts/my-post/alice-2026-02-13t1430.md")
.unwrap();
assert!(!fields.contains_key("parent"));
assert!(!fields.contains_key("user"));
assert_eq!(fields.get("created_at").unwrap(), "2026-02-13t1430");
}
#[test]
fn test_extract_roundtrip() {
let t = PathTemplate::parse("posts/{status}/{date:YYYY-MM-DD}-{title}.md").unwrap();
let data: Value = serde_yaml::from_str(
"title: Quarterly Review\nstatus: published\ndate: '2026-02-13'",
)
.unwrap();
let rendered = t.render(&data, None).unwrap();
let extracted = t.extract(&rendered).unwrap();
assert_eq!(extracted.get("status").unwrap(), "published");
assert_eq!(extracted.get("title").unwrap(), "quarterly-review");
}
}