use std::process::Command;
use std::fs;
use std::path::Path;
use inquire::{Select, Text, Autocomplete, validator::Validation, CustomUserError, ui::{Color, RenderConfig, StyleSheet}};
#[derive(Clone, Default)]
struct LivePathCompleter;
impl Autocomplete for LivePathCompleter {
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, CustomUserError> {
let expanded_input = shellexpand::tilde(input).into_owned();
let input_path = Path::new(&expanded_input);
let (dir_scan, filename_prefix) = if input.ends_with('/') || input.is_empty() {
(input_path, "")
} else {
(
input_path.parent().unwrap_or_else(|| Path::new(".")),
input_path.file_name().and_then(|n| n.to_str()).unwrap_or(""),
)
};
let mut search_dir = dir_scan;
if search_dir.as_os_str().is_empty() {
search_dir = Path::new(".");
}
let entries = match fs::read_dir(search_dir) {
Ok(e) => e,
Err(_) => return Ok(vec![]),
};
let mut suggestions = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let name_str = entry.file_name().to_string_lossy().into_owned();
let mut path_str = if input.starts_with("~") {
if let Some(last_slash_idx) = input.rfind("/") {
let mut base = input[..last_slash_idx + 1].to_string();
base.push_str(&name_str);
base
} else {
format!("/home/{}", name_str)
}
} else {
path.to_string_lossy().into_owned()
};
if !name_str.starts_with(filename_prefix) {
continue;
}
if path_str.starts_with("./") && !input.starts_with("./") {
path_str = path_str.replacen("./", "", 1);
}
if path.is_dir() && !path_str.ends_with("/") {
path_str.push_str("/");
}
suggestions.push(path_str);
}
suggestions.sort();
Ok(suggestions)
}
fn get_completion(&mut self,
_input: &str,
highlighted_suggestion: Option<String>
) -> Result<inquire::autocompletion::Replacement, CustomUserError> {
match highlighted_suggestion {
Some(suggestion) => Ok(inquire::autocompletion::Replacement::Some(suggestion)),
None => Ok(inquire::autocompletion::Replacement::None),
}
}
}
fn main() {
println!("\n-------------------------");
println!("----- Merge PDF App -----");
println!("-------------------------\n");
println!("Select your PDFs. Type 'c' and hit 'Enter' to confirm when you are done collecting.\n");
println!("Hit 'Esc' to exit tool\n");
setup_theme();
let file_paths: Vec<String> = match get_file_names() {
Some(paths) => paths,
None => return,
};
let output_name: String = match prompt_output_name() {
Some(name) => name,
None => return,
};
execute_merge(output_name, file_paths);
}
fn setup_theme() {
let mut custom_theme = RenderConfig::default();
custom_theme.selected_option = Some(
StyleSheet::new().with_bg(Color::Rgb {r: 25, g: 75, b: 0})
);
inquire::set_global_render_config(custom_theme);
}
fn get_file_names() -> Option<Vec<String>> {
let mut file_paths: Vec<String> = Vec::new();
let mut file_counter = 1;
loop {
let prompt_msg = format!("Select PDF file #{}: ", file_counter);
let path_input = Text::new(&prompt_msg)
.with_placeholder("Type file path or press 'c' to confirm list ...")
.with_autocomplete(LivePathCompleter)
.with_validator(|input: &str| {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(Validation::Invalid("Filepath cannot be an empty string!".into()))
}
if trimmed.eq_ignore_ascii_case("c") {
return Ok(Validation::Valid)
}
if !trimmed.ends_with(".pdf") {
return Ok(Validation::Invalid("Filename must end with '.pdf'".into()))
}
let expanded_path = shellexpand::tilde(trimmed).into_owned();
if !Path::new(&expanded_path).exists() {
return Ok(Validation::Invalid("This file does not exist in your system.".into()))
}
Ok(Validation::Valid)
})
.prompt();
match path_input {
Ok(confirmed_path) => {
let trimmed = confirmed_path.trim();
if trimmed.eq_ignore_ascii_case("c") {
if file_paths.len() >= 2 {
println!("-> Files confirmed! Moving to merge step.\n");
break;
} else {
println!("WARNING: You need to select atleast 2 files before the merge!\n");
continue;
}
}
let expanded = shellexpand::tilde(&trimmed).into_owned();
file_paths.push(expanded.to_string());
println!("\n Staged Files:");
for (index, path) in file_paths.iter().enumerate() {
let path_obj = Path::new(path);
let filename = path_obj.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.clone());
println!(" [{}] {}", index + 1, filename);
}
println!();
file_counter += 1;
}
Err(_) => {
println!("Prompt cancelled. Exiting ...");
return None
}
}
}
Some(file_paths)
}
fn prompt_output_name() -> Option<String> {
let merge_name = Text::new("Enter the output PDF name:")
.with_default("merged.pdf")
.with_placeholder("merged.pdf")
.with_validator(|input: &str| {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(Validation::Invalid("Filename cannot be empty.".into()))
}
if !trimmed.to_lowercase().ends_with(".pdf") {
return Ok(Validation::Invalid("Filename must end with '.pdf'".into()))
}
Ok(Validation::Valid)
})
.prompt();
match merge_name {
Ok(name) => {
println!("Output filename is set to: {}\n", name);
Some(name)
},
Err(_) => {
println!("Prompt cancelled.\n");
None
}
}
}
fn execute_merge(output_name: String, file_paths: Vec<String>) {
let options = vec!["Proceed with Merge", "Cancel and Exit"];
let confirmation = Select::new("Ready to finalise?", options)
.with_help_message("Use arrow keys to select")
.prompt();
match confirmation {
Ok("Proceed with Merge") => {
println!("Executing PDF merge ...");
let merge_cmd = Command::new("gs")
.args(["-dBATCH", "-dNOPAUSE", "-q", "-sDEVICE=pdfwrite"])
.arg(format!("-sOutputFile={}", output_name))
.args(&file_paths)
.output()
.expect("ERROR: Failed to execute");
let _stdout = String::from_utf8_lossy(&merge_cmd.stdout);
let stderr = String::from_utf8_lossy(&merge_cmd.stderr);
if merge_cmd.status.success() {
println!("Created the merged PDF: {}", output_name);
} else {
println!("ERROR: {}", stderr);
}
}
Ok("Cancel and Exit") => {
println!("Merge cancelled by user. Aborting ...");
}
Err(_) => {
println!("Prompt interrupted with Ctrl+C. Exiting ...");
}
_ => unreachable!(),
}
}