tagctl 1.2.1

Adds or removes tags to given paths inside square brackets or to extended attributes
Documentation
/*
Copyright (C) 2022  Kody VB
This file is part of Tagctl.

Tagctl is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.

Tagctl is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
details.

You should have received a copy of the GNU General Public License along with
Tagctl. If not, see <https://www.gnu.org/licenses/>.
*/

use clap::{Arg, Command, ValueHint};
use clap_complete::Shell;
use core::panic;
use std::path::PathBuf;

pub fn build() -> Command {
    Command::new("tagctl")
        .about(
            "Adds or removes tags to given paths inside square brackets or to extended attributes",
        )
        .version(env!("CARGO_PKG_VERSION"))
        .after_help("Note: More help information can be found by using --help")
        .after_long_help(
            "Warning:
    If you wish to input file names from the right hand side from an external
    command, i.e. `tagctl -t tag $(ls)`, file names with characters which need
    to be escaped i.e. \"this file.txt\" may appear as multiple files unless
    the external command is surrounded by quotes, as shown below. tagctl
    splits all stdin and right hand side arguments by newline to accommodate
    that.

Examples:
Add tag \"example\" to current directory using file names:
    tagctl -t example \"$(ls)\"
    ls | tagctl --input --tag example

Remove tag \"example\" from current directory using file names:
    tagctl -r --tag=example \"$(ls)\"
    ls | tagctl --remove -it example

Add tag \"example\" to current directory using extended attributes:
    tagctl -xt example \"$(ls)\"
    ls | tagctl --xattr --input --tag example

Remove tag \"example\" from current directory using extended attributes:
    tagctl -xr --tag=example \"$(ls)\"
    ls | tagctl --xattr --remove -it example

Add tag \"example\" to two sets of inputs using file names:
    find /home/user/Documents | tagctl -it \"example\" \"$(ls)\"

",
        )
        .arg(
            Arg::new("tag")
                .short('t')
                .long("tag")
                .required_unless_present_any(["generate_autocomplete", "remove_all"])
                .help(
                    "Tag to add/remove to selected files. \"%p\" uses the parent directory \
        name, \"%y\" uses the modified year, \"%m\" uses modified month, \"%d\" uses \
        modified day, and \"%w\" uses modified weekday",
                ),
        )
        .arg(
            Arg::new("delimiter")
                .short('d')
                .long("delimiter")
                .default_value(",")
                .help("Separator for multiple tags"),
        )
        .arg(
            Arg::new("rhs_in")
                .value_hint(ValueHint::AnyPath)
                .action(clap::ArgAction::Append)
                .help("Files provided from right hand side of program to add/remove tags to"),
        )
        .arg(
            Arg::new("input")
                .short('i')
                .long("input")
                .action(clap::ArgAction::SetTrue)
                .help("Accepts input from stdin"),
        )
        .arg(
            Arg::new("xattr")
                .short('x')
                .long("xattr")
                .conflicts_with("delimiter")
                .action(clap::ArgAction::SetTrue)
                .help("Adds/removes tags via xattr under 'user.xdg.tags'"),
        )
        .arg(
            Arg::new("remove")
                .short('r')
                .long("remove")
                .conflicts_with("remove_all")
                .action(clap::ArgAction::SetTrue)
                .help("Removes tag instead of adding"),
        )
        .arg(
            Arg::new("remove_all")
                .short('R')
                .long("remove_all")
                .action(clap::ArgAction::SetTrue)
                .conflicts_with_all(["remove", "tag"])
                .help("Removes all tags"),
        )
        .arg(
            Arg::new("verbose")
                .short('v')
                .long("verbose")
                .action(clap::ArgAction::SetTrue)
                .help("Increases verbosity of output"),
        )
        .arg(
            Arg::new("generate_autocomplete")
                .short('g')
                .long("generate_autocomplete")
                .num_args(1)
                .ignore_case(true)
                .exclusive(true)
                .value_parser(["bash", "elvish", "fish", "zsh"])
                .help("The shell to generate auto-completion for")
                .required(false),
        )
}

#[derive(Eq, PartialEq)]
pub enum DesiredAction {
    RemoveTag,
    RemoveAllTag,
    AddTag,
    RemoveName,
    RemoveAllName,
    AddName,
    GenerateAutocomplete,
}

pub struct Args {
    pub action: DesiredAction,
    pub input: bool,
    pub verbose: bool,
    pub delimiter: String,
    pub tag: String,
    pub tag_varies: bool,
    pub rhs_in: Vec<String>,
    pub generate_autocomplete: Option<String>,
}

