mirror of
https://github.com/pinnacle-comp/pinnacle.git
synced 2025-01-14 08:01:14 +01:00
611 lines
18 KiB
Rust
611 lines
18 KiB
Rust
use std::{io::IsTerminal, path::PathBuf};
|
|
|
|
use clap::{Parser, ValueHint};
|
|
use tracing::{error, warn};
|
|
|
|
/// Valid backends that Pinnacle can run.
|
|
#[derive(clap::ValueEnum, Debug, Clone, Copy)]
|
|
pub enum Backend {
|
|
/// Run Pinnacle in a window in your graphical environment
|
|
Winit,
|
|
/// Run Pinnacle from a tty
|
|
Udev,
|
|
/// Run the dummy backend
|
|
///
|
|
/// This does not open a window and is used only for testing.
|
|
#[cfg(feature = "testing")]
|
|
Dummy,
|
|
}
|
|
|
|
/// The main CLI struct.
|
|
#[derive(clap::Parser, Debug)]
|
|
#[command(author, version, about, long_about = None, args_conflicts_with_subcommands = true)]
|
|
pub struct Cli {
|
|
/// Start Pinnacle with the config at this directory
|
|
#[arg(short, long, value_name("DIR"), value_hint(ValueHint::DirPath))]
|
|
pub config_dir: Option<PathBuf>,
|
|
|
|
/// Run Pinnacle with the specified backend
|
|
///
|
|
/// This is usually not necessary, but if your environment variables are mucked up
|
|
/// then this can be used to choose a backend.
|
|
#[arg(short, long)]
|
|
pub backend: Option<Backend>,
|
|
|
|
/// Force Pinnacle to run with the provided backend
|
|
#[arg(long, requires = "backend")]
|
|
pub force: bool,
|
|
|
|
/// Allow running Pinnacle as root (this is NOT recommended)
|
|
#[arg(long)]
|
|
pub allow_root: bool,
|
|
|
|
/// Start Pinnacle without a config
|
|
///
|
|
/// This is meant to be used for debugging.
|
|
/// Additionally, Pinnacle will not load the
|
|
/// default config if a manually spawned one
|
|
/// crashes or exits.
|
|
#[arg(long)]
|
|
pub no_config: bool,
|
|
|
|
/// Prevent Xwayland from being started
|
|
#[arg(long)]
|
|
pub no_xwayland: bool,
|
|
|
|
/// Open the gRPC socket at the specified directory
|
|
#[arg(short, long, value_name("DIR"), value_hint(ValueHint::DirPath))]
|
|
pub socket_dir: Option<PathBuf>,
|
|
|
|
/// Cli subcommands
|
|
#[command(subcommand)]
|
|
subcommand: Option<CliSubcommand>,
|
|
}
|
|
|
|
impl Cli {
|
|
pub fn parse_and_prompt() -> Option<Self> {
|
|
let mut cli = Cli::parse();
|
|
|
|
// oh my god rustfmt is starting to piss me off
|
|
|
|
cli.config_dir = cli.config_dir.and_then(|dir| {
|
|
let new_dir = shellexpand::path::full(&dir);
|
|
match new_dir {
|
|
Ok(new_dir) => Some(new_dir.to_path_buf()),
|
|
Err(err) => {
|
|
warn!("Could not shellexpand `--config-dir`'s argument: {err}; unsetting `--config-dir`");
|
|
None
|
|
}
|
|
}
|
|
});
|
|
|
|
if let Some(subcommand) = &cli.subcommand {
|
|
match subcommand {
|
|
CliSubcommand::Config(ConfigSubcommand::Gen(config_gen)) => {
|
|
if let Err(err) = generate_config(config_gen.clone()) {
|
|
error!("Error generating config: {err}");
|
|
}
|
|
}
|
|
CliSubcommand::Info => {
|
|
let info = format!(
|
|
"Pinnacle, built in {opt} mode with Rust {rust_ver}\n\
|
|
\n\
|
|
Branch: {branch}{dirty}\n\
|
|
Commit: {commit} ({commit_msg})\n\
|
|
\n\
|
|
OS: {os}",
|
|
branch = env!("VERGEN_GIT_BRANCH"),
|
|
dirty = if env!("VERGEN_GIT_DIRTY") == "true" {
|
|
" (dirty)"
|
|
} else {
|
|
""
|
|
},
|
|
commit = env!("VERGEN_GIT_SHA"),
|
|
commit_msg = env!("VERGEN_GIT_COMMIT_MESSAGE"),
|
|
opt = if env!("VERGEN_CARGO_DEBUG") == "true" {
|
|
"debug"
|
|
} else {
|
|
"release"
|
|
},
|
|
rust_ver = env!("VERGEN_RUSTC_SEMVER"),
|
|
os = env!("VERGEN_SYSINFO_OS_VERSION"),
|
|
);
|
|
|
|
println!("{info}");
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
Some(cli)
|
|
}
|
|
}
|
|
|
|
/// Cli subcommands.
|
|
#[derive(clap::Subcommand, Debug)]
|
|
enum CliSubcommand {
|
|
/// Commands dealing with configuration
|
|
#[command(subcommand)]
|
|
Config(ConfigSubcommand),
|
|
|
|
/// Print build and system information
|
|
Info,
|
|
}
|
|
|
|
/// Config subcommands
|
|
#[derive(clap::Subcommand, Debug)]
|
|
enum ConfigSubcommand {
|
|
/// Generate a config
|
|
///
|
|
/// If not all flags are provided, this will launch an
|
|
/// interactive prompt unless `--non-interactive` is passed
|
|
/// or this is run in a non-interactive shell.
|
|
Gen(ConfigGen),
|
|
}
|
|
|
|
/// Config arguments.
|
|
#[derive(clap::Args, Debug, Clone, PartialEq)]
|
|
struct ConfigGen {
|
|
/// Generate a config in a specific language
|
|
#[arg(short, long)]
|
|
pub lang: Option<Lang>,
|
|
|
|
/// Generate a config at this directory
|
|
#[arg(short, long, value_hint(ValueHint::DirPath))]
|
|
pub dir: Option<PathBuf>,
|
|
|
|
/// Do not show interactive prompts if both `--lang` and `--dir` are set
|
|
///
|
|
/// This does nothing inside of non-interactive shells.
|
|
#[arg(
|
|
short,
|
|
long,
|
|
requires("lang"),
|
|
requires("dir"),
|
|
default_value_t = !std::io::stdout().is_terminal()
|
|
)]
|
|
pub non_interactive: bool,
|
|
}
|
|
|
|
/// Possible languages for configuration.
|
|
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Lang {
|
|
/// Generate a Lua config
|
|
Lua,
|
|
/// Generate a Rust config
|
|
Rust,
|
|
}
|
|
|
|
impl std::fmt::Display for Lang {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{self:?}")
|
|
}
|
|
}
|
|
|
|
//////////////////////////////////////////////////////////////////////
|
|
|
|
/// Generate a new config.
|
|
///
|
|
/// If `--non-interactive` is passed or the shell is non-interactive, this will not
|
|
/// output interactive prompts.
|
|
fn generate_config(args: ConfigGen) -> anyhow::Result<()> {
|
|
let interactive = !args.non_interactive;
|
|
|
|
if !interactive && (args.lang.is_none() || args.dir.is_none()) {
|
|
eprintln!("Error: both `--lang` and `--dir` must be set in a non-interactive shell.");
|
|
return Ok(());
|
|
}
|
|
|
|
if interactive {
|
|
cliclack::intro("Welcome to the interactive config generator!")?;
|
|
tokio::spawn(async {
|
|
tokio::signal::ctrl_c()
|
|
.await
|
|
.expect("failed to listen for ctrl-c");
|
|
});
|
|
}
|
|
|
|
enum Level {
|
|
Info,
|
|
Success,
|
|
}
|
|
|
|
let message = |msg: &str, level: Level| -> anyhow::Result<()> {
|
|
if interactive {
|
|
Ok(match level {
|
|
Level::Info => cliclack::log::info(msg),
|
|
Level::Success => cliclack::log::success(msg),
|
|
}?)
|
|
} else {
|
|
println!("{msg}");
|
|
Ok(())
|
|
}
|
|
};
|
|
|
|
let exit_message = |msg: &str| {
|
|
if interactive {
|
|
cliclack::outro_cancel(msg).expect("failed to display outro_cancel");
|
|
} else {
|
|
eprintln!("{msg}, exiting");
|
|
}
|
|
};
|
|
|
|
let lang = match args.lang {
|
|
Some(lang) => {
|
|
let msg = format!("Select a language:\n{lang} (from -l/--lang)");
|
|
message(&msg, Level::Success)?;
|
|
|
|
lang
|
|
}
|
|
None => {
|
|
assert!(interactive);
|
|
|
|
cliclack::select("Select a language:")
|
|
.items(&[(Lang::Lua, "Lua", ""), (Lang::Rust, "Rust", "")])
|
|
.interact()?
|
|
}
|
|
};
|
|
|
|
let default_dir = xdg::BaseDirectories::with_prefix("pinnacle")?.get_config_home();
|
|
|
|
let default_dir_clone = default_dir.clone();
|
|
|
|
let dir_validator = move |s: &String| {
|
|
let mut target_dir = if s.is_empty() {
|
|
default_dir_clone.clone()
|
|
} else {
|
|
PathBuf::from(
|
|
shellexpand::full(s)
|
|
.map_err(|err| format!("Directory expansion failed: {err}"))?
|
|
.to_string(),
|
|
)
|
|
};
|
|
|
|
if target_dir.is_relative() {
|
|
let mut new_dir = std::env::current_dir().map_err(|err| {
|
|
format!("Failed to get the current dir to resolve relative path: {err}")
|
|
})?;
|
|
new_dir.push(target_dir);
|
|
target_dir = new_dir;
|
|
}
|
|
|
|
match target_dir.try_exists() {
|
|
Ok(exists) => {
|
|
if exists {
|
|
if !target_dir.is_dir() {
|
|
Err(format!(
|
|
"`{}` exists but is not a directory",
|
|
target_dir.display()
|
|
))
|
|
} else if lang == Lang::Rust
|
|
&& std::fs::read_dir(&target_dir)
|
|
.map_err(|err| {
|
|
format!(
|
|
"Failed to check if `{}` is empty: {err}",
|
|
target_dir.display()
|
|
)
|
|
})?
|
|
.next()
|
|
.is_some()
|
|
{
|
|
Err(format!(
|
|
"`{}` exists but is not empty. Empty it to generate a Rust config in it.",
|
|
target_dir.display()
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
Err(err) => Err(format!(
|
|
"Failed to check if `{}` exists: {err}",
|
|
target_dir.display()
|
|
)),
|
|
}
|
|
};
|
|
|
|
let target_dir = match args.dir {
|
|
Some(dir) => {
|
|
let msg = format!(
|
|
"Choose a directory to place the config in:\n{} (from -d/--dir)",
|
|
dir.display()
|
|
);
|
|
|
|
message(&msg, Level::Success)?;
|
|
|
|
if lang == Lang::Rust && matches!(dir.try_exists(), Ok(true)) {
|
|
exit_message("Directory must be empty to create a Rust config in it");
|
|
anyhow::bail!("{msg}");
|
|
}
|
|
|
|
dir
|
|
}
|
|
None => {
|
|
assert!(interactive);
|
|
|
|
let dir: PathBuf = cliclack::input("Choose a directory to place the config in:")
|
|
.default_input(default_dir.to_string_lossy().as_ref())
|
|
.validate_interactively(dir_validator)
|
|
.interact()?;
|
|
|
|
let mut dir = shellexpand::path::full(&dir)?.to_path_buf();
|
|
|
|
if dir.is_relative() {
|
|
let mut new_dir = std::env::current_dir()?;
|
|
new_dir.push(dir);
|
|
dir = new_dir;
|
|
}
|
|
|
|
dir
|
|
}
|
|
};
|
|
|
|
if let Ok(false) = target_dir.try_exists() {
|
|
let msg = format!(
|
|
"`{}` doesn't exist and will be created.",
|
|
target_dir.display()
|
|
);
|
|
message(&msg, Level::Info)?;
|
|
}
|
|
|
|
if interactive {
|
|
let confirm_creation = cliclack::confirm(format!(
|
|
"Create a {} config inside `{}`?",
|
|
lang,
|
|
target_dir.display()
|
|
))
|
|
.initial_value(false)
|
|
.interact()?;
|
|
|
|
if !confirm_creation {
|
|
exit_message("Config generation cancelled.");
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
std::fs::create_dir_all(&target_dir)?;
|
|
|
|
// Generate the config
|
|
|
|
let xdg_base_dirs = xdg::BaseDirectories::with_prefix("pinnacle")?;
|
|
let mut default_config_dir = xdg_base_dirs.get_data_file("default_config");
|
|
std::fs::create_dir_all(&default_config_dir)?;
|
|
|
|
// %F = %Y-%m-%d or year-month-day in ISO 8601
|
|
// %T = %H:%M:%S
|
|
let now = format!("{}", chrono::Local::now().format("%F.%T"));
|
|
|
|
match lang {
|
|
Lang::Lua => {
|
|
default_config_dir.push("lua");
|
|
|
|
let mut files_to_backup: Vec<(String, String)> = Vec::new();
|
|
|
|
for file in std::fs::read_dir(&default_config_dir)? {
|
|
let file = file?;
|
|
let name = file.file_name();
|
|
let target_file = target_dir.join(&name);
|
|
if let Ok(true) = target_file.try_exists() {
|
|
let backup_name = format!("{}.{now}.bak", name.to_string_lossy());
|
|
files_to_backup.push((name.to_string_lossy().to_string(), backup_name));
|
|
}
|
|
}
|
|
|
|
if !files_to_backup.is_empty() {
|
|
let msg = files_to_backup
|
|
.iter()
|
|
.map(|(src, dst)| format!("{src} -> {dst}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
if interactive {
|
|
cliclack::note("The following files will be renamed:", msg)?;
|
|
let r#continue = cliclack::confirm("Continue?").interact()?;
|
|
|
|
if !r#continue {
|
|
exit_message("Config generation cancelled.");
|
|
return Ok(());
|
|
}
|
|
} else {
|
|
println!("The following files will be renamed:");
|
|
println!("{msg}");
|
|
}
|
|
|
|
for (src, dst) in files_to_backup.iter() {
|
|
std::fs::rename(target_dir.join(src), target_dir.join(dst))?;
|
|
}
|
|
|
|
message("Renamed old files", Level::Info)?;
|
|
}
|
|
|
|
dircpy::copy_dir(&default_config_dir, &target_dir)?;
|
|
}
|
|
Lang::Rust => {
|
|
default_config_dir.push("rust");
|
|
|
|
assert!(
|
|
std::fs::read_dir(&target_dir)?.next().is_none(),
|
|
"target directory was not empty"
|
|
);
|
|
|
|
dircpy::copy_dir(&default_config_dir, &target_dir)?;
|
|
}
|
|
}
|
|
|
|
message("Copied new config over", Level::Info)?;
|
|
|
|
let mut outro_msg = format!("{lang} config created in {}!", target_dir.display());
|
|
if lang == Lang::Rust {
|
|
outro_msg = format!(
|
|
"{outro_msg}\nYou may want to run `cargo build` in the \
|
|
config directory beforehand to avoid waiting when starting up Pinnacle."
|
|
);
|
|
}
|
|
|
|
if interactive {
|
|
cliclack::outro(outro_msg)?;
|
|
} else {
|
|
println!("{outro_msg}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use anyhow::Context;
|
|
|
|
use super::*;
|
|
|
|
// TODO: find a way to test the interactive bits programmatically
|
|
|
|
#[test]
|
|
fn cli_config_gen_parses_correctly() -> anyhow::Result<()> {
|
|
let temp_dir = tempfile::tempdir()?;
|
|
let temp_dir = temp_dir.path().join("cli_config_gen_parses_correctly");
|
|
|
|
let cli = Cli::parse_from([
|
|
"pinnacle",
|
|
"config",
|
|
"gen",
|
|
"--lang",
|
|
"rust",
|
|
"--dir",
|
|
temp_dir.to_str().context("not valid unicode")?,
|
|
"--non-interactive",
|
|
]);
|
|
|
|
let expected_config_gen = ConfigGen {
|
|
lang: Some(Lang::Rust),
|
|
dir: Some(temp_dir.to_path_buf()),
|
|
non_interactive: true,
|
|
};
|
|
|
|
let Some(CliSubcommand::Config(ConfigSubcommand::Gen(config_gen))) = cli.subcommand else {
|
|
anyhow::bail!("cli.subcommand config_gen doesn't exist");
|
|
};
|
|
|
|
assert_eq!(config_gen, expected_config_gen);
|
|
|
|
let cli = Cli::parse_from(["pinnacle", "config", "gen", "--lang", "lua"]);
|
|
|
|
let expected_config_gen = ConfigGen {
|
|
lang: Some(Lang::Lua),
|
|
dir: None,
|
|
non_interactive: !std::io::stdout().is_terminal(),
|
|
};
|
|
|
|
let Some(CliSubcommand::Config(ConfigSubcommand::Gen(config_gen))) = cli.subcommand else {
|
|
anyhow::bail!("cli.subcommand config_gen doesn't exist");
|
|
};
|
|
|
|
assert_eq!(config_gen, expected_config_gen);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn non_interactive_config_gen_lua_works() -> anyhow::Result<()> {
|
|
let temp_dir = tempfile::tempdir()?;
|
|
let temp_dir = temp_dir.path().join("non_interactive_config_gen_lua_works");
|
|
|
|
let config_gen = ConfigGen {
|
|
lang: Some(Lang::Lua),
|
|
dir: Some(temp_dir.clone()),
|
|
non_interactive: true,
|
|
};
|
|
|
|
generate_config(config_gen)?;
|
|
|
|
assert!(matches!(
|
|
temp_dir.join("default_config.lua").try_exists(),
|
|
Ok(true)
|
|
));
|
|
assert!(matches!(
|
|
temp_dir.join("metaconfig.toml").try_exists(),
|
|
Ok(true)
|
|
));
|
|
assert!(matches!(
|
|
temp_dir.join(".luarc.json").try_exists(),
|
|
Ok(true)
|
|
));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn non_interactive_config_gen_rust_works() -> anyhow::Result<()> {
|
|
let temp_dir = tempfile::tempdir()?;
|
|
let temp_dir = temp_dir
|
|
.path()
|
|
.join("non_interactive_config_gen_rust_works");
|
|
|
|
let config_gen = ConfigGen {
|
|
lang: Some(Lang::Rust),
|
|
dir: Some(temp_dir.clone()),
|
|
non_interactive: true,
|
|
};
|
|
|
|
generate_config(config_gen)?;
|
|
|
|
assert!(matches!(
|
|
temp_dir.join("src/main.rs").try_exists(),
|
|
Ok(true)
|
|
));
|
|
assert!(matches!(
|
|
temp_dir.join("metaconfig.toml").try_exists(),
|
|
Ok(true)
|
|
));
|
|
assert!(matches!(temp_dir.join("Cargo.toml").try_exists(), Ok(true)));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn non_interactive_config_gen_lua_backup_works() -> anyhow::Result<()> {
|
|
let temp_dir = tempfile::tempdir()?;
|
|
let temp_dir = temp_dir
|
|
.path()
|
|
.join("non_interactive_config_gen_lua_backup_works");
|
|
|
|
let config_gen = ConfigGen {
|
|
lang: Some(Lang::Lua),
|
|
dir: Some(temp_dir.clone()),
|
|
non_interactive: true,
|
|
};
|
|
|
|
generate_config(config_gen.clone())?;
|
|
generate_config(config_gen)?;
|
|
|
|
let generated_file_count = std::fs::read_dir(&temp_dir)?
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
.len();
|
|
|
|
// 3 for original, 3 for backups
|
|
assert_eq!(generated_file_count, 6);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn non_interactive_config_gen_rust_nonempty_dir_does_not_work() -> anyhow::Result<()> {
|
|
let temp_dir = tempfile::tempdir()?;
|
|
let temp_dir = temp_dir
|
|
.path()
|
|
.join("non_interactive_config_gen_rust_nonempty_dir_does_not_work");
|
|
|
|
let config_gen = ConfigGen {
|
|
lang: Some(Lang::Rust),
|
|
dir: Some(temp_dir),
|
|
non_interactive: true,
|
|
};
|
|
|
|
generate_config(config_gen.clone())?;
|
|
|
|
assert!(generate_config(config_gen.clone()).is_err());
|
|
|
|
Ok(())
|
|
}
|
|
}
|