11use anyhow:: Context as _;
22use clap:: Subcommand ;
33use tracing:: instrument;
4+ use tracing:: { error, info, warn} ;
5+ use walkdir:: WalkDir ;
46
57use crate :: {
68 cli:: { CliCommand , CliContext , CommandOutput } ,
79 config:: { generate_default_config, local_config_path} ,
810} ;
11+ use std:: io:: { self } ;
12+ use std:: path:: { Path , PathBuf } ;
913
14+ #[ instrument( skip( dir) , fields( path = %dir. display( ) ) ) ]
15+ fn get_all_paths_in_dir ( dir : & Path ) -> io:: Result < Vec < PathBuf > > {
16+ let paths: Vec < PathBuf > = WalkDir :: new ( dir)
17+ . into_iter ( )
18+ . filter_map ( |entry| entry. ok ( ) )
19+ . filter ( |entry| entry. path ( ) != dir)
20+ . map ( |entry| entry. into_path ( ) )
21+ . collect ( ) ;
22+
23+ Ok ( paths)
24+ }
1025/// Create a new component project from a template, git repository, or local path
1126#[ derive( Subcommand , Debug , Clone ) ]
1227pub enum ConfigCommand {
@@ -24,7 +39,27 @@ pub enum ConfigCommand {
2439 /// Print the current configuration file for wash
2540 Show { } ,
2641 // TODO(#27): validate config command
27- // TODO(#29): cleanup config command, to clean the dirs we use
42+ /// Clean up wash directories and cached data
43+ #[ clap( group = clap:: ArgGroup :: new( "cleanup_targets" )
44+ . required( true )
45+ . multiple( true ) ) ]
46+ Cleanup {
47+ /// Remove config directory
48+ #[ clap( long, group = "cleanup_targets" ) ]
49+ config : bool ,
50+ /// Remove cache directory
51+ #[ clap( long, group = "cleanup_targets" ) ]
52+ cache : bool ,
53+ /// Remove data directory
54+ #[ clap( long, group = "cleanup_targets" ) ]
55+ data : bool ,
56+ /// Remove all wash directories (config + cache + data)
57+ #[ clap( long, group = "cleanup_targets" ) ]
58+ all : bool ,
59+ /// Show what would be removed without actually deleting
60+ #[ clap( long) ]
61+ dry_run : bool ,
62+ } ,
2863}
2964
3065impl CliCommand for ConfigCommand {
@@ -83,6 +118,211 @@ impl CliCommand for ConfigCommand {
83118 Some ( serde_json:: to_value ( & config) . context ( "failed to serialize config" ) ?) ,
84119 ) )
85120 }
121+ ConfigCommand :: Cleanup {
122+ config,
123+ cache,
124+ data,
125+ all,
126+ dry_run,
127+ } => {
128+ let config_dir = ctx. config_dir ( ) ;
129+ let cache_dir = ctx. cache_dir ( ) ;
130+ let data_dir = ctx. data_dir ( ) ;
131+
132+ let mut cleanup_paths: Vec < PathBuf > = Vec :: new ( ) ;
133+
134+ if * config || * all {
135+ let config_paths: Vec < PathBuf > = get_all_paths_in_dir ( config_dir. as_path ( ) ) ?;
136+ cleanup_paths. extend ( config_paths) ;
137+ }
138+
139+ if * cache || * all {
140+ let cache_paths: Vec < PathBuf > = get_all_paths_in_dir ( cache_dir. as_path ( ) ) ?;
141+ cleanup_paths. extend ( cache_paths) ;
142+ }
143+
144+ if * data || * all {
145+ let data_paths: Vec < PathBuf > = get_all_paths_in_dir ( data_dir. as_path ( ) ) ?;
146+ cleanup_paths. extend ( data_paths) ;
147+ }
148+
149+ let cleanup_files: Vec < PathBuf > = cleanup_paths
150+ . iter ( )
151+ . filter ( |p| p. is_file ( ) )
152+ . cloned ( )
153+ . collect ( ) ;
154+
155+ let mut cleanup_dirs: Vec < PathBuf > = cleanup_paths
156+ . iter ( )
157+ . filter ( |p| p. is_dir ( ) )
158+ . cloned ( )
159+ . collect ( ) ;
160+
161+ // Sort to first delete the deepest directories
162+ cleanup_dirs. sort_by_key ( |path| std:: cmp:: Reverse ( path. components ( ) . count ( ) ) ) ;
163+
164+ // Gather all files as a string for output
165+ let files_summary = cleanup_paths
166+ . iter ( )
167+ . filter ( |p| p. is_file ( ) )
168+ . map ( |p| p. display ( ) . to_string ( ) )
169+ . collect :: < Vec < String > > ( )
170+ . join ( "\n " ) ;
171+
172+ if cleanup_files. is_empty ( ) {
173+ return Ok ( CommandOutput :: ok (
174+ "No files were found to clean up." ,
175+ Some ( serde_json:: json!( {
176+ "message" : "No files were found to clean up." ,
177+ "success" : true ,
178+ } ) ) ,
179+ ) ) ;
180+ }
181+
182+ if * dry_run {
183+ return Ok ( CommandOutput :: ok (
184+ format ! (
185+ "Found {} files for cleanup (Dry Run):\n {}\n \n " ,
186+ cleanup_files. len( ) ,
187+ files_summary
188+ ) ,
189+ Some ( serde_json:: json!( {
190+ "message" : "Dry run executed successfully. No files were deleted." ,
191+ "success" : true ,
192+ "file_count" : cleanup_files. len( ) ,
193+ "files" : files_summary
194+ } ) ) ,
195+ ) ) ;
196+ }
197+
198+ warn ! (
199+ "Found {} files for cleanup. Files to be deleted:\n {}\n \n Do you want to proceed with the deletion? (y/N)" ,
200+ cleanup_files. len( ) ,
201+ files_summary
202+ ) ;
203+
204+ let mut successful_deletions = 0 ;
205+ let mut failed_paths = Vec :: new ( ) ;
206+
207+ let mut confirmation = String :: new ( ) ;
208+ io:: stdin ( ) . read_line ( & mut confirmation) ?;
209+
210+ if !confirmation. trim ( ) . eq_ignore_ascii_case ( "y" ) {
211+ return Ok ( CommandOutput :: ok (
212+ format ! ( "Skipped deletion of {} files" , cleanup_files. len( ) ) ,
213+ Some ( serde_json:: json!( {
214+ "message" : "File deletion skipped." ,
215+ "success" : true ,
216+ } ) ) ,
217+ ) ) ;
218+ }
219+
220+ for path in & cleanup_files {
221+ match std:: fs:: remove_file ( path) {
222+ Ok ( _) => {
223+ info ! ( "Successfully deleted file: {:?}" , path. display( ) ) ;
224+ successful_deletions += 1
225+ }
226+ Err ( e) => {
227+ error ! ( "Failed to delete {} file: {}" , path. display( ) , e) ;
228+ failed_paths. push ( path. clone ( ) ) ;
229+ }
230+ }
231+ }
232+
233+ for path in & cleanup_dirs {
234+ match std:: fs:: remove_dir_all ( path) {
235+ Ok ( _) => {
236+ info ! ( "Successfully deleted dir: {:?}" , path. display( ) ) ;
237+ }
238+ Err ( e) => {
239+ error ! ( "Failed to delete dir {}: {}" , path. display( ) , e) ;
240+ failed_paths. push ( path. clone ( ) ) ;
241+ }
242+ }
243+ }
244+
245+ if !failed_paths. is_empty ( ) {
246+ return Ok ( CommandOutput :: error (
247+ format ! ( "Failed to delete {} files" , failed_paths. len( ) ) ,
248+ Some ( serde_json:: json!( {
249+ "message" : format!( "Partial failure: Deleted {}/{} files." ,
250+ successful_deletions,
251+ cleanup_files. len( ) ) ,
252+ "deleted" : successful_deletions,
253+ "failed_count" : failed_paths. len( ) ,
254+ "success" : false ,
255+ } ) ) ,
256+ ) ) ;
257+ }
258+
259+ return Ok ( CommandOutput :: ok (
260+ format ! ( "Successfully deleted {successful_deletions} files" ) ,
261+ Some ( serde_json:: json!( {
262+ "message" : format!( "{successful_deletions} files deleted successfully." ) ,
263+ "deleted" : successful_deletions,
264+ } ) ) ,
265+ ) ) ;
266+ }
86267 }
87268 }
88269}
270+
271+ #[ cfg( test) ]
272+ mod tests {
273+ use super :: * ;
274+ use tempfile:: TempDir ;
275+
276+ #[ test]
277+ fn test_get_all_paths_in_dir ( ) {
278+ let temp_dir = TempDir :: new ( ) . expect ( "failed to create temp dir" ) ;
279+ let root_path = temp_dir. path ( ) ;
280+
281+ let file1_path = root_path. join ( "file1.txt" ) ;
282+ let subdir_path = root_path. join ( "subdir" ) ;
283+ let file2_path = subdir_path. join ( "file2.log" ) ;
284+ let emptydir_path = root_path. join ( "empty" ) ;
285+
286+ // Create the files and directories
287+ std:: fs:: write ( & file1_path, "content" )
288+ . expect ( & format ! ( "failed to create file {}" , file1_path. display( ) ) ) ;
289+ std:: fs:: create_dir ( & subdir_path) . expect ( & format ! (
290+ "failed to create directory {}" ,
291+ subdir_path. display( )
292+ ) ) ;
293+ std:: fs:: write ( & file2_path, "more content" )
294+ . expect ( & format ! ( "failed to create file {}" , file2_path. display( ) ) ) ;
295+ std:: fs:: create_dir ( & emptydir_path) . expect ( & format ! (
296+ "failed to create directory {}" ,
297+ emptydir_path. display( )
298+ ) ) ;
299+
300+ let mut actual_paths = get_all_paths_in_dir ( root_path) . expect ( & format ! (
301+ "failed to get files from root path {}" ,
302+ root_path. display( )
303+ ) ) ;
304+
305+ let mut expected_files = vec ! [
306+ file1_path. to_path_buf( ) ,
307+ subdir_path. to_path_buf( ) ,
308+ file2_path. to_path_buf( ) ,
309+ emptydir_path. to_path_buf( ) ,
310+ ] ;
311+
312+ // Sort to ensure the order of results doesn't cause the test to fail
313+ actual_paths. sort ( ) ;
314+ expected_files. sort ( ) ;
315+
316+ assert_eq ! (
317+ actual_paths, expected_files,
318+ "Actual files and expected files do not match."
319+ ) ;
320+
321+ // Explicitly check the count for clarity
322+ assert_eq ! (
323+ actual_paths. len( ) ,
324+ 4 ,
325+ "Should have found exactly two files."
326+ ) ;
327+ }
328+ }
0 commit comments