impl Args {
    pub fn get_paths(&self) -> Vec<PathBuf> {
        // Gets valid paths from the rhs_in and stdin strings
        let mut stdin_paths = vec![];
        if self.input {
            let stdin: Vec<String> = get_stdin();
            let stdin_split = split_to_vec(&stdin);
            for each_split in to_paths(&stdin_split) {
                stdin_paths.push(each_split);
            }
        }
        let strings = split_to_vec(&self.rhs_in);
        let mut paths = to_paths(&strings);

        if !stdin_paths.is_empty() {
            for each_path in stdin_paths {
                paths.push(each_path);
            }
        }
        paths
    }

    pub fn autocomplete_created(&self) -> Option<Shell> {
        match &self.generate_autocomplete {
            Some(shell) => match shell.as_ref() {
                "bash" => Some(Shell::Bash),
                "elvish" => Some(Shell::Elvish),
                "fish" => Some(Shell::Fish),
                "zsh" => Some(Shell::Zsh),
                _ => {
                    panic!("Uncaught incorrect shell")
                }
            },
            None => None,
        }
    }
}

pub fn tag_is_variable(tag: &str) -> bool {
    // Checks if the tag has escaped characters (%p) which vary between files
    let special_tags: Vec<&str> = vec!["%p", "%y", "%m", "%d", "%w"];
    for each_tag in special_tags {
        if tag.contains(each_tag) {
            return true;
        }
    }
    false
}

impl From<clap::ArgMatches> for Args {
    fn from(matched_args: clap::ArgMatches) -> Self {
        let mut matched_rhs_in: Vec<String> = Vec::new();
        if matched_args.get_many::<String>("rhs_in").is_some() {
            for each_arg in matched_args.get_many::<String>("rhs_in").unwrap() {
                matched_rhs_in.push(each_arg.to_owned());
            }
        }

        let desired_action = if matched_args.get_flag("xattr") {
            if matched_args.get_flag("remove") {
                DesiredAction::RemoveTag
            } else if matched_args.get_flag("remove_all") {
                DesiredAction::RemoveAllTag
            } else {
                DesiredAction::AddTag
            }
        } else if matched_args
            .get_one::<String>("generate_autocomplete")
            .is_some()
        {
            DesiredAction::GenerateAutocomplete
        } else if matched_args.get_flag("remove") {
            DesiredAction::RemoveName
        } else if matched_args.get_flag("remove_all") {
            DesiredAction::RemoveAllName
        } else {
            DesiredAction::AddName
        };

        let tag = matched_args
            .get_one::<String>("tag")
            .unwrap_or(&String::new())
            .to_owned();

        let args = Args {
            action: desired_action,
            input: matched_args.get_flag("input"),
            verbose: matched_args.get_flag("verbose"),
            delimiter: matched_args
                .get_one::<String>("delimiter")
                .unwrap()
                .to_owned(),
            tag: tag.clone(),
            tag_varies: tag_is_variable(&tag),
            rhs_in: matched_rhs_in,
            generate_autocomplete: matched_args
                .get_one::<String>("generate_autocomplete")
                .cloned(),
        };
        args
    }
}

fn split_to_vec(string_list: &[String]) -> Vec<String> {
    // Splits a vector of strings which may contain new lines into a vector of split strings
    let mut split_string_list = vec![];
    for each_string in string_list {
        for each_split in each_string.split('\n') {
            split_string_list.push(each_split.to_owned());
        }
    }
    split_string_list
}

fn get_stdin() -> Vec<String> {
    // Parses the stdin arguments via newlines
    let mut stdin_vec = vec![];
    loop {
        let mut input = String::new();
        match std::io::stdin().read_line(&mut input) {
            Ok(len) => {
                if len == 0 {
                    break;
                }
                stdin_vec.push(input);
            }
            Err(error) => {
                eprintln!("error: {error}");
                break;
            }
        }
    }
    stdin_vec
}

fn to_paths(string_list: &[String]) -> Vec<PathBuf> {
    // Converts a vector of strings to a vector of PathBufs which have files that exist
    let mut path_list = vec![];
    for each_string in string_list {
        let each_path = PathBuf::from(each_string)
            .canonicalize()
            .unwrap_or_else(|_| PathBuf::from(""));
        if each_path.is_file() {
            path_list.push(each_path);
        } else if each_path.to_str().unwrap() != "" {
            eprintln!(
                "Couldn't find expected file: \"{}\"",
                each_path.to_str().unwrap()
            );
        }
    }
    path_list
}