fif 0.6.0

A command-line tool for detecting and optionally correcting files with incorrect extensions.
Documentation
// SPDX-FileCopyrightText: 2021-2022 Lynnesbian
// SPDX-License-Identifier: GPL-3.0-or-later

//! Logic for handling the various output formats that fif can output to.

#![allow(missing_copy_implementations)]

use std::ffi::OsStr;
use std::io::{self, Write};
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::Path;

use cfg_if::cfg_if;
use snailquote::escape;

use crate::findings::ScanError;
use crate::utils::CLAP_LONG_VERSION;
use crate::Findings;
use crate::String;

/// A macro for creating an array of [`Writable`]s without needing to pepper your code with `into()`s.
/// # Usage
/// ```
/// use crate::fif::writables;
/// use crate::fif::formats::{Writable, smart_write};
/// let mut f = std::io::stdout();
///
/// // Instead of...
/// smart_write(&mut f, &["hello".into(), Writable::Newline]);
/// // ...just use:
/// smart_write(&mut f, writables!["hello", Newline]);
/// ```

#[macro_export]
macro_rules! writables {
	[$($args:tt),+] => {
		&[$(writables!(@do $args),)*]
	};

	(@do Newline) => {
		$crate::formats::Writable::Newline
	};

	(@do $arg:expr) => {
		$arg.into()
	}
}

#[macro_export]
/// Does the same thing as [`writables`], but adds a Newline to the end.
macro_rules! writablesln {
	[$($args:tt),+] => {
		&[$(writables!(@do $args),)* writables!(@do Newline)]
	};
}

#[derive(Debug, PartialEq, Eq)]
pub enum Writable<'a> {
	String(&'a str),
	Path(&'a Path),
	Newline,
}

// the lifetime of a lifetime
impl<'a> From<&'a str> for Writable<'a> {
	fn from(s: &'a str) -> Writable<'a> { Writable::String(s) }
}

impl<'a> From<&'a Path> for Writable<'a> {
	fn from(p: &'a Path) -> Writable<'a> { Writable::Path(p) }
}

impl<'a> From<&'a OsStr> for Writable<'a> {
	fn from(p: &'a OsStr) -> Writable<'a> { Writable::Path(p.as_ref()) }
}

fn generated_by() -> String { format!("Generated by fif {}", CLAP_LONG_VERSION.as_str()).into() }

pub fn smart_write<W: Write>(f: &mut W, writeables: &[Writable]) -> io::Result<()> {
	// ehhhh
	for writeable in writeables {
		match writeable {
			Writable::Newline => {
				cfg_if! {
					if #[cfg(windows)] {
						write!(f, "\r\n")?;
					} else {
						writeln!(f,)?;
					}
				}
			}
			Writable::String(s) => write!(f, "{}", s)?,
			Writable::Path(path) => {
				if let Some(path_str) = path.to_str() {
					let escaped = escape(path_str);
					if escaped.as_ref() == path_str {
						// the escaped string is the same as the input - this will occur for inputs like "file.txt" which don't
						// need to be escaped. however, it's Best Practiceâ„¢ to escape such strings anyway, so we prefix/suffix the
						// escaped string with single quotes.
						write!(f, "'{}'", escaped)?;
					} else {
						write!(f, "{}", escaped)?;
					}
				} else {
					write!(f, "'")?;
					cfg_if! {
						if #[cfg(windows)] {
							// TODO: implement bonked strings for windows
							// something like:
							// f.write_all(&*path.as_os_str().encode_wide().collect::<Vec<u16>>())?;
							write!(f, "{}", path.as_os_str().to_string_lossy())?;
						} else {
							f.write_all(path.as_os_str().as_bytes())?;
						}
					}
					write!(f, "'")?;
				}
			}
		}
	}
	Ok(())
}

pub trait FormatSteps {
	fn rename<W: Write>(&self, _f: &mut W, _from: &Path, _to: &Path) -> io::Result<()>;
	fn no_known_extension<W: Write>(&self, _f: &mut W, _path: &Path) -> io::Result<()>;
	fn unreadable<W: Write>(&self, _f: &mut W, _path: &Path) -> io::Result<()>;
	fn unknown_type<W: Write>(&self, _f: &mut W, _path: &Path) -> io::Result<()>;
	fn header<W: Write>(&self, _f: &mut W) -> io::Result<()>;
	fn footer<W: Write>(&self, _f: &mut W) -> io::Result<()>;
	fn write_steps<W: Write>(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()> {
		self.header(f)?;

		for error in errors {
			match error {
				// failed to read the file
				ScanError::File(path) => self.unreadable(f, path)?,
				// file was read successfully, but we couldn't determine a MIME type
				ScanError::Mime(path) => self.unknown_type(f, path)?,
			}
		}

		if !errors.is_empty() {
			// add a blank line between the errors and commands
			smart_write(f, writables![Newline])?;
		}

		for finding in findings {
			if let Some(name) = finding.recommended_path() {
				self.rename(f, finding.file.as_path(), &name)?;
			} else {
				self.no_known_extension(f, finding.file.as_path())?;
			}
		}

		self.footer(f)
	}
}

pub trait Format {
	fn write_all<W: Write>(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()>;
}

/// Bourne-Shell compatible script.
pub struct Shell;

impl Format for Shell {
	fn write_all<W: Write>(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()> {
		self.write_steps(f, findings, errors)
	}
}

impl FormatSteps for Shell {
	fn rename<W: Write>(&self, f: &mut W, from: &Path, to: &Path) -> io::Result<()> {
		smart_write(f, writablesln!("mv -v -i -- ", from, "\t", to))
	}

