1use anyhow::Result;
22use clap::ArgMatches;
23use std::{fs, path::Path};
24#[derive(Debug)]
29#[non_exhaustive]
30pub enum ProcessError {
31 DirectoryCreation {
37 dir_type: String,
39 path: String,
41 source: std::io::Error,
43 },
44
45 MissingArgument(String),
50
51 CompilationError(String),
56
57 IoError(std::io::Error),
59
60 FrontmatterError(String),
62}
63
64impl std::fmt::Display for ProcessError {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 Self::DirectoryCreation {
68 dir_type,
69 path,
70 source,
71 } => write!(
72 f,
73 "Failed to create {dir_type} directory at '{path}': {source}"
74 ),
75 Self::MissingArgument(arg) => {
76 write!(f, "Required argument missing: {arg}")
77 }
78 Self::CompilationError(msg) => {
79 write!(f, "Compilation error: {msg}")
80 }
81 Self::IoError(e) => write!(f, "{e}"),
82 Self::FrontmatterError(msg) => {
83 write!(f, "Frontmatter processing error: {msg}")
84 }
85 }
86 }
87}
88
89impl std::error::Error for ProcessError {
90 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
91 match self {
92 Self::DirectoryCreation { source, .. } => Some(source),
93 Self::IoError(e) => Some(e),
94 _ => None,
95 }
96 }
97}
98
99impl From<std::io::Error> for ProcessError {
100 fn from(e: std::io::Error) -> Self {
101 Self::IoError(e)
102 }
103}
104
105pub fn get_argument(
132 matches: &ArgMatches,
133 name: &str,
134) -> Result<String, ProcessError> {
135 matches
136 .get_one::<String>(name)
137 .ok_or_else(|| ProcessError::MissingArgument(name.to_string()))
138 .map(String::from)
139}
140
141pub fn ensure_directory(
165 path: &Path,
166 dir_type: &str,
167) -> Result<(), ProcessError> {
168 if path.exists() {
169 if !path.is_dir() {
171 return Err(ProcessError::DirectoryCreation {
172 dir_type: dir_type.to_string(),
173 path: path.display().to_string(),
174 source: std::io::Error::new(
175 std::io::ErrorKind::AlreadyExists,
176 "Path exists but is not a directory",
177 ),
178 });
179 }
180 } else {
181 fs::create_dir_all(path).map_err(|e| {
182 ProcessError::DirectoryCreation {
183 dir_type: dir_type.to_string(),
184 path: path.display().to_string(),
185 source: e,
186 }
187 })?;
188 }
189 Ok(())
190}
191
192fn internal_compile(
209 build_path: &Path,
210 content_path: &Path,
211 site_path: &Path,
212 template_path: &Path,
213) -> Result<(), String> {
214 staticdatagen::compiler::service::compile(
215 build_path,
216 content_path,
217 site_path,
218 template_path,
219 )
220 .map_err(|e| e.to_string())
221}
222
223fn preprocess_content(content_path: &Path) -> Result<(), ProcessError> {
225 if !content_path.exists() {
226 return Ok(());
227 }
228
229 for entry in fs::read_dir(content_path)? {
231 let entry = entry?;
232 let path = entry.path();
233
234 if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
235 let content = fs::read_to_string(&path)?;
236 let processed_content = process_frontmatter(&content)?;
237 fs::write(&path, processed_content)?;
238 }
239 }
240 Ok(())
241}
242
243fn process_frontmatter(content: &str) -> Result<String, ProcessError> {
245 const DELIMITER: &str = "---";
246
247 let parts: Vec<&str> = content.splitn(3, DELIMITER).collect();
248 match parts.len() {
249 3 => {
250 let frontmatter = parts[1].trim();
252 let main_content = parts[2].trim();
253
254 Ok(format!(
257 "---\n{frontmatter}\n---\n<!--frontmatter-processed-->\n{main_content}"
258 ))
259 }
260 _ => Ok(content.to_string()), }
262}
263
264pub fn args(matches: &ArgMatches) -> Result<(), ProcessError> {
286 let content_dir = get_argument(matches, "content")?;
288 let output_dir = get_argument(matches, "output")?;
289 let site_dir = get_argument(matches, "new")?;
290 let template_dir = get_argument(matches, "template")?;
291
292 let content_path = Path::new(&content_dir);
294 let build_path = Path::new(&output_dir);
295 let site_path = Path::new(&site_dir);
296 let template_path = Path::new(&template_dir);
297
298 ensure_directory(content_path, "content")?;
300 ensure_directory(build_path, "output")?;
301 ensure_directory(site_path, "project")?;
302 ensure_directory(template_path, "template")?;
303
304 preprocess_content(content_path)?;
306
307 internal_compile(build_path, content_path, site_path, template_path)
309 .map_err(ProcessError::CompilationError)?;
310
311 Ok(())
312}
313
314#[cfg(test)]
315#[allow(clippy::unwrap_used, clippy::expect_used)]
316mod tests {
317 use super::*;
318 use clap::{arg, Command};
319 use std::fs::{self, File};
320 use tempfile::tempdir;
321
322 fn create_test_command() -> ArgMatches {
324 Command::new("test")
325 .arg(arg!(--"content" <CONTENT> "Content directory"))
326 .arg(arg!(--"output" <OUTPUT> "Output directory"))
327 .arg(arg!(--"new" <NEW> "New site directory"))
328 .arg(arg!(--"template" <TEMPLATE> "Template directory"))
329 .get_matches_from(vec![
330 "test",
331 "--content",
332 "content",
333 "--output",
334 "output",
335 "--new",
336 "new_site",
337 "--template",
338 "template",
339 ])
340 }
341
342 #[test]
343 fn test_get_argument_present() {
344 let matches = create_test_command();
345 let content = get_argument(&matches, "content").unwrap();
346 assert_eq!(content, "content");
347 }
348
349 #[test]
350 fn test_get_argument_missing() {
351 let matches = Command::new("test")
352 .arg(arg!(--"config" <CONFIG> "Config file"))
353 .get_matches_from(vec!["test"]);
354 let result = get_argument(&matches, "config");
355 assert!(matches!(result, Err(ProcessError::MissingArgument(_))));
356 }
357
358 #[test]
359 fn test_ensure_directory_exists() {
360 let temp_dir = tempdir().unwrap();
361 let result = ensure_directory(temp_dir.path(), "temp");
362 assert!(result.is_ok());
363 }
364
365 #[test]
366 fn test_args_missing_template_argument() {
367 let matches = Command::new("test")
368 .arg(arg!(--"content" <CONTENT> "Content directory"))
369 .arg(arg!(--"output" <OUTPUT> "Output directory"))
370 .arg(arg!(--"new" <NEW> "New site directory"))
371 .arg(arg!(--"template" <TEMPLATE> "Template directory"))
372 .get_matches_from(vec![
373 "test",
374 "--content",
375 "content",
376 "--output",
377 "output",
378 "--new",
379 "new_site",
380 ]);
381 let result = args(&matches);
382 assert!(matches!(
383 result,
384 Err(ProcessError::MissingArgument(ref arg)) if arg == "template"
385 ));
386 }
387
388 #[test]
389 fn test_ensure_directory_already_exists() -> Result<()> {
390 let temp_dir = tempdir()?;
391 ensure_directory(temp_dir.path(), "existing")?;
392 assert!(temp_dir.path().exists());
393 Ok(())
394 }
395
396 #[cfg(not(target_os = "windows"))] #[test]
398 fn test_process_error_display() {
399 let error = ProcessError::MissingArgument("content".to_string());
400 assert_eq!(error.to_string(), "Required argument missing: content");
401
402 let error = ProcessError::DirectoryCreation {
403 dir_type: "content".to_string(),
404 path: "/invalid/path".to_string(),
405 source: std::io::Error::from_raw_os_error(13),
406 };
407 assert_eq!(
408 error.to_string(),
409 "Failed to create content directory at '/invalid/path': Permission denied (os error 13)"
410 );
411
412 let error =
413 ProcessError::CompilationError("Failed to compile".to_string());
414 assert_eq!(error.to_string(), "Compilation error: Failed to compile");
415 }
416
417 #[test]
418 fn test_process_error_io_error() {
419 let io_error = std::io::Error::other("an I/O error occurred");
420 let error: ProcessError = io_error.into();
421 assert!(matches!(error, ProcessError::IoError(_)));
422 assert_eq!(error.to_string(), "an I/O error occurred");
423 }
424
425 #[test]
426 fn test_process_error_io_error_format() {
427 let io_error =
428 std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
429 let error: ProcessError = io_error.into();
430 assert!(matches!(error, ProcessError::IoError(_)));
431 assert_eq!(error.to_string(), "File not found");
432 }
433
434 #[cfg(unix)]
435 #[test]
436 fn test_ensure_directory_permission_denied() {
437 use std::fs::Permissions;
438 use std::os::unix::fs::PermissionsExt;
439
440 let temp_dir = tempdir().unwrap();
441 let protected_path = temp_dir.path().join("protected_dir");
442
443 fs::create_dir(&protected_path).unwrap();
445 fs::set_permissions(&protected_path, Permissions::from_mode(0o400))
446 .unwrap();
447
448 let sub_dir = protected_path.join("sub_dir");
450 let result = ensure_directory(&sub_dir, "sub_directory");
451
452 assert!(matches!(
454 result,
455 Err(ProcessError::DirectoryCreation { .. })
456 ));
457
458 fs::set_permissions(&protected_path, Permissions::from_mode(0o700))
460 .unwrap();
461 }
462
463 #[test]
464 fn test_args_all_required_arguments(
465 ) -> Result<(), Box<dyn std::error::Error>> {
466 let temp_dir = tempdir()?;
467 let content_dir = temp_dir.path().join("content");
468 let output_dir = temp_dir.path().join("output");
469 let site_dir = temp_dir.path().join("new_site");
470 let template_dir = temp_dir.path().join("template");
471
472 let matches = Command::new("test")
473 .arg(arg!(--"content" <CONTENT> "Content directory"))
474 .arg(arg!(--"output" <OUTPUT> "Output directory"))
475 .arg(arg!(--"new" <NEW> "New site directory"))
476 .arg(arg!(--"template" <TEMPLATE> "Template directory"))
477 .get_matches_from(vec![
478 "test",
479 "--content",
480 content_dir.to_str().unwrap(),
481 "--output",
482 output_dir.to_str().unwrap(),
483 "--new",
484 site_dir.to_str().unwrap(),
485 "--template",
486 template_dir.to_str().unwrap(),
487 ]);
488
489 let result = args(&matches);
491 assert!(
492 matches!(result, Err(ProcessError::CompilationError(_))),
493 "Expected CompilationError from args"
494 );
495
496 Ok(())
497 }
498 #[test]
499 fn test_process_frontmatter_with_valid_frontmatter(
500 ) -> Result<(), ProcessError> {
501 let content = "\
502---
503title: Test Post
504date: 2024-01-01
505---
506# Main Content
507This is the main content.";
508
509 let processed = process_frontmatter(content)?;
510 assert!(processed.contains("<!--frontmatter-processed-->"));
511 assert!(processed.contains("title: Test Post"));
512 assert!(processed.contains("# Main Content"));
513 Ok(())
514 }
515
516 #[test]
517 fn test_process_frontmatter_without_frontmatter() -> Result<(), ProcessError>
518 {
519 let content = "# Just Content\nNo frontmatter here.";
520 let processed = process_frontmatter(content)?;
521 assert_eq!(processed, content);
522 Ok(())
523 }
524
525 #[test]
526 fn test_process_frontmatter_with_empty_frontmatter(
527 ) -> Result<(), ProcessError> {
528 let content = "---\n---\nContent after empty frontmatter";
529 let processed = process_frontmatter(content)?;
530 assert!(processed.contains("<!--frontmatter-processed-->"));
531 Ok(())
532 }
533
534 #[test]
535 fn test_preprocess_content_with_multiple_files() -> Result<(), ProcessError>
536 {
537 let temp_dir = tempdir()?;
538
539 let file1_path = temp_dir.path().join("post1.md");
541 let file2_path = temp_dir.path().join("post2.md");
542 let non_md_path = temp_dir.path().join("other.txt");
543
544 fs::write(&file1_path, "---\ntitle: Post 1\n---\nContent 1")?;
545 fs::write(&file2_path, "---\ntitle: Post 2\n---\nContent 2")?;
546 fs::write(&non_md_path, "Not a markdown file")?;
547
548 preprocess_content(temp_dir.path())?;
549
550 let content1 = fs::read_to_string(&file1_path)?;
552 let content2 = fs::read_to_string(&file2_path)?;
553 let other = fs::read_to_string(&non_md_path)?;
554
555 assert!(content1.contains("<!--frontmatter-processed-->"));
556 assert!(content2.contains("<!--frontmatter-processed-->"));
557 assert_eq!(other, "Not a markdown file");
558
559 Ok(())
560 }
561
562 #[test]
563 fn test_preprocess_content_with_non_existent_directory(
564 ) -> Result<(), ProcessError> {
565 let non_existent = Path::new("non_existent_directory");
566 let result = preprocess_content(non_existent);
567 assert!(result.is_ok());
568 Ok(())
569 }
570
571 #[cfg(unix)]
572 #[test]
573 fn test_preprocess_content_with_invalid_permissions() {
574 use std::fs::Permissions;
575 use std::os::unix::fs::PermissionsExt;
576
577 let temp_dir = tempdir().unwrap();
578 let file_path = temp_dir.path().join("readonly.md");
579
580 fs::write(&file_path, "---\ntitle: Test\n---\nContent").unwrap();
582
583 fs::set_permissions(&file_path, Permissions::from_mode(0o444)).unwrap();
585
586 let result = preprocess_content(temp_dir.path());
587 assert!(result.is_err());
588
589 fs::set_permissions(&file_path, Permissions::from_mode(0o666)).unwrap();
591 }
592
593 #[test]
594 fn test_internal_compile_error_handling() {
595 let temp_dir = tempdir().unwrap();
596 let result = internal_compile(
597 &temp_dir.path().join("build"),
598 &temp_dir.path().join("content"),
599 &temp_dir.path().join("site"),
600 &temp_dir.path().join("template"),
601 );
602 assert!(result.is_err());
603 }
604
605 #[test]
606 fn test_get_argument_with_empty_value() {
607 let matches = Command::new("test")
608 .arg(arg!(--"empty" <EMPTY> "Empty value"))
609 .get_matches_from(vec!["test", "--empty", ""]);
610
611 let result = get_argument(&matches, "empty");
612 assert!(result.is_ok());
613 assert_eq!(result.unwrap(), "");
614 }
615
616 #[test]
617 fn test_ensure_directory_with_existing_file(
618 ) -> Result<(), Box<dyn std::error::Error>> {
619 let temp_dir = tempdir()?;
620 let file_path = temp_dir.path().join("existing_file");
621
622 let _file = File::create(&file_path)?;
624
625 let result = ensure_directory(&file_path, "test");
627
628 let err = result.unwrap_err();
630 match err {
631 ProcessError::DirectoryCreation { source, .. } => {
632 assert_eq!(source.kind(), std::io::ErrorKind::AlreadyExists);
633 }
634 other => panic!("Expected DirectoryCreation, got: {other}"),
635 }
636
637 Ok(())
638 }
639
640 #[test]
641 fn test_ensure_directory_with_existing_directory(
642 ) -> Result<(), Box<dyn std::error::Error>> {
643 let temp_dir = tempdir()?;
644 let dir_path = temp_dir.path().join("existing_dir");
645
646 fs::create_dir(&dir_path)?;
648
649 let result = ensure_directory(&dir_path, "test");
651
652 assert!(result.is_ok());
654
655 Ok(())
656 }
657
658 #[test]
659 fn test_preprocess_content_with_invalid_utf8() -> Result<()> {
660 let temp_dir = tempdir()?;
661 let file_path = temp_dir.path().join("invalid.md");
662
663 let invalid_bytes = vec![0xFF, 0xFF];
665 fs::write(&file_path, invalid_bytes)?;
666
667 let result = preprocess_content(temp_dir.path());
668 assert!(result.is_err());
669 Ok(())
670 }
671
672 #[test]
673 fn test_process_frontmatter_with_multiple_delimiters() -> Result<()> {
674 let content = "\
675---
676title: First
677---
678---
679title: Second
680---
681Content";
682
683 let processed = process_frontmatter(content)?;
684 assert!(processed.contains("title: First"));
686 assert!(processed.contains("---\ntitle: Second"));
687 Ok(())
688 }
689
690 #[test]
691 fn test_process_frontmatter_with_malformed_delimiters(
692 ) -> Result<(), ProcessError> {
693 let content = "---\ntitle: Test\nContent";
695 let processed = process_frontmatter(content)?;
696 assert_eq!(processed, content); let content = "---\ntitle: Test\n---\nContent";
700 let processed = process_frontmatter(content)?;
701 assert!(processed.contains("<!--frontmatter-processed-->"));
702 assert!(processed.contains("title: Test"));
703 assert!(processed.contains("Content"));
704
705 Ok(())
706 }
707
708 #[test]
709 fn test_process_frontmatter_with_whitespace() -> Result<(), ProcessError> {
710 let content = "\n\n---\ntitle: Test\n---\nContent";
712 let processed = process_frontmatter(content)?;
713 assert!(processed.contains("<!--frontmatter-processed-->"));
715 assert!(processed.contains("title: Test"));
716 assert!(processed.contains("Content"));
717
718 let content = "---\n title: Test \n author: Someone \n---\nContent";
720 let processed = process_frontmatter(content)?;
721 assert!(processed.contains("<!--frontmatter-processed-->"));
722 assert!(processed.contains("title: Test"));
723 assert!(processed.contains("author: Someone"));
724 assert!(processed.contains("Content"));
725
726 Ok(())
727 }
728
729 #[test]
730 fn test_process_frontmatter_with_invalid_format() -> Result<(), ProcessError>
731 {
732 let content = "---\ntitle: Test\nContent";
734 let processed = process_frontmatter(content)?;
735 assert_eq!(processed, content);
736
737 let content = "===\ntitle: Test\n===\nContent";
739 let processed = process_frontmatter(content)?;
740 assert_eq!(processed, content);
741
742 let content = "---\n\n---\nContent";
744 let processed = process_frontmatter(content)?;
745 assert!(processed.contains("<!--frontmatter-processed-->"));
746
747 Ok(())
748 }
749
750 #[test]
751 fn test_preprocess_content_with_nested_directories(
752 ) -> Result<(), ProcessError> {
753 let temp_dir = tempdir()?;
754 let nested_dir = temp_dir.path().join("nested");
755 fs::create_dir(&nested_dir)?;
756
757 let root_file = temp_dir.path().join("root.md");
759 let nested_file = nested_dir.join("nested.md");
760
761 fs::write(&root_file, "---\ntitle: Root\n---\nRoot content")?;
762 fs::write(&nested_file, "---\ntitle: Nested\n---\nNested content")?;
763
764 preprocess_content(temp_dir.path())?;
765
766 let root_content = fs::read_to_string(&root_file)?;
768 assert!(root_content.contains("<!--frontmatter-processed-->"));
769
770 let nested_content = fs::read_to_string(&nested_file)?;
771 assert!(!nested_content.contains("<!--frontmatter-processed-->"));
772
773 Ok(())
774 }
775
776 #[test]
777 fn test_preprocess_content_with_empty_files() -> Result<(), ProcessError> {
778 let temp_dir = tempdir()?;
779 let empty_file = temp_dir.path().join("empty.md");
780
781 fs::write(&empty_file, "")?;
783
784 preprocess_content(temp_dir.path())?;
785
786 let content = fs::read_to_string(&empty_file)?;
788 assert!(content.is_empty());
789
790 Ok(())
791 }
792
793 #[test]
794 fn test_ensure_directory_with_symlink() -> Result<(), ProcessError> {
795 let temp_dir = tempdir()?;
796 let real_dir = temp_dir.path().join("real_dir");
797 let symlink = temp_dir.path().join("symlink_dir");
798
799 fs::create_dir(&real_dir)?;
800
801 #[cfg(unix)]
802 std::os::unix::fs::symlink(&real_dir, &symlink)?;
803 #[cfg(windows)]
804 std::os::windows::fs::symlink_dir(&real_dir, &symlink)?;
805
806 let result = ensure_directory(&symlink, "symlink");
808 assert!(result.is_ok());
809
810 Ok(())
811 }
812
813 #[test]
814 fn test_process_error_frontmatter_display() {
815 let error = ProcessError::FrontmatterError("bad yaml".to_string());
816 assert_eq!(error.to_string(), "Frontmatter processing error: bad yaml");
817 }
818
819 #[test]
820 fn test_process_error_source_for_directory_creation() {
821 use std::error::Error;
822 let error = ProcessError::DirectoryCreation {
823 dir_type: "output".to_string(),
824 path: "/bad".to_string(),
825 source: std::io::Error::new(
826 std::io::ErrorKind::PermissionDenied,
827 "denied",
828 ),
829 };
830 assert!(error.source().is_some());
831 }
832
833 #[test]
834 fn test_process_error_source_for_io_error() {
835 use std::error::Error;
836 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
837 let error = ProcessError::IoError(io_err);
838 assert!(error.source().is_some());
839 }
840
841 #[test]
842 fn test_process_error_source_for_missing_argument() {
843 use std::error::Error;
844 let error = ProcessError::MissingArgument("foo".to_string());
845 assert!(error.source().is_none());
846 }
847
848 #[test]
849 fn test_process_error_source_for_compilation_error() {
850 use std::error::Error;
851 let error = ProcessError::CompilationError("oops".to_string());
852 assert!(error.source().is_none());
853 }
854
855 #[test]
856 fn test_process_error_source_for_frontmatter_error() {
857 use std::error::Error;
858 let error = ProcessError::FrontmatterError("bad".to_string());
859 assert!(error.source().is_none());
860 }
861
862 #[test]
863 fn test_process_error_debug() {
864 let error = ProcessError::MissingArgument("arg".to_string());
865 let debug = format!("{error:?}");
866 assert!(debug.contains("MissingArgument"));
867 }
868
869 #[test]
870 fn test_preprocess_content_empty_directory() -> Result<(), ProcessError> {
871 let temp_dir = tempdir()?;
872 preprocess_content(temp_dir.path())?;
874 Ok(())
875 }
876
877 #[test]
878 fn test_process_frontmatter_only_delimiters() -> Result<(), ProcessError> {
879 let content = "---\n---\n";
880 let processed = process_frontmatter(content)?;
881 assert!(processed.contains("<!--frontmatter-processed-->"));
882 Ok(())
883 }
884
885 #[test]
886 fn test_internal_compile_with_empty_directories() {
887 let temp_dir = tempdir().unwrap();
888
889 let build_dir = temp_dir.path().join("build");
891 let content_dir = temp_dir.path().join("content");
892 let site_dir = temp_dir.path().join("site");
893 let template_dir = temp_dir.path().join("template");
894
895 fs::create_dir_all(&build_dir).unwrap();
896 fs::create_dir_all(&content_dir).unwrap();
897 fs::create_dir_all(&site_dir).unwrap();
898 fs::create_dir_all(&template_dir).unwrap();
899
900 let result = internal_compile(
901 &build_dir,
902 &content_dir,
903 &site_dir,
904 &template_dir,
905 );
906
907 assert!(result.is_err());
908 }
909}