1use clap::ArgMatches;
31use std::{fs, path::Path};
32#[derive(Debug)]
37#[non_exhaustive]
38pub enum ProcessError {
39 DirectoryCreation {
45 dir_type: String,
47 path: String,
49 source: std::io::Error,
51 },
52
53 MissingArgument(String),
58
59 CompilationError(String),
64
65 IoError(std::io::Error),
67
68 FrontmatterError(String),
70}
71
72impl std::fmt::Display for ProcessError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 Self::DirectoryCreation {
76 dir_type,
77 path,
78 source,
79 } => write!(
80 f,
81 "Failed to create {dir_type} directory at '{path}': {source}"
82 ),
83 Self::MissingArgument(arg) => {
84 write!(f, "Required argument missing: {arg}")
85 }
86 Self::CompilationError(msg) => {
87 write!(f, "Compilation error: {msg}")
88 }
89 Self::IoError(e) => write!(f, "{e}"),
90 Self::FrontmatterError(msg) => {
91 write!(f, "Frontmatter processing error: {msg}")
92 }
93 }
94 }
95}
96
97impl std::error::Error for ProcessError {
98 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
99 match self {
100 Self::DirectoryCreation { source, .. } => Some(source),
101 Self::IoError(e) => Some(e),
102 _ => None,
103 }
104 }
105}
106
107impl From<std::io::Error> for ProcessError {
108 fn from(e: std::io::Error) -> Self {
109 Self::IoError(e)
110 }
111}
112
113pub fn get_argument(
140 matches: &ArgMatches,
141 name: &str,
142) -> Result<String, ProcessError> {
143 matches
144 .get_one::<String>(name)
145 .ok_or_else(|| ProcessError::MissingArgument(name.to_string()))
146 .map(String::from)
147}
148
149pub fn ensure_directory(
176 path: &Path,
177 dir_type: &str,
178) -> Result<(), ProcessError> {
179 if path.exists() {
180 if !path.is_dir() {
182 return Err(ProcessError::DirectoryCreation {
183 dir_type: dir_type.to_string(),
184 path: path.display().to_string(),
185 source: std::io::Error::new(
186 std::io::ErrorKind::AlreadyExists,
187 "Path exists but is not a directory",
188 ),
189 });
190 }
191 } else {
192 fs::create_dir_all(path).map_err(|e| {
193 ProcessError::DirectoryCreation {
194 dir_type: dir_type.to_string(),
195 path: path.display().to_string(),
196 source: e,
197 }
198 })?;
199 }
200 Ok(())
201}
202
203fn internal_compile(
220 build_path: &Path,
221 content_path: &Path,
222 site_path: &Path,
223 template_path: &Path,
224) -> Result<(), String> {
225 staticdatagen::compiler::service::compile(
226 build_path,
227 content_path,
228 site_path,
229 template_path,
230 )
231 .map_err(|e| e.to_string())
232}
233
234pub fn args(matches: &ArgMatches) -> Result<(), ProcessError> {
268 let content_dir = get_argument(matches, "content")?;
270 let output_dir = get_argument(matches, "output")?;
271 let site_dir = get_argument(matches, "new")?;
272 let template_dir = get_argument(matches, "template")?;
273
274 let content_path = Path::new(&content_dir);
276 let build_path = Path::new(&output_dir);
277 let site_path = Path::new(&site_dir);
278 let template_path = Path::new(&template_dir);
279
280 ensure_directory(content_path, "content")?;
282 ensure_directory(build_path, "output")?;
283 ensure_directory(site_path, "project")?;
284 ensure_directory(template_path, "template")?;
285
286 internal_compile(build_path, content_path, site_path, template_path)
291 .map_err(ProcessError::CompilationError)?;
292
293 Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use anyhow::Result;
300 use clap::{arg, Command};
301 use std::fs::{self, File};
302 use tempfile::tempdir;
303
304 fn is_missing_argument(
308 r: &Result<String, ProcessError>,
309 name: &str,
310 ) -> bool {
311 matches!(r, Err(ProcessError::MissingArgument(arg)) if arg == name)
312 }
313
314 fn is_missing_argument_unit(
315 r: &Result<(), ProcessError>,
316 name: &str,
317 ) -> bool {
318 matches!(r, Err(ProcessError::MissingArgument(arg)) if arg == name)
319 }
320
321 fn is_directory_creation(r: &Result<(), ProcessError>) -> bool {
322 matches!(r, Err(ProcessError::DirectoryCreation { .. }))
323 }
324
325 fn is_io_error(e: &ProcessError) -> bool {
326 matches!(e, ProcessError::IoError(_))
327 }
328
329 fn is_input_error(r: &Result<(), ProcessError>) -> bool {
330 matches!(
331 r,
332 Err(ProcessError::CompilationError(_)
333 | ProcessError::DirectoryCreation { .. })
334 )
335 }
336
337 fn directory_creation_source_kind(
338 e: ProcessError,
339 ) -> Option<std::io::ErrorKind> {
340 match e {
341 ProcessError::DirectoryCreation { source, .. } => {
342 Some(source.kind())
343 }
344 _ => None,
345 }
346 }
347
348 #[test]
349 fn variant_helpers_reject_non_matching_values() {
350 assert!(!is_missing_argument(&Ok("v".to_string()), "content"));
351 assert!(!is_missing_argument(
352 &Err(ProcessError::MissingArgument("a".to_string())),
353 "b"
354 ));
355 assert!(!is_missing_argument_unit(&Ok(()), "content"));
356 assert!(!is_missing_argument_unit(
357 &Err(ProcessError::MissingArgument("a".to_string())),
358 "b"
359 ));
360 assert!(!is_directory_creation(&Ok(())));
361 assert!(is_input_error(&Err(ProcessError::CompilationError(
362 "x".to_string()
363 ))));
364 assert!(!is_input_error(&Ok(())));
365 assert!(!is_io_error(&ProcessError::FrontmatterError(
366 "f".to_string()
367 )));
368 assert!(
369 directory_creation_source_kind(ProcessError::MissingArgument(
370 "m".to_string()
371 ))
372 .is_none()
373 );
374 }
375
376 fn create_test_command() -> ArgMatches {
378 Command::new("test")
379 .arg(arg!(--"content" <CONTENT> "Content directory"))
380 .arg(arg!(--"output" <OUTPUT> "Output directory"))
381 .arg(arg!(--"new" <NEW> "New site directory"))
382 .arg(arg!(--"template" <TEMPLATE> "Template directory"))
383 .get_matches_from(vec![
384 "test",
385 "--content",
386 "content",
387 "--output",
388 "output",
389 "--new",
390 "new_site",
391 "--template",
392 "template",
393 ])
394 }
395
396 #[test]
397 fn test_get_argument_present() {
398 let matches = create_test_command();
399 let content = get_argument(&matches, "content").unwrap();
400 assert_eq!(content, "content");
401 }
402
403 #[test]
404 fn test_get_argument_missing() {
405 let matches = Command::new("test")
406 .arg(arg!(--"config" <CONFIG> "Config file"))
407 .get_matches_from(vec!["test"]);
408 let result = get_argument(&matches, "config");
409 assert!(is_missing_argument(&result, "config"));
410 }
411
412 #[test]
413 fn test_ensure_directory_exists() {
414 let temp_dir = tempdir().unwrap();
415 let result = ensure_directory(temp_dir.path(), "temp");
416 assert!(result.is_ok());
417 }
418
419 #[test]
420 fn test_args_missing_content_argument() {
421 let matches = Command::new("test")
426 .arg(arg!(--"content" <CONTENT> "Content directory"))
427 .arg(arg!(--"output" <OUTPUT> "Output directory"))
428 .arg(arg!(--"new" <NEW> "New site directory"))
429 .arg(arg!(--"template" <TEMPLATE> "Template directory"))
430 .get_matches_from(vec![
431 "test",
432 "--output",
433 "output",
434 "--new",
435 "new_site",
436 "--template",
437 "template",
438 ]);
439 let result = args(&matches);
440 assert!(is_missing_argument_unit(&result, "content"));
441 }
442
443 #[test]
444 fn test_args_missing_output_argument() {
445 let matches = Command::new("test")
446 .arg(arg!(--"content" <CONTENT> "Content directory"))
447 .arg(arg!(--"output" <OUTPUT> "Output directory"))
448 .arg(arg!(--"new" <NEW> "New site directory"))
449 .arg(arg!(--"template" <TEMPLATE> "Template directory"))
450 .get_matches_from(vec![
451 "test",
452 "--content",
453 "content",
454 "--new",
455 "new_site",
456 "--template",
457 "template",
458 ]);
459 let result = args(&matches);
460 assert!(is_missing_argument_unit(&result, "output"));
461 }
462
463 #[test]
464 fn test_args_missing_new_argument() {
465 let matches = Command::new("test")
466 .arg(arg!(--"content" <CONTENT> "Content directory"))
467 .arg(arg!(--"output" <OUTPUT> "Output directory"))
468 .arg(arg!(--"new" <NEW> "New site directory"))
469 .arg(arg!(--"template" <TEMPLATE> "Template directory"))
470 .get_matches_from(vec![
471 "test",
472 "--content",
473 "content",
474 "--output",
475 "output",
476 "--template",
477 "template",
478 ]);
479 let result = args(&matches);
480 assert!(is_missing_argument_unit(&result, "new"));
481 }
482
483 #[test]
484 fn test_args_missing_template_argument() {
485 let matches = Command::new("test")
486 .arg(arg!(--"content" <CONTENT> "Content directory"))
487 .arg(arg!(--"output" <OUTPUT> "Output directory"))
488 .arg(arg!(--"new" <NEW> "New site directory"))
489 .arg(arg!(--"template" <TEMPLATE> "Template directory"))
490 .get_matches_from(vec![
491 "test",
492 "--content",
493 "content",
494 "--output",
495 "output",
496 "--new",
497 "new_site",
498 ]);
499 let result = args(&matches);
500 assert!(is_missing_argument_unit(&result, "template"));
501 }
502
503 #[test]
504 fn test_ensure_directory_already_exists() {
505 let temp_dir = tempdir().unwrap();
506 ensure_directory(temp_dir.path(), "existing").unwrap();
507 assert!(temp_dir.path().exists());
508 }
509
510 #[cfg(not(target_os = "windows"))] #[test]
512 fn test_process_error_display() {
513 let error = ProcessError::MissingArgument("content".to_string());
514 assert_eq!(error.to_string(), "Required argument missing: content");
515
516 let error = ProcessError::DirectoryCreation {
517 dir_type: "content".to_string(),
518 path: "/invalid/path".to_string(),
519 source: std::io::Error::from_raw_os_error(13),
520 };
521 assert_eq!(
522 error.to_string(),
523 "Failed to create content directory at '/invalid/path': Permission denied (os error 13)"
524 );
525
526 let error =
527 ProcessError::CompilationError("Failed to compile".to_string());
528 assert_eq!(error.to_string(), "Compilation error: Failed to compile");
529 }
530
531 #[test]
532 fn test_process_error_io_error() {
533 let io_error = std::io::Error::other("an I/O error occurred");
534 let error: ProcessError = io_error.into();
535 assert!(is_io_error(&error));
536 assert_eq!(error.to_string(), "an I/O error occurred");
537 }
538
539 #[test]
540 fn test_process_error_io_error_format() {
541 let io_error =
542 std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
543 let error: ProcessError = io_error.into();
544 assert!(is_io_error(&error));
545 assert_eq!(error.to_string(), "File not found");
546 }
547
548 #[cfg(unix)]
549 #[test]
550 fn test_ensure_directory_permission_denied() {
551 use std::fs::Permissions;
552 use std::os::unix::fs::PermissionsExt;
553
554 let temp_dir = tempdir().unwrap();
555 let protected_path = temp_dir.path().join("protected_dir");
556
557 fs::create_dir(&protected_path).unwrap();
559 fs::set_permissions(&protected_path, Permissions::from_mode(0o400))
560 .unwrap();
561
562 let sub_dir = protected_path.join("sub_dir");
564 let result = ensure_directory(&sub_dir, "sub_directory");
565
566 assert!(is_directory_creation(&result));
568
569 fs::set_permissions(&protected_path, Permissions::from_mode(0o700))
571 .unwrap();
572 }
573
574 #[test]
575 fn test_args_all_required_arguments() {
576 let temp_dir = tempdir().unwrap();
582 let content_file = temp_dir.path().join("content_file");
583 fs::write(&content_file, "not a directory").unwrap();
584 let output_dir = temp_dir.path().join("output");
585 let site_dir = temp_dir.path().join("new_site");
586 let template_dir = temp_dir.path().join("template");
587
588 let matches = Command::new("test")
589 .arg(arg!(--"content" <CONTENT> "Content directory"))
590 .arg(arg!(--"output" <OUTPUT> "Output directory"))
591 .arg(arg!(--"new" <NEW> "New site directory"))
592 .arg(arg!(--"template" <TEMPLATE> "Template directory"))
593 .get_matches_from(vec![
594 "test",
595 "--content",
596 content_file.to_str().unwrap(),
597 "--output",
598 output_dir.to_str().unwrap(),
599 "--new",
600 site_dir.to_str().unwrap(),
601 "--template",
602 template_dir.to_str().unwrap(),
603 ]);
604
605 let result = args(&matches);
606 assert!(
613 is_input_error(&result),
614 "Expected DirectoryCreation or CompilationError from args, got: {result:?}"
615 );
616 }
617
618 fn matches_for_paths(
620 content: &Path,
621 output: &Path,
622 site: &Path,
623 template: &Path,
624 ) -> ArgMatches {
625 Command::new("test")
626 .arg(arg!(--"content" <CONTENT> "Content directory"))
627 .arg(arg!(--"output" <OUTPUT> "Output directory"))
628 .arg(arg!(--"new" <NEW> "New site directory"))
629 .arg(arg!(--"template" <TEMPLATE> "Template directory"))
630 .get_matches_from(vec![
631 "test",
632 "--content",
633 content.to_str().unwrap(),
634 "--output",
635 output.to_str().unwrap(),
636 "--new",
637 site.to_str().unwrap(),
638 "--template",
639 template.to_str().unwrap(),
640 ])
641 }
642
643 #[test]
644 fn test_args_succeeds_with_empty_content_and_templates() {
645 let temp_dir = tempdir().unwrap();
649 let content = temp_dir.path().join("content");
650 let output = temp_dir.path().join("output");
651 let site = temp_dir.path().join("new_site");
652 let template = temp_dir.path().join("template");
653
654 let matches = matches_for_paths(&content, &output, &site, &template);
655 let result = args(&matches);
656 assert!(result.is_ok(), "expected success, got: {result:?}");
657 assert!(content.is_dir(), "content dir should have been created");
658 assert!(template.is_dir(), "template dir should have been created");
659 }
660
661 #[test]
662 fn test_args_output_directory_creation_failure() {
663 let temp_dir = tempdir().unwrap();
666 let content = temp_dir.path().join("content");
667 fs::create_dir_all(&content).unwrap();
668 let blocker = temp_dir.path().join("blocker");
669 fs::write(&blocker, "file").unwrap();
670
671 let matches = matches_for_paths(
672 &content,
673 &blocker.join("output"),
674 &temp_dir.path().join("site"),
675 &temp_dir.path().join("template"),
676 );
677 assert!(is_directory_creation(&args(&matches)));
678 }
679
680 #[test]
681 fn test_args_site_directory_creation_failure() {
682 let temp_dir = tempdir().unwrap();
683 let content = temp_dir.path().join("content");
684 fs::create_dir_all(&content).unwrap();
685 let blocker = temp_dir.path().join("blocker");
686 fs::write(&blocker, "file").unwrap();
687
688 let matches = matches_for_paths(
689 &content,
690 &temp_dir.path().join("output"),
691 &blocker.join("site"),
692 &temp_dir.path().join("template"),
693 );
694 assert!(is_directory_creation(&args(&matches)));
695 }
696
697 #[test]
698 fn test_args_template_directory_creation_failure() {
699 let temp_dir = tempdir().unwrap();
700 let content = temp_dir.path().join("content");
701 fs::create_dir_all(&content).unwrap();
702 let blocker = temp_dir.path().join("blocker");
703 fs::write(&blocker, "file").unwrap();
704
705 let matches = matches_for_paths(
706 &content,
707 &temp_dir.path().join("output"),
708 &temp_dir.path().join("site"),
709 &blocker.join("template"),
710 );
711 assert!(is_directory_creation(&args(&matches)));
712 }
713
714 #[cfg(unix)]
715 #[test]
716 fn test_args_compilation_error_from_unreadable_content() {
717 use std::os::unix::fs::PermissionsExt;
718
719 let temp_dir = tempdir().unwrap();
723 let content = temp_dir.path().join("content");
724 fs::create_dir_all(&content).unwrap();
725 fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
726 .unwrap();
727
728 let matches = matches_for_paths(
729 &content,
730 &temp_dir.path().join("output"),
731 &temp_dir.path().join("site"),
732 &temp_dir.path().join("template"),
733 );
734 let result = args(&matches);
735
736 fs::set_permissions(&content, fs::Permissions::from_mode(0o755))
738 .unwrap();
739
740 assert!(
741 result.is_err(),
742 "expected CompilationError, got: {result:?}"
743 );
744 let msg = result.unwrap_err().to_string();
745 assert!(msg.contains("Compilation error"), "got: {msg}");
746 }
747 #[test]
754 fn test_internal_compile_error_handling() {
755 let temp_dir = tempdir().unwrap();
756 let result = internal_compile(
757 &temp_dir.path().join("build"),
758 &temp_dir.path().join("content"),
759 &temp_dir.path().join("site"),
760 &temp_dir.path().join("template"),
761 );
762 assert!(result.is_err());
763 }
764
765 #[test]
766 fn test_get_argument_with_empty_value() {
767 let matches = Command::new("test")
768 .arg(arg!(--"empty" <EMPTY> "Empty value"))
769 .get_matches_from(vec!["test", "--empty", ""]);
770
771 let result = get_argument(&matches, "empty");
772 assert!(result.is_ok());
773 assert_eq!(result.unwrap(), "");
774 }
775
776 #[test]
777 fn test_ensure_directory_with_existing_file() {
778 let temp_dir = tempdir().unwrap();
779 let file_path = temp_dir.path().join("existing_file");
780
781 let _file = File::create(&file_path).unwrap();
783
784 let result = ensure_directory(&file_path, "test");
786
787 let err = result.unwrap_err();
789 let kind = directory_creation_source_kind(err)
790 .expect("expected DirectoryCreation error");
791 assert_eq!(kind, std::io::ErrorKind::AlreadyExists);
792 }
793
794 #[test]
795 fn test_ensure_directory_with_existing_directory() {
796 let temp_dir = tempdir().unwrap();
797 let dir_path = temp_dir.path().join("existing_dir");
798
799 fs::create_dir(&dir_path).unwrap();
801
802 let result = ensure_directory(&dir_path, "test");
804
805 assert!(result.is_ok());
807 }
808
809 #[test]
810 fn test_ensure_directory_with_symlink() {
811 let temp_dir = tempdir().unwrap();
812 let real_dir = temp_dir.path().join("real_dir");
813 let symlink = temp_dir.path().join("symlink_dir");
814
815 fs::create_dir(&real_dir).unwrap();
816
817 #[cfg(unix)]
818 std::os::unix::fs::symlink(&real_dir, &symlink).unwrap();
819 #[cfg(windows)]
820 std::os::windows::fs::symlink_dir(&real_dir, &symlink).unwrap();
821
822 let result = ensure_directory(&symlink, "symlink");
824 assert!(result.is_ok());
825 }
826
827 #[test]
828 fn test_process_error_frontmatter_display() {
829 let error = ProcessError::FrontmatterError("bad yaml".to_string());
830 assert_eq!(error.to_string(), "Frontmatter processing error: bad yaml");
831 }
832
833 #[test]
834 fn test_process_error_source_for_directory_creation() {
835 use std::error::Error;
836 let error = ProcessError::DirectoryCreation {
837 dir_type: "output".to_string(),
838 path: "/bad".to_string(),
839 source: std::io::Error::new(
840 std::io::ErrorKind::PermissionDenied,
841 "denied",
842 ),
843 };
844 assert!(error.source().is_some());
845 }
846
847 #[test]
848 fn test_process_error_source_for_io_error() {
849 use std::error::Error;
850 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
851 let error = ProcessError::IoError(io_err);
852 assert!(error.source().is_some());
853 }
854
855 #[test]
856 fn test_process_error_source_for_missing_argument() {
857 use std::error::Error;
858 let error = ProcessError::MissingArgument("foo".to_string());
859 assert!(error.source().is_none());
860 }
861
862 #[test]
863 fn test_process_error_source_for_compilation_error() {
864 use std::error::Error;
865 let error = ProcessError::CompilationError("oops".to_string());
866 assert!(error.source().is_none());
867 }
868
869 #[test]
870 fn test_process_error_source_for_frontmatter_error() {
871 use std::error::Error;
872 let error = ProcessError::FrontmatterError("bad".to_string());
873 assert!(error.source().is_none());
874 }
875
876 #[test]
877 fn test_process_error_debug() {
878 let error = ProcessError::MissingArgument("arg".to_string());
879 let debug = format!("{error:?}");
880 assert!(debug.contains("MissingArgument"));
881 }
882
883 #[test]
884 fn test_internal_compile_with_empty_directories() {
885 let temp_dir = tempdir().unwrap();
891
892 let build_dir = temp_dir.path().join("build");
893 let content_file = temp_dir.path().join("content_file");
894 let site_dir = temp_dir.path().join("site");
895 let template_dir = temp_dir.path().join("template");
896
897 fs::create_dir_all(&build_dir).unwrap();
898 fs::write(&content_file, "not a directory").unwrap();
899 fs::create_dir_all(&site_dir).unwrap();
900 fs::create_dir_all(&template_dir).unwrap();
901
902 let result = internal_compile(
903 &build_dir,
904 &content_file,
905 &site_dir,
906 &template_dir,
907 );
908
909 assert!(
910 result.is_err(),
911 "internal_compile should propagate the io error when \
912 content_dir is a file, got: {result:?}"
913 );
914 }
915}