edmv 1.0.1

Bulk rename files using your favorite editor
use {
  anyhow::{anyhow, bail},
  clap::Parser,
  path_absolutize::*,
  std::{
    collections::HashMap,
    env, fs,
    io::Write,
    path::{Path, PathBuf},
    process::{self, Command},
  },
  tempfile::{Builder, NamedTempFile, TempDir},
};

#[derive(Debug)]
enum Intermediate {
  Directory(TempDir),
  File(NamedTempFile),
}

impl TryFrom<PathBuf> for Intermediate {
  type Error = anyhow::Error;

  fn try_from(path: PathBuf) -> Result<Self> {
    Ok(if path.is_file() {
      Intermediate::File(NamedTempFile::new()?)
    } else {
      Intermediate::Directory(TempDir::new()?)
    })
  }
}

impl Intermediate {
  fn path(&self) -> &Path {
    match self {
      Intermediate::File(file) => file.path(),
      Intermediate::Directory(dir) => dir.path(),
    }
  }
}

trait PathBufExt {
  fn to_string(&self) -> Result<String>;
  fn with(&self, source: &Path) -> Self;
}

impl PathBufExt for PathBuf {
  fn to_string(&self) -> Result<String> {
    Ok(
      self
        .to_str()
        .ok_or(anyhow!("Failed to convert path to string"))?
        .to_string(),
    )
  }

  fn with(&self, source: &Path) -> Self {
    if self.is_dir() {
      self.join(source)
    } else {
      self.clone()
    }
  }
}

#[derive(Debug, Parser)]
#[command(about, author, version)]
struct Arguments {
  #[clap(long, help = "Run without making any changes")]
  dry_run: bool,
  #[clap(long, help = "Editor command to use")]
  editor: Option<String>,
  #[clap(long, help = "Overwrite existing files")]
  force: bool,
  #[clap(long, help = "Resolve conflicting renames")]
  resolve: bool,
  #[clap(name = "sources", help = "Paths to edit")]
  sources: Vec<String>,
}

