1use anyhow::{Context, Result};
26use clap::{Parser, Subcommand};
27use std::path::PathBuf;
28
29use crate::{extract, to_format, Format};
30
31#[derive(Parser, Debug)]
33#[command(author, version, about, long_about = None)]
34pub struct Cli {
35 #[command(subcommand)]
36 command: Commands,
37}
38
39#[derive(Subcommand, Debug)]
41enum Commands {
42 Extract {
44 #[arg(required = true)]
46 input: PathBuf,
47
48 #[arg(short, long, default_value = "yaml")]
50 format: String,
51
52 #[arg(short, long)]
54 output: Option<PathBuf>,
55 },
56
57 Validate {
59 #[arg(required = true)]
61 input: PathBuf,
62
63 #[arg(short, long)]
65 required: Option<String>,
66 },
67}
68
69impl Cli {
70 pub async fn process(&self) -> Result<()> {
80 match &self.command {
81 Commands::Extract {
82 input,
83 format,
84 output,
85 } => process_extract(input, format, output).await,
86 Commands::Validate { input, required } => {
87 process_validate(input, required).await
88 }
89 }
90 }
91}
92
93async fn process_extract(
109 input: &PathBuf,
110 format: &str,
111 output: &Option<PathBuf>,
112) -> Result<()> {
113 let content =
115 tokio::fs::read_to_string(input).await.with_context(|| {
116 format!("Failed to read input file: {}", input.display())
117 })?;
118
119 let (frontmatter, remaining) = extract(&content)
121 .with_context(|| "Failed to extract frontmatter")?;
122
123 let output_format = match format.to_lowercase().as_str() {
125 "yaml" => Format::Yaml,
126 "toml" => Format::Toml,
127 "json" => Format::Json,
128 _ => {
129 return Err(anyhow::anyhow!(
130 "Unsupported format: {}",
131 format
132 ))
133 }
134 };
135
136 let formatted = to_format(&frontmatter, output_format)
137 .with_context(|| "Failed to format frontmatter")?;
138
139 if let Some(output_path) = output {
141 tokio::fs::write(output_path, formatted)
142 .await
143 .with_context(|| {
144 format!(
145 "Failed to write to output file: {}",
146 output_path.display()
147 )
148 })?;
149 log::info!(
150 "Frontmatter extracted to `{}`",
151 output_path.display()
152 );
153 } else {
154 log::info!(
155 "Extracted Frontmatter as {}\n\n{}\n\n",
156 output_format,
157 formatted
158 );
159 log::info!("Remaining Markdown Content\n\n{}\n\n", remaining);
160 }
161
162 Ok(())
163}
164
165async fn process_validate(
179 input: &PathBuf,
180 required: &Option<String>,
181) -> Result<()> {
182 let content =
184 tokio::fs::read_to_string(input).await.with_context(|| {
185 format!("Failed to read input file: {}", input.display())
186 })?;
187
188 let (frontmatter, _) = extract(&content)
190 .with_context(|| "Failed to extract frontmatter")?;
191
192 if let Some(required_fields) = required {
194 let fields: Vec<&str> = required_fields.split(',').collect();
195 for field in fields {
196 if !frontmatter.contains_key(field) {
197 return Err(anyhow::anyhow!(
198 "Missing required field: {}",
199 field
200 ));
201 }
202 }
203 }
204
205 println!("Validation successful!");
206 Ok(())
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use std::fs::File;
213 use std::io::Write;
214 use tempfile::tempdir;
215
216 mod extract_tests {
218 use super::*;
219
220 #[tokio::test]
221 async fn test_extract_command_default_format() -> Result<()> {
222 let dir = tempdir()?;
223 let input_path = dir.path().join("test.md");
224 let output_path = dir.path().join("output.yaml");
225
226 let content = r#"---
228title: "Test"
229date: "2024-01-01"
230---
231Content here"#;
232
233 let mut file = File::create(&input_path)?;
234 writeln!(file, "{}", content)?;
235
236 let args = vec![
238 "program",
239 "extract",
240 input_path.to_str().unwrap(),
241 "--output",
242 output_path.to_str().unwrap(),
243 ];
244 let cli = Cli::parse_from(args);
245 let result = cli.process().await;
246 assert!(result.is_ok());
247
248 let output_content =
250 tokio::fs::read_to_string(&output_path).await?;
251 assert!(output_content.contains("title:"));
252 assert!(output_content.contains("Test"));
253
254 Ok(())
255 }
256
257 #[tokio::test]
258 async fn test_extract_command_uppercase_format() -> Result<()> {
259 let dir = tempdir()?;
260 let input_path = dir.path().join("test.md");
261 let output_path = dir.path().join("output.yaml");
262
263 let content = r#"---
265title: "Test"
266date: "2024-01-01"
267---
268Content here"#;
269
270 let mut file = File::create(&input_path)?;
271 writeln!(file, "{}", content)?;
272
273 let result = process_extract(
275 &input_path,
276 "YAML",
277 &Some(output_path.clone()),
278 )
279 .await;
280 assert!(result.is_ok());
281
282 let output_content =
284 tokio::fs::read_to_string(&output_path).await?;
285 assert!(output_content.contains("title:"));
286 assert!(output_content.contains("Test"));
287
288 Ok(())
289 }
290
291 #[tokio::test]
292 async fn test_extract_command_invalid_format() -> Result<()> {
293 let dir = tempdir()?;
294 let input_path = dir.path().join("test.md");
295
296 let content = r#"---
298title: "Test"
299---
300Content here"#;
301
302 let mut file = File::create(&input_path)?;
303 writeln!(file, "{}", content)?;
304
305 let result =
307 process_extract(&input_path, "invalid_format", &None)
308 .await;
309 let e = result.unwrap_err();
310 assert!(e.to_string().contains("Unsupported format"));
311
312 Ok(())
313 }
314
315 #[tokio::test]
316 async fn test_extract_command() -> Result<()> {
317 log::set_max_level(log::LevelFilter::Trace);
318 let dir = tempdir()?;
319 let input_path = dir.path().join("test.md");
320 let output_path = dir.path().join("output.yaml");
321
322 let content = r#"---
324title: "Test"
325date: "2024-01-01"
326---
327Content here"#;
328 let mut file = File::create(&input_path)?;
329 writeln!(file, "{}", content)?;
330
331 process_extract(
333 &input_path,
334 "yaml",
335 &Some(output_path.clone()),
336 )
337 .await
338 .unwrap();
339
340 let output_content =
342 tokio::fs::read_to_string(&output_path).await?;
343 log::debug!("Generated YAML content:\n{}", output_content);
344
345 assert!(
347 output_content.contains("title:"),
348 "title field not found in output"
349 );
350 assert!(
351 output_content.contains("Test"),
352 "Test value not found in output"
353 );
354 assert!(
355 output_content.contains("date:"),
356 "date field not found in output"
357 );
358 assert!(
359 output_content.contains("2024-01-01"),
360 "date value not found in output"
361 );
362
363 Ok(())
364 }
365
366 #[tokio::test]
367 async fn test_extract_command_invalid_input_file() -> Result<()>
368 {
369 let input_path = PathBuf::from("nonexistent.md");
370 let output_path = None;
371 let result =
372 process_extract(&input_path, "yaml", &output_path)
373 .await;
374 let e = result.unwrap_err();
375 assert!(e
376 .to_string()
377 .contains("Failed to read input file"));
378 Ok(())
379 }
380
381 #[tokio::test]
382 async fn test_extract_command_unsupported_format() -> Result<()>
383 {
384 let dir = tempdir()?;
385 let input_path = dir.path().join("test.md");
386
387 let content = r"---
389title: Test
390date: 2024-01-01
391---
392Content here";
393 let mut file = File::create(&input_path)?;
394 writeln!(file, "{}", content)?;
395
396 let result =
397 process_extract(&input_path, "xml", &None).await;
398 let e = result.unwrap_err();
399 assert!(e.to_string().contains("Unsupported format"));
400 Ok(())
401 }
402
403 #[tokio::test]
404 async fn test_extract_command_no_frontmatter() -> Result<()> {
405 let dir = tempdir()?;
406 let input_path = dir.path().join("test.md");
407
408 let content = "Content here without frontmatter";
410 let mut file = File::create(&input_path)?;
411 writeln!(file, "{}", content)?;
412
413 let result =
414 process_extract(&input_path, "yaml", &None).await;
415 let e = result.unwrap_err();
416 assert!(e
417 .to_string()
418 .contains("Failed to extract frontmatter"));
419 Ok(())
420 }
421
422 #[tokio::test]
423 async fn test_extract_command_invalid_frontmatter() -> Result<()>
424 {
425 let dir = tempdir()?;
426 let input_path = dir.path().join("test.md");
427
428 let content = r#"---
430title: "Test
431date: 2024-01-01
432---
433Content here"#; let mut file = File::create(&input_path)?;
436 writeln!(file, "{}", content)?;
437
438 let result =
439 process_extract(&input_path, "yaml", &None).await;
440 let e = result.unwrap_err();
441 assert!(e
442 .to_string()
443 .contains("Failed to extract frontmatter"));
444 Ok(())
445 }
446
447 #[cfg(unix)]
448 #[tokio::test]
449 async fn test_extract_command_output_write_error() -> Result<()>
450 {
451 let dir = tempdir()?;
452 let input_path = dir.path().join("test.md");
453 let output_dir = dir.path().join("readonly_dir");
454
455 let content = r#"---
457title: "Test"
458date: "2024-01-01"
459---
460Content here"#;
461 let mut file = File::create(&input_path)?;
462 writeln!(file, "{}", content)?;
463
464 tokio::fs::create_dir(&output_dir).await?;
466 let mut perms =
467 tokio::fs::metadata(&output_dir).await?.permissions();
468 perms.set_readonly(true);
469 tokio::fs::set_permissions(&output_dir, perms).await?;
470
471 let output_path = output_dir.join("output.yaml");
472
473 let result = process_extract(
475 &input_path,
476 "yaml",
477 &Some(output_path.clone()),
478 )
479 .await;
480 let e = result.unwrap_err();
481 assert!(e
482 .to_string()
483 .contains("Failed to write to output file"));
484
485 Ok(())
486 }
487
488 #[tokio::test]
489 async fn test_extract_command_toml_format() -> Result<()> {
490 let dir = tempdir()?;
491 let input_path = dir.path().join("test.md");
492 let output_path = dir.path().join("output.toml");
493
494 let content = r#"---
496title: "Test"
497date: "2024-01-01"
498---
499Content here"#;
500
501 let mut file = File::create(&input_path)?;
502 writeln!(file, "{}", content)?;
503
504 let result = process_extract(
505 &input_path,
506 "toml",
507 &Some(output_path.clone()),
508 )
509 .await;
510 assert!(result.is_ok());
511
512 let output_content =
514 tokio::fs::read_to_string(&output_path).await?;
515 log::debug!("Generated TOML content:\n{}", output_content);
516
517 assert!(output_content.contains("title = "));
519 assert!(output_content.contains("Test"));
520 assert!(output_content.contains("date = "));
521 assert!(output_content.contains("2024-01-01"));
522
523 Ok(())
524 }
525
526 #[tokio::test]
527 async fn test_extract_command_json_format() -> Result<()> {
528 let dir = tempdir()?;
529 let input_path = dir.path().join("test.md");
530 let output_path = dir.path().join("output.json");
531
532 let content = r#"---
534title: "Test"
535date: "2024-01-01"
536---
537Content here"#;
538
539 let mut file = File::create(&input_path)?;
540 writeln!(file, "{}", content)?;
541
542 let result = process_extract(
543 &input_path,
544 "json",
545 &Some(output_path.clone()),
546 )
547 .await;
548 assert!(result.is_ok());
549
550 let output_content =
552 tokio::fs::read_to_string(&output_path).await?;
553 log::debug!("Generated JSON content:\n{}", output_content);
554
555 assert!(output_content.contains("\"title\":"));
557 assert!(output_content.contains("Test"));
558 assert!(output_content.contains("\"date\":"));
559 assert!(output_content.contains("2024-01-01"));
560
561 Ok(())
562 }
563
564 #[tokio::test]
565 async fn test_extract_command_no_output_file() -> Result<()> {
566 log::set_max_level(log::LevelFilter::Trace);
567 let dir = tempdir()?;
568 let input_path = dir.path().join("test.md");
569
570 let content = r#"---
572title: "Test"
573date: "2024-01-01"
574---
575Content here"#;
576
577 let mut file = File::create(&input_path)?;
578 writeln!(file, "{}", content)?;
579
580 let result =
581 process_extract(&input_path, "yaml", &None).await;
582 assert!(result.is_ok());
583
584 Ok(())
588 }
589 }
590
591 mod validate_tests {
593 use super::*;
594
595 #[tokio::test]
596 async fn test_validate_command_required_fields_whitespace_only(
597 ) -> Result<()> {
598 let dir = tempdir()?;
599 let input_path = dir.path().join("test.md");
600
601 let content = r#"---
603title: "Test"
604date: "2024-01-01"
605---
606Content here"#;
607
608 let mut file = File::create(&input_path)?;
609 writeln!(file, "{}", content)?;
610
611 let result =
613 process_validate(&input_path, &Some(" ".to_string()))
614 .await;
615 let e = result.unwrap_err();
616 assert!(e
617 .to_string()
618 .contains("Missing required field: "));
619
620 Ok(())
621 }
622
623 #[tokio::test]
624 async fn test_cli_process_with_invalid_subcommand() {
625 let result =
626 Cli::try_parse_from(["program", "invalid_command"]);
627 assert!(result.is_err());
628 }
629
630 #[tokio::test]
631 async fn test_validate_command_missing_required_field(
632 ) -> Result<()> {
633 let dir = tempdir()?;
634 let input_path = dir.path().join("test.md");
635
636 let content = r#"---
638title: "Test"
639date: "2024-01-01"
640---
641Content here"#;
642
643 let mut file = File::create(&input_path)?;
644 writeln!(file, "{}", content)?;
645
646 let result = process_validate(
648 &input_path,
649 &Some("author".to_string()),
650 )
651 .await;
652 let e = result.unwrap_err();
653 assert!(e
654 .to_string()
655 .contains("Missing required field: author"));
656
657 Ok(())
658 }
659
660 #[tokio::test]
661 async fn test_extract_command_output_is_directory() -> Result<()>
662 {
663 let dir = tempdir()?;
664 let input_path = dir.path().join("test.md");
665 let output_path = dir.path(); let content = r#"---
669title: "Test"
670date: "2024-01-01"
671---
672Content here"#;
673
674 let mut file = File::create(&input_path)?;
675 writeln!(file, "{}", content)?;
676
677 let result = process_extract(
678 &input_path,
679 "yaml",
680 &Some(output_path.to_path_buf()),
681 )
682 .await;
683 let e = result.unwrap_err();
684 assert!(e
685 .to_string()
686 .contains("Failed to write to output file"));
687
688 Ok(())
689 }
690
691 #[tokio::test]
692 async fn test_extract_command_with_empty_frontmatter(
693 ) -> Result<()> {
694 let dir = tempdir()?;
695 let input_path = dir.path().join("test.md");
696 let output_path = dir.path().join("output.yaml");
697
698 let content = r"---
700---
701Content here";
702
703 let mut file = File::create(&input_path)?;
704 writeln!(file, "{}", content)?;
705
706 let result = process_extract(
707 &input_path,
708 "yaml",
709 &Some(output_path.clone()),
710 )
711 .await;
712 let e = result.unwrap_err();
713 assert!(e
714 .to_string()
715 .contains("Failed to extract frontmatter"));
716
717 Ok(())
718 }
719
720 #[tokio::test]
721 async fn test_validate_command() -> Result<()> {
722 let dir = tempdir()?;
723 let input_path = dir.path().join("test.md");
724
725 let content = r"---
727title: Test
728date: 2024-01-01
729---
730Content here";
731 let mut file = File::create(&input_path)?;
732 writeln!(file, "{}", content)?;
733
734 process_validate(
736 &input_path,
737 &Some("title,date".to_string()),
738 )
739 .await
740 .unwrap();
741
742 let result = process_validate(
744 &input_path,
745 &Some("title,author".to_string()),
746 )
747 .await;
748 assert!(result.is_err());
749
750 Ok(())
751 }
752
753 #[tokio::test]
754 async fn test_validate_command_invalid_input_file() -> Result<()>
755 {
756 let input_path = PathBuf::from("nonexistent.md");
757
758 let result = process_validate(
759 &input_path,
760 &Some("title".to_string()),
761 )
762 .await;
763 let e = result.unwrap_err();
764 assert!(e
765 .to_string()
766 .contains("Failed to read input file"));
767
768 Ok(())
769 }
770
771 #[tokio::test]
772 async fn test_validate_command_no_frontmatter() -> Result<()> {
773 let dir = tempdir()?;
774 let input_path = dir.path().join("test.md");
775
776 let content = "Content here without frontmatter";
778 let mut file = File::create(&input_path)?;
779 writeln!(file, "{}", content)?;
780
781 let result = process_validate(
782 &input_path,
783 &Some("title".to_string()),
784 )
785 .await;
786 let e = result.unwrap_err();
787 assert!(e
788 .to_string()
789 .contains("Failed to extract frontmatter"));
790
791 Ok(())
792 }
793
794 #[tokio::test]
795 async fn test_validate_command_invalid_frontmatter(
796 ) -> Result<()> {
797 let dir = tempdir()?;
798 let input_path = dir.path().join("test.md");
799
800 let content = r"---
802title: 'Test
803date: 2024-01-01
804---
805Content here";
806
807 let mut file = File::create(&input_path)?;
808 writeln!(file, "{}", content)?;
809
810 let result = process_validate(
811 &input_path,
812 &Some("title".to_string()),
813 )
814 .await;
815 let e = result.unwrap_err();
816 assert!(e
817 .to_string()
818 .contains("Failed to extract frontmatter"));
819
820 Ok(())
821 }
822
823 #[tokio::test]
824 async fn test_validate_command_no_required_fields() -> Result<()>
825 {
826 let dir = tempdir()?;
827 let input_path = dir.path().join("test.md");
828
829 let content = r"---
831title: Test
832date: 2024-01-01
833---
834Content here";
835
836 let mut file = File::create(&input_path)?;
837 writeln!(file, "{}", content)?;
838
839 let result = process_validate(&input_path, &None).await;
841 assert!(result.is_ok());
842
843 Ok(())
844 }
845 }
846
847 mod cli_parsing_tests {
849 use super::*;
850 use clap::Parser;
851
852 #[test]
853 fn test_cli_parsing_extract_default_format() {
854 let args =
856 Cli::parse_from(["program", "extract", "input.md"]);
857 assert!(matches!(
858 args.command,
859 Commands::Extract { ref input, ref format, .. }
860 if input == &PathBuf::from("input.md") && format == "yaml"
861 ));
862 }
863
864 #[test]
865 fn test_cli_parsing_invalid_command() {
866 let result =
868 Cli::try_parse_from(["program", "invalid", "input.md"]);
869 assert!(result.is_err());
870 }
871
872 #[test]
873 fn test_cli_parsing() {
874 let args = Cli::parse_from([
876 "program", "extract", "input.md", "--format", "yaml",
877 ]);
878 assert!(matches!(
879 args.command,
880 Commands::Extract { ref input, ref format, .. }
881 if input == &PathBuf::from("input.md") && format == "yaml"
882 ));
883
884 let args = Cli::parse_from([
886 "program",
887 "validate",
888 "input.md",
889 "--required",
890 "title,date",
891 ]);
892 assert!(matches!(
893 args.command,
894 Commands::Validate { ref input, ref required }
895 if input == &PathBuf::from("input.md")
896 && required == &Some("title,date".to_string())
897 ));
898 }
899 }
900
901 mod cli_process_tests {
903 use super::*;
904
905 #[tokio::test]
906 async fn test_cli_process_extract() -> Result<()> {
907 let dir = tempdir()?;
908 let input_path = dir.path().join("test.md");
909 let output_path = dir.path().join("output.yaml");
910
911 let content = r"---
913title: Test
914date: 2024-01-01
915---
916Content here";
917
918 let mut file = File::create(&input_path)?;
919 writeln!(file, "{}", content)?;
920
921 let cli = Cli {
922 command: Commands::Extract {
923 input: input_path.clone(),
924 format: "yaml".to_string(),
925 output: Some(output_path.clone()),
926 },
927 };
928
929 let result = cli.process().await;
930 assert!(result.is_ok());
931
932 let output_content =
934 tokio::fs::read_to_string(&output_path).await?;
935 assert!(output_content.contains("title:"));
936 assert!(output_content.contains("Test"));
937
938 Ok(())
939 }
940
941 #[tokio::test]
942 async fn test_cli_process_validate() -> Result<()> {
943 let dir = tempdir()?;
944 let input_path = dir.path().join("test.md");
945
946 let content = r"---
948title: Test
949date: 2024-01-01
950---
951Content here";
952 let mut file = File::create(&input_path)?;
953 writeln!(file, "{}", content)?;
954
955 let cli = Cli {
956 command: Commands::Validate {
957 input: input_path.clone(),
958 required: Some("title,date".to_string()),
959 },
960 };
961
962 let result = cli.process().await;
963 assert!(result.is_ok());
964
965 Ok(())
966 }
967 }
968
969 #[tokio::test]
970 async fn test_extract_command_empty_format() -> Result<()> {
971 let dir = tempdir()?;
972 let input_path = dir.path().join("test.md");
973
974 let content = r"---
976title: 'Test'
977---
978Content here";
979
980 let mut file = File::create(&input_path)?;
981 writeln!(file, "{}", content)?;
982
983 let result = process_extract(&input_path, "", &None).await;
985 let e = result.unwrap_err();
986 assert!(e.to_string().contains("Unsupported format"));
987
988 Ok(())
989 }
990
991 #[tokio::test]
992 async fn test_validate_command_required_fields_with_whitespace(
993 ) -> Result<()> {
994 let dir = tempdir()?;
995 let input_path = dir.path().join("test.md");
996
997 let content = r"---
999title: 'Test'
1000date: '2024-01-01'
1001---
1002Content here";
1003
1004 let mut file = File::create(&input_path)?;
1005 writeln!(file, "{}", content)?;
1006
1007 let result = process_validate(
1009 &input_path,
1010 &Some(" title , date ".to_string()),
1011 )
1012 .await;
1013 let e = result.unwrap_err();
1014 assert!(e
1015 .to_string()
1016 .contains("Missing required field: title "));
1017
1018 Ok(())
1019 }
1020
1021 #[tokio::test]
1022 async fn test_validate_command_duplicate_required_fields(
1023 ) -> Result<()> {
1024 let dir = tempdir()?;
1025 let input_path = dir.path().join("test.md");
1026
1027 let content = r"---
1029title: 'Test'
1030date: '2024-01-01'
1031---
1032Content here";
1033
1034 let mut file = File::create(&input_path)?;
1035 writeln!(file, "{}", content)?;
1036
1037 let result = process_validate(
1039 &input_path,
1040 &Some("title,date,title".to_string()),
1041 )
1042 .await;
1043 assert!(result.is_ok());
1044
1045 Ok(())
1046 }
1047
1048 #[tokio::test]
1049 async fn test_extract_command_with_complex_data() -> Result<()> {
1050 let dir = tempdir()?;
1051 let input_path = dir.path().join("test.md");
1052 let output_path = dir.path().join("output.json");
1053
1054 let content = r"---
1056title: 'Test'
1057tags:
1058 - rust
1059 - cli
1060nested:
1061 level1:
1062 level2: 'deep value'
1063---
1064Content here";
1065
1066 let mut file = File::create(&input_path)?;
1067 writeln!(file, "{}", content)?;
1068
1069 let result = process_extract(
1071 &input_path,
1072 "json",
1073 &Some(output_path.clone()),
1074 )
1075 .await;
1076 assert!(result.is_ok());
1077
1078 let output_content =
1080 tokio::fs::read_to_string(&output_path).await?;
1081 let json_output: serde_json::Value =
1082 serde_json::from_str(&output_content)?;
1083 assert_eq!(json_output["title"], "Test");
1084 assert_eq!(json_output["tags"][0], "rust");
1085 assert_eq!(
1086 json_output["nested"]["level1"]["level2"],
1087 "deep value"
1088 );
1089
1090 let null_input = dir.path().join("null.md");
1092 std::fs::write(&null_input, "---\na: null\n---\nbody")?;
1093 let null_res =
1094 process_extract(&null_input, "toml", &None).await;
1095 assert!(null_res.is_err());
1096
1097 Ok(())
1098 }
1099}