Skip to main content

join_ai/
cli.rs

1use clap::{Args as ClapArgs, ColorChoice, Parser, Subcommand};
2use std::path::PathBuf;
3
4/// A CLI application to traverse files in a folder and concatenate them
5/// into a single text file, suitable for GenAI model input.
6#[derive(Parser, Debug, Clone)]
7#[command(author, version, about, long_about = None, color = ColorChoice::Always)]
8pub struct Cli {
9    /// The subcommand to execute (e.g., 'join' or 'update').
10    #[command(subcommand)]
11    pub command: Commands,
12}
13
14/// Defines the available subcommands for the application.
15#[derive(Subcommand, Debug, Clone)]
16pub enum Commands {
17    /// Concatenate files into a single text file.
18    Join(JoinArgs),
19    /// Update the application to the latest version [placeholder].
20    Update(UpdateArgs),
21}
22
23/// Defines the arguments for the 'join' subcommand.
24#[derive(ClapArgs, Debug, Clone)]
25pub struct JoinArgs {
26    /// The root folder to start traversing for files. This is a required argument.
27    #[arg(required = true)]
28    pub input_folder: PathBuf,
29
30    /// The path to the output file where the concatenated content will be written.
31    #[arg(short, long, default_value = "concatenated.txt")]
32    pub output_file: PathBuf,
33
34    /// Glob patterns for files to *include*. Can be specified multiple times.
35    /// If not provided, all files are considered (subject to exclusions).
36    /// Example: -p "*.rs" -p "*.md"
37    #[arg(short = 'p', long, action = clap::ArgAction::Append, value_name = "PATTERN")]
38    pub patterns: Option<Vec<String>>,
39
40    /// Glob patterns for files or folders to *exclude*. Can be specified multiple times.
41    /// This is a powerful way to filter out unwanted content like build artifacts or logs.
42    /// Example: -x "*.log" -x "target/"
43    #[arg(short = 'x', long, action = clap::ArgAction::Append, value_name = "PATTERN")]
44    pub exclude: Option<Vec<String>>,
45
46    /// If set, the output file will be deleted before writing new content.
47    #[arg(short, long)]
48    pub clear_file: bool,
49
50    /// Sets the maximum depth for directory traversal. A depth of 0 means only the
51    /// input folder itself will be scanned.
52    #[arg(long)]
53    pub max_depth: Option<usize>,
54
55    /// If set, hidden files and directories (those starting with a '.') will be included.
56    #[arg(long)]
57    pub hidden: bool,
58
59    /// If set to false, the walker will follow symbolic links. Defaults to true (no-follow).
60    #[arg(long, default_value_t = true)]
61    pub no_follow: bool,
62}
63
64/// Defines the arguments for the 'update' subcommand. Currently a placeholder.
65#[derive(ClapArgs, Debug, Clone)]
66pub struct UpdateArgs {}
67
68// --- Unit Tests for CLI Parsing ---
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use clap::error::ErrorKind;
73
74    /// Verifies that the `join` command parses the required input folder and
75    /// correctly applies default values for all optional arguments.
76    #[test]
77    fn test_basic_join_command_and_defaults() {
78        let args = vec!["join-ai", "join", "./my-project"];
79        let cli = Cli::try_parse_from(args).unwrap();
80
81        match cli.command {
82            Commands::Join(join_args) => {
83                assert_eq!(join_args.input_folder, PathBuf::from("./my-project"));
84                // Assert default values
85                assert_eq!(join_args.output_file, PathBuf::from("concatenated.txt"));
86                assert!(!join_args.clear_file);
87                assert!(!join_args.hidden);
88                assert!(join_args.patterns.is_none());
89                assert!(join_args.exclude.is_none());
90                assert!(join_args.max_depth.is_none());
91                assert!(join_args.no_follow); // Default is true
92            }
93            _ => panic!("Expected Join command to be parsed"),
94        }
95    }
96
97    /// Verifies that all provided flags and options for the `join` command
98    /// are parsed correctly into the `JoinArgs` struct.
99    #[test]
100    fn test_all_join_options_are_parsed() {
101        let args = vec![
102            "join-ai",
103            "join",
104            "src",
105            "-o",
106            "output.txt",
107            "-p",
108            "*.rs",
109            "-p",
110            "*.toml",
111            "--clear-file",
112            "-x",
113            "target/",
114            "--exclude",
115            "*.log",
116            "--max-depth",
117            "10",
118            "--hidden",
119        ];
120        let cli = Cli::try_parse_from(args).unwrap();
121
122        match cli.command {
123            Commands::Join(join_args) => {
124                assert_eq!(join_args.input_folder, PathBuf::from("src"));
125                assert_eq!(join_args.output_file, PathBuf::from("output.txt"));
126                assert_eq!(
127                    join_args.patterns,
128                    Some(vec!["*.rs".to_string(), "*.toml".to_string()])
129                );
130                assert!(join_args.clear_file);
131                assert_eq!(
132                    join_args.exclude,
133                    Some(vec!["target/".to_string(), "*.log".to_string()])
134                );
135                assert_eq!(join_args.max_depth, Some(10));
136                assert!(join_args.hidden);
137                assert!(join_args.no_follow);
138            }
139            _ => panic!("Expected Join command to be parsed"),
140        }
141    }
142
143    /// Ensures the `update` subcommand is recognized and parsed correctly.
144    #[test]
145    fn test_update_subcommand_is_parsed() {
146        let args = vec!["join-ai", "update"];
147        let cli = Cli::try_parse_from(args).unwrap();
148
149        assert!(matches!(cli.command, Commands::Update(_)));
150    }
151
152    /// Confirms that parsing fails if the required `input_folder` argument is missing.
153    #[test]
154    fn test_missing_required_argument_fails() {
155        let args = vec!["join-ai", "join", "-o", "output.txt"];
156        let result = Cli::try_parse_from(args);
157
158        assert!(
159            result.is_err(),
160            "Parsing should fail without the required input_folder"
161        );
162        assert_eq!(
163            result.unwrap_err().kind(),
164            ErrorKind::MissingRequiredArgument
165        );
166    }
167
168    /// Confirms that parsing fails if no subcommand (like 'join') is provided.
169    #[test]
170    fn test_no_subcommand_fails() {
171        let args = vec!["join-ai"];
172        let result = Cli::try_parse_from(args);
173
174        assert!(result.is_err(), "Parsing should fail without a subcommand");
175        assert_eq!(
176            result.unwrap_err().kind(),
177            ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
178        );
179    }
180}