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            assert!(result.is_err());
310            if let Err(e) = result {
311                assert!(e.to_string().contains("Unsupported format"));
312            }
313
314            Ok(())
315        }
316
317        #[tokio::test]
318        async fn test_extract_command() -> Result<()> {
319            let dir = tempdir()?;
320            let input_path = dir.path().join("test.md");
321            let output_path = dir.path().join("output.yaml");
322
323            // Create test input file with strict YAML formatting
324            let content = r#"---
325title: "Test"
326date: "2024-01-01"
327---
328Content here"#;
329            let mut file = File::create(&input_path)?;
330            writeln!(file, "{}", content)?;
331
332            // Test extract command
333            process_extract(
334                &input_path,
335                "yaml",
336                &Some(output_path.clone()),
337            )
338            .await?;
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            assert!(result.is_err());
375            if let Err(e) = result {
376                assert!(e
377                    .to_string()
378                    .contains("Failed to read input file"));
379            }
380            Ok(())
381        }
382
383        #[tokio::test]
384        async fn test_extract_command_unsupported_format() -> Result<()>
385        {
386            let dir = tempdir()?;
387            let input_path = dir.path().join("test.md");
388
389            // Create test input file
390            let content = r"---
391title: Test
392date: 2024-01-01
393---
394Content here";
395            let mut file = File::create(&input_path)?;
396            writeln!(file, "{}", content)?;
397
398            let result =
399                process_extract(&input_path, "xml", &None).await;
400            assert!(result.is_err());
401            if let Err(e) = result {
402                assert!(e.to_string().contains("Unsupported format"));
403            }
404            Ok(())
405        }
406
407        #[tokio::test]
408        async fn test_extract_command_no_frontmatter() -> Result<()> {
409            let dir = tempdir()?;
410            let input_path = dir.path().join("test.md");
411
412            // Create test input file without frontmatter
413            let content = "Content here without frontmatter";
414            let mut file = File::create(&input_path)?;
415            writeln!(file, "{}", content)?;
416
417            let result =
418                process_extract(&input_path, "yaml", &None).await;
419            assert!(result.is_err());
420            if let Err(e) = result {
421                assert!(e
422                    .to_string()
423                    .contains("Failed to extract frontmatter"));
424            }
425            Ok(())
426        }
427
428        #[tokio::test]
429        async fn test_extract_command_invalid_frontmatter() -> Result<()>
430        {
431            let dir = tempdir()?;
432            let input_path = dir.path().join("test.md");
433
434            // Create test input file with invalid frontmatter
435            let content = r#"---
436title: "Test
437date: 2024-01-01
438---
439Content here"#; // Note the missing closing quote for title
440
441            let mut file = File::create(&input_path)?;
442            writeln!(file, "{}", content)?;
443
444            let result =
445                process_extract(&input_path, "yaml", &None).await;
446            assert!(result.is_err());
447            if let Err(e) = result {
448                assert!(e
449                    .to_string()
450                    .contains("Failed to extract frontmatter"));
451            }
452            Ok(())
453        }
454
455        #[cfg(unix)]
456        #[tokio::test]
457        async fn test_extract_command_output_write_error() -> Result<()>
458        {
459            let dir = tempdir()?;
460            let input_path = dir.path().join("test.md");
461            let output_dir = dir.path().join("readonly_dir");
462
463            // Create test input file with valid frontmatter
464            let content = r#"---
465title: "Test"
466date: "2024-01-01"
467---
468Content here"#;
469            let mut file = File::create(&input_path)?;
470            writeln!(file, "{}", content)?;
471
472            // Create a read-only directory
473            tokio::fs::create_dir(&output_dir).await?;
474            let mut perms =
475                tokio::fs::metadata(&output_dir).await?.permissions();
476            perms.set_readonly(true);
477            tokio::fs::set_permissions(&output_dir, perms).await?;
478
479            let output_path = output_dir.join("output.yaml");
480
481            // Attempt to write to the read-only directory
482            let result = process_extract(
483                &input_path,
484                "yaml",
485                &Some(output_path.clone()),
486            )
487            .await;
488            assert!(result.is_err());
489            if let Err(e) = result {
490                assert!(e
491                    .to_string()
492                    .contains("Failed to write to output file"));
493            }
494
495            Ok(())
496        }
497
498        #[tokio::test]
499        async fn test_extract_command_toml_format() -> Result<()> {
500            let dir = tempdir()?;
501            let input_path = dir.path().join("test.md");
502            let output_path = dir.path().join("output.toml");
503
504            // Create test input file with valid frontmatter
505            let content = r#"---
506title: "Test"
507date: "2024-01-01"
508---
509Content here"#;
510
511            let mut file = File::create(&input_path)?;
512            writeln!(file, "{}", content)?;
513
514            let result = process_extract(
515                &input_path,
516                "toml",
517                &Some(output_path.clone()),
518            )
519            .await;
520            assert!(result.is_ok());
521
522            // Read and log the output for debugging
523            let output_content =
524                tokio::fs::read_to_string(&output_path).await?;
525            log::debug!("Generated TOML content:\n{}", output_content);
526
527            // Verify output
528            assert!(output_content.contains("title = "));
529            assert!(output_content.contains("Test"));
530            assert!(output_content.contains("date = "));
531            assert!(output_content.contains("2024-01-01"));
532
533            Ok(())
534        }
535
536        #[tokio::test]
537        async fn test_extract_command_json_format() -> Result<()> {
538            let dir = tempdir()?;
539            let input_path = dir.path().join("test.md");
540            let output_path = dir.path().join("output.json");
541
542            // Create test input file with valid frontmatter
543            let content = r#"---
544title: "Test"
545date: "2024-01-01"
546---
547Content here"#;
548
549            let mut file = File::create(&input_path)?;
550            writeln!(file, "{}", content)?;
551
552            let result = process_extract(
553                &input_path,
554                "json",
555                &Some(output_path.clone()),
556            )
557            .await;
558            assert!(result.is_ok());
559
560            // Read and log the output for debugging
561            let output_content =
562                tokio::fs::read_to_string(&output_path).await?;
563            log::debug!("Generated JSON content:\n{}", output_content);
564
565            // Verify output
566            assert!(output_content.contains("\"title\":"));
567            assert!(output_content.contains("Test"));
568            assert!(output_content.contains("\"date\":"));
569            assert!(output_content.contains("2024-01-01"));
570
571            Ok(())
572        }
573
574        #[tokio::test]
575        async fn test_extract_command_no_output_file() -> Result<()> {
576            let dir = tempdir()?;
577            let input_path = dir.path().join("test.md");
578
579            // Create test input file with valid frontmatter
580            let content = r#"---
581title: "Test"
582date: "2024-01-01"
583---
584Content here"#;
585
586            let mut file = File::create(&input_path)?;
587            writeln!(file, "{}", content)?;
588
589            let result =
590                process_extract(&input_path, "yaml", &None).await;
591            assert!(result.is_ok());
592
593            // Since output is to stdout, we can't easily capture it here
594            // We can assume that if no error occurred, the function worked as expected
595
596            Ok(())
597        }
598    }
599
600    // Tests for process_validate function
601    mod validate_tests {
602        use super::*;
603
604        #[tokio::test]
605        async fn test_validate_command_required_fields_whitespace_only(
606        ) -> Result<()> {
607            let dir = tempdir()?;
608            let input_path = dir.path().join("test.md");
609
610            // Create test input file with valid frontmatter
611            let content = r#"---
612title: "Test"
613date: "2024-01-01"
614---
615Content here"#;
616
617            let mut file = File::create(&input_path)?;
618            writeln!(file, "{}", content)?;
619
620            // Test validate command with required fields containing only whitespace
621            let result =
622                process_validate(&input_path, &Some("   ".to_string()))
623                    .await;
624            assert!(result.is_err());
625            if let Err(e) = result {
626                assert!(e
627                    .to_string()
628                    .contains("Missing required field:    "));
629            }
630
631            Ok(())
632        }
633
634        #[tokio::test]
635        async fn test_cli_process_with_invalid_subcommand() {
636            let result =
637                Cli::try_parse_from(["program", "invalid_command"]);
638            assert!(result.is_err());
639        }
640
641        #[tokio::test]
642        async fn test_validate_command_missing_required_field(
643        ) -> Result<()> {
644            let dir = tempdir()?;
645            let input_path = dir.path().join("test.md");
646
647            // Create test input file without the 'author' field
648            let content = r#"---
649title: "Test"
650date: "2024-01-01"
651---
652Content here"#;
653
654            let mut file = File::create(&input_path)?;
655            writeln!(file, "{}", content)?;
656
657            // 'author' field is required but missing
658            let result = process_validate(
659                &input_path,
660                &Some("author".to_string()),
661            )
662            .await;
663            assert!(result.is_err());
664            if let Err(e) = result {
665                assert!(e
666                    .to_string()
667                    .contains("Missing required field: author"));
668            }
669
670            Ok(())
671        }
672
673        #[tokio::test]
674        async fn test_extract_command_output_is_directory() -> Result<()>
675        {
676            let dir = tempdir()?;
677            let input_path = dir.path().join("test.md");
678            let output_path = dir.path(); // Use the directory path instead of a file
679
680            // Create test input file with valid frontmatter
681            let content = r#"---
682title: "Test"
683date: "2024-01-01"
684---
685Content here"#;
686
687            let mut file = File::create(&input_path)?;
688            writeln!(file, "{}", content)?;
689
690            let result = process_extract(
691                &input_path,
692                "yaml",
693                &Some(output_path.to_path_buf()),
694            )
695            .await;
696            assert!(result.is_err());
697            if let Err(e) = result {
698                assert!(e
699                    .to_string()
700                    .contains("Failed to write to output file"));
701            }
702
703            Ok(())
704        }
705
706        #[tokio::test]
707        async fn test_extract_command_with_empty_frontmatter(
708        ) -> Result<()> {
709            let dir = tempdir()?;
710            let input_path = dir.path().join("test.md");
711            let output_path = dir.path().join("output.yaml");
712
713            // Create test input file with empty frontmatter
714            let content = r"---
715---
716Content here";
717
718            let mut file = File::create(&input_path)?;
719            writeln!(file, "{}", content)?;
720
721            let result = process_extract(
722                &input_path,
723                "yaml",
724                &Some(output_path.clone()),
725            )
726            .await;
727            assert!(result.is_err());
728            if let Err(e) = result {
729                assert!(e
730                    .to_string()
731                    .contains("Failed to extract frontmatter"));
732            }
733
734            Ok(())
735        }
736
737        #[tokio::test]
738        async fn test_validate_command() -> Result<()> {
739            let dir = tempdir()?;
740            let input_path = dir.path().join("test.md");
741
742            // Create test input file
743            let content = r"---
744title: Test
745date: 2024-01-01
746---
747Content here";
748            let mut file = File::create(&input_path)?;
749            writeln!(file, "{}", content)?;
750
751            // Test validate command with valid fields
752            process_validate(
753                &input_path,
754                &Some("title,date".to_string()),
755            )
756            .await?;
757
758            // Test validate command with missing field
759            let result = process_validate(
760                &input_path,
761                &Some("title,author".to_string()),
762            )
763            .await;
764            assert!(result.is_err());
765
766            Ok(())
767        }
768
769        #[tokio::test]
770        async fn test_validate_command_invalid_input_file() -> Result<()>
771        {
772            let input_path = PathBuf::from("nonexistent.md");
773
774            let result = process_validate(
775                &input_path,
776                &Some("title".to_string()),
777            )
778            .await;
779            assert!(result.is_err());
780            if let Err(e) = result {
781                assert!(e
782                    .to_string()
783                    .contains("Failed to read input file"));
784            }
785
786            Ok(())
787        }
788
789        #[tokio::test]
790        async fn test_validate_command_no_frontmatter() -> Result<()> {
791            let dir = tempdir()?;
792            let input_path = dir.path().join("test.md");
793
794            // Create test input file without frontmatter
795            let content = "Content here without frontmatter";
796            let mut file = File::create(&input_path)?;
797            writeln!(file, "{}", content)?;
798
799            let result = process_validate(
800                &input_path,
801                &Some("title".to_string()),
802            )
803            .await;
804            assert!(result.is_err());
805            if let Err(e) = result {
806                assert!(e
807                    .to_string()
808                    .contains("Failed to extract frontmatter"));
809            }
810
811            Ok(())
812        }
813
814        #[tokio::test]
815        async fn test_validate_command_invalid_frontmatter(
816        ) -> Result<()> {
817            let dir = tempdir()?;
818            let input_path = dir.path().join("test.md");
819
820            // Create test input file with invalid frontmatter
821            let content = r"---
822title: 'Test
823date: 2024-01-01
824---
825Content here";
826
827            let mut file = File::create(&input_path)?;
828            writeln!(file, "{}", content)?;
829
830            let result = process_validate(
831                &input_path,
832                &Some("title".to_string()),
833            )
834            .await;
835            assert!(result.is_err());
836            if let Err(e) = result {
837                assert!(e
838                    .to_string()
839                    .contains("Failed to extract frontmatter"));
840            }
841
842            Ok(())
843        }
844
845        #[tokio::test]
846        async fn test_validate_command_no_required_fields() -> Result<()>
847        {
848            let dir = tempdir()?;
849            let input_path = dir.path().join("test.md");
850
851            // Create test input file with valid frontmatter
852            let content = r"---
853title: Test
854date: 2024-01-01
855---
856Content here";
857
858            let mut file = File::create(&input_path)?;
859            writeln!(file, "{}", content)?;
860
861            // Test validate command with no required fields
862            let result = process_validate(&input_path, &None).await;
863            assert!(result.is_ok());
864
865            Ok(())
866        }
867    }
868
869    // Tests for CLI parsing
870    mod cli_parsing_tests {
871        use super::*;
872        use clap::Parser;
873
874        #[test]
875        fn test_cli_parsing_extract_default_format() {
876            // Test extract command parsing without format argument
877            let args =
878                Cli::parse_from(["program", "extract", "input.md"]);
879            match args.command {
880                Commands::Extract { input, format, .. } => {
881                    assert_eq!(input, PathBuf::from("input.md"));
882                    assert_eq!(format, "yaml"); // Default value
883                }
884                _ => panic!("Expected Extract command"),
885            }
886        }
887
888        #[test]
889        fn test_cli_parsing_invalid_command() {
890            // Test parsing an invalid command
891            let result =
892                Cli::try_parse_from(["program", "invalid", "input.md"]);
893            assert!(result.is_err());
894        }
895
896        #[test]
897        fn test_cli_parsing() {
898            // Test extract command parsing
899            let args = Cli::parse_from([
900                "program", "extract", "input.md", "--format", "yaml",
901            ]);
902            match args.command {
903                Commands::Extract { input, format, .. } => {
904                    assert_eq!(input, PathBuf::from("input.md"));
905                    assert_eq!(format, "yaml");
906                }
907                _ => panic!("Expected Extract command"),
908            }
909
910            // Test validate command parsing
911            let args = Cli::parse_from([
912                "program",
913                "validate",
914                "input.md",
915                "--required",
916                "title,date",
917            ]);
918            match args.command {
919                Commands::Validate { input, required } => {
920                    assert_eq!(input, PathBuf::from("input.md"));
921                    assert_eq!(
922                        required,
923                        Some("title,date".to_string())
924                    );
925                }
926                _ => panic!("Expected Validate command"),
927            }
928        }
929    }
930
931    // Tests for CLI process function
932    mod cli_process_tests {
933        use super::*;
934
935        #[tokio::test]
936        async fn test_cli_process_extract() -> Result<()> {
937            let dir = tempdir()?;
938            let input_path = dir.path().join("test.md");
939            let output_path = dir.path().join("output.yaml");
940
941            // Create test input file with valid frontmatter
942            let content = r"---
943title: Test
944date: 2024-01-01
945---
946Content here";
947
948            let mut file = File::create(&input_path)?;
949            writeln!(file, "{}", content)?;
950
951            let cli = Cli {
952                command: Commands::Extract {
953                    input: input_path.clone(),
954                    format: "yaml".to_string(),
955                    output: Some(output_path.clone()),
956                },
957            };
958
959            let result = cli.process().await;
960            assert!(result.is_ok());
961
962            // Verify output file was created
963            let output_content =
964                tokio::fs::read_to_string(&output_path).await?;
965            assert!(output_content.contains("title:"));
966            assert!(output_content.contains("Test"));
967
968            Ok(())
969        }
970
971        #[tokio::test]
972        async fn test_cli_process_validate() -> Result<()> {
973            let dir = tempdir()?;
974            let input_path = dir.path().join("test.md");
975
976            // Create test input file
977            let content = r"---
978title: Test
979date: 2024-01-01
980---
981Content here";
982            let mut file = File::create(&input_path)?;
983            writeln!(file, "{}", content)?;
984
985            let cli = Cli {
986                command: Commands::Validate {
987                    input: input_path.clone(),
988                    required: Some("title,date".to_string()),
989                },
990            };
991
992            let result = cli.process().await;
993            assert!(result.is_ok());
994
995            Ok(())
996        }
997    }
998
999    #[tokio::test]
1000    async fn test_extract_command_empty_format() -> Result<()> {
1001        let dir = tempdir()?;
1002        let input_path = dir.path().join("test.md");
1003
1004        // Create test input file with valid frontmatter
1005        let content = r"---
1006title: 'Test'
1007---
1008Content here";
1009
1010        let mut file = File::create(&input_path)?;
1011        writeln!(file, "{}", content)?;
1012
1013        // Test extract command with an empty format string
1014        let result = process_extract(&input_path, "", &None).await;
1015        assert!(result.is_err());
1016        if let Err(e) = result {
1017            assert!(e.to_string().contains("Unsupported format"));
1018        }
1019
1020        Ok(())
1021    }
1022
1023    #[tokio::test]
1024    async fn test_validate_command_required_fields_with_whitespace(
1025    ) -> Result<()> {
1026        let dir = tempdir()?;
1027        let input_path = dir.path().join("test.md");
1028
1029        // Create test input file with valid frontmatter
1030        let content = r"---
1031title: 'Test'
1032date: '2024-01-01'
1033---
1034Content here";
1035
1036        let mut file = File::create(&input_path)?;
1037        writeln!(file, "{}", content)?;
1038
1039        // Required fields with leading/trailing whitespace
1040        let result = process_validate(
1041            &input_path,
1042            &Some(" title , date ".to_string()),
1043        )
1044        .await;
1045        assert!(result.is_err());
1046        if let Err(e) = result {
1047            assert!(e
1048                .to_string()
1049                .contains("Missing required field:  title "));
1050        }
1051
1052        Ok(())
1053    }
1054
1055    #[tokio::test]
1056    async fn test_validate_command_duplicate_required_fields(
1057    ) -> Result<()> {
1058        let dir = tempdir()?;
1059        let input_path = dir.path().join("test.md");
1060
1061        // Create test input file with valid frontmatter
1062        let content = r"---
1063title: 'Test'
1064date: '2024-01-01'
1065---
1066Content here";
1067
1068        let mut file = File::create(&input_path)?;
1069        writeln!(file, "{}", content)?;
1070
1071        // Required fields with duplicates
1072        let result = process_validate(
1073            &input_path,
1074            &Some("title,date,title".to_string()),
1075        )
1076        .await;
1077        assert!(result.is_ok());
1078
1079        Ok(())
1080    }
1081
1082    #[tokio::test]
1083    async fn test_extract_command_with_complex_data() -> Result<()> {
1084        let dir = tempdir()?;
1085        let input_path = dir.path().join("test.md");
1086        let output_path = dir.path().join("output.json");
1087
1088        // Create test input file with complex data types
1089        let content = r"---
1090title: 'Test'
1091tags:
1092  - rust
1093  - cli
1094nested:
1095  level1:
1096    level2: 'deep value'
1097---
1098Content here";
1099
1100        let mut file = File::create(&input_path)?;
1101        writeln!(file, "{}", content)?;
1102
1103        // Test extract command with JSON format
1104        let result = process_extract(
1105            &input_path,
1106            "json",
1107            &Some(output_path.clone()),
1108        )
1109        .await;
1110        assert!(result.is_ok());
1111
1112        // Read and verify the output
1113        let output_content =
1114            tokio::fs::read_to_string(&output_path).await?;
1115        let json_output: serde_json::Value =
1116            serde_json::from_str(&output_content)?;
1117        assert_eq!(json_output["title"], "Test");
1118        assert_eq!(json_output["tags"][0], "rust");
1119        assert_eq!(
1120            json_output["nested"]["level1"]["level2"],
1121            "deep value"
1122        );
1123
1124        Ok(())
1125    }
1126}