join_ai/
lib.rs

1use std::fs;
2
3pub mod cli;
4pub mod processor;
5pub mod walker;
6
7use cli::{Commands, JoinArgs};
8
9/// The core logic of the application.
10pub fn run(command: Commands) -> anyhow::Result<()> {
11    match command {
12        Commands::Join(args) => run_join(args),
13        Commands::Update(_args) => {
14            println!("Update functionality is not yet implemented.");
15            println!("Please check for new releases at the GitHub repository:");
16            println!("https://github.com/luizvbo/join-ai/releases");
17            Ok(())
18        }
19    }
20}
21
22/// The logic for the 'join' command.
23fn run_join(args: JoinArgs) -> anyhow::Result<()> {
24    // Log the arguments being used
25    println!(
26        "Processing files in folder: {}",
27        args.input_folder.display()
28    );
29    if let Some(patterns) = &args.patterns {
30        println!("Using patterns: {}", patterns.join(", "));
31    } else {
32        println!("Using patterns: all files");
33    }
34    if let Some(exclude_folders) = &args.exclude_folders {
35        println!("Excluding folders: {}", exclude_folders.join(", "));
36    }
37    if let Some(exclude_extensions) = &args.exclude_extensions {
38        println!("Excluding extensions: {}", exclude_extensions.join(", "));
39    }
40
41    // Clear the output file if specified
42    if args.clear_file && args.output_file.exists() {
43        fs::remove_file(&args.output_file)?;
44        println!(
45            "Output file {} has been cleared.",
46            args.output_file.display()
47        );
48    }
49
50    // 1. Find all relevant files using the walker module
51    let receiver = walker::find_files(&args)?;
52
53    // 2. Process the files found by the walker
54    processor::process_files(receiver, &args.output_file)?;
55
56    println!(
57        "Files have been processed and written to {}",
58        args.output_file.display()
59    );
60
61    Ok(())
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::cli::{Commands, JoinArgs};
68    use assert_fs::TempDir;
69    use assert_fs::prelude::*;
70    use std::fs::{self};
71    use std::path::Path;
72
73    /// Helper to create a standard JoinArgs struct for tests.
74    fn get_test_args(input_folder: &Path, output_file: &Path) -> JoinArgs {
75        JoinArgs {
76            input_folder: input_folder.to_path_buf(),
77            output_file: output_file.to_path_buf(),
78            patterns: None,
79            clear_file: false, // Default to false to test clearing explicitly
80            exclude_folders: None,
81            exclude_extensions: None,
82            max_depth: None,
83            hidden: false,
84            no_follow: true,
85        }
86    }
87
88    /// Helper to run the join command and read the resulting output file.
89    fn run_join_and_read_output(args: JoinArgs) -> anyhow::Result<String> {
90        let output_path = args.output_file.clone();
91        run(Commands::Join(args))?;
92        // Use fs::read_to_string for simplicity if the file might not be created.
93        Ok(fs::read_to_string(output_path).unwrap_or_default())
94    }
95
96    #[test]
97    fn test_filter_by_multiple_patterns() -> anyhow::Result<()> {
98        let dir = TempDir::new()?;
99        dir.child("Cargo.toml").write_str("[package]")?;
100        dir.child("README.md").write_str("# Project")?;
101        dir.child("src/main.rs").write_str("fn main(){}")?;
102
103        let output_file = dir.path().join("output.txt");
104        let mut args = get_test_args(dir.path(), &output_file);
105        args.patterns = Some(vec!["*.rs".to_string(), "*.toml".to_string()]);
106
107        let result = run_join_and_read_output(args)?;
108
109        assert!(result.contains("// FILE:"));
110        assert!(result.contains("main.rs"));
111        assert!(result.contains("Cargo.toml"));
112        assert!(!result.contains("README.md"));
113
114        Ok(())
115    }
116
117    #[test]
118    fn test_skip_binary_files() -> anyhow::Result<()> {
119        let dir = TempDir::new()?;
120        dir.child("text.txt").write_str("some text")?;
121        dir.child("binary.bin")
122            .write_binary(&[b'b', b'i', b'n', 0, b'a', b'r', b'y'])?;
123
124        let output_file = dir.path().join("output.txt");
125        let args = get_test_args(dir.path(), &output_file);
126
127        let result = run_join_and_read_output(args)?;
128
129        assert!(result.contains("text.txt"));
130        assert!(!result.contains("binary.bin"));
131
132        Ok(())
133    }
134
135    #[test]
136    fn test_exclude_folders() -> anyhow::Result<()> {
137        let dir = TempDir::new()?;
138        dir.child("src/main.rs").write_str("main")?;
139        dir.child("target/debug/app").write_str("binary")?;
140        dir.child("docs/guide.md").write_str("guide")?;
141
142        let output_file = dir.path().join("output.txt");
143        let mut args = get_test_args(dir.path(), &output_file);
144        args.exclude_folders = Some(vec!["target".to_string(), "docs".to_string()]);
145
146        let result = run_join_and_read_output(args)?;
147
148        assert!(result.contains("main.rs"));
149        assert!(!result.contains("app"));
150        assert!(!result.contains("guide.md"));
151
152        Ok(())
153    }
154
155    #[test]
156    fn test_exclude_extensions() -> anyhow::Result<()> {
157        let dir = TempDir::new()?;
158        dir.child("code.rs").write_str("main")?;
159        dir.child("notes.txt").write_str("notes")?;
160        dir.child("log.log").write_str("log")?;
161
162        let output_file = dir.path().join("output.txt");
163        let mut args = get_test_args(dir.path(), &output_file);
164        args.exclude_extensions = Some(vec!["log".to_string(), "txt".to_string()]);
165
166        let result = run_join_and_read_output(args)?;
167
168        assert!(result.contains("code.rs"));
169        assert!(!result.contains("notes.txt"));
170        assert!(!result.contains("log.log"));
171
172        Ok(())
173    }
174
175    #[test]
176    fn test_max_depth() -> anyhow::Result<()> {
177        let dir = TempDir::new()?;
178        dir.child("level1.txt").write_str("1")?;
179        dir.child("a/level2.txt").write_str("2")?;
180        dir.child("a/b/level3.txt").write_str("3")?;
181
182        let output_file = dir.path().join("output.txt");
183        let mut args = get_test_args(dir.path(), &output_file);
184        args.max_depth = Some(2); // Root (depth 0), level1 (depth 1), level2 (depth 2)
185
186        let result = run_join_and_read_output(args)?;
187
188        assert!(result.contains("level1.txt"));
189        assert!(result.contains("level2.txt"));
190        assert!(!result.contains("level3.txt"));
191
192        Ok(())
193    }
194
195    #[test]
196    fn test_hidden_files_are_skipped_by_default() -> anyhow::Result<()> {
197        let dir = TempDir::new()?;
198        dir.child(".env").write_str("secret")?;
199        dir.child("visible.txt").write_str("visible")?;
200
201        let output_file = dir.path().join("output.txt");
202        let args = get_test_args(dir.path(), &output_file);
203
204        let result = run_join_and_read_output(args)?;
205
206        assert!(!result.contains(".env"));
207        assert!(result.contains("visible.txt"));
208
209        Ok(())
210    }
211
212    #[test]
213    fn test_hidden_files_are_included_with_flag() -> anyhow::Result<()> {
214        let dir = TempDir::new()?;
215        dir.child(".env").write_str("secret")?;
216        dir.child("visible.txt").write_str("visible")?;
217
218        let output_file = dir.path().join("output.txt");
219        let mut args = get_test_args(dir.path(), &output_file);
220        args.hidden = true;
221
222        let result = run_join_and_read_output(args)?;
223
224        assert!(result.contains(".env"));
225        assert!(result.contains("visible.txt"));
226
227        Ok(())
228    }
229
230    #[test]
231    fn test_output_file_is_skipped() -> anyhow::Result<()> {
232        let dir = TempDir::new()?;
233        let output_file = dir.path().join("output.txt");
234        // Create the output file beforehand to ensure it exists during the walk
235        fs::write(&output_file, "initial content")?;
236        dir.child("input.txt").write_str("input")?;
237
238        let args = get_test_args(dir.path(), &output_file);
239        let result = run_join_and_read_output(args)?;
240
241        // The result should not contain its own initial content
242        assert!(!result.contains("initial content"));
243        assert!(result.contains("input.txt"));
244
245        Ok(())
246    }
247
248    #[test]
249    fn test_clear_file_option() -> anyhow::Result<()> {
250        let dir = TempDir::new()?;
251        let output_file = dir.path().join("output.txt");
252        fs::write(&output_file, "this should be cleared")?;
253        dir.child("input.txt").write_str("new content")?;
254
255        let mut args = get_test_args(dir.path(), &output_file);
256        args.clear_file = true;
257
258        let result = run_join_and_read_output(args)?;
259
260        assert!(!result.contains("this should be cleared"));
261        assert!(result.contains("new content"));
262
263        Ok(())
264    }
265
266    #[test]
267    fn test_empty_directory_produces_empty_file() -> anyhow::Result<()> {
268        let dir = TempDir::new()?;
269        let output_file = dir.path().join("output.txt");
270        let args = get_test_args(dir.path(), &output_file);
271
272        let result = run_join_and_read_output(args)?;
273
274        assert!(result.is_empty());
275
276        Ok(())
277    }
278
279    #[test]
280    fn test_update_command_placeholder() -> anyhow::Result<()> {
281        // This test simply ensures the update command can be called without error.
282        // A more advanced test would capture stdout, but this is sufficient.
283        let update_args = cli::UpdateArgs {};
284        run(Commands::Update(update_args))?;
285        Ok(())
286    }
287}