use crate::{
CliArgs, CliResult, Command, Error, curry1, data_dir, email_settings_path,
render_invoice_sample, render_invoice_sample_with_nonce, run_data_command, run_email_command,
run_invoice_command,
};
use klirr_core_invoice::Version;
use log::{error, warn};
use std::path::Path;
#[cfg(test)]
use strum::IntoEnumIterator;
use std::env::consts::OS;
pub const DATA_INIT_HINT: &str =
"💡 You seem to not have set up klirr, run `klirr data init` to get started";
pub const EMAIL_INIT_HINT: &str =
"💡 You seem to not have set up email, run `klirr email init` to get started";
pub const DATA_MANUAL_MIGRATION_HINT: &str =
"💡 Your klirr data version is incompatible and must be manually migrated.";
macro_rules! migration_guides {
($($version:ident => $path:literal),+ $(,)?) => {
fn migration_guide_source_path(version: Version) -> &'static str {
match version {
$(Version::$version => $path,)+
}
}
fn migration_guide_markdown(version: Version) -> &'static str {
match version {
$(Version::$version => include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $path)),)+
}
}
#[cfg(test)]
const MIGRATION_GUIDES_COUNT: usize = [$(stringify!($version)),+].len();
};
}
migration_guides! {
V0 => "migration/v0.md",
V1 => "migration/v1.md",
}
#[cfg(test)]
fn empty_migration_guides() -> Vec<(Version, &'static str)> {
Version::iter()
.map(|version| (version, migration_guide_markdown(version).trim()))
.filter(|(_, guide)| guide.is_empty())
.map(|(version, _)| (version, migration_guide_source_path(version)))
.collect()
}
fn has_missing_setup_data(error: &Error) -> bool {
matches!(
error,
Error::Core(klirr_core_invoice::Error::FileNotFound { path, .. })
if Path::new(path).starts_with(data_dir())
)
}
fn has_missing_email_setup_data(error: &Error) -> bool {
matches!(
error,
Error::Core(klirr_core_invoice::Error::FileNotFound { path, .. })
if Path::new(path) == email_settings_path(data_dir())
)
}
#[derive(Clone, Debug)]
enum MigrationGuide {
OldVersion(MigrationGuideOldVersion),
}
impl std::fmt::Display for MigrationGuide {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.instructions())
}
}
impl MigrationGuide {
pub fn instructions(&self) -> String {
match self {
MigrationGuide::OldVersion(migration) => migration.instructions(),
}
}
}
#[derive(Clone, Debug)]
struct MigrationGuideOldVersion {
from: Version,
to: Version,
}
impl MigrationGuideOldVersion {
pub fn instructions(&self) -> String {
let guide_markdown = migration_guide_markdown(self.to);
let guide_source_path = migration_guide_source_path(self.to);
format!(
"{hint}\n\nFrom data version: {from}\nTo data version: {to}\nRON files location: {data_dir}\nMigration guide: {guide_source_path}\n\n{guide}",
hint = DATA_MANUAL_MIGRATION_HINT,
from = self.from,
to = self.to,
data_dir = data_dir().display(),
guide_source_path = guide_source_path,
guide = guide_markdown
)
}
}
fn requires_manual_data_migration(error: &Error) -> Option<MigrationGuide> {
match error {
Error::Core(klirr_core_invoice::Error::DataVersionMismatch { found, current }) => {
Some(MigrationGuide::OldVersion(MigrationGuideOldVersion {
from: *found,
to: *current,
}))
}
_ => None,
}
}
fn log_data_setup_hint_or_error(context: &str, error: &Error) {
if let Some(migration) = requires_manual_data_migration(error) {
let instructions = migration.instructions();
if !instructions.is_empty() {
warn!("{}", instructions);
}
} else if has_missing_email_setup_data(error) {
warn!("{}", EMAIL_INIT_HINT);
} else if has_missing_setup_data(error) {
warn!("{}", DATA_INIT_HINT);
} else {
error!("{context}: {error}");
}
}
fn open_file_at(path: impl AsRef<std::path::Path>) {
let path = path.as_ref().display().to_string();
let result = match OS {
"macos" => std::process::Command::new("open").arg(&path).spawn(),
"linux" => std::process::Command::new("xdg-open").arg(&path).spawn(),
"windows" => std::process::Command::new("cmd")
.args(["/C", "start", "", &path])
.spawn(),
_ => panic!("Unsupported operating system"),
};
if let Err(e) = result {
eprintln!("Failed to open path: {}", e);
}
}
pub fn run(input: CliArgs) -> CliResult<()> {
match input.command {
Command::Email(email_input) => {
run_email_command(
email_input.command(),
curry1(render_invoice_sample_with_nonce, true),
)
.inspect_err(|e| error!("Failed to execute email command: {}", e))?;
}
Command::Sample => {
let outcome = render_invoice_sample().inspect_err(|e| {
error!("Error creating sample invoice: {}", e);
})?;
open_file_at(outcome.saved_at());
}
Command::Invoice(invoice_input) => {
let outcome = run_invoice_command(invoice_input)
.inspect_err(|e| log_data_setup_hint_or_error("Error creating PDF", e))?;
open_file_at(outcome.saved_at());
}
Command::Data(data_admin_input) => {
run_data_command(data_admin_input.command()).inspect_err(|e| {
log_data_setup_hint_or_error("Error running data admin command", e);
})?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_missing_email_settings_file_as_missing_email_setup() {
let missing_email_file = email_settings_path(data_dir());
let err = Error::Core(klirr_core_invoice::Error::FileNotFound {
path: missing_email_file.display().to_string(),
underlying: "No such file or directory".to_string(),
});
assert!(has_missing_email_setup_data(&err));
}
#[test]
fn does_not_classify_non_email_file_as_missing_email_setup() {
let missing_data_file = data_dir().join("client.ron");
let err = Error::Core(klirr_core_invoice::Error::FileNotFound {
path: missing_data_file.display().to_string(),
underlying: "No such file or directory".to_string(),
});
assert!(!has_missing_email_setup_data(&err));
}
#[test]
fn classifies_missing_file_in_data_dir_as_missing_setup() {
let missing_data_file = data_dir().join("client.ron");
let err = Error::Core(klirr_core_invoice::Error::FileNotFound {
path: missing_data_file.display().to_string(),
underlying: "No such file or directory".to_string(),
});
assert!(has_missing_setup_data(&err));
}
#[test]
fn does_not_classify_non_file_not_found_errors_as_missing_setup() {
let err = Error::SpecifiedOutputPathDoesNotExist {
path: "/tmp/nowhere".to_string(),
};
assert!(!has_missing_setup_data(&err));
}
#[test]
fn does_not_classify_missing_file_outside_data_dir_as_missing_setup() {
let err = Error::Core(klirr_core_invoice::Error::FileNotFound {
path: "/tmp/client.ron".to_string(),
underlying: "No such file or directory".to_string(),
});
assert!(!has_missing_setup_data(&err));
}
#[test]
fn classifies_data_version_mismatch_as_manual_migration() {
let err = Error::Core(klirr_core_invoice::Error::DataVersionMismatch {
found: klirr_core_invoice::Version::V0,
current: klirr_core_invoice::Version::current(),
});
assert!(requires_manual_data_migration(&err).is_some());
}
#[test]
fn migration_guide_exists_for_every_version_variant() {
let empty_guides = empty_migration_guides();
assert!(
empty_guides.is_empty(),
"Missing or empty migration guides for versions: {}",
empty_guides
.iter()
.map(|(version, source_path)| format!("{version} ({source_path})"))
.collect::<Vec<_>>()
.join(", ")
);
}
#[test]
fn embedded_guide_count_matches_version_count() {
assert_eq!(
MIGRATION_GUIDES_COUNT,
Version::iter().count(),
"Update migration_guides! when adding/removing Version variants"
);
}
}