1use std::collections::HashMap;
2use std::error::Error as StdError;
3use std::path::{Path, PathBuf};
4
5pub mod parse;
7pub use parse::{decompose, ParsedDocument, BODY_FIELD};
8
9pub mod templating;
11pub use templating::{Glue, TemplateError};
12
13pub mod backend;
15pub use backend::Backend;
16
17pub mod error;
19pub use error::{Diagnostic, Location, RenderError, RenderResult, Severity};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
23pub enum OutputFormat {
24 Txt,
25 Svg,
26 Pdf,
27}
28
29#[derive(Debug)]
31pub struct Artifact {
32 pub bytes: Vec<u8>,
33 pub output_format: OutputFormat,
34}
35
36#[derive(Debug)]
38pub struct RenderOptions {
39 pub output_format: Option<OutputFormat>,
40}
41
42#[derive(Debug, Clone)]
44pub struct FileEntry {
45 pub contents: Vec<u8>,
47 pub path: PathBuf,
49 pub is_dir: bool,
51}
52
53#[derive(Debug, Clone)]
55pub struct QuillIgnore {
56 patterns: Vec<String>,
57}
58
59impl QuillIgnore {
60 pub fn new(patterns: Vec<String>) -> Self {
62 Self { patterns }
63 }
64
65 pub fn from_content(content: &str) -> Self {
67 let patterns = content
68 .lines()
69 .map(|line| line.trim())
70 .filter(|line| !line.is_empty() && !line.starts_with('#'))
71 .map(|line| line.to_string())
72 .collect();
73 Self::new(patterns)
74 }
75
76 pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
78 let path = path.as_ref();
79 let path_str = path.to_string_lossy();
80
81 for pattern in &self.patterns {
82 if self.matches_pattern(pattern, &path_str) {
83 return true;
84 }
85 }
86 false
87 }
88
89 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
91 if pattern.ends_with('/') {
93 let pattern_prefix = &pattern[..pattern.len() - 1];
94 return path.starts_with(pattern_prefix)
95 && (path.len() == pattern_prefix.len()
96 || path.chars().nth(pattern_prefix.len()) == Some('/'));
97 }
98
99 if !pattern.contains('*') {
101 return path == pattern || path.ends_with(&format!("/{}", pattern));
102 }
103
104 if pattern == "*" {
106 return true;
107 }
108
109 let pattern_parts: Vec<&str> = pattern.split('*').collect();
111 if pattern_parts.len() == 2 {
112 let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
113 if prefix.is_empty() {
114 return path.ends_with(suffix);
115 } else if suffix.is_empty() {
116 return path.starts_with(prefix);
117 } else {
118 return path.starts_with(prefix) && path.ends_with(suffix);
119 }
120 }
121
122 false
123 }
124}
125
126#[derive(Debug, Clone)]
128pub struct Quill {
129 pub glue_template: String,
131 pub metadata: HashMap<String, serde_yaml::Value>,
133 pub base_path: PathBuf,
135 pub name: String,
137 pub glue_file: String,
139 pub files: HashMap<PathBuf, FileEntry>,
141}
142
143impl Quill {
144 pub fn from_path<P: AsRef<std::path::Path>>(
146 path: P,
147 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
148 use std::fs;
149
150 let path = path.as_ref();
151 let name = path
152 .file_name()
153 .and_then(|n| n.to_str())
154 .unwrap_or("unnamed")
155 .to_string();
156
157 let quill_toml_path = path.join("Quill.toml");
159 let quill_toml_content = fs::read_to_string(&quill_toml_path)
160 .map_err(|e| format!("Failed to read Quill.toml: {}", e))?;
161
162 let quill_toml: toml::Value = toml::from_str(&quill_toml_content)
163 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
164
165 let mut metadata = HashMap::new();
166 let mut glue_file = "glue.typ".to_string(); let mut quill_name = name; if let Some(quill_section) = quill_toml.get("Quill") {
171 if let Some(name_val) = quill_section.get("name").and_then(|v| v.as_str()) {
173 quill_name = name_val.to_string();
174 }
175
176 if let Some(backend_val) = quill_section.get("backend").and_then(|v| v.as_str()) {
177 match Self::toml_to_yaml_value(&toml::Value::String(backend_val.to_string())) {
178 Ok(yaml_value) => {
179 metadata.insert("backend".to_string(), yaml_value);
180 }
181 Err(e) => {
182 eprintln!("Warning: Failed to convert backend field: {}", e);
183 }
184 }
185 }
186
187 if let Some(glue_val) = quill_section.get("glue").and_then(|v| v.as_str()) {
188 glue_file = glue_val.to_string();
189 }
190
191 if let toml::Value::Table(table) = quill_section {
193 for (key, value) in table {
194 if key != "name" && key != "backend" && key != "glue" && key != "version" {
195 match Self::toml_to_yaml_value(value) {
196 Ok(yaml_value) => {
197 metadata.insert(key.clone(), yaml_value);
198 }
199 Err(e) => {
200 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
201 }
202 }
203 }
204 }
205 }
206 }
207
208 if let Some(typst_section) = quill_toml.get("typst") {
210 if let toml::Value::Table(table) = typst_section {
211 for (key, value) in table {
212 match Self::toml_to_yaml_value(value) {
213 Ok(yaml_value) => {
214 metadata.insert(format!("typst_{}", key), yaml_value);
215 }
216 Err(e) => {
217 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
218 }
219 }
220 }
221 }
222 }
223
224 let glue_path = path.join(&glue_file);
226 let template_content = fs::read_to_string(&glue_path)
227 .map_err(|e| format!("Failed to read glue file '{}': {}", glue_file, e))?;
228
229 let quillignore_path = path.join(".quillignore");
231 let ignore = if quillignore_path.exists() {
232 let ignore_content = fs::read_to_string(&quillignore_path)
233 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
234 QuillIgnore::from_content(&ignore_content)
235 } else {
236 QuillIgnore::new(vec![
238 ".git/".to_string(),
239 ".gitignore".to_string(),
240 ".quillignore".to_string(),
241 "target/".to_string(),
242 "node_modules/".to_string(),
243 ])
244 };
245
246 let mut files = HashMap::new();
248 Self::load_directory_recursive(path, path, &mut files, &ignore)?;
249
250 let quill = Quill {
251 glue_template: template_content,
252 metadata,
253 base_path: path.to_path_buf(),
254 name: quill_name,
255 glue_file,
256 files,
257 };
258
259 quill.validate()?;
261
262 Ok(quill)
263 }
264
265 fn load_directory_recursive(
267 current_dir: &Path,
268 base_dir: &Path,
269 files: &mut HashMap<PathBuf, FileEntry>,
270 ignore: &QuillIgnore,
271 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
272 use std::fs;
273
274 if !current_dir.exists() {
275 return Ok(());
276 }
277
278 for entry in fs::read_dir(current_dir)? {
279 let entry = entry?;
280 let path = entry.path();
281 let relative_path = path
282 .strip_prefix(base_dir)
283 .map_err(|e| format!("Failed to get relative path: {}", e))?
284 .to_path_buf();
285
286 if ignore.is_ignored(&relative_path) {
288 continue;
289 }
290
291 if path.is_file() {
292 let contents = fs::read(&path)
293 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
294
295 files.insert(
296 relative_path.clone(),
297 FileEntry {
298 contents,
299 path: relative_path,
300 is_dir: false,
301 },
302 );
303 } else if path.is_dir() {
304 files.insert(
306 relative_path.clone(),
307 FileEntry {
308 contents: Vec::new(),
309 path: relative_path,
310 is_dir: true,
311 },
312 );
313
314 Self::load_directory_recursive(&path, base_dir, files, ignore)?;
316 }
317 }
318
319 Ok(())
320 }
321
322 pub fn toml_to_yaml_value(
324 toml_val: &toml::Value,
325 ) -> Result<serde_yaml::Value, Box<dyn StdError + Send + Sync>> {
326 let json_val = serde_json::to_value(toml_val)?;
327 let yaml_val = serde_yaml::to_value(json_val)?;
328 Ok(yaml_val)
329 }
330
331 pub fn assets_path(&self) -> PathBuf {
333 self.base_path.join("assets")
334 }
335
336 pub fn packages_path(&self) -> PathBuf {
338 self.base_path.join("packages")
339 }
340
341 pub fn glue_path(&self) -> PathBuf {
343 self.base_path.join(&self.glue_file)
344 }
345
346 pub fn typst_packages(&self) -> Vec<String> {
348 self.metadata
349 .get("typst_packages")
350 .and_then(|v| v.as_sequence())
351 .map(|seq| {
352 seq.iter()
353 .filter_map(|v| v.as_str().map(|s| s.to_string()))
354 .collect()
355 })
356 .unwrap_or_default()
357 }
358
359 pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
361 let glue_path = PathBuf::from(&self.glue_file);
363 if !self.files.contains_key(&glue_path) {
364 return Err(format!("Glue file '{}' does not exist", self.glue_file).into());
365 }
366 Ok(())
367 }
368
369 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
371 let path = path.as_ref();
372 self.files.get(path).map(|entry| entry.contents.as_slice())
373 }
374
375 pub fn get_file_entry<P: AsRef<Path>>(&self, path: P) -> Option<&FileEntry> {
377 let path = path.as_ref();
378 self.files.get(path)
379 }
380
381 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
383 let path = path.as_ref();
384 self.files.contains_key(path)
385 }
386
387 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
389 let dir_path = dir_path.as_ref();
390 let mut entries = Vec::new();
391
392 for (path, entry) in &self.files {
393 if let Some(parent) = path.parent() {
394 if parent == dir_path && !entry.is_dir {
395 entries.push(path.clone());
396 }
397 } else if dir_path == Path::new("") && !entry.is_dir {
398 entries.push(path.clone());
400 }
401 }
402
403 entries.sort();
404 entries
405 }
406
407 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
409 let dir_path = dir_path.as_ref();
410 let mut entries = Vec::new();
411
412 for (path, entry) in &self.files {
413 if entry.is_dir {
414 if let Some(parent) = path.parent() {
415 if parent == dir_path {
416 entries.push(path.clone());
417 }
418 } else if dir_path == Path::new("") {
419 entries.push(path.clone());
421 }
422 }
423 }
424
425 entries.sort();
426 entries
427 }
428
429 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
431 let pattern_str = pattern.as_ref().to_string_lossy();
432 let mut matches = Vec::new();
433
434 for (path, entry) in &self.files {
435 if !entry.is_dir {
436 let path_str = path.to_string_lossy();
437 if self.matches_simple_pattern(&pattern_str, &path_str) {
438 matches.push(path.clone());
439 }
440 }
441 }
442
443 matches.sort();
444 matches
445 }
446
447 fn matches_simple_pattern(&self, pattern: &str, path: &str) -> bool {
449 if pattern == "*" {
450 return true;
451 }
452
453 if !pattern.contains('*') {
454 return path == pattern;
455 }
456
457 if pattern.ends_with("/*") {
459 let dir_pattern = &pattern[..pattern.len() - 2];
460 return path.starts_with(&format!("{}/", dir_pattern));
461 }
462
463 let parts: Vec<&str> = pattern.split('*').collect();
464 if parts.len() == 2 {
465 let (prefix, suffix) = (parts[0], parts[1]);
466 if prefix.is_empty() {
467 return path.ends_with(suffix);
468 } else if suffix.is_empty() {
469 return path.starts_with(prefix);
470 } else {
471 return path.starts_with(prefix) && path.ends_with(suffix);
472 }
473 }
474
475 false
476 }
477}
478#[cfg(test)]
479mod quill_tests {
480 use super::*;
481 use std::fs;
482 use tempfile::TempDir;
483
484 #[test]
485 fn test_quillignore_parsing() {
486 let ignore_content = r#"
487# This is a comment
488*.tmp
489target/
490node_modules/
491.git/
492"#;
493 let ignore = QuillIgnore::from_content(ignore_content);
494 assert_eq!(ignore.patterns.len(), 4);
495 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
496 assert!(ignore.patterns.contains(&"target/".to_string()));
497 }
498
499 #[test]
500 fn test_quillignore_matching() {
501 let ignore = QuillIgnore::new(vec![
502 "*.tmp".to_string(),
503 "target/".to_string(),
504 "node_modules/".to_string(),
505 ".git/".to_string(),
506 ]);
507
508 assert!(ignore.is_ignored("test.tmp"));
510 assert!(ignore.is_ignored("path/to/file.tmp"));
511 assert!(!ignore.is_ignored("test.txt"));
512
513 assert!(ignore.is_ignored("target"));
515 assert!(ignore.is_ignored("target/debug"));
516 assert!(ignore.is_ignored("target/debug/deps"));
517 assert!(!ignore.is_ignored("src/target.rs"));
518
519 assert!(ignore.is_ignored("node_modules"));
520 assert!(ignore.is_ignored("node_modules/package"));
521 assert!(!ignore.is_ignored("my_node_modules"));
522 }
523
524 #[test]
525 fn test_in_memory_file_system() {
526 let temp_dir = TempDir::new().unwrap();
527 let quill_dir = temp_dir.path();
528
529 fs::write(
531 quill_dir.join("Quill.toml"),
532 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
533 )
534 .unwrap();
535 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
536
537 let assets_dir = quill_dir.join("assets");
538 fs::create_dir_all(&assets_dir).unwrap();
539 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
540
541 let packages_dir = quill_dir.join("packages");
542 fs::create_dir_all(&packages_dir).unwrap();
543 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
544
545 let quill = Quill::from_path(quill_dir).unwrap();
547
548 assert!(quill.file_exists("glue.typ"));
550 assert!(quill.file_exists("assets/test.txt"));
551 assert!(quill.file_exists("packages/package.typ"));
552 assert!(!quill.file_exists("nonexistent.txt"));
553
554 let asset_content = quill.get_file("assets/test.txt").unwrap();
556 assert_eq!(asset_content, b"asset content");
557
558 let asset_files = quill.list_directory("assets");
560 assert_eq!(asset_files.len(), 1);
561 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
562 }
563
564 #[test]
565 fn test_quillignore_integration() {
566 let temp_dir = TempDir::new().unwrap();
567 let quill_dir = temp_dir.path();
568
569 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
571
572 fs::write(
574 quill_dir.join("Quill.toml"),
575 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
576 )
577 .unwrap();
578 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
579 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
580
581 let target_dir = quill_dir.join("target");
582 fs::create_dir_all(&target_dir).unwrap();
583 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
584
585 let quill = Quill::from_path(quill_dir).unwrap();
587
588 assert!(quill.file_exists("glue.typ"));
590 assert!(!quill.file_exists("should_ignore.tmp"));
591 assert!(!quill.file_exists("target/debug.txt"));
592 }
593
594 #[test]
595 fn test_find_files_pattern() {
596 let temp_dir = TempDir::new().unwrap();
597 let quill_dir = temp_dir.path();
598
599 fs::write(
601 quill_dir.join("Quill.toml"),
602 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
603 )
604 .unwrap();
605 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
606
607 let assets_dir = quill_dir.join("assets");
608 fs::create_dir_all(&assets_dir).unwrap();
609 fs::write(assets_dir.join("image.png"), "png data").unwrap();
610 fs::write(assets_dir.join("data.json"), "json data").unwrap();
611
612 let fonts_dir = assets_dir.join("fonts");
613 fs::create_dir_all(&fonts_dir).unwrap();
614 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
615
616 let quill = Quill::from_path(quill_dir).unwrap();
618
619 let all_assets = quill.find_files("assets/*");
621 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
624 assert_eq!(typ_files.len(), 1);
625 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
626 }
627
628 #[test]
629 fn test_new_standardized_toml_format() {
630 let temp_dir = TempDir::new().unwrap();
631 let quill_dir = temp_dir.path();
632
633 let toml_content = r#"[Quill]
635name = "my-custom-quill"
636backend = "typst"
637glue = "custom_glue.typ"
638description = "Test quill with new format"
639author = "Test Author"
640"#;
641 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
642 fs::write(
643 quill_dir.join("custom_glue.typ"),
644 "= Custom Template\n\nThis is a custom template.",
645 )
646 .unwrap();
647
648 let quill = Quill::from_path(quill_dir).unwrap();
650
651 assert_eq!(quill.name, "my-custom-quill");
653
654 assert_eq!(quill.glue_file, "custom_glue.typ");
656
657 assert!(quill.metadata.contains_key("backend"));
659 if let Some(backend_val) = quill.metadata.get("backend") {
660 if let Some(backend_str) = backend_val.as_str() {
661 assert_eq!(backend_str, "typst");
662 } else {
663 panic!("Backend value is not a string");
664 }
665 }
666
667 assert!(quill.metadata.contains_key("description"));
669 assert!(quill.metadata.contains_key("author"));
670 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue_template.contains("Custom Template"));
674 assert!(quill.glue_template.contains("custom template"));
675 }
676
677 #[test]
678 fn test_typst_packages_parsing() {
679 let temp_dir = TempDir::new().unwrap();
680 let quill_dir = temp_dir.path();
681
682 let toml_content = r#"
683[Quill]
684name = "test-quill"
685backend = "typst"
686glue = "glue.typ"
687
688[typst]
689packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
690"#;
691
692 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
693 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
694
695 let quill = Quill::from_path(quill_dir).unwrap();
696 let packages = quill.typst_packages();
697
698 assert_eq!(packages.len(), 2);
699 assert_eq!(packages[0], "@preview/bubble:0.2.2");
700 assert_eq!(packages[1], "@preview/example:1.0.0");
701 }
702}