fast_files/
lib.rs

1use std::io::{self, Write, BufRead};
2use std::collections::HashMap;
3use regex::Regex;
4use std::{thread, time};
5use std::fs::{self, OpenOptions, File};
6use std::path::Path;
7use std::process::Command;
8
9/// This program uses state-based design. On startup, a blank state is declared and is manipulated
10/// by the functions of the library to, for example, change the menu status, change possible input
11/// commands, and make changes to local storage hashmaps.
12
13pub struct State {
14    menu: String,
15    commands: Vec<String>,
16    comment: String,
17    directories: HashMap<String, String>, // saved in order of (filepath, endpoint)
18    launch_command: HashMap<String, String>, // saved in order of (extension, command)
19}
20
21pub fn start_splash() {
22    println!("Fast Files - File directory storage UI for fast access and sorting\n");
23}
24
25fn poll_commands(state: &mut State) -> () { // poll_command() should return type fn()
26    // initialize text input variables
27    let mut text_entry = String::new();
28    let mut commands: Vec<String> = Vec::new();
29
30    // preparing IO
31    print!("> ");
32    io::stdout().flush().expect("Failed to flush");
33    io::stdin()
34        .read_line(&mut text_entry)
35        .expect("Failed to read line");   
36
37    // tokenizing commands
38    for word in text_entry.split_whitespace() {
39        commands.push(String::from(word));
40    }        
41
42    // making sure first command is only a single character, else try polling again
43    if commands[0].len() != 1 {
44        println!("Invalid command at index 0, character length should be 1");
45        poll_commands(state);
46    }
47
48    // creating list of valid arguments depending on the menu state
49    let valid_args = &state.commands;
50
51    // command list: level1 checks if the command is available in the menu state, level2 executes the command.
52    let _command_level1 = match &commands[0] {
53        x if valid_args.contains(x) => {
54            let _command_level2 = match commands[0].as_str() {
55                "l" => return state.directories(),
56                "o" => return state.open_directory(),
57                "n" => if &state.menu == "Directories" {
58                    return state.new_directory()
59                } else if &state.menu == "LaunchCommands" {
60                    return state.new_launchcommand() 
61                },
62                "r" => if &state.menu == "MainMenu" {
63                    return state.refresh_list()
64                } else {
65                    return state.main_menu()
66                },
67                "d" => if &state.menu == "MainMenu" {
68                    return state.launch_commands()
69                } else if &state.menu == "Directories" {
70                    return state.delete_directory()
71                } else if &state.menu == "LaunchCommands" {
72                    return state.delete_launchcommand()
73                },
74                "q" => std::process::exit(0),
75                &_ => poll_commands(state),
76            };
77        },
78        &_ => poll_commands(state), 
79    };
80    // ToDo: Check to see if second command which should be a directory exists in the directory storage file.
81
82}
83
84impl State {
85    
86    // On program startup, create a new state which dictates what menu and options are displayed
87    pub fn new() -> State {
88        State {
89            menu: String::new(),
90            commands: Vec::new(),
91            comment: String::new(),
92            directories: HashMap::new(),
93            launch_command: HashMap::new(),
94        }
95    }
96
97    // On a blank state, populate directories and launch_command hashmaps from saved file
98    pub fn build(&mut self, directories: String, launchcommands: String) {
99        // populate from directories file into state.directories
100        if let Ok(lines) = read_lines(directories) {
101            for line in lines.flatten() {
102                let mut kv_pair: Vec<String> = Vec::new();
103                for keyvalue in line.split_whitespace() {
104                    kv_pair.push(keyvalue.to_string());
105                }
106                self.directories.insert(String::from(&kv_pair[0]), String::from(&kv_pair[1]));
107            }
108        }
109        // populate from launchcommands file into state.launch_command
110        if let Ok(lines) = read_lines(launchcommands) {
111            for line in lines.flatten() {
112                let mut kv_pair: Vec<String> = Vec::new();
113                for keyvalue in line.split_whitespace() {
114                    kv_pair.push(keyvalue.to_string());
115                }
116                self.launch_command.insert(String::from(&kv_pair[0]), String::from(&kv_pair[1]));
117            }
118        }
119
120        let _ = File::options().write(true).create(true).open("ff_directories.txt");
121        let _ = File::options().write(true).create(true).open("ff_launchcommands.txt");
122    }
123
124    // Update state with corresponding options: main_menu(), directories().
125    // called from the aforementioned functions.
126    fn update(&mut self, status: &str) {
127        self.menu = String::from(status);
128        self.update_commands()
129    }
130
131    // Update command list display following change to State's 'menu' field.
132    // called from update()
133    fn update_commands(&mut self) {
134        match self.menu.as_str() {
135            "MainMenu" => {
136                self.comment = String::from("Select Action\n[l] - List saved directories\n[d] - List default opening process\n[r] - Refresh saved directories\n[q] - Quit\n\n");
137                self.commands = Vec::from(["l".to_string(), "d".to_string(), "r".to_string(), "q".to_string()]);
138            },
139            "Directories" => {
140                self.comment = String::from("Select Action\n[o] - Open file endpoint/number\n[s] - Change sort (current: last modified)\n[n] - New Directory\n[d] - Delete directory\n[r] - Return to main menu\n[q] - Quit\n\n");
141                self.commands = vec!["o".to_string(), "s".to_string(), "n".to_string(),  "d".to_string(), "r".to_string(), "q".to_string()];
142            },
143            "LaunchCommands" => {
144                self.comment = String::from("Select Action\n[n] - New Launch Command\n[d] - Delete Launch Command\n[r] - Return to main menu\n[q] - Quit\n\n");
145                self.commands = vec!["n".to_string(), "d".to_string(), "r".to_string(), "q".to_string()];
146            },
147            &_ => ()
148        };
149    }
150
151    // returns list of available commands following a call from State change functions
152    fn print_commands(&self) -> &String {
153        &self.comment
154    }
155
156    // adds a new directory and endpoint to the hashmap of status.directories
157    fn new_directory(&mut self) {
158        // initialize text input variables
159        let mut text_entry = String::new();
160        let mut commands: Vec<String> = Vec::new();
161
162        // preparing IO
163        print!("\nPlease enter a new filepath...\n> ");
164        io::stdout().flush().expect("Failed to flush");
165        io::stdin()
166            .read_line(&mut text_entry)
167            .expect("Failed to read line");
168
169        // tokenizing commands
170        for word in text_entry.split_whitespace() {
171            commands.push(String::from(word));
172        }
173
174        // declaring regex for use in the for loop, documentation said its 'expensive' to declare
175        // so its only done once.       
176        let re_endpoint = Regex::new(r"(?<endpoint>[[:word:]]+\.[[:word:]]+)").unwrap();
177        let re_ext = Regex::new(r"(?<ext>\.[[:word:]]+\w$)").unwrap();
178        
179        // this loop captures the endpoint and extension for every inputted directory, then adds
180        // them to their respective hashmaps in state.directories and state.launch_command
181        for directory in commands.iter() {
182
183            // endpoints are captured and taken out of their Option() state.
184            let cap = re_endpoint.captures(directory).and_then(|cap| {
185                cap.name("endpoint").map(|endpoint| endpoint.as_str())
186            });
187            let endpoint = match cap {
188                Some(endp) => endp,
189                None => "",
190            };
191
192            // extensions are captured and taken out of their Option() state
193            let cap = re_ext.captures(directory).and_then(|cap| {
194                cap.name("ext").map(|ext| ext.as_str())
195            });
196            let extension = match cap {
197                Some(ex) => ex,
198                None => "",
199            };
200            
201            // directory:endpoint key-value pairs are inserted to the hashmap
202            self.directories.insert(String::from(directory), String::from(endpoint));
203            println!("\n{} added and saved to directories...", endpoint);
204
205            // creating vector list of known (registered) extensions for checking later
206            let mut registered_extensions: Vec<&str> = Vec::new();
207
208            for (reg_ext, _process) in &self.launch_command {
209                registered_extensions.push(reg_ext);
210            }
211
212            // if the extension is not registered, register with a command and add to hashmap
213            if !registered_extensions.contains(&extension) {
214                println!("New filetype discovered, what process would you like to open '{}' files with?", extension);
215
216                // initialize text input variables
217                let mut launch_command = String::new();
218
219                // preparing IO
220                print!("> ");
221                io::stdout().flush().expect("Failed to flush");
222                io::stdin()
223                    .read_line(&mut launch_command)
224                    .expect("Failed to read line");
225
226                let launch_command = launch_command.trim_end();
227
228                println!("\n'{}' file will now be opened with '{}' by default, use 'd' from the default opening process menu to change.", extension, launch_command);
229                self.launch_command.insert(String::from(extension), String::from(launch_command));
230
231            }
232
233        }
234
235        // write changes to file "ff_directories.txt" which allows for cross-instance usage
236        fs::remove_file("ff_directories.txt").unwrap();
237        let mut file = OpenOptions::new()
238                .write(true)
239                .create(true)
240                .open("ff_directories.txt")
241                .expect("Unable to open file");
242        
243        for (directory, endpoint) in &self.directories { // issue: currently rewrites entire storage when saving as a redundancy against overwrites
244            let data = format!("{directory} {endpoint}\n");
245            file.write(data.as_bytes()).expect("Unable to write to directories");
246        }
247
248        // write changes to file "ff_launchcommands.txt" which allows for cross-instance usage
249        fs::remove_file("ff_launchcommands.txt").unwrap();
250        let mut file = OpenOptions::new()
251            .write(true)
252            .create(true)
253            .open("ff_launchcommands.txt")
254            .expect("Unable to open file");
255
256        for (extension, commands) in &self.launch_command { // same issue
257            let data = format!("{extension} {commands}\n");
258            file.write(data.as_bytes()).expect("Unable to write to launchcommands");
259        }
260
261        // after waiting, return to main menu
262        thread::sleep(time::Duration::from_secs(2));
263        std::process::Command::new("clear").status().unwrap();
264        self.directories();
265    }
266
267    fn new_launchcommand(&mut self) {
268        // initialize text input variables
269        let mut new_extension = String::new();
270        let mut launch_command = String::new();
271
272        // preparing IO
273        print!("\nPlease enter a file extension...\n> ");
274        io::stdout().flush().expect("Failed to flush");
275        io::stdin()
276            .read_line(&mut new_extension)
277            .expect("Failed to read line");
278
279        let new_extension = new_extension.trim_end();
280
281        print!("\nWhat command would you like to open '{new_extension}' files with?\n> ");
282        io::stdout().flush().expect("Failed to flush");
283        io::stdin()
284            .read_line(&mut launch_command)
285            .expect("Failed to read line");
286
287        let launch_command = launch_command.trim_end();
288
289        println!("\n'{}' files will now be opened with '{}' by default, use 'd' from the main menu to change.", new_extension, launch_command);
290        self.launch_command.insert(String::from(new_extension), String::from(launch_command));
291
292        // write changes to file "ff_launchcommands.txt" which allows for cross-instance usage
293        fs::remove_file("ff_launchcommands.txt").unwrap();
294        let mut file = OpenOptions::new()
295            .write(true)
296            .create(true)
297            .open("ff_launchcommands.txt")
298            .expect("Unable to open file");
299
300        for (extension, commands) in &self.launch_command { // same issue
301            let data = format!("{extension} {commands}\n");
302            file.write(data.as_bytes()).expect("Unable to write to launchcommands");
303        }
304
305        // after waiting, return to directory listing
306        thread::sleep(time::Duration::from_secs(2));
307        std::process::Command::new("clear").status().unwrap();
308        self.launch_commands();
309
310    }
311
312    fn delete_directory(&mut self) {
313        
314        // initialize text input variables
315        let mut text_entry = String::new();
316        let mut commands: Vec<String> = Vec::new();
317
318        // preparing IO
319        print!("\nSelect directory number or file name to delete...\n");
320        print!("> ");
321        io::stdout().flush().expect("Failed to flush");
322        io::stdin()
323            .read_line(&mut text_entry)
324            .expect("Failed to read line");
325
326
327        // tokenizing commands
328        for word in text_entry.split_whitespace() {
329            commands.push(String::from(word));
330        }
331
332        // because of the borrow-checker, use this variable to queue up a directory to be deleted
333        let mut tobe_deleted = String::new();
334
335        // for every directory (command), check against the endpoint (value) and remove by directory (key). Once its found, break the loop
336        for command in commands.iter() {
337            for (key, value) in &self.directories {
338                if value == command {
339                    println!("\nDeleteing '{}' from registry... (fast_files does not truly delete files)", command);
340                    tobe_deleted = key.to_string();
341                    break;
342                } 
343            }
344            if tobe_deleted == "" {
345                println!("\nDirectory not found");
346            }
347            self.directories.remove(&tobe_deleted);
348        }
349
350        // write changes to file "ff_launchcommands.txt" which allows for cross-instance usage
351        fs::remove_file("ff_directories.txt").unwrap();
352        let mut file = OpenOptions::new()
353            .write(true)
354            .create(true)
355            .open("ff_directories.txt")
356            .expect("Unable to open file");
357
358        for (directory, endpoint) in &self.directories { // same issue
359            let data = format!("{directory} {endpoint}\n");
360            file.write(data.as_bytes()).expect("Unable to write to directory");
361        }
362
363        // after waiting, return to directory listing
364        thread::sleep(time::Duration::from_secs(2));
365        std::process::Command::new("clear").status().unwrap();
366        self.directories();
367    }
368
369    fn delete_launchcommand(&mut self) {
370
371        // initialize text input variables
372        let mut text_entry = String::new();
373        let mut commands: Vec<String> = Vec::new();
374
375        // preparing IO
376        print!("\nSelect launch command to delete...\n");
377        print!("> ");
378        io::stdout().flush().expect("Failed to flush");
379        io::stdin()
380            .read_line(&mut text_entry)
381            .expect("Failed to read line");
382
383        // tokenizing commands
384        for word in text_entry.split_whitespace() {
385            commands.push(String::from(word));
386        }
387
388        // because of the borrow-checker, use this variable to queue up a directory to be deleted
389        let mut tobe_deleted = String::new();
390
391        // for every directory (command), check against the endpoint (value) and remove by directory (key). Once its found, break the loop
392        for command in commands.iter() {
393            for (key, _value) in &self.launch_command {
394                if key  == command {
395                    println!("\nDeleteing '{}' from registry... (fast_files does not truly delete files)", command);
396                    tobe_deleted = key.to_string();
397                    break;
398                }
399            }
400            if tobe_deleted == "" {
401                println!("\nDirectory not found");
402            }
403            self.launch_command.remove(&tobe_deleted);
404        }
405
406        // write changes to file "ff_directories.txt" which allows for cross-instance usage
407        fs::remove_file("ff_launchcommands.txt").unwrap();
408        let mut file = OpenOptions::new()
409                .write(true)
410                .create(true)
411                .open("ff_launchcommands.txt")
412                .expect("Unable to open file");
413
414        for (extension, commands) in &self.launch_command { // same issue
415            let data = format!("{extension} {commands}\n");
416            file.write(data.as_bytes()).expect("Unable to write to launchcommands");
417        }
418
419        // after waiting, return to directory listing
420        thread::sleep(time::Duration::from_secs(2));
421        std::process::Command::new("clear").status().unwrap();
422        self.launch_commands();
423    }
424
425    fn open_directory(&mut self) {
426        // initialize text input variables
427        let mut ex_endpoint = String::new();
428
429        // initialize execution fields
430        let mut ex_command = String::new(); 
431        let mut ex_filepath = String::new();
432
433        // preparing IO
434        print!("\nPlease enter a file or file number to open...\n> ");
435        io::stdout().flush().expect("Failed to flush");
436        io::stdin()
437            .read_line(&mut ex_endpoint)
438            .expect("Failed to read line");
439
440        let ex_endpoint = ex_endpoint.trim_end();
441
442        // finding extension from ex_endpoint
443        let re_ext = Regex::new(r"(?<ext>\.[[:word:]]+)").unwrap();
444
445        let cap = re_ext.captures(ex_endpoint).and_then(|cap| {
446            cap.name("ext").map(|ext| ext.as_str())
447        });
448        let ex_extension= match cap {
449            Some(ex) => ex,
450            None => "",
451        };
452
453        // finding launch command from extension
454        for (extension, launch_command) in &self.launch_command {
455            if extension == ex_extension {
456                ex_command = launch_command.to_string();
457            }
458        }
459
460        // finding filepath from endpoint
461        for (filepath, endpoint) in &self.directories {
462            if endpoint == ex_endpoint {
463                ex_filepath = filepath.to_string();
464            }
465        }
466
467        // exiting message
468        println!("\nOpening {ex_endpoint} with {ex_command}...");
469        thread::sleep(time::Duration::from_secs(2));
470
471        // execute file opening
472        Command::new(ex_command).arg(ex_filepath).status().expect("Couldn't open file");
473    }
474
475    fn refresh_list(&mut self) {
476        // clears current directories and launch_command hashmaps
477        self.directories = HashMap::new();
478        self.launch_command = HashMap::new();
479
480        // rebuilds from file
481        // note: the use case of this is if the files are changed while the program is running
482        self.build("ff_directories.txt".to_string(), "ff_launchcommands.txt".to_string());
483
484        // after waiting, return to main menu
485        println!("\nRefreshing directories and launch commands from file...");
486        thread::sleep(time::Duration::from_secs(2));
487        std::process::Command::new("clear").status().unwrap();
488        print!("{}", self.print_commands());
489        poll_commands(self);
490
491    }
492
493    // State change functions
494    //
495    // Changes State to main menu
496    pub fn main_menu(&mut self) {
497        self.update("MainMenu");
498        std::process::Command::new("clear").status().unwrap();
499        start_splash();
500        print!("{}", self.print_commands());
501        poll_commands(self);
502    }
503
504    // Changes State to directories
505    pub fn directories(&mut self) {
506        self.update("Directories");
507        std::process::Command::new("clear").status().unwrap();
508
509        // prints directories from state.directories hashmap
510        println!("Listing Saved Directories...");
511
512        let mut count: i32 = 1;
513        for (_filepath, endpoint) in &self.directories {
514            println!("{}. {}", count, endpoint);
515            count += 1;
516        }
517
518        print!("\n{}", self.print_commands());
519        poll_commands(self);
520    }
521
522    // Changes State to directories
523    pub fn launch_commands(&mut self) {
524        self.update("LaunchCommands");
525        std::process::Command::new("clear").status().unwrap();
526
527        // prints directories from state.directories hashmap
528        println!("Listing Saved Opening Actions...");
529
530        let mut count: i32 = 1;
531        for (extension, launchcommand) in &self.launch_command{
532            println!("{}. {} -> {}", count, extension, launchcommand);
533            count += 1;
534        }
535
536        print!("\n{}", self.print_commands());
537        poll_commands(self);
538    }
539
540}
541
542fn read_lines<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
543where P: AsRef<Path>, {
544    let file = File::open(filename)?;
545    Ok(io::BufReader::new(file).lines())
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn generate_state() {
554        let mut state = State::new();
555        assert_eq!(state.menu, "", "testing menu State creation");
556    }
557
558    #[test]
559    fn generate_commands() {
560        let mut state = State::new();
561        assert_eq!(state.commands, "", "testing commands State creation");
562    }
563
564    #[test]
565    fn change_main_menu() {
566        let mut state = State::new();
567        state.directories();
568        state.main_menu();
569        assert_eq!(state.menu, "MainMenu", "testing if fn main_menu() acts correctly");
570    }
571
572    #[test]
573    fn change_directories() {
574        let mut state = State::new();
575        state.main_menu();
576        state.directories();
577        assert_eq!(state.menu, "Directories", "testing if fn directories() acts correctly");
578    }
579
580    #[test]
581    fn display_correct_commands() {
582        let mut state = State::new();
583        state.main_menu();
584        assert_eq!(state.commands,
585                   "[l] - List saved directories\n[o] - Open file\n[n] - New Directory\n[r] - Refresh saved directories\n[d] - Default opening process\n[q] - Quit\n",
586                   "testing if commands are selected correctly")
587    }
588}
589