use super::*;
use crate::{Diagnostic, Severity};
use std::collections::HashMap;
use std::error::Error as StdError;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn load_tree(path: &Path) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
let default_ignore = QuillIgnore::new(vec![
".git/".to_string(),
".gitignore".to_string(),
".quillignore".to_string(),
"target/".to_string(),
"node_modules/".to_string(),
]);
let quillignore_path = path.join(".quillignore");
let ignore = if quillignore_path.exists() {
let content = fs::read_to_string(&quillignore_path)?;
QuillIgnore::from_content(&content)
} else {
default_ignore
};
load_dir(path, path, &ignore)
}
fn load_dir(
current: &Path,
base: &Path,
ignore: &QuillIgnore,
) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
if !current.exists() {
return Ok(FileTreeNode::Directory {
files: HashMap::new(),
});
}
let mut files = HashMap::new();
for entry in fs::read_dir(current)? {
let entry = entry?;
let p = entry.path();
let rel = p.strip_prefix(base)?;
if ignore.is_ignored(rel) {
continue;
}
let name = p
.file_name()
.and_then(|n| n.to_str())
.ok_or("invalid filename")?
.to_string();
if p.is_file() {
files.insert(
name,
FileTreeNode::File {
contents: fs::read(&p)?,
},
);
} else if p.is_dir() {
files.insert(name, load_dir(&p, base, ignore)?);
}
}
Ok(FileTreeNode::Directory { files })
}
fn load_from_path<P: AsRef<Path>>(path: P) -> Result<QuillSource, Box<dyn StdError + Send + Sync>> {
let tree = load_tree(path.as_ref())?;
QuillSource::from_tree(tree).map_err(|diags| {
diags
.iter()
.map(|d| d.fmt_pretty())
.collect::<Vec<_>>()
.join("\n")
.into()
})
}
#[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 = load_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 = load_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 = load_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 = load_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_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 = load_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!(quill
.config
.example_markdown
.as_ref()
.is_some_and(|value| value.contains("title: Test")));
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 = load_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 = load_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 = QuillSource::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 = QuillSource::from_tree(root).unwrap();
assert_eq!(quill.example, Some(template_content.to_string()));
}
#[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 = QuillSource::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_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 = QuillSource::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"
main:
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 = QuillSource::from_tree(root).unwrap();
assert_eq!(quill.config.main.fields.len(), 3);
assert!(quill.config.main.fields.contains_key("author"));
assert!(quill.config.main.fields.contains_key("ice_cream"));
assert!(quill.config.main.fields.contains_key("title"));
let author_schema = quill.config.main.fields.get("author").unwrap();
assert_eq!(
author_schema.description.as_deref(),
Some("Author of document")
);
let ice_cream_schema = quill.config.main.fields.get("ice_cream").unwrap();
assert_eq!(
ice_cream_schema.description.as_deref(),
Some("favorite ice cream flavor")
);
let title_schema = quill.config.main.fields.get("title").unwrap();
assert_eq!(
title_schema.description.as_deref(),
Some("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.example, None);
assert_eq!(schema1.default, None);
let yaml_str = r#"
description: "Full field schema"
type: "string"
example: "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.example.as_ref().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_field_schema_single_example() {
let yaml_str = r#"
description: "Field schema with single example"
type: "date"
example: "2024-01-15"
"#;
let quill_value = QuillValue::from_yaml_str(yaml_str).unwrap();
let schema = FieldSchema::from_quill_value("effective_date".to_string(), &quill_value).unwrap();
assert_eq!(
schema.example.as_ref().and_then(|v| v.as_str()),
Some("2024-01-15")
);
}
#[test]
fn test_field_schema_ui_compact() {
let yaml_str = r#"
type: "string"
description: "A compact field"
ui:
compact: true
"#;
let quill_value = QuillValue::from_yaml_str(yaml_str).unwrap();
let schema = FieldSchema::from_quill_value("compact_field".to_string(), &quill_value).unwrap();
assert_eq!(schema.ui.as_ref().unwrap().compact, Some(true));
}
#[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 = QuillSource::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"
main:
fields:
title:
description: Document title
type: string
author:
type: string
description: Document author
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
assert_eq!(config.name, "test_config");
assert_eq!(config.main.name, "main");
assert_eq!(config.backend, "typst");
assert_eq!(config.description, "Test configuration parsing");
assert_eq!(config.main.description, None);
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.backend_config.contains_key("packages"));
assert_eq!(config.main.fields.len(), 2);
assert!(config.main.fields.contains_key("title"));
assert!(config.main.fields.contains_key("author"));
let title_field = &config.main.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_parses_example_alias() {
let yaml_content = r#"
quill:
name: test_example_alias
version: "1.0"
backend: typst
description: Test example alias parsing
example: examples/basic.md
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
assert_eq!(config.example_file, Some("examples/basic.md".to_string()));
}
#[test]
fn test_quill_from_path_rejects_example_traversal() {
let temp_dir = TempDir::new().unwrap();
let quill_dir = temp_dir.path();
let yaml_content = r#"quill:
name: traversal_test
version: "1.0"
backend: typst
description: Traversal test
example: ../outside.md
"#;
fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
let result = load_from_path(quill_dir);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("outside the quill directory"));
}
#[test]
fn test_quill_from_path_errors_when_explicit_example_missing() {
let temp_dir = TempDir::new().unwrap();
let quill_dir = temp_dir.path();
let yaml_content = r#"quill:
name: missing_example_test
version: "1.0"
backend: typst
description: Missing explicit example test
example: examples/missing.md
"#;
fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
let result = load_from_path(quill_dir);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("referenced in Quill.yaml not found"));
}
#[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_config_rejects_root_level_fields() {
let yaml = r#"
quill:
name: root_fields_test
version: "1.0"
backend: typst
description: Root fields must not be used
fields:
title:
type: string
"#;
let result = QuillConfig::from_yaml(yaml);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("main.fields"));
}
#[test]
fn test_quill_config_rejects_non_snake_case_quill_name() {
let yaml = r#"
quill:
name: BadQuill
version: "1.0"
backend: typst
description: Bad quill name
"#;
let result = QuillConfig::from_yaml(yaml);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("BadQuill"));
assert!(err.contains("snake_case"));
}
#[test]
fn test_quill_config_rejects_non_snake_case_card_name() {
let yaml = r#"
quill:
name: good_quill
version: "1.0"
backend: typst
description: Bad card name
card_types:
BadCard:
fields:
title:
type: string
"#;
let result = QuillConfig::from_yaml(yaml);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("BadCard"));
assert!(err.contains("[a-z_][a-z0-9_]*"));
}
#[test]
fn test_quill_config_accepts_leading_underscore_card_name() {
let yaml = r#"
quill:
name: good_quill
version: "1.0"
backend: typst
description: Leading underscore card name
card_types:
_private_card:
fields:
title:
type: string
"#;
let result = QuillConfig::from_yaml(yaml);
assert!(result.is_ok());
}
#[test]
fn test_quill_config_rejects_non_snake_case_main_field_keys() {
let yaml = r#"
quill:
name: bad_field_key
version: "1.0"
backend: typst
description: Bad main field key
main:
fields:
BadField:
type: string
"#;
let result = QuillConfig::from_yaml(yaml);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("BadField"));
assert!(err.contains("snake_case"));
}
#[test]
fn test_quill_config_rejects_non_snake_case_card_field_keys() {
let yaml = r#"
quill:
name: bad_card_field_key
version: "1.0"
backend: typst
description: Bad card field key
card_types:
profile:
fields:
DisplayName:
type: string
"#;
let result = QuillConfig::from_yaml(yaml);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("DisplayName"));
assert!(err.contains("snake_case"));
}
#[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
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 = QuillSource::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("typst_packages"));
}
#[test]
fn test_config_defaults() {
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
typst:
packages:
- "@preview/bubble:0.2.2"
main:
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 = QuillSource::from_tree(root).unwrap();
let defaults = quill.config.main.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_config_defaults_method() {
let yaml_content = r#"
quill:
name: defaults_test
version: "1.0"
backend: typst
description: Defaults test
main:
fields:
author:
type: string
default: Anonymous
example: Alice
status:
type: string
default: draft
title:
type: string
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let defaults = config.main.defaults();
assert_eq!(defaults.len(), 2);
assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
assert!(!defaults.contains_key("title"));
let author_example = config.main.fields.get("author").unwrap().example.as_ref();
assert_eq!(author_example.and_then(|v| v.as_str()), Some("Alice"));
}
#[test]
fn test_card_defaults_method() {
let yaml_content = r#"
quill:
name: card_defaults_test
version: "1.0"
backend: typst
description: Card defaults test
card_types:
indorsement:
fields:
signature_block:
type: string
default: Commander
example: Col Smith
office:
type: string
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let card = config.card_type("indorsement").unwrap();
let card_defaults = card.defaults();
assert_eq!(card_defaults.len(), 1);
assert_eq!(
card_defaults.get("signature_block").unwrap().as_str(),
Some("Commander")
);
let sig_example = card.fields.get("signature_block").unwrap().example.as_ref();
assert_eq!(sig_example.and_then(|v| v.as_str()), Some("Col Smith"));
assert!(config.card_type("unknown").is_none());
}
#[test]
fn test_field_order_preservation() {
let yaml_content = r#"
quill:
name: order_test
version: "1.0"
backend: typst
description: Test field order
main:
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.main.fields.get("first").unwrap();
assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
let second = config.main.fields.get("second").unwrap();
assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
let third = config.main.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.main.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
main:
fields:
author:
description: The full name of the document author
type: string
ui:
group: Author Info
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let author_field = &config.main.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_description() {
let yaml = r#"
description: "Detailed field description"
type: "string"
example: "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.description,
Some("Detailed field description".to_string())
);
assert_eq!(
schema.example.as_ref().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"
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.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
card_types:
endorsements:
description: Chain of endorsements
fields:
name:
type: string
description: Name of the endorsing official
required: true
org:
type: string
description: Endorser's organization
default: Unknown
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
assert!(config.card_type("endorsements").is_some());
let card = config.card_type("endorsements").unwrap();
assert_eq!(card.name, "endorsements");
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!(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
main:
fields:
regular:
description: Regular field
type: string
card_types:
indorsements:
description: Chain of endorsements
fields:
name:
type: string
description: Name field
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
assert!(config.main.fields.contains_key("regular"));
let regular = config.main.fields.get("regular").unwrap();
assert_eq!(regular.r#type, FieldType::String);
assert!(config.card_type("indorsements").is_some());
let card = config.card_type("indorsements").unwrap();
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
card_types:
myscope:
description: My scope
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let card = config.card_type("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
main:
fields:
conflict:
description: Field
type: string
card_types:
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.main.fields.contains_key("conflict"));
assert!(config.card_type("conflict").is_some());
}
#[test]
fn test_quill_config_ordering_with_cards() {
let yaml_content = r#"
quill:
name: ordering_test
version: "1.0"
backend: typst
description: Test ordering
main:
fields:
first:
type: string
description: First
zero:
type: string
description: Zero
card_types:
second:
description: Second
fields:
card_field:
type: string
description: A card field
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let first = config.main.fields.get("first").unwrap();
let zero = config.main.fields.get("zero").unwrap();
let second = config.card_type("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
card_types:
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.card_type("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
main:
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
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let list_field = config.main.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);
}
#[test]
fn test_standalone_object_field_rejected_with_error() {
let yaml_content = r#"
quill:
name: obj_test
version: "1.0"
backend: typst
description: Test standalone object rejection
main:
fields:
valid_field:
type: string
description: A normal field
address:
type: object
description: Standalone object — should be rejected
properties:
street:
type: string
"#;
let err = QuillConfig::from_yaml_with_warnings(yaml_content).unwrap_err();
assert_eq!(err.len(), 1);
assert_eq!(err[0].severity, Severity::Error);
assert_eq!(
err[0].code.as_deref(),
Some("quill::standalone_object_not_supported")
);
assert!(err[0].message.contains("address"));
}
#[test]
fn test_nested_object_in_typed_table_rejected_with_error() {
let yaml_content = r#"
quill:
name: nested_obj_test
version: "1.0"
backend: typst
description: Test nested object in typed table rejection
main:
fields:
rows:
type: array
items:
type: object
properties:
score:
type: number
nested:
type: object
properties:
inner:
type: string
"#;
let err = QuillConfig::from_yaml_with_warnings(yaml_content).unwrap_err();
assert_eq!(err.len(), 1);
assert_eq!(err[0].severity, Severity::Error);
assert_eq!(
err[0].code.as_deref(),
Some("quill::nested_object_not_supported")
);
assert!(err[0].message.contains("rows"));
}
#[test]
fn test_array_items_recursive_coercion() {
let yaml_content = r#"
quill:
name: coerce_test
version: "1.0"
backend: typst
description: Test recursive coercion for array items
main:
fields:
scores:
type: array
items:
type: object
properties:
name:
type: string
value:
type: number
active:
type: boolean
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let mut frontmatter = indexmap::IndexMap::new();
frontmatter.insert(
"scores".to_string(),
crate::value::QuillValue::from_json(serde_json::json!([
{"name": "Math", "value": "95", "active": "true"},
{"name": "Science", "value": "88.5", "active": "false"}
])),
);
let coerced = config.coerce_frontmatter(&frontmatter).unwrap();
let scores = coerced.get("scores").unwrap();
let arr = scores.as_array().unwrap();
let first = arr[0].as_object().unwrap();
assert_eq!(first["name"], serde_json::json!("Math"));
assert_eq!(first["value"], serde_json::json!(95)); assert_eq!(first["active"], serde_json::json!(true));
let second = arr[1].as_object().unwrap();
assert_eq!(second["value"], serde_json::json!(88.5)); assert_eq!(second["active"], serde_json::json!(false)); }
#[test]
fn test_config_coerce_number_boolean_date_datetime_success() {
let yaml_content = r#"
quill:
name: coerce_success_test
version: "1.0"
backend: typst
description: Coerce success
main:
fields:
count:
type: number
active:
type: boolean
signed_on:
type: date
created_at:
type: datetime
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let mut frontmatter = indexmap::IndexMap::new();
frontmatter.insert(
"count".to_string(),
QuillValue::from_json(serde_json::json!("42")),
);
frontmatter.insert(
"active".to_string(),
QuillValue::from_json(serde_json::json!("true")),
);
frontmatter.insert(
"signed_on".to_string(),
QuillValue::from_json(serde_json::json!("2026-04-13")),
);
frontmatter.insert(
"created_at".to_string(),
QuillValue::from_json(serde_json::json!("2026-04-13T20:00:00Z")),
);
let coerced = config.coerce_frontmatter(&frontmatter).unwrap();
assert_eq!(coerced.get("count").unwrap().as_i64(), Some(42));
assert_eq!(coerced.get("active").unwrap().as_bool(), Some(true));
assert_eq!(
coerced.get("signed_on").unwrap().as_str(),
Some("2026-04-13")
);
assert_eq!(
coerced.get("created_at").unwrap().as_str(),
Some("2026-04-13T20:00:00Z")
);
}
#[test]
fn test_config_coerce_integer_success() {
let yaml_content = r#"
quill:
name: coerce_integer_success_test
version: "1.0"
backend: typst
description: Coerce integer success
main:
fields:
count:
type: integer
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let mut frontmatter = indexmap::IndexMap::new();
frontmatter.insert(
"count".to_string(),
QuillValue::from_json(serde_json::json!("42")),
);
let coerced = config.coerce_frontmatter(&frontmatter).unwrap();
assert_eq!(coerced.get("count").unwrap().as_i64(), Some(42));
}
#[test]
fn test_config_coerce_integer_rejects_decimal() {
let yaml_content = r#"
quill:
name: coerce_integer_error_test
version: "1.0"
backend: typst
description: Coerce integer errors
main:
fields:
count:
type: integer
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let mut frontmatter = indexmap::IndexMap::new();
frontmatter.insert(
"count".to_string(),
QuillValue::from_json(serde_json::json!("42.5")),
);
let error = config.coerce_frontmatter(&frontmatter).unwrap_err();
assert!(matches!(
error,
super::CoercionError::Uncoercible { ref path, ref target, .. }
if path == "count" && target == "integer"
));
}
#[test]
fn test_config_coerce_array_item_wise() {
let yaml_content = r#"
quill:
name: coerce_array_items_test
version: "1.0"
backend: typst
description: Coerce arrays
main:
fields:
items:
type: array
items:
type: object
properties:
score:
type: number
active:
type: boolean
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let mut frontmatter = indexmap::IndexMap::new();
frontmatter.insert(
"items".to_string(),
QuillValue::from_json(serde_json::json!([
{"score": "90", "active": "true"},
{"score": "87.5", "active": "false"}
])),
);
let coerced = config.coerce_frontmatter(&frontmatter).unwrap();
let items = coerced.get("items").unwrap().as_array().unwrap();
let first = items[0].as_object().unwrap();
let second = items[1].as_object().unwrap();
assert_eq!(first["score"], serde_json::json!(90));
assert_eq!(first["active"], serde_json::json!(true));
assert_eq!(second["score"], serde_json::json!(87.5));
assert_eq!(second["active"], serde_json::json!(false));
}
#[test]
fn test_config_coerce_cards_item_wise() {
let yaml_content = r#"
quill:
name: coerce_cards_items_test
version: "1.0"
backend: typst
description: Coerce cards
card_types:
indorsement:
fields:
score:
type: number
active:
type: boolean
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let mut card_fields = indexmap::IndexMap::new();
card_fields.insert(
"score".to_string(),
QuillValue::from_json(serde_json::json!("100")),
);
card_fields.insert(
"active".to_string(),
QuillValue::from_json(serde_json::json!("false")),
);
let coerced = config.coerce_card("indorsement", &card_fields).unwrap();
assert_eq!(coerced.get("score").unwrap().as_i64(), Some(100));
assert_eq!(coerced.get("active").unwrap().as_bool(), Some(false));
}
#[test]
fn test_config_coerce_error_unparseable_date() {
let yaml_content = r#"
quill:
name: coerce_date_error_test
version: "1.0"
backend: typst
description: Coerce date errors
main:
fields:
signed_on:
type: date
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let mut frontmatter = indexmap::IndexMap::new();
frontmatter.insert(
"signed_on".to_string(),
QuillValue::from_json(serde_json::json!("13-04-2026")),
);
let error = config.coerce_frontmatter(&frontmatter).unwrap_err();
assert!(matches!(
error,
super::CoercionError::Uncoercible { ref path, ref target, .. }
if path == "signed_on" && target == "date"
));
}
#[test]
fn test_config_coerce_error_unparseable_number() {
let yaml_content = r#"
quill:
name: coerce_number_error_test
version: "1.0"
backend: typst
description: Coerce number errors
main:
fields:
count:
type: number
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let mut frontmatter = indexmap::IndexMap::new();
frontmatter.insert(
"count".to_string(),
QuillValue::from_json(serde_json::json!("forty-two")),
);
let error = config.coerce_frontmatter(&frontmatter).unwrap_err();
assert!(matches!(
error,
super::CoercionError::Uncoercible { ref path, ref target, .. }
if path == "count" && target == "number"
));
}
#[test]
fn test_multiline_ui_field_parses() {
let yaml_content = r#"
quill:
name: multiline_test
version: "1.0"
backend: typst
description: Test multiline ui hint
main:
fields:
summary:
type: markdown
description: Document summary
ui:
multiline: true
notes:
type: markdown
description: Short notes
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let summary = config.main.fields.get("summary").unwrap();
assert_eq!(summary.r#type, FieldType::Markdown);
assert_eq!(summary.ui.as_ref().unwrap().multiline, Some(true));
let notes = config.main.fields.get("notes").unwrap();
assert_eq!(notes.r#type, FieldType::Markdown);
assert_eq!(notes.ui.as_ref().unwrap().multiline, None);
}
#[test]
fn test_multiline_ui_field_on_string_type() {
let yaml_content = r#"
quill:
name: multiline_string_test
version: "1.0"
backend: typst
description: Test multiline ui hint on string field
main:
fields:
address:
type: string
description: Mailing address
ui:
multiline: true
name:
type: string
description: Full name
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
let address = config.main.fields.get("address").unwrap();
assert_eq!(address.r#type, FieldType::String);
assert_eq!(address.ui.as_ref().unwrap().multiline, Some(true));
let name = config.main.fields.get("name").unwrap();
assert_eq!(name.r#type, FieldType::String);
assert!(name.ui.as_ref().map_or(true, |ui| ui.multiline.is_none()));
}
#[test]
fn test_card_ui_title_parses_literal_and_template_forms() {
let yaml_content = r#"
quill:
name: card_title_test
version: "1.0"
backend: typst
description: Test ui.title on cards
main:
ui:
title: Memorandum
fields:
subject:
type: string
card_types:
indorsement:
ui:
title: "{from} → {for}"
fields:
from:
type: string
for:
type: string
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
assert_eq!(
config.main.ui.as_ref().unwrap().title.as_deref(),
Some("Memorandum"),
"literal main.ui.title"
);
let indorsement = config.card_type("indorsement").unwrap();
assert_eq!(
indorsement.ui.as_ref().unwrap().title.as_deref(),
Some("{from} → {for}"),
"template card ui.title carried verbatim"
);
let schema = config.schema();
assert_eq!(schema["main"]["ui"]["title"].as_str(), Some("Memorandum"));
assert_eq!(
schema["card_types"]["indorsement"]["ui"]["title"].as_str(),
Some("{from} → {for}")
);
}
#[test]
fn test_card_ui_title_omitted_when_absent() {
let yaml_content = r#"
quill:
name: no_title_test
version: "1.0"
backend: typst
description: ui.title omitted when not declared
main:
fields:
subject:
type: string
"#;
let config = QuillConfig::from_yaml(yaml_content).unwrap();
assert!(config
.main
.ui
.as_ref()
.map_or(true, |ui| ui.title.is_none()));
}
#[test]
fn test_quill_config_from_yaml_errors_on_invalid_field() {
let yaml_content = r#"
quill:
name: error_config
version: "1.0"
backend: typst
description: Error on invalid field test
main:
fields:
valid_field:
type: string
description: Valid
broken_field:
description: Missing required type
"#;
let err = QuillConfig::from_yaml_with_warnings(yaml_content).unwrap_err();
assert_eq!(err.len(), 1);
assert_eq!(err[0].severity, Severity::Error);
assert_eq!(err[0].code.as_deref(), Some("quill::field_parse_error"));
assert!(err[0].message.contains("broken_field"));
}
#[test]
fn test_unknown_key_in_quill_section_errors() {
let yaml_content = r#"
quill:
name: unk_key
version: "1.0"
backend: typst
description: Unknown key test
platefile: foo.typ
"#;
let err = QuillConfig::from_yaml_with_warnings(yaml_content).unwrap_err();
assert_eq!(err.len(), 1);
assert_eq!(err[0].code.as_deref(), Some("quill::unknown_key"));
assert!(err[0].message.contains("platefile"));
assert!(err[0].hint.as_deref().unwrap_or("").contains("plate_file"));
}
#[test]
fn test_unknown_top_level_section_errors() {
let yaml_content = r#"
quill:
name: unk_section
version: "1.0"
backend: typst
description: Unknown section test
card_type:
foo:
description: Should not silently disappear
fields:
bar:
type: string
"#;
let err = QuillConfig::from_yaml_with_warnings(yaml_content).unwrap_err();
assert!(err.iter().any(|d| {
d.code.as_deref() == Some("quill::unknown_section") && d.message.contains("card_type")
}));
}
#[test]
fn test_root_level_fields_gets_targeted_hint() {
let yaml_content = r#"
quill:
name: root_fields
version: "1.0"
backend: typst
description: Root fields test
fields:
author:
type: string
"#;
let err = QuillConfig::from_yaml_with_warnings(yaml_content).unwrap_err();
let fields_errors: Vec<&Diagnostic> = err
.iter()
.filter(|d| d.message.contains("fields"))
.collect();
assert_eq!(
fields_errors.len(),
1,
"expected exactly one error for root-level `fields`, got {} ({:?})",
fields_errors.len(),
fields_errors
);
assert_eq!(
fields_errors[0].code.as_deref(),
Some("quill::unknown_section")
);
assert!(fields_errors[0]
.hint
.as_deref()
.unwrap_or("")
.contains("main.fields"));
}
#[test]
fn test_multiple_errors_collected_in_one_pass() {
let yaml_content = r#"
quill:
name: BadName
version: "1.0"
backend: typst
description: Multi-error test
platefile: foo.typ
main:
fields:
BadFieldName:
type: string
legit:
title: Bad legacy key
"#;
let err = QuillConfig::from_yaml_with_warnings(yaml_content).unwrap_err();
assert!(
err.len() >= 4,
"expected >=4 errors collected at once, got {}: {:?}",
err.len(),
err.iter().map(|d| d.code.as_deref()).collect::<Vec<_>>()
);
let codes: Vec<&str> = err.iter().filter_map(|d| d.code.as_deref()).collect();
assert!(
codes.contains(&"quill::invalid_name"),
"missing invalid_name: {:?}",
codes
);
assert!(
codes.contains(&"quill::unknown_key"),
"missing unknown_key: {:?}",
codes
);
assert!(
codes.contains(&"quill::invalid_field_name"),
"missing invalid_field_name: {:?}",
codes
);
assert!(
codes.contains(&"quill::field_parse_error"),
"missing field_parse_error: {:?}",
codes
);
}
#[test]
fn test_main_ui_malformed_errors_with_hint() {
let yaml_content = r#"
quill:
name: bad_ui
version: "1.0"
backend: typst
description: Bad UI test
main:
ui:
bogus_key: nope
"#;
let err = QuillConfig::from_yaml_with_warnings(yaml_content).unwrap_err();
assert!(err
.iter()
.any(|d| d.code.as_deref() == Some("quill::invalid_ui")));
}
#[test]
fn test_field_with_title_key_errors_with_hint() {
let yaml_content = r#"
quill:
name: hint_test
version: "1.0"
backend: typst
description: Hint test
main:
fields:
author:
type: string
title: The document author
"#;
let err = QuillConfig::from_yaml_with_warnings(yaml_content).unwrap_err();
assert_eq!(err.len(), 1);
assert_eq!(err[0].code.as_deref(), Some("quill::field_parse_error"));
assert_eq!(
err[0].hint.as_deref(),
Some("'title' is not a valid field key; use 'description' instead.")
);
}
#[test]
fn test_field_ui_title_is_valid() {
let yaml_content = r#"
quill:
name: ui_title_test
version: "1.0"
backend: typst
description: ui.title is valid on individual fields
main:
fields:
status:
type: string
ui:
title: Status Label
"#;
let config = QuillConfig::from_yaml(yaml_content).expect("ui.title on field should parse");
assert_eq!(
config.main.fields["status"]
.ui
.as_ref()
.unwrap()
.title
.as_deref(),
Some("Status Label")
);
assert_eq!(
config.schema()["main"]["fields"]["status"]["ui"]["title"].as_str(),
Some("Status Label")
);
}
fn check_schema_snapshot(
yaml_of: impl Fn(&QuillConfig) -> String,
json_of: impl Fn(&QuillConfig) -> serde_json::Value,
golden: &str,
) {
let quill = load_from_path(quillmark_fixtures::resource_path("quills/usaf_memo/0.1.0"))
.expect("load usaf_memo fixture");
let yaml = yaml_of(&quill.config);
let golden_path =
quillmark_fixtures::resource_path(&format!("quills/usaf_memo/0.1.0/__golden__/{golden}"));
if std::env::var("UPDATE_GOLDEN").is_ok() {
fs::write(&golden_path, &yaml).expect("write golden");
}
assert_eq!(
yaml,
fs::read_to_string(&golden_path).expect("read golden"),
"{golden} drifted"
);
let parsed: serde_json::Value = serde_saphyr::from_str(&yaml).expect("parse yaml");
assert_eq!(json_of(&quill.config), parsed, "{golden} json/yaml parity");
assert!(parsed.get("main").and_then(|v| v.get("fields")).is_some());
assert!(parsed.get("card_types").is_some());
assert!(parsed.get("ref").is_none() && parsed.get("example").is_none());
assert!(yaml.contains("ui:"), "{golden} must include ui hints");
}
#[test]
fn schema_snapshot_usaf_memo_0_1_0() {
check_schema_snapshot(|c| c.schema_yaml().unwrap(), |c| c.schema(), "schema.yaml");
}
#[test]
fn body_description_with_body_disabled_emits_warning() {
let yaml = r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
title: { type: string }
card_types:
skills:
body:
enabled: false
description: This description is unused
fields:
items: { type: array, required: true }
"#;
let (_config, warnings) = QuillConfig::from_yaml_with_warnings(yaml).unwrap();
assert!(
warnings.iter().any(|d| d
.code
.as_deref()
.map(|c| c == "quill::body_description_unused")
.unwrap_or(false)),
"expected body_description_unused warning, got: {:?}",
warnings
);
}