use std::collections::HashMap;
use std::error::Error as StdError;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::value::QuillValue;
pub mod field_key {
pub const TITLE: &str = "title";
pub const TYPE: &str = "type";
pub const DESCRIPTION: &str = "description";
pub const DEFAULT: &str = "default";
pub const EXAMPLES: &str = "examples";
pub const UI: &str = "ui";
pub const REQUIRED: &str = "required";
pub const ENUM: &str = "enum";
pub const FORMAT: &str = "format";
}
pub mod ui_key {
pub const GROUP: &str = "group";
pub const ORDER: &str = "order";
pub const HIDE_BODY: &str = "hide_body";
pub const VISIBLE_WHEN: &str = "visible_when";
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UiFieldSchema {
pub group: Option<String>,
pub order: Option<i32>,
pub visible_when: Option<HashMap<String, Vec<String>>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UiContainerSchema {
pub hide_body: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CardSchema {
pub name: String,
pub title: Option<String>,
pub description: Option<String>,
pub fields: HashMap<String, FieldSchema>,
pub ui: Option<UiContainerSchema>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldType {
#[serde(alias = "str")]
String,
Number,
Boolean,
Array,
Object,
Date,
DateTime,
Markdown,
}
impl FieldType {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"string" | "str" => Some(FieldType::String),
"number" => Some(FieldType::Number),
"boolean" => Some(FieldType::Boolean),
"array" => Some(FieldType::Array),
"object" | "dict" => Some(FieldType::Object),
"date" => Some(FieldType::Date),
"datetime" => Some(FieldType::DateTime),
"markdown" => Some(FieldType::Markdown),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
FieldType::String => "string",
FieldType::Number => "number",
FieldType::Boolean => "boolean",
FieldType::Array => "array",
FieldType::Object => "dict",
FieldType::Date => "date",
FieldType::DateTime => "datetime",
FieldType::Markdown => "markdown",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FieldSchema {
pub name: String,
pub title: Option<String>,
pub r#type: FieldType,
pub description: Option<String>,
pub default: Option<QuillValue>,
pub examples: Option<QuillValue>,
pub ui: Option<UiFieldSchema>,
pub required: bool,
pub enum_values: Option<Vec<String>>,
pub properties: Option<HashMap<String, Box<FieldSchema>>>,
pub items: Option<Box<FieldSchema>>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct FieldSchemaDef {
pub title: Option<String>,
pub r#type: FieldType,
pub description: Option<String>,
pub default: Option<QuillValue>,
pub examples: Option<QuillValue>,
pub ui: Option<UiFieldSchema>,
#[serde(default)]
pub required: bool,
#[serde(rename = "enum")]
pub enum_values: Option<Vec<String>>,
pub properties: Option<serde_json::Map<String, serde_json::Value>>,
pub items: Option<serde_json::Value>,
}
impl FieldSchema {
pub fn new(name: String, r#type: FieldType, description: Option<String>) -> Self {
Self {
name,
title: None,
r#type,
description,
default: None,
examples: None,
ui: None,
required: false,
enum_values: None,
properties: None,
items: None,
}
}
pub fn from_quill_value(key: String, value: &QuillValue) -> Result<Self, String> {
let def: FieldSchemaDef = serde_json::from_value(value.clone().into_json())
.map_err(|e| format!("Failed to parse field schema: {}", e))?;
Ok(Self {
name: key,
title: def.title,
r#type: def.r#type,
description: def.description,
default: def.default,
examples: def.examples,
ui: def.ui,
required: def.required,
enum_values: def.enum_values,
properties: if let Some(props) = def.properties {
let mut p = HashMap::new();
for (key, value) in props {
p.insert(
key.clone(),
Box::new(FieldSchema::from_quill_value(
key,
&QuillValue::from_json(value),
)?),
);
}
Some(p)
} else {
None
},
items: if let Some(item_def) = def.items {
Some(Box::new(FieldSchema::from_quill_value(
"items".to_string(),
&QuillValue::from_json(item_def),
)?))
} else {
None
},
})
}
}
#[derive(Debug, Clone)]
pub enum FileTreeNode {
File {
contents: Vec<u8>,
},
Directory {
files: HashMap<String, FileTreeNode>,
},
}
impl FileTreeNode {
pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
let path = path.as_ref();
if path == Path::new("") {
return Some(self);
}
let components: Vec<_> = path
.components()
.filter_map(|c| {
if let std::path::Component::Normal(s) = c {
s.to_str()
} else {
None
}
})
.collect();
if components.is_empty() {
return Some(self);
}
let mut current_node = self;
for component in components {
match current_node {
FileTreeNode::Directory { files } => {
current_node = files.get(component)?;
}
FileTreeNode::File { .. } => {
return None; }
}
}
Some(current_node)
}
pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
match self.get_node(path)? {
FileTreeNode::File { contents } => Some(contents.as_slice()),
FileTreeNode::Directory { .. } => None,
}
}
pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
}
pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
}
pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
match self.get_node(dir_path) {
Some(FileTreeNode::Directory { files }) => files
.iter()
.filter_map(|(name, node)| {
if matches!(node, FileTreeNode::File { .. }) {
Some(name.clone())
} else {
None
}
})
.collect(),
_ => Vec::new(),
}
}
pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
match self.get_node(dir_path) {
Some(FileTreeNode::Directory { files }) => files
.iter()
.filter_map(|(name, node)| {
if matches!(node, FileTreeNode::Directory { .. }) {
Some(name.clone())
} else {
None
}
})
.collect(),
_ => Vec::new(),
}
}
pub fn insert<P: AsRef<Path>>(
&mut self,
path: P,
node: FileTreeNode,
) -> Result<(), Box<dyn StdError + Send + Sync>> {
let path = path.as_ref();
let components: Vec<_> = path
.components()
.filter_map(|c| {
if let std::path::Component::Normal(s) = c {
s.to_str().map(|s| s.to_string())
} else {
None
}
})
.collect();
if components.is_empty() {
return Err("Cannot insert at root path".into());
}
let mut current_node = self;
for component in &components[..components.len() - 1] {
match current_node {
FileTreeNode::Directory { files } => {
current_node =
files
.entry(component.clone())
.or_insert_with(|| FileTreeNode::Directory {
files: HashMap::new(),
});
}
FileTreeNode::File { .. } => {
return Err("Cannot traverse into a file".into());
}
}
}
let filename = &components[components.len() - 1];
match current_node {
FileTreeNode::Directory { files } => {
files.insert(filename.clone(), node);
Ok(())
}
FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
}
}
fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
Ok(FileTreeNode::File {
contents: contents_str.as_bytes().to_vec(),
})
} else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
let contents: Vec<u8> = bytes_array
.iter()
.filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
.collect();
Ok(FileTreeNode::File { contents })
} else if let Some(obj) = value.as_object() {
let mut files = HashMap::new();
for (name, child_value) in obj {
files.insert(name.clone(), Self::from_json_value(child_value)?);
}
Ok(FileTreeNode::Directory { files })
} else {
Err(format!("Invalid file tree node: {:?}", value).into())
}
}
pub fn print_tree(&self) -> String {
self.print_tree_recursive("", "", true)
}
fn print_tree_recursive(&self, name: &str, prefix: &str, is_last: bool) -> String {
let mut result = String::new();
let connector = if is_last { "└── " } else { "├── " };
let extension = if is_last { " " } else { "│ " };
match self {
FileTreeNode::File { .. } => {
result.push_str(&format!("{}{}{}\n", prefix, connector, name));
}
FileTreeNode::Directory { files } => {
result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
let child_prefix = format!("{}{}", prefix, extension);
let count = files.len();
for (i, (child_name, node)) in files.iter().enumerate() {
let is_last_child = i == count - 1;
result.push_str(&node.print_tree_recursive(
child_name,
&child_prefix,
is_last_child,
));
}
}
}
result
}
}
#[derive(Debug, Clone)]
pub struct QuillIgnore {
patterns: Vec<String>,
}
impl QuillIgnore {
pub fn new(patterns: Vec<String>) -> Self {
Self { patterns }
}
pub fn from_content(content: &str) -> Self {
let patterns = content
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| line.to_string())
.collect();
Self::new(patterns)
}
pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
let path = path.as_ref();
let path_str = path.to_string_lossy();
for pattern in &self.patterns {
if self.matches_pattern(pattern, &path_str) {
return true;
}
}
false
}
fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
if let Some(pattern_prefix) = pattern.strip_suffix('/') {
return path.starts_with(pattern_prefix)
&& (path.len() == pattern_prefix.len()
|| path.chars().nth(pattern_prefix.len()) == Some('/'));
}
if !pattern.contains('*') {
return path == pattern || path.ends_with(&format!("/{}", pattern));
}
if pattern == "*" {
return true;
}
let pattern_parts: Vec<&str> = pattern.split('*').collect();
if pattern_parts.len() == 2 {
let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
if prefix.is_empty() {
return path.ends_with(suffix);
} else if suffix.is_empty() {
return path.starts_with(prefix);
} else {
return path.starts_with(prefix) && path.ends_with(suffix);
}
}
false
}
}
#[derive(Debug, Clone)]
pub struct Quill {
pub metadata: HashMap<String, QuillValue>,
pub name: String,
pub backend: String,
pub plate: Option<String>,
pub example: Option<String>,
pub schema: QuillValue,
pub defaults: HashMap<String, QuillValue>,
pub examples: HashMap<String, Vec<QuillValue>>,
pub files: FileTreeNode,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuillConfig {
pub document: CardSchema,
pub backend: String,
pub version: String,
pub author: String,
pub example_file: Option<String>,
pub plate_file: Option<String>,
pub cards: HashMap<String, CardSchema>,
#[serde(flatten)]
pub metadata: HashMap<String, QuillValue>,
#[serde(default)]
pub typst_config: HashMap<String, QuillValue>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CardSchemaDef {
pub title: Option<String>,
pub description: Option<String>,
pub fields: Option<serde_json::Map<String, serde_json::Value>>,
pub ui: Option<UiContainerSchema>,
}
impl QuillConfig {
fn parse_fields_with_order(
fields_map: &serde_json::Map<String, serde_json::Value>,
key_order: &[String],
context: &str,
) -> HashMap<String, FieldSchema> {
let mut fields = HashMap::new();
let mut fallback_counter = 0;
for (field_name, field_value) in fields_map {
let order = if let Some(idx) = key_order.iter().position(|k| k == field_name) {
idx as i32
} else {
let o = key_order.len() as i32 + fallback_counter;
fallback_counter += 1;
o
};
let quill_value = QuillValue::from_json(field_value.clone());
match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
Ok(mut schema) => {
if schema.ui.is_none() {
schema.ui = Some(UiFieldSchema {
group: None,
order: Some(order),
visible_when: None,
});
} else if let Some(ui) = &mut schema.ui {
if ui.order.is_none() {
ui.order = Some(order);
}
}
fields.insert(field_name.clone(), schema);
}
Err(e) => {
eprintln!(
"Warning: Failed to parse {} '{}': {}",
context, field_name, e
);
}
}
}
fields
}
pub fn from_yaml(yaml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
let quill_yaml_val: serde_json::Value = serde_saphyr::from_str(yaml_content)
.map_err(|e| format!("Failed to parse Quill.yaml: {}", e))?;
let quill_section = quill_yaml_val
.get("Quill")
.ok_or("Missing required 'Quill' section in Quill.yaml")?;
let name = quill_section
.get("name")
.and_then(|v| v.as_str())
.ok_or("Missing required 'name' field in 'Quill' section")?
.to_string();
let backend = quill_section
.get("backend")
.and_then(|v| v.as_str())
.ok_or("Missing required 'backend' field in 'Quill' section")?
.to_string();
let description = quill_section
.get("description")
.and_then(|v| v.as_str())
.ok_or("Missing required 'description' field in 'Quill' section")?;
if description.trim().is_empty() {
return Err("'description' field in 'Quill' section cannot be empty".into());
}
let description = description.to_string();
let version_val = quill_section
.get("version")
.ok_or("Missing required 'version' field in 'Quill' section")?;
let version = if let Some(s) = version_val.as_str() {
s.to_string()
} else if let Some(n) = version_val.as_f64() {
n.to_string()
} else {
return Err("Invalid 'version' field format".into());
};
use std::str::FromStr;
crate::version::Version::from_str(&version)
.map_err(|e| format!("Invalid version '{}': {}", version, e))?;
let author = quill_section
.get("author")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "Unknown".to_string());
let example_file = quill_section
.get("example_file")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let plate_file = quill_section
.get("plate_file")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let ui_section: Option<UiContainerSchema> = quill_section
.get("ui")
.cloned()
.and_then(|v| serde_json::from_value(v).ok());
let mut metadata = HashMap::new();
if let Some(table) = quill_section.as_object() {
for (key, value) in table {
if key != "name"
&& key != "backend"
&& key != "description"
&& key != "version"
&& key != "author"
&& key != "example_file"
&& key != "plate_file"
&& key != "ui"
{
metadata.insert(key.clone(), QuillValue::from_json(value.clone()));
}
}
}
let mut typst_config = HashMap::new();
if let Some(typst_val) = quill_yaml_val.get("typst") {
if let Some(table) = typst_val.as_object() {
for (key, value) in table {
typst_config.insert(key.clone(), QuillValue::from_json(value.clone()));
}
}
}
let fields = if let Some(fields_val) = quill_yaml_val.get("fields") {
if let Some(fields_map) = fields_val.as_object() {
let field_order: Vec<String> = fields_map.keys().cloned().collect();
Self::parse_fields_with_order(fields_map, &field_order, "field schema")
} else {
HashMap::new()
}
} else {
HashMap::new()
};
let mut cards: HashMap<String, CardSchema> = HashMap::new();
if let Some(cards_val) = quill_yaml_val.get("cards") {
let cards_table = cards_val
.as_object()
.ok_or("'cards' section must be an object")?;
for (card_name, card_value) in cards_table {
let card_def: CardSchemaDef = serde_json::from_value(card_value.clone())
.map_err(|e| format!("Failed to parse card '{}': {}", card_name, e))?;
let card_fields = if let Some(card_fields_table) =
card_value.get("fields").and_then(|v| v.as_object())
{
let card_field_order: Vec<String> = card_fields_table.keys().cloned().collect();
Self::parse_fields_with_order(
card_fields_table,
&card_field_order,
&format!("card '{}' field", card_name),
)
} else if let Some(_toml_fields) = &card_def.fields {
HashMap::new()
} else {
HashMap::new()
};
let card_schema = CardSchema {
name: card_name.clone(),
title: card_def.title,
description: card_def.description,
fields: card_fields,
ui: card_def.ui,
};
cards.insert(card_name.clone(), card_schema);
}
}
let document = CardSchema {
name: name.clone(),
title: Some(name),
description: Some(description),
fields,
ui: ui_section,
};
Ok(QuillConfig {
document,
backend,
version,
author,
example_file,
plate_file,
cards,
metadata,
typst_config,
})
}
}
impl Quill {
pub fn from_path<P: AsRef<std::path::Path>>(
path: P,
) -> Result<Self, Box<dyn StdError + Send + Sync>> {
use std::fs;
let path = path.as_ref();
let quillignore_path = path.join(".quillignore");
let ignore = if quillignore_path.exists() {
let ignore_content = fs::read_to_string(&quillignore_path)
.map_err(|e| format!("Failed to read .quillignore: {}", e))?;
QuillIgnore::from_content(&ignore_content)
} else {
QuillIgnore::new(vec![
".git/".to_string(),
".gitignore".to_string(),
".quillignore".to_string(),
"target/".to_string(),
"node_modules/".to_string(),
])
};
let root = Self::load_directory_as_tree(path, path, &ignore)?;
Self::from_tree(root)
}
pub fn from_tree(root: FileTreeNode) -> Result<Self, Box<dyn StdError + Send + Sync>> {
let quill_yaml_bytes = root
.get_file("Quill.yaml")
.ok_or("Quill.yaml not found in file tree")?;
let quill_yaml_content = String::from_utf8(quill_yaml_bytes.to_vec())
.map_err(|e| format!("Quill.yaml is not valid UTF-8: {}", e))?;
let config = QuillConfig::from_yaml(&quill_yaml_content)?;
Self::from_config(config, root)
}
fn from_config(
config: QuillConfig,
root: FileTreeNode,
) -> Result<Self, Box<dyn StdError + Send + Sync>> {
let mut metadata = config.metadata.clone();
metadata.insert(
"backend".to_string(),
QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
);
metadata.insert(
"description".to_string(),
QuillValue::from_json(serde_json::Value::String(
config.document.description.clone().unwrap_or_default(),
)),
);
metadata.insert(
"author".to_string(),
QuillValue::from_json(serde_json::Value::String(config.author.clone())),
);
metadata.insert(
"version".to_string(),
QuillValue::from_json(serde_json::Value::String(config.version.clone())),
);
for (key, value) in &config.typst_config {
metadata.insert(format!("typst_{}", key), value.clone());
}
let schema = crate::schema::build_schema(&config.document, &config.cards)
.map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?;
let plate_content: Option<String> = if let Some(ref plate_file_name) = config.plate_file {
let plate_bytes = root.get_file(plate_file_name).ok_or_else(|| {
format!("Plate file '{}' not found in file tree", plate_file_name)
})?;
let content = String::from_utf8(plate_bytes.to_vec()).map_err(|e| {
format!("Plate file '{}' is not valid UTF-8: {}", plate_file_name, e)
})?;
Some(content)
} else {
None
};
let example_content = if let Some(ref example_file_name) = config.example_file {
root.get_file(example_file_name).and_then(|bytes| {
String::from_utf8(bytes.to_vec())
.map_err(|e| {
eprintln!(
"Warning: Example file '{}' is not valid UTF-8: {}",
example_file_name, e
);
e
})
.ok()
})
} else if root.file_exists("example.md") {
root.get_file("example.md").and_then(|bytes| {
String::from_utf8(bytes.to_vec())
.map_err(|e| {
eprintln!(
"Warning: Default example file 'example.md' is not valid UTF-8: {}",
e
);
e
})
.ok()
})
} else {
None
};
let defaults = crate::schema::extract_defaults_from_schema(&schema);
let examples = crate::schema::extract_examples_from_schema(&schema);
let quill = Quill {
metadata,
name: config.document.name,
backend: config.backend,
plate: plate_content,
example: example_content,
schema,
defaults,
examples,
files: root,
};
Ok(quill)
}
pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
use serde_json::Value as JsonValue;
let json: JsonValue =
serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
let obj = json.as_object().ok_or("Root must be an object")?;
let files_obj = obj
.get("files")
.and_then(|v| v.as_object())
.ok_or("Missing or invalid 'files' key")?;
let mut root_files = HashMap::new();
for (key, value) in files_obj {
root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
}
let root = FileTreeNode::Directory { files: root_files };
Self::from_tree(root)
}
fn load_directory_as_tree(
current_dir: &Path,
base_dir: &Path,
ignore: &QuillIgnore,
) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
use std::fs;
if !current_dir.exists() {
return Ok(FileTreeNode::Directory {
files: HashMap::new(),
});
}
let mut files = HashMap::new();
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
let relative_path = path
.strip_prefix(base_dir)
.map_err(|e| format!("Failed to get relative path: {}", e))?
.to_path_buf();
if ignore.is_ignored(&relative_path) {
continue;
}
let filename = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| format!("Invalid filename: {}", path.display()))?
.to_string();
if path.is_file() {
let contents = fs::read(&path)
.map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
files.insert(filename, FileTreeNode::File { contents });
} else if path.is_dir() {
let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
files.insert(filename, subdir_tree);
}
}
Ok(FileTreeNode::Directory { files })
}
pub fn typst_packages(&self) -> Vec<String> {
self.metadata
.get("typst_packages")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
&self.defaults
}
pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
&self.examples
}
pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
self.files.get_file(path)
}
pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
self.files.file_exists(path)
}
pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
self.files.dir_exists(path)
}
pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
self.files.list_files(path)
}
pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
self.files.list_subdirectories(path)
}
pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
let dir_path = dir_path.as_ref();
let filenames = self.files.list_files(dir_path);
filenames
.iter()
.map(|name| {
if dir_path == Path::new("") {
PathBuf::from(name)
} else {
dir_path.join(name)
}
})
.collect()
}
pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
let dir_path = dir_path.as_ref();
let subdirs = self.files.list_subdirectories(dir_path);
subdirs
.iter()
.map(|name| {
if dir_path == Path::new("") {
PathBuf::from(name)
} else {
dir_path.join(name)
}
})
.collect()
}
pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
let pattern_str = pattern.as_ref().to_string_lossy();
let mut matches = Vec::new();
let glob_pattern = match glob::Pattern::new(&pattern_str) {
Ok(pat) => pat,
Err(_) => return matches, };
Self::find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
matches.sort();
matches
}
fn find_files_recursive(
node: &FileTreeNode,
current_path: &Path,
pattern: &glob::Pattern,
matches: &mut Vec<PathBuf>,
) {
match node {
FileTreeNode::File { .. } => {
let path_str = current_path.to_string_lossy();
if pattern.matches(&path_str) {
matches.push(current_path.to_path_buf());
}
}
FileTreeNode::Directory { files } => {
for (name, child_node) in files {
let child_path = if current_path == Path::new("") {
PathBuf::from(name)
} else {
current_path.join(name)
};
Self::find_files_recursive(child_node, &child_path, pattern, matches);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_quillignore_parsing() {
let ignore_content = r#"
# This is a comment
*.tmp
target/
node_modules/
.git/
"#;
let ignore = QuillIgnore::from_content(ignore_content);
assert_eq!(ignore.patterns.len(), 4);
assert!(ignore.patterns.contains(&"*.tmp".to_string()));
assert!(ignore.patterns.contains(&"target/".to_string()));
}
#[test]
fn test_quillignore_matching() {
let ignore = QuillIgnore::new(vec![
"*.tmp".to_string(),
"target/".to_string(),
"node_modules/".to_string(),
".git/".to_string(),
]);
assert!(ignore.is_ignored("test.tmp"));
assert!(ignore.is_ignored("path/to/file.tmp"));
assert!(!ignore.is_ignored("test.txt"));
assert!(ignore.is_ignored("target"));
assert!(ignore.is_ignored("target/debug"));
assert!(ignore.is_ignored("target/debug/deps"));
assert!(!ignore.is_ignored("src/target.rs"));
assert!(ignore.is_ignored("node_modules"));
assert!(ignore.is_ignored("node_modules/package"));
assert!(!ignore.is_ignored("my_node_modules"));
}
#[test]
fn test_in_memory_file_system() {
let temp_dir = TempDir::new().unwrap();
let quill_dir = temp_dir.path();
fs::write(
quill_dir.join("Quill.yaml"),
"Quill:\n name: \"test\"\n version: \"1.0\"\n backend: \"typst\"\n plate_file: \"plate.typ\"\n description: \"Test quill\"",
)
.unwrap();
fs::write(quill_dir.join("plate.typ"), "test plate").unwrap();
let assets_dir = quill_dir.join("assets");
fs::create_dir_all(&assets_dir).unwrap();
fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
let packages_dir = quill_dir.join("packages");
fs::create_dir_all(&packages_dir).unwrap();
fs::write(packages_dir.join("package.typ"), "package content").unwrap();
let quill = Quill::from_path(quill_dir).unwrap();
assert!(quill.file_exists("plate.typ"));
assert!(quill.file_exists("assets/test.txt"));
assert!(quill.file_exists("packages/package.typ"));
assert!(!quill.file_exists("nonexistent.txt"));
let asset_content = quill.get_file("assets/test.txt").unwrap();
assert_eq!(asset_content, b"asset content");
let asset_files = quill.list_directory("assets");
assert_eq!(asset_files.len(), 1);
assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
}
#[test]
fn test_quillignore_integration() {
let temp_dir = TempDir::new().unwrap();
let quill_dir = temp_dir.path();
fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
fs::write(
quill_dir.join("Quill.yaml"),
"Quill:\n name: \"test\"\n version: \"1.0\"\n backend: \"typst\"\n plate_file: \"plate.typ\"\n description: \"Test quill\"",
)
.unwrap();
fs::write(quill_dir.join("plate.typ"), "test template").unwrap();
fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
let target_dir = quill_dir.join("target");
fs::create_dir_all(&target_dir).unwrap();
fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
let quill = Quill::from_path(quill_dir).unwrap();
assert!(quill.file_exists("plate.typ"));
assert!(!quill.file_exists("should_ignore.tmp"));
assert!(!quill.file_exists("target/debug.txt"));
}
#[test]
fn test_find_files_pattern() {
let temp_dir = TempDir::new().unwrap();
let quill_dir = temp_dir.path();
fs::write(
quill_dir.join("Quill.yaml"),
"Quill:\n name: \"test\"\n version: \"1.0\"\n backend: \"typst\"\n plate_file: \"plate.typ\"\n description: \"Test quill\"",
)
.unwrap();
fs::write(quill_dir.join("plate.typ"), "template").unwrap();
let assets_dir = quill_dir.join("assets");
fs::create_dir_all(&assets_dir).unwrap();
fs::write(assets_dir.join("image.png"), "png data").unwrap();
fs::write(assets_dir.join("data.json"), "json data").unwrap();
let fonts_dir = assets_dir.join("fonts");
fs::create_dir_all(&fonts_dir).unwrap();
fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
let quill = Quill::from_path(quill_dir).unwrap();
let all_assets = quill.find_files("assets/*");
assert!(all_assets.len() >= 3);
let typ_files = quill.find_files("*.typ");
assert_eq!(typ_files.len(), 1);
assert!(typ_files.contains(&PathBuf::from("plate.typ")));
}
#[test]
fn test_new_standardized_yaml_format() {
let temp_dir = TempDir::new().unwrap();
let quill_dir = temp_dir.path();
let yaml_content = r#"
Quill:
name: my-custom-quill
version: "1.0"
backend: typst
plate_file: custom_plate.typ
description: Test quill with new format
author: Test Author
"#;
fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
fs::write(
quill_dir.join("custom_plate.typ"),
"= Custom Template\n\nThis is a custom template.",
)
.unwrap();
let quill = Quill::from_path(quill_dir).unwrap();
assert_eq!(quill.name, "my-custom-quill");
assert!(quill.metadata.contains_key("backend"));
if let Some(backend_val) = quill.metadata.get("backend") {
if let Some(backend_str) = backend_val.as_str() {
assert_eq!(backend_str, "typst");
} else {
panic!("Backend value is not a string");
}
}
assert!(quill.metadata.contains_key("description"));
assert!(quill.metadata.contains_key("author"));
assert!(quill.metadata.contains_key("version")); if let Some(version_val) = quill.metadata.get("version") {
if let Some(version_str) = version_val.as_str() {
assert_eq!(version_str, "1.0");
}
}
assert!(quill.plate.unwrap().contains("Custom Template"));
}
#[test]
fn test_typst_packages_parsing() {
let temp_dir = TempDir::new().unwrap();
let quill_dir = temp_dir.path();
let yaml_content = r#"
Quill:
name: "test-quill"
version: "1.0"
backend: "typst"
plate_file: "plate.typ"
description: "Test quill for packages"
typst:
packages:
- "@preview/bubble:0.2.2"
- "@preview/example:1.0.0"
"#;
fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
fs::write(quill_dir.join("plate.typ"), "test").unwrap();
let quill = Quill::from_path(quill_dir).unwrap();
let packages = quill.typst_packages();
assert_eq!(packages.len(), 2);
assert_eq!(packages[0], "@preview/bubble:0.2.2");
assert_eq!(packages[1], "@preview/example:1.0.0");
}
#[test]
fn test_template_loading() {
let temp_dir = TempDir::new().unwrap();
let quill_dir = temp_dir.path();
let yaml_content = r#"Quill:
name: "test-with-template"
version: "1.0"
backend: "typst"
plate_file: "plate.typ"
example_file: "example.md"
description: "Test quill with template"
"#;
fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
fs::write(
quill_dir.join("example.md"),
"---\ntitle: Test\n---\n\nThis is a test template.",
)
.unwrap();
let quill = Quill::from_path(quill_dir).unwrap();
assert!(quill.example.is_some());
let example = quill.example.unwrap();
assert!(example.contains("title: Test"));
assert!(example.contains("This is a test template"));
assert_eq!(quill.plate.unwrap(), "plate content");
}
#[test]
fn test_template_smart_default() {
let temp_dir = TempDir::new().unwrap();
let quill_dir = temp_dir.path();
let yaml_content = r#"Quill:
name: "test-smart-default"
version: "1.0"
backend: "typst"
plate_file: "plate.typ"
description: "Test quill with smart default"
"#;
fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
fs::write(
quill_dir.join("example.md"),
"---\ntitle: Smart Default\n---\n\nPicked up automatically.",
)
.unwrap();
let quill = Quill::from_path(quill_dir).unwrap();
assert!(quill.example.is_some());
let example = quill.example.unwrap();
assert!(example.contains("title: Smart Default"));
assert!(example.contains("Picked up automatically"));
}
#[test]
fn test_template_optional() {
let temp_dir = TempDir::new().unwrap();
let quill_dir = temp_dir.path();
let yaml_content = r#"Quill:
name: "test-without-template"
version: "1.0"
backend: "typst"
plate_file: "plate.typ"
description: "Test quill without template"
"#;
fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
let quill = Quill::from_path(quill_dir).unwrap();
assert_eq!(quill.example, None);
assert_eq!(quill.plate.unwrap(), "plate content");
}
#[test]
fn test_from_tree() {
let mut root_files = HashMap::new();
let quill_yaml = r#"Quill:
name: "test-from-tree"
version: "1.0"
backend: "typst"
plate_file: "plate.typ"
description: "A test quill from tree"
"#;
root_files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File {
contents: quill_yaml.as_bytes().to_vec(),
},
);
let plate_content = "= Test Template\n\nThis is a test.";
root_files.insert(
"plate.typ".to_string(),
FileTreeNode::File {
contents: plate_content.as_bytes().to_vec(),
},
);
let root = FileTreeNode::Directory { files: root_files };
let quill = Quill::from_tree(root).unwrap();
assert_eq!(quill.name, "test-from-tree");
assert_eq!(quill.plate.unwrap(), plate_content);
assert!(quill.metadata.contains_key("backend"));
assert!(quill.metadata.contains_key("description"));
}
#[test]
fn test_from_tree_with_template() {
let mut root_files = HashMap::new();
let quill_yaml = r#"
Quill:
name: test-tree-template
version: "1.0"
backend: typst
plate_file: plate.typ
example_file: template.md
description: Test tree with template
"#;
root_files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File {
contents: quill_yaml.as_bytes().to_vec(),
},
);
root_files.insert(
"plate.typ".to_string(),
FileTreeNode::File {
contents: b"plate content".to_vec(),
},
);
let template_content = "# {{ title }}\n\n{{ body }}";
root_files.insert(
"template.md".to_string(),
FileTreeNode::File {
contents: template_content.as_bytes().to_vec(),
},
);
let root = FileTreeNode::Directory { files: root_files };
let quill = Quill::from_tree(root).unwrap();
assert_eq!(quill.example, Some(template_content.to_string()));
}
#[test]
fn test_from_json() {
let json_str = r#"{
"metadata": {
"name": "test_from_json"
},
"files": {
"Quill.yaml": {
"contents": "Quill:\n name: test_from_json\n version: \"1.0\"\n backend: typst\n plate_file: plate.typ\n description: Test quill from JSON\n"
},
"plate.typ": {
"contents": "= Test Plate\n\nThis is test content."
}
}
}"#;
let quill = Quill::from_json(json_str).unwrap();
assert_eq!(quill.name, "test_from_json");
assert!(quill.plate.unwrap().contains("Test Plate"));
assert!(quill.metadata.contains_key("backend"));
}
#[test]
fn test_from_json_with_byte_array() {
let json_str = r#"{
"files": {
"Quill.yaml": {
"contents": "Quill:\n name: test\n version: \"1.0\"\n backend: typst\n plate_file: plate.typ\n description: Test quill\n"
},
"plate.typ": {
"contents": "test plate"
}
}
}"#;
let quill = Quill::from_json(json_str).unwrap();
assert_eq!(quill.name, "test");
assert_eq!(quill.plate.unwrap(), "test plate");
}
#[test]
fn test_from_json_missing_files() {
let json_str = r#"{
"metadata": {
"name": "test"
}
}"#;
let result = Quill::from_json(json_str);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("files"));
}
#[test]
fn test_from_json_tree_structure() {
let json_str = r#"{
"files": {
"Quill.yaml": {
"contents": "Quill:\n name: test_tree_json\n version: \"1.0\"\n backend: typst\n plate_file: plate.typ\n description: Test tree JSON\n"
},
"plate.typ": {
"contents": "= Test Plate\n\nTree structure content."
}
}
}"#;
let quill = Quill::from_json(json_str).unwrap();
assert_eq!(quill.name, "test_tree_json");
assert!(quill.plate.unwrap().contains("Tree structure content"));
assert!(quill.metadata.contains_key("backend"));
}
#[test]
fn test_from_json_nested_tree_structure() {
let json_str = r#"{
"files": {
"Quill.yaml": {
"contents": "Quill:\n name: nested_test\n version: \"1.0\"\n backend: typst\n plate_file: plate.typ\n description: Nested test\n"
},
"plate.typ": {
"contents": "plate"
},
"src": {
"main.rs": {
"contents": "fn main() {}"
},
"lib.rs": {
"contents": "// lib"
}
}
}
}"#;
let quill = Quill::from_json(json_str).unwrap();
assert_eq!(quill.name, "nested_test");
assert!(quill.file_exists("src/main.rs"));
assert!(quill.file_exists("src/lib.rs"));
let main_rs = quill.get_file("src/main.rs").unwrap();
assert_eq!(main_rs, b"fn main() {}");
}
#[test]
fn test_from_tree_structure_direct() {
let mut root_files = HashMap::new();
root_files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File {
contents:
b"Quill:\n name: direct_tree\n version: \"1.0\"\n backend: typst\n plate_file: plate.typ\n description: Direct tree test\n"
.to_vec(),
},
);
root_files.insert(
"plate.typ".to_string(),
FileTreeNode::File {
contents: b"plate content".to_vec(),
},
);
let mut src_files = HashMap::new();
src_files.insert(
"main.rs".to_string(),
FileTreeNode::File {
contents: b"fn main() {}".to_vec(),
},
);
root_files.insert(
"src".to_string(),
FileTreeNode::Directory { files: src_files },
);
let root = FileTreeNode::Directory { files: root_files };
let quill = Quill::from_tree(root).unwrap();
assert_eq!(quill.name, "direct_tree");
assert!(quill.file_exists("src/main.rs"));
assert!(quill.file_exists("plate.typ"));
}
#[test]
fn test_from_json_with_metadata_override() {
let json_str = r#"{
"metadata": {
"name": "override_name"
},
"files": {
"Quill.yaml": {
"contents": "Quill:\n name: toml_name\n version: \"1.0\"\n backend: typst\n plate_file: plate.typ\n description: TOML name test\n"
},
"plate.typ": {
"contents": "= plate"
}
}
}"#;
let quill = Quill::from_json(json_str).unwrap();
assert_eq!(quill.name, "toml_name");
}
#[test]
fn test_from_json_empty_directory() {
let json_str = r#"{
"files": {
"Quill.yaml": {
"contents": "Quill:\n name: empty_dir_test\n version: \"1.0\"\n backend: typst\n plate_file: plate.typ\n description: Empty directory test\n"
},
"plate.typ": {
"contents": "plate"
},
"empty_dir": {}
}
}"#;
let quill = Quill::from_json(json_str).unwrap();
assert_eq!(quill.name, "empty_dir_test");
assert!(quill.dir_exists("empty_dir"));
assert!(!quill.file_exists("empty_dir"));
}
#[test]
fn test_dir_exists_and_list_apis() {
let mut root_files = HashMap::new();
root_files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File {
contents: b"Quill:\n name: test\n version: \"1.0\"\n backend: typst\n plate_file: plate.typ\n description: Test quill\n"
.to_vec(),
},
);
root_files.insert(
"plate.typ".to_string(),
FileTreeNode::File {
contents: b"plate content".to_vec(),
},
);
let mut assets_files = HashMap::new();
assets_files.insert(
"logo.png".to_string(),
FileTreeNode::File {
contents: vec![137, 80, 78, 71],
},
);
assets_files.insert(
"icon.svg".to_string(),
FileTreeNode::File {
contents: b"<svg></svg>".to_vec(),
},
);
let mut fonts_files = HashMap::new();
fonts_files.insert(
"font.ttf".to_string(),
FileTreeNode::File {
contents: b"font data".to_vec(),
},
);
assets_files.insert(
"fonts".to_string(),
FileTreeNode::Directory { files: fonts_files },
);
root_files.insert(
"assets".to_string(),
FileTreeNode::Directory {
files: assets_files,
},
);
root_files.insert(
"empty".to_string(),
FileTreeNode::Directory {
files: HashMap::new(),
},
);
let root = FileTreeNode::Directory { files: root_files };
let quill = Quill::from_tree(root).unwrap();
assert!(quill.dir_exists("assets"));
assert!(quill.dir_exists("assets/fonts"));
assert!(quill.dir_exists("empty"));
assert!(!quill.dir_exists("nonexistent"));
assert!(!quill.dir_exists("plate.typ"));
assert!(quill.file_exists("plate.typ"));
assert!(quill.file_exists("assets/logo.png"));
assert!(quill.file_exists("assets/fonts/font.ttf"));
assert!(!quill.file_exists("assets"));
let root_files_list = quill.list_files("");
assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.yaml".to_string()));
assert!(root_files_list.contains(&"plate.typ".to_string()));
let assets_files_list = quill.list_files("assets");
assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
assert!(assets_files_list.contains(&"icon.svg".to_string()));
let root_subdirs = quill.list_subdirectories("");
assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
assert!(root_subdirs.contains(&"empty".to_string()));
let assets_subdirs = quill.list_subdirectories("assets");
assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
let empty_subdirs = quill.list_subdirectories("empty");
assert_eq!(empty_subdirs.len(), 0);
}
#[test]
fn test_field_schemas_parsing() {
let mut root_files = HashMap::new();
let quill_yaml = r#"Quill:
name: "taro"
version: "1.0"
backend: "typst"
plate_file: "plate.typ"
example_file: "taro.md"
description: "Test template for field schemas"
fields:
author:
type: "string"
description: "Author of document"
ice_cream:
type: "string"
description: "favorite ice cream flavor"
title:
type: "string"
description: "title of document"
"#;
root_files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File {
contents: quill_yaml.as_bytes().to_vec(),
},
);
let plate_content = "= Test Template\n\nThis is a test.";
root_files.insert(
"plate.typ".to_string(),
FileTreeNode::File {
contents: plate_content.as_bytes().to_vec(),
},
);
root_files.insert(
"taro.md".to_string(),
FileTreeNode::File {
contents: b"# Template".to_vec(),
},
);
let root = FileTreeNode::Directory { files: root_files };
let quill = Quill::from_tree(root).unwrap();
assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 4);
assert!(quill.schema["properties"]
.as_object()
.unwrap()
.contains_key("author"));
assert!(quill.schema["properties"]
.as_object()
.unwrap()
.contains_key("ice_cream"));
assert!(quill.schema["properties"]
.as_object()
.unwrap()
.contains_key("title"));
assert!(quill.schema["properties"]
.as_object()
.unwrap()
.contains_key("BODY"));
let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
assert_eq!(author_schema["description"], "Author of document");
let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
assert_eq!(title_schema["description"], "title of document");
}
#[test]
fn test_field_schema_struct() {
let schema1 = FieldSchema::new(
"test_name".to_string(),
FieldType::String,
Some("Test description".to_string()),
);
assert_eq!(schema1.description, Some("Test description".to_string()));
assert_eq!(schema1.r#type, FieldType::String);
assert_eq!(schema1.examples, None);
assert_eq!(schema1.default, None);
let yaml_str = r#"
description: "Full field schema"
type: "string"
examples:
- "Example value"
default: "Default value"
"#;
let quill_value = QuillValue::from_yaml_str(yaml_str).unwrap();
let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
assert_eq!(schema2.name, "test_name");
assert_eq!(schema2.description, Some("Full field schema".to_string()));
assert_eq!(schema2.r#type, FieldType::String);
assert_eq!(
schema2
.examples
.as_ref()
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str()),
Some("Example value")
);
assert_eq!(
schema2.default.as_ref().and_then(|v| v.as_str()),
Some("Default value")
);
}
#[test]
fn test_quill_without_plate_file() {
let mut root_files = HashMap::new();
let quill_yaml = r#"Quill:
name: "test-no-plate"
version: "1.0"
backend: "typst"
description: "Test quill without plate file"
"#;
root_files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File {
contents: quill_yaml.as_bytes().to_vec(),
},
);
let root = FileTreeNode::Directory { files: root_files };
let quill = Quill::from_tree(root).unwrap();
assert!(quill.plate.clone().is_none());
assert_eq!(quill.name, "test-no-plate");
}
#[test]
fn test_quill_config_from_yaml() {
let yaml_content = r#"
Quill:
name: test_config
version: "1.0"
backend: typst
description: Test configuration parsing
author: Test Author
plate_file: plate.typ
example_file: example.md
typst:
packages:
- "@preview/bubble:0.2.2"
fields:
title:
description: Document title
type: string
author:
type: string
description: Document author
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
assert_eq!(config.document.name, "test_config");
assert_eq!(config.backend, "typst");
assert_eq!(
config.document.description,
Some("Test configuration parsing".to_string())
);
assert_eq!(config.version, "1.0");
assert_eq!(config.author, "Test Author");
assert_eq!(config.plate_file, Some("plate.typ".to_string()));
assert_eq!(config.example_file, Some("example.md".to_string()));
assert!(config.typst_config.contains_key("packages"));
assert_eq!(config.document.fields.len(), 2);
assert!(config.document.fields.contains_key("title"));
assert!(config.document.fields.contains_key("author"));
let title_field = &config.document.fields["title"];
assert_eq!(title_field.description, Some("Document title".to_string()));
assert_eq!(title_field.r#type, FieldType::String);
}
#[test]
fn test_quill_config_missing_required_fields() {
let yaml_missing_name = r#"
Quill:
backend: typst
description: Missing name
"#;
let result = QuillConfig::from_yaml(yaml_missing_name);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required 'name'"));
let yaml_missing_backend = r#"
Quill:
name: test
description: Missing backend
"#;
let result = QuillConfig::from_yaml(yaml_missing_backend);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required 'backend'"));
let yaml_missing_description = r#"
Quill:
name: test
version: "1.0"
backend: typst
"#;
let result = QuillConfig::from_yaml(yaml_missing_description);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required 'description'"));
}
#[test]
fn test_quill_config_empty_description() {
let yaml_empty_description = r#"
Quill:
name: test
version: "1.0"
backend: typst
description: " "
"#;
let result = QuillConfig::from_yaml(yaml_empty_description);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("description' field in 'Quill' section cannot be empty"));
}
#[test]
fn test_quill_config_missing_quill_section() {
let yaml_no_section = r#"
fields:
title:
description: Title
"#;
let result = QuillConfig::from_yaml(yaml_no_section);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required 'Quill' section"));
}
#[test]
fn test_quill_from_config_metadata() {
let mut root_files = HashMap::new();
let quill_yaml = r#"
Quill:
name: metadata-test
version: "1.0"
backend: typst
description: Test metadata flow
author: Test Author
custom_field: custom_value
typst:
packages:
- "@preview/bubble:0.2.2"
"#;
root_files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File {
contents: quill_yaml.as_bytes().to_vec(),
},
);
let root = FileTreeNode::Directory { files: root_files };
let quill = Quill::from_tree(root).unwrap();
assert!(quill.metadata.contains_key("backend"));
assert!(quill.metadata.contains_key("description"));
assert!(quill.metadata.contains_key("author"));
assert!(quill.metadata.contains_key("custom_field"));
assert_eq!(
quill.metadata.get("custom_field").unwrap().as_str(),
Some("custom_value")
);
assert!(quill.metadata.contains_key("typst_packages"));
}
#[test]
fn test_extract_defaults_method() {
let mut root_files = HashMap::new();
let quill_yaml = r#"
Quill:
name: metadata-test-yaml
version: "1.0"
backend: typst
description: Test metadata flow
author: Test Author
custom_field: custom_value
typst:
packages:
- "@preview/bubble:0.2.2"
fields:
author:
type: string
default: Anonymous
status:
type: string
default: draft
title:
type: string
"#;
root_files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File {
contents: quill_yaml.as_bytes().to_vec(),
},
);
let root = FileTreeNode::Directory { files: root_files };
let quill = Quill::from_tree(root).unwrap();
let defaults = quill.extract_defaults();
assert_eq!(defaults.len(), 2);
assert!(!defaults.contains_key("title")); assert!(defaults.contains_key("author"));
assert!(defaults.contains_key("status"));
assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
}
#[test]
fn test_field_order_preservation() {
let yaml_content = r#"
Quill:
name: order-test
version: "1.0"
backend: typst
description: Test field order
fields:
first:
type: string
description: First field
second:
type: string
description: Second field
third:
type: string
description: Third field
ui:
group: Test Group
fourth:
type: string
description: Fourth field
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let first = config.document.fields.get("first").unwrap();
assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
let second = config.document.fields.get("second").unwrap();
assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
let third = config.document.fields.get("third").unwrap();
assert_eq!(third.ui.as_ref().unwrap().order, Some(2));
assert_eq!(
third.ui.as_ref().unwrap().group,
Some("Test Group".to_string())
);
let fourth = config.document.fields.get("fourth").unwrap();
assert_eq!(fourth.ui.as_ref().unwrap().order, Some(3));
}
#[test]
fn test_quill_with_all_ui_properties() {
let yaml_content = r#"
Quill:
name: full-ui-test
version: "1.0"
backend: typst
description: Test all UI properties
fields:
author:
description: The full name of the document author
type: str
ui:
group: Author Info
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let author_field = &config.document.fields["author"];
let ui = author_field.ui.as_ref().unwrap();
assert_eq!(ui.group, Some("Author Info".to_string()));
assert_eq!(ui.order, Some(0)); }
#[test]
fn test_field_schema_with_title_and_description() {
let yaml = r#"
title: "Field Title"
description: "Detailed field description"
type: "string"
examples:
- "Example value"
ui:
group: "Test Group"
"#;
let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
let schema = FieldSchema::from_quill_value("test_field".to_string(), &quill_value).unwrap();
assert_eq!(schema.title, Some("Field Title".to_string()));
assert_eq!(
schema.description,
Some("Detailed field description".to_string())
);
assert_eq!(
schema
.examples
.as_ref()
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str()),
Some("Example value")
);
let ui = schema.ui.as_ref().unwrap();
assert_eq!(ui.group, Some("Test Group".to_string()));
}
#[test]
fn test_parse_card_field_type() {
let yaml = r#"
type: "string"
title: "Simple Field"
description: "A simple string field"
"#;
let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
let schema =
FieldSchema::from_quill_value("simple_field".to_string(), &quill_value).unwrap();
assert_eq!(schema.name, "simple_field");
assert_eq!(schema.r#type, FieldType::String);
assert_eq!(schema.title, Some("Simple Field".to_string()));
assert_eq!(
schema.description,
Some("A simple string field".to_string())
);
}
#[test]
fn test_parse_card_with_fields_in_yaml() {
let yaml_content = r#"
Quill:
name: cards-fields-test
version: "1.0"
backend: typst
description: Test [cards.X.fields.Y] syntax
cards:
endorsements:
title: Endorsements
description: Chain of endorsements
fields:
name:
type: string
title: Endorser Name
description: Name of the endorsing official
required: true
org:
type: string
title: Organization
description: Endorser's organization
default: Unknown
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
assert!(config.cards.contains_key("endorsements"));
let card = config.cards.get("endorsements").unwrap();
assert_eq!(card.name, "endorsements");
assert_eq!(card.title, Some("Endorsements".to_string()));
assert_eq!(card.description, Some("Chain of endorsements".to_string()));
assert_eq!(card.fields.len(), 2);
let name_field = card.fields.get("name").unwrap();
assert_eq!(name_field.r#type, FieldType::String);
assert_eq!(name_field.title, Some("Endorser Name".to_string()));
assert!(name_field.required);
let org_field = card.fields.get("org").unwrap();
assert_eq!(org_field.r#type, FieldType::String);
assert!(org_field.default.is_some());
assert_eq!(
org_field.default.as_ref().unwrap().as_str(),
Some("Unknown")
);
}
#[test]
fn test_field_schema_rejects_unknown_keys() {
let yaml = r#"
type: "string"
description: "A string field"
invalid_key:
sub_field:
type: "string"
description: "Nested field"
"#;
let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
let result = FieldSchema::from_quill_value("author".to_string(), &quill_value);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("unknown field `invalid_key`"),
"Error was: {}",
err
);
}
#[test]
fn test_quill_config_with_cards_section() {
let yaml_content = r#"
Quill:
name: cards-test
version: "1.0"
backend: typst
description: Test [cards] section
fields:
regular:
description: Regular field
type: string
cards:
indorsements:
title: Routing Indorsements
description: Chain of endorsements
fields:
name:
title: Name
type: string
description: Name field
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
assert!(config.document.fields.contains_key("regular"));
let regular = config.document.fields.get("regular").unwrap();
assert_eq!(regular.r#type, FieldType::String);
assert!(config.cards.contains_key("indorsements"));
let card = config.cards.get("indorsements").unwrap();
assert_eq!(card.title, Some("Routing Indorsements".to_string()));
assert_eq!(card.description, Some("Chain of endorsements".to_string()));
assert!(card.fields.contains_key("name"));
}
#[test]
fn test_quill_config_cards_empty_fields() {
let yaml_content = r#"
Quill:
name: cards-empty-fields-test
version: "1.0"
backend: typst
description: Test cards without fields
cards:
myscope:
description: My scope
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let card = config.cards.get("myscope").unwrap();
assert_eq!(card.name, "myscope");
assert_eq!(card.description, Some("My scope".to_string()));
assert!(card.fields.is_empty());
}
#[test]
fn test_quill_config_allows_card_collision() {
let yaml_content = r#"
Quill:
name: collision-test
version: "1.0"
backend: typst
description: Test collision
fields:
conflict:
description: Field
type: string
cards:
conflict:
description: Card
"#;
let result = QuillConfig::from_yaml(yaml_content);
if let Err(e) = &result {
panic!(
"Card name collision should be allowed, but got error: {}",
e
);
}
assert!(result.is_ok());
let config = result.unwrap();
assert!(config.document.fields.contains_key("conflict"));
assert!(config.cards.contains_key("conflict"));
}
#[test]
fn test_quill_config_ordering_with_cards() {
let yaml_content = r#"
Quill:
name: ordering-test
version: "1.0"
backend: typst
description: Test ordering
fields:
first:
type: string
description: First
zero:
type: string
description: Zero
cards:
second:
description: Second
fields:
card_field:
type: string
description: A card field
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let first = config.document.fields.get("first").unwrap();
let zero = config.document.fields.get("zero").unwrap();
let second = config.cards.get("second").unwrap();
let ord_first = first.ui.as_ref().unwrap().order.unwrap();
let ord_zero = zero.ui.as_ref().unwrap().order.unwrap();
assert!(ord_first < ord_zero);
assert_eq!(ord_first, 0);
assert_eq!(ord_zero, 1);
let card_field = second.fields.get("card_field").unwrap();
let ord_card_field = card_field.ui.as_ref().unwrap().order.unwrap();
assert_eq!(ord_card_field, 0); }
#[test]
fn test_card_field_order_preservation() {
let yaml_content = r#"
Quill:
name: card-order-test
version: "1.0"
backend: typst
description: Test card field order
cards:
mycard:
description: Test card
fields:
z_first:
type: string
description: Defined first
a_second:
type: string
description: Defined second
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let card = config.cards.get("mycard").unwrap();
let z_first = card.fields.get("z_first").unwrap();
let a_second = card.fields.get("a_second").unwrap();
let z_order = z_first.ui.as_ref().unwrap().order.unwrap();
let a_order = a_second.ui.as_ref().unwrap().order.unwrap();
assert_eq!(z_order, 0, "z_first should be 0 (defined first)");
assert_eq!(a_order, 1, "a_second should be 1 (defined second)");
}
#[test]
fn test_nested_schema_parsing() {
let yaml_content = r#"
Quill:
name: nested-test
version: "1.0"
backend: typst
description: Test nested elements
fields:
my_list:
type: array
description: List of objects
items:
type: object
properties:
sub_a:
type: string
description: Subfield A
sub_b:
type: number
description: Subfield B
my_obj:
type: object
description: Single object
properties:
child:
type: boolean
description: Child field
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let list_field = config.document.fields.get("my_list").unwrap();
assert_eq!(list_field.r#type, FieldType::Array);
assert!(list_field.items.is_some());
let items_schema = list_field.items.as_ref().unwrap();
assert_eq!(items_schema.r#type, FieldType::Object);
assert!(items_schema.properties.is_some());
let props = items_schema.properties.as_ref().unwrap();
assert!(props.contains_key("sub_a"));
assert!(props.contains_key("sub_b"));
assert_eq!(props["sub_a"].r#type, FieldType::String);
assert_eq!(props["sub_b"].r#type, FieldType::Number);
let obj_field = config.document.fields.get("my_obj").unwrap();
assert_eq!(obj_field.r#type, FieldType::Object);
assert!(obj_field.properties.is_some());
let obj_props = obj_field.properties.as_ref().unwrap();
assert!(obj_props.contains_key("child"));
assert_eq!(obj_props["child"].r#type, FieldType::Boolean);
}
}