use std::{any, env, path::PathBuf, process::ExitCode};
use clap::{
ArgAction, Args, ColorChoice, CommandFactory, Parser, Subcommand, ValueEnum,
builder::{Styles, styling::AnsiColor},
};
use colored::Colorize;
fn format_long_help() -> String {
format!(
"Sets a custom output format for the modlist.\n\n\
A string literal with {{PLACEHOLDER}} holes in it, one per field the cache holds.\n\
Backslash escapes (\\n, \\t, \\\\, \\{{, \\}}) are resolved by sculkr rather than by\n\
the shell, so quote the template and write \\n where you want a line break.\n\n\
Placeholders with no value for a given mod (CurseForge sends no license,\n\
Modrinth sends no authors) render as an empty string.\n\n\
Available placeholders:\n{}\n\n\
[default: {}]",
crate::format::placeholder_help(),
crate::format::DEFAULT_FORMAT
)
}
const HELP_STYLES: Styles = Styles::styled()
.header(AnsiColor::Yellow.on_default().bold().underline())
.usage(AnsiColor::Yellow.on_default().bold())
.literal(AnsiColor::Green.on_default().bold())
.placeholder(AnsiColor::Cyan.on_default());
#[derive(Debug, Parser)]
#[command(
name = "sculkr",
color = ColorChoice::Auto,
styles = HELP_STYLES,
version,
about = "Companion CLI for packwiz - generate modlists and track Minecraft modpack changes",
long_about = "A companion CLI application for packwiz that parses its output data to deliver advanced utility commands and extended features for Minecraft modpack development.",
propagate_version = true,
// DEBUG TESTING
// arg_required_else_help = true
)]
pub(crate) struct Cli {
#[arg(short, long, action = ArgAction::Count, global = true)]
pub(crate) verbose: u8,
#[arg(short, long, global = true)]
pub(crate) quiet: bool,
#[clap(
short,
long,
global = true,
value_name = "PATH",
help = format!("The path to the packwiz root directory. [default: {:?}]", PathBuf::from(".").canonicalize().unwrap_or(PathBuf::from("."))),
)]
pub(crate) path: Option<PathBuf>,
#[clap(short, long, global = true, value_name = "PATH")]
pub(crate) output: Option<PathBuf>,
#[clap(
long,
short = 'f',
allow_hyphen_values = true,
long_help = format_long_help()
)]
pub(crate) format: Option<String>,
#[command(subcommand)]
pub(crate) command: Option<Command>,
}
impl Cli {
pub(crate) fn apply(&mut self, config: &crate::config::Config) {
self.path = self.path.take().or_else(|| config.path.clone());
self.output = self.output.take().or_else(|| config.output.clone());
self.format = self.format.take().or_else(|| config.format.clone());
if self.verbose == 0 {
self.verbose = config.verbose.unwrap_or(0);
}
if !self.quiet {
self.quiet = config.quiet.unwrap_or(false);
}
}
pub(crate) fn format(&self) -> &str {
self.format
.as_deref()
.unwrap_or(crate::format::DEFAULT_FORMAT)
}
}
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
About,
Config,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Verbosity {
Quiet,
Normal,
Info,
Debug,
}
impl Verbosity {
pub fn resolve(verbose: u8, quiet: bool) -> Self {
match (quiet, verbose) {
(true, _) => Verbosity::Quiet,
(_, 0) => Verbosity::Normal,
(_, 1) => Verbosity::Info,
(..) => Verbosity::Debug,
}
}
pub fn to_level_filter(self) -> log::LevelFilter {
match self {
Verbosity::Quiet => log::LevelFilter::Error,
Verbosity::Normal => log::LevelFilter::Warn,
Verbosity::Info => log::LevelFilter::Info,
Verbosity::Debug => log::LevelFilter::Trace,
}
}
}
fn write_fancy_header(out: &mut dyn std::io::Write, subtitle: &str) -> anyhow::Result<()> {
writeln!(out);
writeln!(
out,
" {} {} {}",
"⣿".cyan(),
"sculkr".bold().cyan(),
"⣿".cyan()
)?;
writeln!(out, " {}\n", subtitle.dimmed())?;
Ok(())
}
fn write_fancy_divider(out: &mut dyn std::io::Write, title: &str) -> anyhow::Result<()> {
writeln!(
out,
"\n┏━ {} ━━━━━━━━━━━━━━━╾──────────────┈┈┈┈┈┈┈┈┈┈┈┈\n",
format!("▓▒░ {} ░▒▓", title).black().bold().on_bright_cyan()
)?;
Ok(())
}
struct Runtime {
pack_root: PathBuf,
config_sources: Vec<PathBuf>,
config_home: Option<PathBuf>,
mods: Option<(usize, usize)>,
cache: PathBuf,
cache_entries: Option<usize>,
api_key: Option<(String, crate::config::KeySource)>,
output: Option<PathBuf>,
format: String,
verbosity: Verbosity,
}
impl Runtime {
fn gather(cli: &Cli, loaded: &crate::config::Loaded) -> Self {
let pack_root = crate::util::resolve_for_display(
cli.path.clone().unwrap_or_else(|| PathBuf::from(".")),
);
let cache = crate::util::resolve_for_display(crate::CACHE_PATH);
let cache_entries = crate::cache::Cache::load(&cache)
.ok()
.filter(|_| cache.exists())
.map(|cache| cache.get_data().len());
Self {
mods: crate::parser::packwiz::PackwizParser::load_from(&pack_root)
.ok()
.map(|pack| (pack.modrinth_mods.len(), pack.curseforge_mods.len()))
.filter(|(modrinth, curseforge)| modrinth + curseforge > 0),
pack_root,
config_sources: loaded.sources.clone(),
config_home: crate::config::global_path(),
cache,
cache_entries,
api_key: loaded
.curseforge_api_key()
.map(|(key, source)| (key.fingerprint(), source)),
output: cli.output.clone(),
format: cli.format().to_owned(),
verbosity: Verbosity::resolve(cli.verbose, cli.quiet),
}
}
}
pub(crate) fn config(
out: &mut dyn std::io::Write,
cli: &Cli,
loaded: &crate::config::Loaded,
) -> anyhow::Result<()> {
render_config(out, &Runtime::gather(cli, loaded))
}
fn render_config(out: &mut dyn std::io::Write, rt: &Runtime) -> anyhow::Result<()> {
write_fancy_header(out, "Runtime Configuration");
match rt.config_sources.split_first() {
Some((first, rest)) => {
writeln!(out, " {:<12} {}", "Config:".bold(), first.display())?;
for source in rest {
writeln!(out, " {:<12} {}", "", source.display())?;
}
}
None => writeln!(
out,
" {:<12} {} {}",
"Config:".bold(),
format!("no {} file found", crate::config::CONFIG_FILE_NAME).yellow(),
match &rt.config_home {
Some(path) => format!("(create one at {})", path.display()).dimmed(),
None => "".dimmed(),
}
)?,
}
writeln!(out, " {:<12} {}", "Output:".bold(), match &rt.output {
Some(path) => path.display().to_string(),
None => "stdout".to_owned(),
})?;
writeln!(out, " {:<12} {}", "Format:".bold(), rt.format)?;
writeln!(
out,
" {:<12} {}",
"Log level:".bold(),
rt.verbosity.to_level_filter().to_string().to_lowercase()
)?;
write_fancy_divider(out, "Modpack");
writeln!(
out,
" {:<12} {}",
"Pack root:".bold(),
rt.pack_root.display()
)?;
match rt.mods {
Some((modrinth, curseforge)) => writeln!(
out,
" {:<12} {} ({modrinth} Modrinth, {curseforge} CurseForge)",
"Mods:".bold(),
(modrinth + curseforge).to_string().green()
)?,
None => writeln!(
out,
" {:<12} {}",
"Mods:".bold(),
"no *.pw.toml files found here".yellow()
)?,
}
match rt.cache_entries {
Some(entries) => writeln!(
out,
" {:<12} {} ({entries} cached)",
"Cache:".bold(),
rt.cache.display()
)?,
None => writeln!(
out,
" {:<12} {} {}",
"Cache:".bold(),
rt.cache.display(),
"(not written yet)".dimmed()
)?,
}
write_fancy_divider(out, "Secrets");
match &rt.api_key {
Some((fingerprint, source)) => writeln!(
out,
" {:<12} {} {}",
format!("{}:", crate::env::CF_API_KEY).bold(),
fingerprint.green(),
format!("(from {source})").dimmed()
)?,
None => writeln!(
out,
" {:<12} {} {}",
format!("{}:", crate::env::CF_API_KEY).bold(),
"not set".yellow(),
"(CurseForge mods will fail)".dimmed()
)?,
}
writeln!(out)?;
Ok(())
}
pub(crate) fn about(out: &mut dyn std::io::Write) -> anyhow::Result<()> {
write_fancy_header(out, &format!("Companion CLI for {}", "packwiz".yellow()));
writeln!(
out,
" {:<12} {}",
"Version:".bold(),
env!("CARGO_PKG_VERSION")
)?;
writeln!(
out,
" {:<12} {}",
"Authors:".bold(),
env!("CARGO_PKG_AUTHORS")
)?;
writeln!(
out,
" {:<12} {}",
"Description:".bold(),
env!("CARGO_PKG_DESCRIPTION")
)?;
writeln!(
out,
" {:<12} {}",
"Repository:".bold(),
env!("CARGO_PKG_REPOSITORY")
)?;
writeln!(
out,
" {:<12} {}",
"License:".bold(),
env!("CARGO_PKG_LICENSE")
)?;
writeln!(out)?;
Ok(())
}
#[cfg(test)]
mod tests {
use clap::CommandFactory;
use super::*;
fn cli() -> clap::Command {
let cmd = Cli::command();
cmd.clone().debug_assert();
cmd
}
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
mod cli_surface {
use std::collections::BTreeSet;
use super::*;
fn collect_surface(cmd: &clap::Command, prefix: &str, out: &mut BTreeSet<String>) {
for arg in cmd.get_arguments() {
if matches!(arg.get_id().as_str(), "help" | "version") {
continue;
}
let id = arg.get_id().as_str();
if arg.is_positional() {
out.insert(format!("{prefix}<{id}>"));
} else {
out.insert(format!("{prefix}--{id}"));
}
}
for sub in cmd.get_subcommands() {
out.insert(format!("{prefix}{}", sub.get_name()));
collect_surface(sub, &format!("{prefix}{} ", sub.get_name()), out);
}
}
#[allow(dead_code)]
fn subcommands_are_exhaustive(c: &Command) {
match c {
Command::About | Command::Config => {}
}
}
#[test]
fn cli_surface_matches_expectations() {
let mut actual = BTreeSet::new();
collect_surface(&cli(), "", &mut actual);
let expected: BTreeSet<String> = [
"about",
"config",
"--verbose",
"--quiet",
"--path",
"--output",
"--format",
]
.into_iter()
.map(String::from)
.collect();
let missing: Vec<_> = expected.difference(&actual).collect();
let unexpected: Vec<_> = actual.difference(&expected).collect();
assert!(
missing.is_empty() && unexpected.is_empty(),
"CLI surface changed, update this test.\n \
In the list but not in clap (removed or renamed?): {missing:?}\n \
In clap but not in the list (newly added?): {unexpected:?}",
);
}
#[test]
fn help_snapshot() {
let help = cli().term_width(80).render_long_help().to_string();
insta::with_settings!({filters => vec![
(r#"\[default: "[^"]*"\]"#, r#"[default: "[CWD]"]"#),
]}, {
insta::assert_snapshot!(help);
});
}
}
mod commands {
use super::*;
#[test]
fn about_command_outputs_expected() -> anyhow::Result<()> {
let mut buf = Vec::new();
about(&mut buf)?;
let rendered = String::from_utf8(buf)?;
insta::with_settings!({filters => vec![
(r"\x1b\[[0-9;]*m", ""),
(r"\d+\.\d+\.\d+", "[VERSION]"),
]}, {
insta::assert_snapshot!(rendered);
});
Ok(())
}
fn render(rt: &Runtime) -> anyhow::Result<String> {
let mut buf = Vec::new();
render_config(&mut buf, rt)?;
Ok(String::from_utf8(buf)?)
}
#[test]
fn config_reports_a_working_setup() -> anyhow::Result<()> {
let rendered = render(&Runtime {
pack_root: PathBuf::from("/home/user/modpack"),
config_sources: vec![
PathBuf::from("/home/user/.config/sculkr/.sculk"),
PathBuf::from("/home/user/modpack/.sculk"),
],
config_home: Some(PathBuf::from("/home/user/.config/sculkr/.sculk")),
mods: Some((32, 15)),
cache: PathBuf::from("/home/user/modpack/.packwiz-modlist.cache.json"),
cache_entries: Some(38),
api_key: Some((
"$2a$...e345".to_owned(),
crate::config::KeySource::Config(PathBuf::from(
"/home/user/.config/sculkr/.sculk",
)),
)),
output: Some(PathBuf::from("modlist.md")),
format: crate::format::DEFAULT_FORMAT.to_owned(),
verbosity: Verbosity::Info,
})?;
insta::with_settings!({filters => vec![(r"\x1b\[[0-9;]*m", "")]}, {
insta::assert_snapshot!(rendered);
});
Ok(())
}
#[test]
fn config_reports_what_is_missing() -> anyhow::Result<()> {
let rendered = render(&Runtime {
pack_root: PathBuf::from("/home/user"),
config_sources: Vec::new(),
config_home: Some(PathBuf::from("/home/user/.config/sculkr/.sculk")),
mods: None,
cache: PathBuf::from("/home/user/.packwiz-modlist.cache.json"),
cache_entries: None,
api_key: None,
output: None,
format: crate::format::DEFAULT_FORMAT.to_owned(),
verbosity: Verbosity::Normal,
})?;
insta::with_settings!({filters => vec![(r"\x1b\[[0-9;]*m", "")]}, {
insta::assert_snapshot!(rendered);
});
Ok(())
}
}
}