diff --git a/Cargo.lock b/Cargo.lock index 9f5dbb3..71f3715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.52.0", +] + [[package]] name = "const-random" version = "0.1.17" @@ -416,6 +429,18 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6e854126756c496b8c81dec88f9a706b15b875c5849d4097a3854476b9fdf94" +[[package]] +name = "dialoguer" +version = "0.10.4" +source = "git+https://github.com/SIMULATAN/dialoguer#4d222a6ed1d7cf54e939e57c2c268e3b1d7eda3e" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + [[package]] name = "diff" version = "0.1.13" @@ -466,6 +491,7 @@ dependencies = [ "clap", "clap_complete", "crossterm", + "dialoguer", "diff", "dunce", "evalexpr", @@ -504,6 +530,12 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "endian-type" version = "0.1.2" @@ -1747,6 +1779,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shellexpand" version = "2.1.2" @@ -2527,3 +2565,9 @@ dependencies = [ "quote", "syn 2.0.49", ] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/Cargo.toml b/Cargo.toml index ddc9d86..fffef26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ anyhow = "1.*" clap = { version = "4.0.26", features = ["derive"] } clap_complete = "4.0.5" crossterm = "0.25.0" +dialoguer = { git = "https://github.com/SIMULATAN/dialoguer", features = [] } diff = "0.1.*" handlebars = "5.*" hostname = "0.3.*" diff --git a/README.md b/README.md index 612c42c..dd0a271 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Usage: dotter [OPTIONS] [COMMAND] Commands: deploy Deploy the files to their respective targets. This is the default subcommand + config Interactively modify the local configuration file undeploy Delete all deployed files from their target locations. Note that this operates on all files that are currently in cache init Initialize global.toml with a single package containing all the files in the current directory pointing to a dummy value and a local.toml that selects that package watch Run continuously, watching the repository for changes and deploying as soon as they happen. Can be ran with `--dry-run` diff --git a/src/args.rs b/src/args.rs index ff8d29c..46dfccb 100644 --- a/src/args.rs +++ b/src/args.rs @@ -93,6 +93,9 @@ pub enum Action { #[default] Deploy, + /// Interactively modify the local configuration file. + Config, + /// Delete all deployed files from their target locations. /// Note that this operates on all files that are currently in cache. Undeploy, diff --git a/src/config.rs b/src/config.rs index 5ce299b..28c2d0d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -96,7 +96,7 @@ pub struct Configuration { #[serde(deny_unknown_fields)] pub struct Package { #[serde(default)] - depends: Vec, + pub depends: Vec, #[serde(default)] files: Files, #[serde(default)] @@ -104,22 +104,22 @@ pub struct Package { } #[derive(Debug, Deserialize, Serialize)] -struct GlobalConfig { +pub struct GlobalConfig { #[serde(default)] #[cfg(feature = "scripting")] helpers: Helpers, #[serde(flatten)] - packages: BTreeMap, + pub packages: BTreeMap, } type IncludedConfig = BTreeMap; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Default)] #[serde(deny_unknown_fields)] -struct LocalConfig { +pub struct LocalConfig { #[serde(default)] includes: Vec, - packages: Vec, + pub packages: Vec, #[serde(default)] files: Files, #[serde(default)] @@ -127,33 +127,13 @@ struct LocalConfig { } pub fn load_configuration( - local_config: &Path, - global_config: &Path, + local_config_path: &Path, + global_config_path: &Path, patch: Option, ) -> Result { - let global: GlobalConfig = filesystem::load_file(global_config) - .and_then(|c| c.ok_or_else(|| anyhow::anyhow!("file not found"))) - .with_context(|| format!("load global config {:?}", global_config))?; - trace!("Global config: {:#?}", global); - - // If local.toml can't be found, look for a file named .toml instead - let mut local_config_buf = local_config.to_path_buf(); - if !local_config_buf.exists() { - let hostname = hostname::get() - .context("failed to get the computer hostname")? - .into_string() - .expect("hostname cannot be converted to string"); - info!( - "{:?} not found, using {}.toml instead (based on hostname)", - local_config, hostname - ); - local_config_buf.set_file_name(&format!("{}.toml", hostname)); - } + let global = load_global_config(global_config_path)?; - let local: LocalConfig = filesystem::load_file(local_config_buf.as_path()) - .and_then(|c| c.ok_or_else(|| anyhow::anyhow!("file not found"))) - .with_context(|| format!("load local config {:?}", local_config))?; - trace!("Local config: {:#?}", local); + let local = load_local_config(local_config_path)?.context("local config not found")?; let mut merged_config = merge_configuration_files(global, local, patch).context("merge configuration files")?; @@ -185,6 +165,37 @@ pub fn load_configuration( Ok(merged_config) } +pub fn load_global_config(global_config: &Path) -> Result { + let global: GlobalConfig = filesystem::load_file(global_config) + .and_then(|c| c.ok_or_else(|| anyhow::anyhow!("file not found"))) + .with_context(|| format!("load global config {:?}", global_config))?; + trace!("Global config: {:#?}", global); + + Ok(global) +} + +pub fn load_local_config(local_config: &Path) -> Result> { + // If local.toml can't be found, look for a file named .toml instead + let mut local_config_buf = local_config.to_path_buf(); + if !local_config_buf.exists() { + let hostname = hostname::get() + .context("failed to get the computer hostname")? + .into_string() + .expect("hostname cannot be converted to string"); + info!( + "{:?} not found, using {}.toml instead (based on hostname)", + local_config, hostname + ); + local_config_buf.set_file_name(&format!("{}.toml", hostname)); + } + + let local: Option = filesystem::load_file(local_config_buf.as_path()) + .with_context(|| format!("load local config {:?}", local_config))?; + trace!("Local config: {:#?}", local); + + Ok(local) +} + #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(deny_unknown_fields)] pub struct Cache { @@ -258,7 +269,7 @@ fn recursive_extend_map( } #[allow(clippy::map_entry)] -fn merge_configuration_files( +pub fn merge_configuration_files( mut global: GlobalConfig, local: LocalConfig, patch: Option, diff --git a/src/local_config.rs b/src/local_config.rs new file mode 100644 index 0000000..6f5bde3 --- /dev/null +++ b/src/local_config.rs @@ -0,0 +1,227 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::iter::FromIterator; +use std::path::Path; + +use anyhow::Result; +use anyhow::{Context, Error}; +use crossterm::style::{style, Color, Stylize}; +use dialoguer::{MultiSelectPlus, MultiSelectPlusItem, MultiSelectPlusStatus, SelectCallback}; + +use crate::args::Options; +use crate::config::{load_global_config, load_local_config, LocalConfig, Package}; +use crate::filesystem; + +const DEPENDENCY: MultiSelectPlusStatus = MultiSelectPlusStatus { + checked: false, + symbol: "-", +}; + +/// Returns true if an error was printed +pub fn config(opt: &Options) -> Result { + let global_config = load_global_config(&opt.global_config)?; + let local_config = load_local_config(&opt.local_config)?; + + let packages = global_config + .packages + .iter() + .map(|(name, package)| { + let mut dependencies = BTreeSet::new(); + visit_recursively(&global_config.packages, name, package, &mut dependencies); + dependencies.remove(name); + (name.clone(), dependencies) + }) + .collect::>>(); + + trace!("Available packages: {:?}", packages); + + let enabled_packages = if let Some(ref local_config) = local_config { + BTreeSet::from_iter(local_config.packages.iter().cloned()) + } else { + debug!( + "No local configuration file found at {}", + opt.local_config.display() + ); + + BTreeSet::new() + }; + + let multi_select = MultiSelectPlus::new().with_select_callback(select_callback(&packages)); + + let selected_items = prompt(multi_select, &packages, &enabled_packages)?; + trace!("Selected elements: {:?}", selected_items); + + write_selected_elements( + &opt.local_config, + local_config.unwrap_or_default(), + selected_items, + &packages, + )?; + + Ok(false) +} + +fn select_callback(packages: &BTreeMap>) -> Box { + Box::new(move |_, items| { + // update the status of the items, making ones that were enabled through a transitive + // dependency set to unchecked + let enabled_packages = items + .iter() + .filter_map(|item| { + if item.status.checked { + Some(item.summary_text.clone()) + } else { + None + } + }) + .collect::>(); + + let new_items = items + .iter() + .map(|item| { + let Some(package) = packages.get_key_value(&item.summary_text) else { + // items that are not in the package list are just cloned as that + return item.clone(); + }; + + if is_transitive_dependency(&item.summary_text, packages, &enabled_packages) + && item.status != MultiSelectPlusStatus::CHECKED + { + // items that are enabled due to a transitive dependency + // CHECKED is excluded because it means the user explicitly enabled it + MultiSelectPlusItem { + name: format_package(package.0, package.1), + status: DEPENDENCY, + summary_text: item.summary_text.clone(), + } + } else if item.status.symbol == "-" { + // previous transitive dependencies that are now unchecked + MultiSelectPlusItem { + name: format_package(package.0, package.1), + status: MultiSelectPlusStatus::UNCHECKED, + summary_text: item.summary_text.clone(), + } + } else { + // checked or unchecked items are just cloned as that + item.clone() + } + }) + .collect(); + Some(new_items) + }) +} + +fn prompt( + multi_select: MultiSelectPlus, + packages: &BTreeMap>, + enabled_packages: &BTreeSet, +) -> dialoguer::Result>> { + multi_select + .with_prompt("Select packages to install") + .items( + packages + .iter() + .map(|(key, value)| MultiSelectPlusItem { + name: format_package(key, value), + status: if enabled_packages.contains(key) { + MultiSelectPlusStatus::CHECKED + } else if is_transitive_dependency(key, packages, enabled_packages) { + DEPENDENCY + } else { + MultiSelectPlusStatus::UNCHECKED + }, + summary_text: key.clone(), + }) + .collect::>(), + ) + .interact_opt() +} + +/// checks if a package is a transitive dependency of an enabled package +fn is_transitive_dependency( + package_name: &String, + packages: &BTreeMap>, + enabled_packages: &BTreeSet, +) -> bool { + packages + .iter() + .filter(|(key, _)| enabled_packages.contains(*key)) + .any(|(_, dependencies)| dependencies.contains(package_name)) +} + +fn write_selected_elements( + config_path: &Path, + mut local_config: LocalConfig, + selected_elements: Option>, + packages: &BTreeMap>, +) -> Result<(), Error> { + match selected_elements { + Some(selected_elements) => modify_and_save( + config_path, + &mut local_config, + packages + .iter() + .map(|(key, _)| key) + .collect::>(), + selected_elements, + ), + None => { + // user pressed "Esc" or "q" to quit + println!("Aborting."); + Ok(()) + } + } +} + +fn format_package(package_name: &String, dependencies: &BTreeSet) -> String { + let dependencies: Vec<&str> = dependencies.iter().map(|s| s.as_str()).collect(); + let dependencies_string = if !dependencies.is_empty() { + style(format!(" # (will enable {})", dependencies.join(", "))) + // fallback for terms not supporting 8-bit ANSI + .with(Color::White) + .with(Color::AnsiValue(244)) + .to_string() + } else { + String::new() + }; + format!("{package_name}{dependencies_string}") +} + +fn visit_recursively( + package_map: &BTreeMap, + package_name: &str, + package: &Package, + visited: &mut BTreeSet, +) { + if visited.contains(package_name) { + // Avoid infinite recursion caused by circular dependencies + return; + } + visited.insert(package_name.to_string()); + + for dep_name in &package.depends { + if let Some(package) = package_map.get(dep_name) { + visit_recursively(package_map, dep_name, package, visited); + } + } +} + +fn modify_and_save( + config_path: &Path, + local_config: &mut LocalConfig, + items_in_order: Vec<&String>, + selected_items: Vec, +) -> Result<()> { + println!("Writing configuration to {}", config_path.display()); + trace!( + "Selected indexes: {:?} of {:?}", + selected_items, + items_in_order + ); + + local_config.packages = selected_items + .into_iter() + .map(|i| items_in_order[i].clone()) + .collect::>(); + + filesystem::save_file(config_path, local_config).context("Writing local config to file") +} diff --git a/src/main.rs b/src/main.rs index 2a2e7c1..686ce3c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod filesystem; mod handlebars_helpers; mod hooks; mod init; +mod local_config; #[cfg(feature = "watch")] mod watch; @@ -98,6 +99,13 @@ Otherwise, run `dotter undeploy` as root, remove cache.toml and cache/ folders, return Ok(false); } } + args::Action::Config => { + debug!("Configuring..."); + if local_config::config(&opt).context("config")? { + // An error occurred + return Ok(false); + } + } args::Action::Undeploy => { debug!("Un-Deploying..."); if deploy::undeploy(opt).context("undeploy")? {