	fn no_known_extension<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
		smart_write(
			f,
			writablesln!["cat <<- '???'", Newline, "No known extension for ", path, Newline, "???"],
		)
	}

	fn unreadable<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
		smart_write(f, writablesln!["# Failed to read", path])
	}

	fn unknown_type<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
		smart_write(f, writablesln!["# Failed to detect MIME type for ", path])
	}

	fn header<W: Write>(&self, f: &mut W) -> io::Result<()> {
		smart_write(f, writablesln!["#!/usr/bin/env sh", Newline, "# ", (generated_by().as_str())])?;

		if let Ok(working_directory) = std::env::current_dir() {
			smart_write(f, writablesln!["# Run from ", (working_directory.as_path())])?;
		}
		write!(f, "# Happy with these changes? Run `fif --fix` from the same directory!")?;

		smart_write(f, writablesln![Newline, "set -e", Newline])
	}

	fn footer<W: Write>(&self, f: &mut W) -> io::Result<()> { smart_write(f, writablesln![Newline, "echo 'Done.'"]) }
}

// PowerShell is a noun, not a type
#[allow(clippy::doc_markdown)]
/// PowerShell script.
pub struct PowerShell;

impl Format for PowerShell {
	fn write_all<W: Write>(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()> {
		self.write_steps(f, findings, errors)
	}
}

impl FormatSteps for PowerShell {
	fn rename<W: Write>(&self, f: &mut W, from: &Path, to: &Path) -> io::Result<()> {
		// unfortunately there doesn't seem to be an equivalent of sh's `mv -i` -- passing the '-Confirm' flag will prompt
		// the user to confirm every single rename, and using Move-Item -Force will always overwrite without prompting.
		// there doesn't seem to be a way to rename the file, prompting only if the target already exists.
		smart_write(
			f,
			writablesln!["Rename-Item -Verbose -Path ", from, " -NewName ", (to.file_name().unwrap())],
		)
	}

	fn no_known_extension<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
		smart_write(
			f,
			writablesln!["Write-Output @'", Newline, "No known extension for ", path, Newline, "'@"],
		)
	}

	fn unreadable<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
		smart_write(
			f,
			writablesln!["Write-Output @'", Newline, "Failed to read ", path, Newline, "'@"],
		)
	}

	fn unknown_type<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
		smart_write(f, writablesln!["<# Failed to detect MIME type for ", path, " #>"])
	}

	fn header<W: Write>(&self, f: &mut W) -> io::Result<()> {
		smart_write(
			f,
			writablesln!["#!/usr/bin/env pwsh", Newline, "<# ", (generated_by().as_str()), " #>"],
		)?;

		if let Ok(working_directory) = std::env::current_dir() {
			smart_write(f, writablesln!["<# Run from ", (working_directory.as_path()), " #>"])?;
		}
		write!(f, "<# Happy with these changes? Run `fif --fix` from the same directory! #>")?;

		smart_write(f, writables![Newline])
	}

	fn footer<W: Write>(&self, f: &mut W) -> io::Result<()> {
		smart_write(f, writablesln![Newline, "Write-Output 'Done!'"])
	}
}

pub struct Text;
impl Format for Text {
	fn write_all<W: Write>(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()> {
		self.write_steps(f, findings, errors)
	}
}

impl FormatSteps for Text {
	fn rename<W: Write>(&self, f: &mut W, from: &Path, to: &Path) -> io::Result<()> {
		smart_write(f, writablesln![from, " should be renamed to ", to])
	}

	fn no_known_extension<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
		smart_write(f, writablesln!["No known extension for ", path])
	}

	fn unreadable<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
		smart_write(f, writablesln!["Encountered IO error while accessing ", path])
	}

	fn unknown_type<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
		smart_write(f, writablesln!["Couldn't determine type for ", path])
	}

	fn header<W: Write>(&self, f: &mut W) -> io::Result<()> {
		smart_write(f, writablesln![(generated_by().as_str()), Newline])?;
		if let Ok(working_directory) = std::env::current_dir() {
			smart_write(f, writablesln!["Run from ", (working_directory.as_path())])?;
		}
		write!(f, "Happy with these changes? Run `fif --fix` from the same directory!")
	}

	fn footer<W: Write>(&self, f: &mut W) -> io::Result<()> {
		smart_write(
			f,
			// writablesln![Newline, "Processed ", (entries.len().to_string().as_str()), " files"],
			&[Writable::Newline],
		)
	}
}

#[cfg(feature = "json")]
pub struct Json;

#[cfg(feature = "json")]
impl Format for Json {
	fn write_all<W: Write>(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()> {
		#[derive(serde::Serialize)]
		struct SerdeEntries<'a> {
			errors: &'a [ScanError<'a>],
			findings: &'a [Findings],
		}

		let result = serde_json::to_writer_pretty(f, &SerdeEntries { errors, findings });

		if let Err(err) = result {
			log::error!("Error while serialising: {}", err);
			return Err(err.into());
		}

		Ok(())
	}
}