fm/io/
plugin_management.rs

1use std::{
2    borrow::Cow,
3    collections::BTreeMap,
4    fs::File,
5    path::{Path, PathBuf},
6    process::exit,
7};
8
9use serde_yaml_ng::{from_reader, from_value, Value};
10use url::Url;
11
12use crate::common::{tilde, CONFIG_PATH, PLUGIN_LIBSO_PATH, REPOSITORIES_PATH};
13use crate::io::execute_and_capture_output_with_path;
14
15/// Install a plugin from its github repository.
16/// It can also be used to update a plugin if its repository was updated.
17///
18/// # Usage
19///
20/// `fmconfig plugin install qkzk/bat_previewer`
21///
22/// # Steps
23///
24/// It will successively perform:
25/// 1. parse the author and plugin name from `author_plugin`, spliting it at the first /
26/// 2. build the repository address which is a temporary folder located at  [`crate::common::REPOSITORIES_PATH`]
27/// 3. clone the repository in the temporary folder
28/// 4. build the release libso file of the plugin
29/// 5. copy the libso file to its destination [`crate::common::PLUGIN_LIBSO_PATH`] and check the result of compilation
30/// 6. add the plugin to the config file in [`crate::common::CONFIG_PATH`]
31/// 7. remove the repository folder from temporary files
32///
33/// # Failure
34///
35/// If any step fails (except adding the plugin to config file), it exits with an error printed to stderr.
36pub fn install_plugin(url: &str) {
37    println!("Installing {url} from its github repository");
38    // 1. Parse author & plugin name
39    let (hostname, author, plugin) = parse_hostname_author_plugin(url);
40    // 2. build repository address
41    let repositories_path = tilde(REPOSITORIES_PATH);
42    let repositories_path = Path::new(repositories_path.as_ref());
43    // 3. Create clone dir
44    create_clone_directory(repositories_path);
45    // 4. git clone
46    clone_repository(hostname, repositories_path, &author, &plugin);
47    // 5. build release target
48    let plugin_path = cargo_build_release(repositories_path, &plugin);
49    // 6. check compilation process
50    let Some(libso_path) = find_compiled_target(plugin_path, &plugin) else {
51        remove_repo_directory().expect("Couldn't delete plugin repository");
52        exit(6);
53    };
54    if _add_plugin(&libso_path) {
55        // 7. remove plugin repository from ~/.local/share/fm/
56        remove_repo_directory().expect("Couldn't delete plugin repository");
57        println!("Installation done");
58    }
59}
60
61/// Split the given url
62///
63/// # Failure
64/// prints error to stderr and exits with
65/// - error code 2 if the url can't be parsed.
66fn parse_hostname_author_plugin(url: &str) -> (String, String, String) {
67    let nb_slashes = url.chars().filter(|c| *c == '/').count();
68
69    let mut author_plugin = url.to_string();
70    let hostname: String;
71    if nb_slashes == 1 {
72        // "qkzk/bat_previewer"; // 1
73        println!("No host provided, using github.com");
74        hostname = "github.com".to_string();
75    } else if nb_slashes == 4 {
76        // "https://github.com/qkzk/bat_previewer"; // 4
77        hostname = match Url::parse(&author_plugin) {
78            Ok(url) => {
79                if url.cannot_be_a_base() {
80                    eprintln!("{author_plugin} isn't a valid url.");
81                    exit(2);
82                } else {
83                    let Some(hostname) = url.host_str() else {
84                        eprintln!("{author_plugin} has no hostname");
85                        exit(2);
86                    };
87                    author_plugin = url.path().to_string().chars().skip(1).collect();
88                    hostname.to_string()
89                }
90            }
91            Err(_) => {
92                eprintln!("Cannot parse {author_plugin} as an url.");
93                exit(2);
94            }
95        };
96    } else {
97        eprintln!("Can't parse {url}. It should have 1 or 4 '/' like \"qkzk/bat_previewer\" or \"https://github.com/qkzk/bat_previewer\"");
98        exit(2);
99    }
100
101    let mut split = author_plugin.split('/');
102    let Some(author) = split.next() else {
103        eprintln!(
104            "Error installing plugin {author_plugin} isn't valid. Please use author/plugin format."
105        );
106        exit(2);
107    };
108    let Some(plugin) = split.next() else {
109        eprintln!(
110            "Error installing plugin {author_plugin} isn't valid. Please use author/plugin format."
111        );
112        exit(2);
113    };
114    (hostname, author.to_string(), plugin.to_string())
115}
116
117/// Creates the [`REPOSITORIES_PATH`] directory
118///
119/// # Failure
120/// Exits with error code 3 if the directory can't be created.
121fn create_clone_directory(repositories_path: &Path) {
122    match std::fs::create_dir_all(repositories_path) {
123        Ok(()) => println!(
124            "- Created {repositories_path}",
125            repositories_path = repositories_path.display()
126        ),
127        Err(error) => {
128            eprintln!("Error creating directories for repostories: {error:?}");
129            exit(3);
130        }
131    }
132}
133
134/// Clone the plugin repository.
135/// Executes "git clone --depth 1 git@github.com:{author_plugin}.git" from [`crate::common::REPOSITORIES_PATH`]
136///
137/// # Failure
138/// Exits with error code 4 if the clone failed, printing the error to stderr.
139fn clone_repository(hostname: String, plugin_repositories: &Path, author: &str, plugin: &str) {
140    let args = [
141        "clone",
142        "--depth",
143        "1",
144        &format!("git@{hostname}:{author}/{plugin}.git"),
145    ];
146    let output = execute_and_capture_output_with_path("git", plugin_repositories, &args);
147    match output {
148        Ok(stdout) => println!("- Cloned {author}/{plugin} git repository - {stdout}"),
149        Err(stderr) => {
150            eprintln!("Error cloning the repository :");
151            eprintln!("{}", stderr);
152            let _ = remove_repo_directory();
153            exit(4);
154        }
155    }
156}
157
158/// Build the libso file.
159/// Executes `cargo build --release` from `plugin_path` which should be a subdirectory of `/tmp/fm/repositories/` called `plugin`.
160/// Returns the builded libso file path.
161///
162/// # Failure
163/// Exits with error code 5 if the compilation failed.
164fn cargo_build_release(plugin_path: &Path, plugin: &str) -> PathBuf {
165    // 4. cargo build --release
166    let args = ["build", "--release"];
167    let mut plugin_path = plugin_path.to_path_buf();
168    plugin_path.push(plugin);
169    let output = execute_and_capture_output_with_path("cargo", &plugin_path, &args);
170    match output {
171        Ok(stdout) => {
172            println!("- Compiled plugin {plugin} libso file");
173            if !stdout.is_empty() {
174                println!("- {stdout}")
175            }
176        }
177        Err(stderr) => {
178            eprintln!("Error compiling the plugin :");
179            eprintln!("{}", stderr);
180            remove_repo_directory().expect("Couldn't delete plugin repository");
181            exit(5);
182        }
183    }
184    plugin_path
185}
186
187/// Find the compilation target from plugin_path and returns its full path.
188fn find_compiled_target(mut plugin_path: PathBuf, plugin: &str) -> Option<PathBuf> {
189    let ext = format!("target/release/lib{plugin}.so");
190    plugin_path.push(ext);
191    if plugin_path.exists() {
192        Some(plugin_path)
193    } else {
194        None
195    }
196}
197
198/// Add a plugin from a libso file path.
199///
200/// # Steps
201/// 1. copy the plugin to [`crate::common::PLUGIN_LIBSO_PATH`].
202/// 2. add "`plugin_name: libso_file_path`" in config file, as first element.
203///
204/// # Failure
205///
206/// Warn the user and exit the process with various error codes if any error occurs.
207///
208/// It should never crash.
209pub fn add_plugin<P>(path: P)
210where
211    P: AsRef<Path>,
212{
213    println!("Installing {path}...", path = path.as_ref().display());
214    if _add_plugin(&path) {
215        println!(
216            "Plugin {path} added to configuration file.",
217            path = path.as_ref().display()
218        );
219    } else {
220        eprintln!(
221            "Something went wrong installing {path}.",
222            path = path.as_ref().display()
223        );
224        exit(1);
225    }
226}
227
228/// Internal adding a plugin.
229/// This functions exists to allow more precised messages.
230/// All the work is done here.
231/// See [crate::io::add_plugin] for more details.
232pub fn _add_plugin<P>(path: P) -> bool
233where
234    P: AsRef<Path>,
235{
236    let source = path.as_ref();
237    if !source.exists() {
238        eprintln!(
239            "Error installing plugin {path}. File doesn't exist.",
240            path = path.as_ref().display()
241        );
242        exit(1);
243    }
244    let dest = build_libso_destination_path(source);
245    copy_source_to_dest(source, &dest);
246    println!("- Copied libso file to {dest}", dest = dest.display());
247    let plugin_name = get_plugin_name(source);
248    add_to_config(&plugin_name, &dest);
249    println!(
250        "- Added {plugin_name}: {dest} to config file.",
251        dest = dest.display()
252    );
253    true
254}
255
256/// Build the destination filepath from the source libso file.
257/// It will be located in [`crate::common::PLUGIN_LIBSO_PATH`].
258///
259/// # Failure
260/// Exists with error code 1 if the path doesn't exist.
261fn build_libso_destination_path(source: &Path) -> PathBuf {
262    let mut dest = PathBuf::from(tilde(PLUGIN_LIBSO_PATH).as_ref());
263    if let Err(error) = std::fs::create_dir_all(&dest) {
264        eprintln!("Couldn't create {PLUGIN_LIBSO_PATH}");
265        eprintln!("Error: {error:?}");
266        exit(1);
267    };
268
269    let Some(filename) = source.file_name() else {
270        eprintln!("Error: couldn't extract filename");
271        exit(1);
272    };
273    dest.push(filename);
274    dest
275}
276
277/// Copy the libso file to its destination.
278///
279/// # Failure
280/// Exists with error code 1 if the file can't be copied.
281fn copy_source_to_dest(source: &Path, dest: &Path) {
282    if let Err(error) = std::fs::copy(source, dest) {
283        eprintln!("Error copying the libsofile: {error}");
284        exit(1);
285    }
286}
287
288/// Get the plugin name from ist libso file path.
289/// ~/.local/shared/fm/plugins/libbat_previewer.so -> bat_previewer.
290///
291/// # Failure
292/// It will crash if `source` doesn't start with `"lib"` or doesn't end with `".so"`.
293fn get_plugin_name(source: &Path) -> String {
294    let filename = source.file_name().expect("source should have a filename");
295    let mut plugin_name = filename.to_string_lossy().to_string();
296    if plugin_name.starts_with("lib") {
297        plugin_name = plugin_name
298            .strip_prefix("lib")
299            .expect("Should start with lib")
300            .to_owned();
301    }
302    if plugin_name.ends_with(".so") {
303        plugin_name = plugin_name
304            .strip_suffix(".so")
305            .expect("Should end with .so")
306            .to_owned();
307    }
308    plugin_name
309}
310
311/// Remove a plugin by its name.
312/// Plugin lib.so file will be deleted and removed from config file
313///
314/// # Steps
315///
316/// 1. remove the repository folder if it exists
317/// 2. remove the libso file from [`crate::common::REPOSITORIES_PATH`]
318/// 3. remove the `plugin_name: plugin_libso_path` from [`crate::common::CONFIG_PATH`]
319///
320/// # Failure
321///
322/// If something went wrong it will exit with an error message printed to stderr.
323pub fn remove_plugin(removed_name: &str) {
324    let _ = remove_repo_directory();
325    remove_libso_file(removed_name);
326    remove_lib_from_config(&config_path(), removed_name);
327}
328
329/// Remove the repository folder located at [`crate::common::REPOSITORIES_PATH`].
330///
331/// # Failure
332/// Exits with code 2 if the repository couldn't be removed.
333fn remove_repo_directory() -> std::io::Result<()> {
334    match std::fs::remove_dir_all(REPOSITORIES_PATH) {
335        Ok(()) => {
336            println!("- Removed repository");
337            Ok(())
338        }
339        Err(err) => {
340            eprintln!("Coudln't remove repository: {err:?}",);
341            Err(err)
342        }
343    }
344}
345
346/// Remove the libso file of `removed_name`.
347/// If the plugin was installed with `fmconfig plugin install author/plugin` it should be located at `~/.local/share/fm/plugins/lib{removed_name}.so`.
348///
349/// # Failure
350/// Exits with code 2 if the file couldn't be removed.
351fn remove_libso_file(removed_name: &str) {
352    let mut found_in_config = false;
353    for (installed_name, path, exist) in list_plugins_details().iter() {
354        if installed_name == removed_name && *exist {
355            found_in_config = true;
356            match std::fs::remove_file(path) {
357                Ok(()) => println!("Removed {path}"),
358                Err(e) => eprintln!("Couldn't remove {path}: {e:?}"),
359            };
360        }
361    }
362    if !found_in_config {
363        eprintln!("Didn't find {removed_name} in config file. Run `fm plugin list` to see installed plugins.");
364        exit(1);
365    }
366}
367
368/// List all installed plugins referenced in `[crate::common::CONFIG_PATH]`
369/// It will print a list of `status`, plugin name and libso paths to stdout.
370///
371/// `status` can either be:
372/// - `ok` if the libso file exists or
373/// - `??` if it doesn't.
374///
375/// # Warning
376///
377/// It doesn't check if the plugin works, only the existence of its libso file.
378///
379/// # Error
380///
381/// It may fail if the config file isn't formated properly.
382pub fn list_plugins() {
383    println!("Installed plugins:");
384    for (name, path, exist) in list_plugins_details().iter() {
385        let exists = if *exist { "ok" } else { "??" };
386        println!("[{exists}]: {name}: {path}");
387    }
388}
389
390/// Returns a vector of  `(name, path, existance)` for each referenced plugin in config file.
391fn list_plugins_details() -> Vec<(String, String, bool)> {
392    let config_file = File::open(config_path().as_ref()).expect("Couldn't open config file");
393    let mut installed = vec![];
394
395    let config_values: Value =
396        from_reader(&config_file).expect("Couldn't read config file as yaml");
397    let plugins = &config_values["plugins"]["previewer"];
398    let Ok(dmap) = from_value::<BTreeMap<String, String>>(plugins.to_owned()) else {
399        return vec![];
400    };
401    for (plugin, path) in dmap.into_iter() {
402        let exists = Path::new(&path).exists();
403        installed.push((plugin, path, exists))
404    }
405    installed
406}
407
408/// Build the config_file path from [`crate::common::CONFIG_PATH`].
409fn config_path() -> Cow<'static, str> {
410    tilde(CONFIG_PATH)
411}
412
413/// Add the plugin to config file. Does nothing if the plugin is already there.
414fn add_to_config(plugin_name: &str, dest: &Path) {
415    let config_path = config_path();
416    if is_plugin_name_in_config(&config_path, plugin_name) {
417        println!("- Config file {config_path} already contains a plugin called \"{plugin_name}\"");
418        return;
419    }
420    add_lib_to_config(&config_path, plugin_name, dest);
421}
422
423/// True iff the plugin is referenced in the config file.
424fn is_plugin_name_in_config(config_path: &str, plugin_name: &str) -> bool {
425    let config_file = File::open(config_path).expect("Couldn't open config file");
426    let config_values: Value =
427        from_reader(&config_file).expect("Couldn't read config file as yaml");
428    let plugins = &config_values["plugins"]["previewer"];
429    let Ok(dmap) = from_value::<BTreeMap<String, String>>(plugins.to_owned()) else {
430        return false;
431    };
432    dmap.contains_key(plugin_name)
433}
434
435/// Writes the config file to the config file.
436/// Expects the file to not have the plugin name already.
437///
438/// # Failure
439/// Will crash if the config can't be read
440/// or if it can't be written (error code 1),
441/// or if the `plugin:previewer:` part can't be found (error code 2).
442fn add_lib_to_config(config_path: &str, plugin_name: &str, dest: &Path) {
443    let mut lines = extract_config_lines(config_path);
444    let new_line = format!("    '{plugin_name}': \"{d}\"", d = dest.display());
445
446    complete_lines_with_required_parts(&mut lines, new_line);
447
448    let new_content = lines.join("\n");
449    if let Err(e) = std::fs::write(config_path, new_content) {
450        eprintln!("Error installing {plugin_name}. Couldn't write to config file: {e:?}");
451        exit(1);
452    }
453}
454
455/// Ensures new plugins are inserted AFTER the `plugins:previewer:` mapping.
456/// If no such mapping is found in configuration, we add it at the end of the file before inserting the new plugin.
457/// We only cover for the cases where:
458/// - `plugins:previewer:` doesn't contain the plugin,
459/// - `plugins:previewer:` is empty,
460/// - `plugins:` is empty,
461/// - `there's no plugins:` in config file.
462///
463/// So, the strange case where previewer: is present and plugin: isn't covered.
464fn complete_lines_with_required_parts(lines: &mut Vec<String>, new_line: String) {
465    match find_dest_index(lines) {
466        Some(index) => {
467            if index >= lines.len() {
468                lines.push(new_line)
469            } else {
470                lines.insert(index, new_line)
471            }
472        }
473        None => {
474            if lines.iter().all(|s| s != "plugins:") {
475                lines.push("plugins:".to_string());
476            }
477            lines.push("  previewer:".to_string());
478            lines.push(new_line);
479        }
480    }
481}
482
483/// Read the config and returns its content as a vector of lines.
484/// Expect `config_path` to be an expanded full path like `/home/user/.config/fm` and not `~/.config.fm`.
485fn extract_config_lines(config_path: &str) -> Vec<String> {
486    let config_content = std::fs::read_to_string(config_path).expect("Couldn't read config file");
487    config_content
488        .lines()
489        .map(|line| line.to_string())
490        .collect()
491}
492
493/// Returns the index of the `plugin: previewer:` section for its content.
494fn find_dest_index(lines: &[String]) -> Option<usize> {
495    // println!("{least_before}", least_before = lines[lines.len() - 2]);
496    // println!("{least}", least = lines[lines.len() - 1]);
497    for (plugin_index, line) in lines.iter().enumerate() {
498        if line.starts_with("plugins:") {
499            for (previewer_index, line) in lines.iter().enumerate().skip(plugin_index) {
500                if line.starts_with("  previewer:") {
501                    return Some(previewer_index + 1);
502                }
503            }
504            break;
505        }
506    }
507    None
508}
509
510/// Remove the libso file from the config file.
511/// Expect `config_path` to be an expanded full path like `/home/user/.config/fm` and not `~/.config.fm`.
512///
513/// # Failure
514/// Will crash if the config can't be read
515/// or if it can't be written (error code 1),
516/// or if the `plugin:previewer:` part can't be found (error code 2).
517fn remove_lib_from_config(config_path: &str, plugin_name: &str) {
518    let config_content = std::fs::read_to_string(config_path).expect("Couldn't read config file");
519    let mut lines: Vec<_> = config_content.lines().map(|l| l.to_string()).collect();
520    for index in 0..lines.len() {
521        let line = &lines[index];
522        if line.starts_with(&format!("    '{plugin_name}': ",)) {
523            lines.remove(index);
524            break;
525        }
526    }
527    let new_content = lines.join("\n");
528    match std::fs::write(config_path, new_content) {
529        Ok(()) => println!("Removed {plugin_name} from config file"),
530        Err(e) => {
531            eprintln!("Error removing {plugin_name}. Couldn't write to config file: {e:?}");
532            exit(1);
533        }
534    }
535}