impl Arguments {
  fn run(self) -> Result {
    let editor = self.editor.unwrap_or(
      env::var("EDMV_EDITOR")
        .unwrap_or(env::var("EDITOR").unwrap_or("vi".to_string())),
    );

    let absent = self
      .sources
      .clone()
      .into_iter()
      .filter(|path| fs::metadata(path).is_err())
      .collect::<Vec<String>>();

    if !absent.is_empty() {
      bail!("Found non-existent path(s): {}", absent.join(", "));
    }

    let mut file = Builder::new()
      .prefix(&format!("{}-", env!("CARGO_PKG_NAME")))
      .suffix(".txt")
      .tempfile()?;

    writeln!(file, "{}", &self.sources.join("\n"))?;

    let status = Command::new(editor).arg(file.path()).status()?;

    if !status.success() {
      bail!("Failed to open temporary file in editor");
    }

    let destinations = fs::read_to_string(file.path())?
      .trim()
      .lines()
      .map(str::to_string)
      .collect::<Vec<String>>();

    if self.sources.len() != destinations.len() {
      bail!(
        "Destination count mismatch, should be {} but received {}",
        self.sources.len(),
        destinations.len()
      );
    }

    let pairs = self
      .sources
      .iter()
      .zip(destinations.iter())
      .filter(|(source, destination)| source != destination)
      .map(|(source, destination)| {
        (PathBuf::from(source), PathBuf::from(destination))
      })
      .collect::<Vec<(PathBuf, PathBuf)>>();

    let mut duplicates = pairs
      .iter()
      .fold(HashMap::new(), |mut acc, (_, v)| {
        *acc.entry(v).or_insert(0) += 1;
        acc
      })
      .into_iter()
      .filter(|&(_, count)| count > 1)
      .collect::<Vec<_>>();

    duplicates.sort();

    if !duplicates.is_empty() {
      bail!(
        "Found duplicate destination(s): {}",
        duplicates
          .iter()
          .map(|(path, _)| path.to_string())
          .collect::<Result<Vec<String>>>()?
          .join(", ")
      );
    }

    let existing = pairs
      .iter()
      .filter(|(_, destination)| fs::metadata(destination).is_ok())
      .map(|(_, destination)| destination.display().to_string())
      .collect::<Vec<_>>();

    if !self.force && !existing.is_empty() {
      bail!(
        "Found destination(s) that already exist: {}, use --force to overwrite",
        existing.join(", ")
      );
    }

    let map = pairs.iter().cloned().collect::<HashMap<PathBuf, PathBuf>>();

    let mut conflicting = map
      .iter()
      .filter(|(_, destination)| map.contains_key(destination.to_owned()))
      .map(|(source, destination)| {
        format!("{} -> {}", source.display(), destination.display())
      })
      .collect::<Vec<String>>();

    conflicting.sort();

    if !conflicting.is_empty() && !self.resolve {
      bail!(
        "Found conflicting operation(s): {}, use --resolve to properly handle the conflicts",
        conflicting.join(", ")
      );
    }

    let dir_to_file = pairs
      .iter()
      .filter(|(source, destination)| source.is_dir() && destination.is_file())
      .map(|(source, destination)| {
        format!("{} -> {}", source.display(), destination.display())
      })
      .collect::<Vec<_>>();

    if !dir_to_file.is_empty() {
      bail!(
        "Found directory to file operation(s): {}",
        dir_to_file.join(", ")
      );
    }

    let absolutes = pairs
      .iter()
      .map(|(_, destination)| {
        destination.absolutize().map_err(anyhow::Error::from)
      })
      .collect::<Result<Vec<_>>>()?;

    let absent = absolutes
      .iter()
      .zip(destinations.iter())
      .filter_map(|(path, destination)| {
        path
          .parent()
          .filter(|parent| !parent.exists())
          .map(|_| destination.clone())
      })
      .collect::<Vec<String>>();

    if !absent.is_empty() {
      bail!(
        "Found destination(s) placed within a non-existent directory: {}",
        absent.join(", ")
      );
    }

    let mut changed = 0;

    let intermediates = self.resolve.then_some(
      self
        .sources
        .iter()
        .map(|path| Intermediate::try_from(PathBuf::from(path)))
        .collect::<Result<Vec<_>>>()?,
    );

    let transform = |input: Vec<Vec<PathBuf>>| -> Vec<Vec<(PathBuf, PathBuf)>> {
      (0..input.iter().map(|inner| inner.len() - 1).min().unwrap_or(0))
        .map(|i| {
          input
            .iter()
            .filter_map(|inner| inner.windows(2).nth(i))
            .map(|chunk| (chunk[0].clone(), chunk[1].clone()))
            .collect()
        })
        .collect()
    };

    let mut rename = |pipeline: Vec<Vec<(PathBuf, PathBuf)>>| -> Result {
      let first = pipeline.first().unwrap_or(&Vec::new()).clone();

      pipeline.iter().enumerate().try_for_each(|(i, stage)| {
        stage
          .iter()
          .enumerate()
          .try_for_each(|(j, (source, destination))| {
            let destination = destination.with(source);

            if !self.dry_run {
              fs::rename(source, &destination)?;
            }

            if i == pipeline.len() - 1 && j < first.len() {
              println!("{} -> {}", first[j].0.display(), destination.display());
              changed += usize::from(!self.dry_run);
            }

            Ok(())
          })
      })
    };

    match intermediates {
      Some(intermediates) => rename(transform(
        pairs
          .into_iter()
          .zip(intermediates.iter())
          .map(|((source, destination), intermediate)| {
            vec![source, intermediate.path().to_path_buf(), destination]
          })
          .collect(),
      ))?,
      None => rename(transform(
        pairs
          .into_iter()
          .map(|(source, destination)| vec![source, destination])
          .collect(),
      ))?,
    }

    println!("{changed} path(s) changed",);

    Ok(())
  }
}

type Result<T = (), E = anyhow::Error> = std::result::Result<T, E>;

fn main() {
  if let Err(error) = Arguments::parse().run() {
    eprintln!("error: {error}");
    process::exit(1);
  }
}