Skip to main content

frontmatter_gen/
cli.rs

1// Copyright © 2024 Shokunin Static Site Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Command Line Interface Module
5//!
6//! This module provides the command-line interface functionality for the frontmatter-gen library.
7//! It handles parsing of command-line arguments and executing the corresponding operations.
8//!
9//! ## Features
10//!
11//! - Command-line argument parsing using clap
12//! - Subcommands for different operations (extract, validate)
13//! - Error handling and user-friendly messages
14//!
15//! ## Usage
16//!
17//! ```bash
18//! # Extract frontmatter
19//! cargo run --features="cli" extract input.md --format yaml
20//!
21//! # Validate frontmatter
22//! cargo run --features="cli" validate input.md --required title,date
23//! ```
24
25use anyhow::{Context, Result};
26use clap::{Parser, Subcommand};
27use std::path::PathBuf;
28
29use crate::{extract, to_format, Format};
30
31/// Command line arguments parser
32#[derive(Parser, Debug)]
33#[command(author, version, about, long_about = None)]
34pub struct Cli {
35    #[command(subcommand)]
36    command: Commands,
37}
38
39/// Available CLI commands
40#[derive(Subcommand, Debug)]
41enum Commands {
42    /// Extract frontmatter from a file
43    Extract {
44        /// Input file path
45        #[arg(required = true)]
46        input: PathBuf,
47
48        /// Output format (yaml, toml, json)
49        #[arg(short, long, default_value = "yaml")]
50        format: String,
51
52        /// Output file path (optional)
53        #[arg(short, long)]
54        output: Option<PathBuf>,
55    },
56
57    /// Validate frontmatter in a file
58    Validate {
59        /// Input file path
60        #[arg(required = true)]
61        input: PathBuf,
62
63        /// Required fields (comma-separated)
64        #[arg(short, long)]
65        required: Option<String>,
66    },
67}
68
69impl Cli {
70    /// Process CLI commands
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if:
75    /// - File operations fail
76    /// - Frontmatter parsing fails
77    /// - Validation fails
78    /// - Format conversion fails
79    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
93/// Process extract command
94///
95/// # Arguments
96///
97/// * `input` - Path to input file
98/// * `format` - Output format
99/// * `output` - Optional output file path
100///
101/// # Errors
102///
103/// Returns an error if:
104/// - Input file cannot be read
105/// - Frontmatter parsing fails
106/// - Format conversion fails
107/// - Output file cannot be written
108async fn process_extract(
109    input: &PathBuf,
110    format: &str,
111    output: &Option<PathBuf>,
112) -> Result<()> {
113    // Read input file
114    let content =
115        tokio::fs::read_to_string(input).await.with_context(|| {
116            format!("Failed to read input file: {}", input.display())
117        })?;
118
119    // Extract frontmatter
120    let (frontmatter, remaining) = extract(&content)
121        .with_context(|| "Failed to extract frontmatter")?;
122
123    // Convert to specified format
124    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    // Handle output
140    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
165/// Process validate command
166///
167/// # Arguments
168///
169/// * `input` - Path to input file
170/// * `required` - Optional comma-separated list of required fields
171///
172/// # Errors
173///
174/// Returns an error if:
175/// - Input file cannot be read
176/// - Frontmatter parsing fails
177/// - Required fields are missing
178async fn process_validate(
179    input: &PathBuf,
180    required: &Option<String>,
181) -> Result<()> {
182    // Read input file
183    let content =
184        tokio::fs::read_to_string(input).await.with_context(|| {
185            format!("Failed to read input file: {}", input.display())
186        })?;
187
188    // Extract frontmatter
189    let (frontmatter, _) = extract(&content)
190        .with_context(|| "Failed to extract frontmatter")?;
191
192    // Validate required fields
193    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    // Tests for process_extract function
217    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            // Create test input file with valid frontmatter
227            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            // Test extract command without specifying format (should default to "yaml")
237            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            // Verify output file was created
249            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            // Create test input file with valid frontmatter
264            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            // Test extract command with uppercase format
274            let result = process_extract(
275                &input_path,
276                "YAML",
277                &Some(output_path.clone()),
278            )
279            .await;
280            assert!(result.is_ok());
281
282            // Verify output file was created
283            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            // Create test input file with valid frontmatter
297            let content = r#"---
298title: "Test"
299---
300Content here"#;
301
302            let mut file = File::create(&input_path)?;
303            writeln!(file, "{}", content)?;
304
305            // Test extract command with an invalid format to ensure it returns an error
306            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            // Create test input file with strict YAML formatting
323            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            // Test extract command
332            process_extract(
333                &input_path,
334                "yaml",
335                &Some(output_path.clone()),
336            )
337            .await
338            .unwrap();
339
340            // Read and log the output for debugging
341            let output_content =
342                tokio::fs::read_to_string(&output_path).await?;
343            log::debug!("Generated YAML content:\n{}", output_content);
344
345            // Verify output - use more flexible assertions
346            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            // Create test input file
388            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            // Create test input file without frontmatter
409            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            // Create test input file with invalid frontmatter
429            let content = r#"---
430title: "Test
431date: 2024-01-01
432---
433Content here"#; // Note the missing closing quote for title
434
435            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            // Create test input file with valid frontmatter
456            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            // Create a read-only directory
465            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            // Attempt to write to the read-only directory
474            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            // Create test input file with valid frontmatter
495            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            // Read and log the output for debugging
513            let output_content =
514                tokio::fs::read_to_string(&output_path).await?;
515            log::debug!("Generated TOML content:\n{}", output_content);
516
517            // Verify output
518            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            // Create test input file with valid frontmatter
533            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            // Read and log the output for debugging
551            let output_content =
552                tokio::fs::read_to_string(&output_path).await?;
553            log::debug!("Generated JSON content:\n{}", output_content);
554
555            // Verify output
556            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            // Create test input file with valid frontmatter
571            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            // Since output is to stdout, we can't easily capture it here
585            // We can assume that if no error occurred, the function worked as expected
586
587            Ok(())
588        }
589    }
590
591    // Tests for process_validate function
592    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            // Create test input file with valid frontmatter
602            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            // Test validate command with required fields containing only whitespace
612            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            // Create test input file without the 'author' field
637            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            // 'author' field is required but missing
647            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(); // Use the directory path instead of a file
666
667            // Create test input file with valid frontmatter
668            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            // Create test input file with empty frontmatter
699            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            // Create test input file
726            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            // Test validate command with valid fields
735            process_validate(
736                &input_path,
737                &Some("title,date".to_string()),
738            )
739            .await
740            .unwrap();
741
742            // Test validate command with missing field
743            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            // Create test input file without frontmatter
777            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            // Create test input file with invalid frontmatter
801            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            // Create test input file with valid frontmatter
830            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            // Test validate command with no required fields
840            let result = process_validate(&input_path, &None).await;
841            assert!(result.is_ok());
842
843            Ok(())
844        }
845    }
846
847    // Tests for CLI parsing
848    mod cli_parsing_tests {
849        use super::*;
850        use clap::Parser;
851
852        #[test]
853        fn test_cli_parsing_extract_default_format() {
854            // Test extract command parsing without format argument
855            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            // Test parsing an invalid command
867            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            // Test extract command parsing
875            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            // Test validate command parsing
885            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    // Tests for CLI process function
902    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            // Create test input file with valid frontmatter
912            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            // Verify output file was created
933            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            // Create test input file
947            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        // Create test input file with valid frontmatter
975        let content = r"---
976title: 'Test'
977---
978Content here";
979
980        let mut file = File::create(&input_path)?;
981        writeln!(file, "{}", content)?;
982
983        // Test extract command with an empty format string
984        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        // Create test input file with valid frontmatter
998        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        // Required fields with leading/trailing whitespace
1008        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        // Create test input file with valid frontmatter
1028        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        // Required fields with duplicates
1038        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        // Create test input file with complex data types
1055        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        // Test extract command with JSON format
1070        let result = process_extract(
1071            &input_path,
1072            "json",
1073            &Some(output_path.clone()),
1074        )
1075        .await;
1076        assert!(result.is_ok());
1077
1078        // Read and verify the output
1079        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        // Null values cannot be serialised at root in TOML, triggering format error
1091        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}