Skip to main content

join_ai/
lib.rs

1use std::fs;
2
3// Public modules that make up the library's functionality.
4pub mod cli;
5pub mod processor;
6pub mod walker;
7
8use cli::{Commands, JoinArgs};
9
10/// The primary entry point for the library's logic.
11/// It takes a parsed `Commands` enum and dispatches to the appropriate handler.
12pub fn run(command: Commands) -> anyhow::Result<()> {
13    match command {
14        Commands::Join(args) => run_join(args),
15        Commands::Update(_args) => {
16            // Placeholder for future update functionality.
17            println!("Update functionality is not yet implemented.");
18            println!("Please check for new releases at the GitHub repository:");
19            println!("https://github.com/luizvbo/join-ai/releases");
20            Ok(())
21        }
22    }
23}
24
25/// Handles the logic for the 'join' command.
26/// This function orchestrates the file finding and processing steps.
27fn run_join(args: JoinArgs) -> anyhow::Result<()> {
28    // --- 1. Log the configuration for user feedback ---
29    println!(
30        "Processing files in folder: {}",
31        args.input_folder.display()
32    );
33    if let Some(patterns) = &args.patterns {
34        println!("Using patterns: {}", patterns.join(", "));
35    } else {
36        println!("Using patterns: all files");
37    }
38    if let Some(exclude_patterns) = &args.exclude {
39        println!("Excluding patterns: {}", exclude_patterns.join(", "));
40    }
41
42    // --- 2. Prepare the output file ---
43    if args.clear_file && args.output_file.exists() {
44        fs::remove_file(&args.output_file)?;
45        println!(
46            "Output file {} has been cleared.",
47            args.output_file.display()
48        );
49    }
50
51    // --- 3. Find all relevant files using the walker module ---
52    // The walker runs in a background thread and sends file paths via a channel.
53    let receiver = walker::find_files(&args)?;
54
55    // --- 4. Process the files found by the walker ---
56    // The processor reads each file and appends its content to the output file.
57    processor::process_files(receiver, &args.output_file)?;
58
59    println!(
60        "Files have been processed and written to {}",
61        args.output_file.display()
62    );
63
64    Ok(())
65}
66
67// --- Integration-style Tests for Core Logic ---
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::cli::{Commands, JoinArgs};
72    use assert_fs::TempDir;
73    use assert_fs::prelude::*;
74    use std::fs::{self};
75    use std::path::Path;
76
77    /// Test helper to create a standard `JoinArgs` struct with common defaults.
78    fn get_test_args(input_folder: &Path, output_file: &Path) -> JoinArgs {
79        JoinArgs {
80            input_folder: input_folder.to_path_buf(),
81            output_file: output_file.to_path_buf(),
82            patterns: None,
83            exclude: None,
84            clear_file: false,
85            max_depth: None,
86            hidden: false,
87            no_follow: true,
88        }
89    }
90
91    /// Test helper to execute the `run_join` command and return the content of the output file.
92    fn run_join_and_read_output(args: JoinArgs) -> anyhow::Result<String> {
93        let output_path = args.output_file.clone();
94        run(Commands::Join(args))?;
95        Ok(fs::read_to_string(output_path).unwrap_or_default())
96    }
97
98    /// Verifies that only files matching the include patterns are processed.
99    #[test]
100    fn test_filter_by_multiple_patterns() -> anyhow::Result<()> {
101        let dir = TempDir::new()?;
102        dir.child("Cargo.toml").write_str("[package]")?;
103        dir.child("README.md").write_str("# Project")?;
104        dir.child("src/main.rs").write_str("fn main(){}")?;
105
106        let output_file = dir.path().join("output.txt");
107        let mut args = get_test_args(dir.path(), &output_file);
108        args.patterns = Some(vec!["*.rs".to_string(), "*.toml".to_string()]);
109
110        let result = run_join_and_read_output(args)?;
111
112        assert!(result.contains("// FILE:"));
113        assert!(result.contains("main.rs"));
114        assert!(result.contains("Cargo.toml"));
115        assert!(!result.contains("README.md"));
116
117        Ok(())
118    }
119
120    /// Verifies that binary files (containing NUL bytes) are automatically skipped.
121    #[test]
122    fn test_skip_binary_files() -> anyhow::Result<()> {
123        let dir = TempDir::new()?;
124        dir.child("text.txt").write_str("some text")?;
125        dir.child("binary.bin")
126            .write_binary(&[b'b', b'i', b'n', 0, b'a', b'r', b'y'])?;
127
128        let output_file = dir.path().join("output.txt");
129        let args = get_test_args(dir.path(), &output_file);
130
131        let result = run_join_and_read_output(args)?;
132
133        assert!(result.contains("text.txt"));
134        assert!(!result.contains("binary.bin"));
135
136        Ok(())
137    }
138
139    /// Verifies that the `--max-depth` argument correctly limits traversal.
140    #[test]
141    fn test_max_depth() -> anyhow::Result<()> {
142        let dir = TempDir::new()?;
143        dir.child("level1.txt").write_str("1")?;
144        dir.child("a/level2.txt").write_str("2")?;
145        dir.child("a/b/level3.txt").write_str("3")?;
146
147        let output_file = dir.path().join("output.txt");
148        let mut args = get_test_args(dir.path(), &output_file);
149        args.max_depth = Some(2);
150
151        let result = run_join_and_read_output(args)?;
152
153        assert!(result.contains("level1.txt"));
154        assert!(result.contains("level2.txt"));
155        assert!(!result.contains("level3.txt"));
156
157        Ok(())
158    }
159
160    /// Verifies that hidden files are ignored by default.
161    #[test]
162    fn test_hidden_files_are_skipped_by_default() -> anyhow::Result<()> {
163        let dir = TempDir::new()?;
164        dir.child(".env").write_str("secret")?;
165        dir.child("visible.txt").write_str("visible")?;
166
167        let output_file = dir.path().join("output.txt");
168        let args = get_test_args(dir.path(), &output_file);
169
170        let result = run_join_and_read_output(args)?;
171
172        assert!(!result.contains(".env"));
173        assert!(result.contains("visible.txt"));
174
175        Ok(())
176    }
177
178    /// Verifies that the `--hidden` flag includes hidden files in the output.
179    #[test]
180    fn test_hidden_files_are_included_with_flag() -> anyhow::Result<()> {
181        let dir = TempDir::new()?;
182        dir.child(".env").write_str("secret")?;
183        dir.child("visible.txt").write_str("visible")?;
184
185        let output_file = dir.path().join("output.txt");
186        let mut args = get_test_args(dir.path(), &output_file);
187        args.hidden = true;
188
189        let result = run_join_and_read_output(args)?;
190
191        assert!(result.contains(".env"));
192        assert!(result.contains("visible.txt"));
193
194        Ok(())
195    }
196
197    /// Verifies that the application does not read and include its own output file.
198    #[test]
199    fn test_output_file_is_skipped() -> anyhow::Result<()> {
200        let dir = TempDir::new()?;
201        let output_file = dir.path().join("output.txt");
202        fs::write(&output_file, "initial content")?;
203        dir.child("input.txt").write_str("input")?;
204
205        let args = get_test_args(dir.path(), &output_file);
206        let result = run_join_and_read_output(args)?;
207
208        assert!(!result.contains("initial content"));
209        assert!(result.contains("input.txt"));
210
211        Ok(())
212    }
213
214    /// Verifies that the `--clear-file` flag deletes existing content before writing.
215    #[test]
216    fn test_clear_file_option() -> anyhow::Result<()> {
217        let dir = TempDir::new()?;
218        let output_file = dir.path().join("output.txt");
219        fs::write(&output_file, "this should be cleared")?;
220        dir.child("input.txt").write_str("new content")?;
221
222        let mut args = get_test_args(dir.path(), &output_file);
223        args.clear_file = true;
224
225        let result = run_join_and_read_output(args)?;
226
227        assert!(!result.contains("this should be cleared"));
228        assert!(result.contains("new content"));
229
230        Ok(())
231    }
232
233    /// Verifies that running on an empty directory produces an empty output file.
234    #[test]
235    fn test_empty_directory_produces_empty_file() -> anyhow::Result<()> {
236        let dir = TempDir::new()?;
237        let output_file = dir.path().join("output.txt");
238        let args = get_test_args(dir.path(), &output_file);
239
240        let result = run_join_and_read_output(args)?;
241
242        assert!(result.is_empty());
243
244        Ok(())
245    }
246
247    /// Verifies that the `update` command can be called without error.
248    #[test]
249    fn test_update_command_placeholder() -> anyhow::Result<()> {
250        let update_args = cli::UpdateArgs {};
251        run(Commands::Update(update_args))?;
252        Ok(())
253    }
254
255    // --- New Tests for Exclude Functionality ---
256
257    /// Verifies that a folder pattern (e.g., "target/") excludes all its contents.
258    #[test]
259    fn test_exclude_by_folder_pattern() -> anyhow::Result<()> {
260        let dir = TempDir::new()?;
261        dir.child("src/main.rs").write_str("main")?;
262        dir.child("target/debug/app").write_str("binary")?;
263        dir.child("docs/guide.md").write_str("guide")?;
264
265        let output_file = dir.path().join("output.txt");
266        let mut args = get_test_args(dir.path(), &output_file);
267        args.exclude = Some(vec!["target/".to_string(), "docs/".to_string()]);
268
269        let result = run_join_and_read_output(args)?;
270
271        assert!(result.contains("main.rs"));
272        assert!(!result.contains("app"));
273        assert!(!result.contains("guide.md"));
274
275        Ok(())
276    }
277
278    /// Verifies that a file extension pattern (e.g., "*.log") excludes matching files.
279    #[test]
280    fn test_exclude_by_extension_pattern() -> anyhow::Result<()> {
281        let dir = TempDir::new()?;
282        dir.child("code.rs").write_str("main")?;
283        dir.child("notes.md").write_str("notes")?;
284        dir.child("log.log").write_str("log")?;
285
286        let output_file = dir.path().join("output.txt");
287        let mut args = get_test_args(dir.path(), &output_file);
288        args.exclude = Some(vec!["*.log".to_string(), "*.md".to_string()]);
289
290        let result = run_join_and_read_output(args)?;
291
292        assert!(result.contains("code.rs"));
293        assert!(!result.contains("notes.md"));
294        assert!(!result.contains("log.log"));
295
296        Ok(())
297    }
298
299    /// Verifies that multiple, different exclusion patterns work together.
300    #[test]
301    fn test_exclude_by_multiple_patterns() -> anyhow::Result<()> {
302        let dir = TempDir::new()?;
303        dir.child("src/main.rs").write_str("main")?;
304        dir.child("src/error.log").write_str("log")?;
305        dir.child("target/app").write_str("binary")?;
306
307        let output_file = dir.path().join("output.txt");
308        let mut args = get_test_args(dir.path(), &output_file);
309        args.exclude = Some(vec!["target/".to_string(), "*.log".to_string()]);
310
311        let result = run_join_and_read_output(args)?;
312
313        assert!(result.contains("main.rs"));
314        assert!(!result.contains("error.log"));
315        assert!(!result.contains("app"));
316
317        Ok(())
318    }
319
320    /// Verifies that an exclude pattern will override an include pattern.
321    #[test]
322    fn test_exclude_takes_precedence_over_include() -> anyhow::Result<()> {
323        let dir = TempDir::new()?;
324        dir.child("src/main.rs").write_str("main")?;
325        dir.child("src/lib.rs").write_str("lib")?;
326        dir.child("tests/integration_test.rs").write_str("test")?;
327
328        let output_file = dir.path().join("output.txt");
329        let mut args = get_test_args(dir.path(), &output_file);
330        args.patterns = Some(vec!["*.rs".to_string()]); // Include all .rs files
331        args.exclude = Some(vec!["tests/".to_string()]); // But exclude the tests folder
332
333        let result = run_join_and_read_output(args)?;
334
335        assert!(result.contains("main.rs"));
336        assert!(result.contains("lib.rs"));
337        assert!(!result.contains("integration_test.rs"));
338
339        Ok(())
340    }
341
342    /// Verifies that a specific file can be excluded by its full path relative to the input.
343    #[test]
344    fn test_exclude_specific_file() -> anyhow::Result<()> {
345        let dir = TempDir::new()?;
346        dir.child("src/main.rs").write_str("main")?;
347        dir.child("src/config.rs").write_str("config")?;
348        dir.child("README.md").write_str("readme")?;
349
350        let output_file = dir.path().join("output.txt");
351        let mut args = get_test_args(dir.path(), &output_file);
352        args.exclude = Some(vec!["src/config.rs".to_string()]);
353
354        let result = run_join_and_read_output(args)?;
355
356        assert!(result.contains("main.rs"));
357        assert!(result.contains("README.md"));
358        assert!(!result.contains("config.rs"));
359
360        Ok(())
361    }
362}