diff --git a/Cargo.lock b/Cargo.lock index 3e2127449..8a50e2d2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,6 +324,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "ascii" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" + [[package]] name = "assert_cmd" version = "1.0.3" @@ -573,6 +579,19 @@ dependencies = [ "vec_map", ] +[[package]] +name = "combine" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680" +dependencies = [ + "ascii", + "byteorder", + "either", + "memchr", + "unreachable", +] + [[package]] name = "const_fn" version = "0.4.6" @@ -2158,6 +2177,7 @@ dependencies = [ "termcolor", "textwrap 0.13.4", "toml", + "toml_edit", ] [[package]] @@ -2404,6 +2424,17 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_edit" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09391a441b373597cf0888d2b052dcf82c5be4fee05da3636ae30fb57aad8484" +dependencies = [ + "chrono", + "combine", + "linked-hash-map", +] + [[package]] name = "tracing" version = "0.1.25" @@ -2522,6 +2553,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" +[[package]] +name = "unreachable" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" +dependencies = [ + "void", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -2578,6 +2618,12 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "wait-timeout" version = "0.2.0" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 8d07f7e21..d66cc63b9 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -15,6 +15,7 @@ textwrap = { version="^0.13.4", features=["terminal_size"] } termcolor = "^1.1.2" atty = "^0.2.14" toml = "^0.5.8" +toml_edit = "^0.2.0" [dependencies.taskchampion] path = "../taskchampion" diff --git a/cli/src/argparse/config.rs b/cli/src/argparse/config.rs new file mode 100644 index 000000000..924164564 --- /dev/null +++ b/cli/src/argparse/config.rs @@ -0,0 +1,36 @@ +use super::args::{any, arg_matching, literal}; +use super::ArgList; +use crate::usage; +use nom::{combinator::*, sequence::*, IResult}; + +#[derive(Debug, PartialEq)] +/// A config operation +pub(crate) enum ConfigOperation { + /// Set a configuration value + Set(String, String), +} + +impl ConfigOperation { + pub(super) fn parse(input: ArgList) -> IResult { + fn set_to_op(input: (&str, &str, &str)) -> Result { + Ok(ConfigOperation::Set(input.1.to_owned(), input.2.to_owned())) + } + map_res( + tuple(( + arg_matching(literal("set")), + arg_matching(any), + arg_matching(any), + )), + set_to_op, + )(input) + } + + pub(super) fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "config set", + syntax: "config set ", + summary: "Set a configuration value", + description: "Update Taskchampion configuration file to set key = value", + }); + } +} diff --git a/cli/src/argparse/mod.rs b/cli/src/argparse/mod.rs index 3bdac1a8f..88de59046 100644 --- a/cli/src/argparse/mod.rs +++ b/cli/src/argparse/mod.rs @@ -18,12 +18,14 @@ That is, they contain no references, and have no methods to aid in their executi */ mod args; mod command; +mod config; mod filter; mod modification; mod subcommand; pub(crate) use args::TaskId; pub(crate) use command::Command; +pub(crate) use config::ConfigOperation; pub(crate) use filter::{Condition, Filter}; pub(crate) use modification::{DescriptionMod, Modification}; pub(crate) use subcommand::Subcommand; diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs index 1d7ddc2ba..5609192b7 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -1,5 +1,5 @@ use super::args::*; -use super::{ArgList, DescriptionMod, Filter, Modification}; +use super::{ArgList, ConfigOperation, DescriptionMod, Filter, Modification}; use crate::usage; use nom::{branch::alt, combinator::*, sequence::*, IResult}; use taskchampion::Status; @@ -25,6 +25,11 @@ pub(crate) enum Subcommand { summary: bool, }, + /// Manipulate configuration + Config { + config_operation: ConfigOperation, + }, + /// Add a new task Add { modification: Modification, @@ -61,6 +66,7 @@ impl Subcommand { all_consuming(alt(( Version::parse, Help::parse, + Config::parse, Add::parse, Modify::parse, Info::parse, @@ -74,6 +80,7 @@ impl Subcommand { pub(super) fn get_usage(u: &mut usage::Usage) { Version::get_usage(u); Help::get_usage(u); + Config::get_usage(u); Add::get_usage(u); Modify::get_usage(u); Info::get_usage(u); @@ -131,6 +138,26 @@ impl Help { fn get_usage(_u: &mut usage::Usage) {} } +struct Config; + +impl Config { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: (&str, ConfigOperation)) -> Result { + Ok(Subcommand::Config { + config_operation: input.1, + }) + } + map_res( + tuple((arg_matching(literal("config")), ConfigOperation::parse)), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + ConfigOperation::get_usage(u); + } +} + struct Add; impl Add { @@ -427,6 +454,19 @@ mod test { ); } + #[test] + fn test_config_set() { + assert_eq!( + Subcommand::parse(argv!["config", "set", "x", "y"]).unwrap(), + ( + &EMPTY[..], + Subcommand::Config { + config_operation: ConfigOperation::Set("x".to_owned(), "y".to_owned()) + } + ) + ); + } + #[test] fn test_add_description() { let subcommand = Subcommand::Add { diff --git a/cli/src/invocation/cmd/config.rs b/cli/src/invocation/cmd/config.rs new file mode 100644 index 000000000..0f34defae --- /dev/null +++ b/cli/src/invocation/cmd/config.rs @@ -0,0 +1,62 @@ +use crate::argparse::ConfigOperation; +use crate::settings::Settings; +use termcolor::{ColorSpec, WriteColor}; + +pub(crate) fn execute( + w: &mut W, + config_operation: ConfigOperation, + settings: &Settings, +) -> anyhow::Result<()> { + match config_operation { + ConfigOperation::Set(key, value) => { + let filename = settings.set(&key, &value)?; + write!(w, "Set configuration value ")?; + w.set_color(ColorSpec::new().set_bold(true))?; + write!(w, "{}", &key)?; + w.set_color(ColorSpec::new().set_bold(false))?; + write!(w, " in ")?; + w.set_color(ColorSpec::new().set_bold(true))?; + writeln!(w, "{:?}.", filename)?; + w.set_color(ColorSpec::new().set_bold(false))?; + } + } + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_config_set() { + let cfg_dir = TempDir::new().unwrap(); + let cfg_file = cfg_dir.path().join("foo.toml"); + fs::write( + cfg_file.clone(), + "# store data everywhere\ndata_dir = \"/nowhere\"\n", + ) + .unwrap(); + + let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap(); + + let mut w = test_writer(); + + execute( + &mut w, + ConfigOperation::Set("data_dir".to_owned(), "/somewhere".to_owned()), + &settings, + ) + .unwrap(); + assert!(w.into_string().starts_with("Set configuration value ")); + + let updated_toml = fs::read_to_string(cfg_file.clone()).unwrap(); + dbg!(&updated_toml); + assert_eq!( + updated_toml, + "# store data everywhere\ndata_dir = \"/somewhere\"\n" + ); + } +} diff --git a/cli/src/invocation/cmd/mod.rs b/cli/src/invocation/cmd/mod.rs index 18a973ebb..9371f1f2c 100644 --- a/cli/src/invocation/cmd/mod.rs +++ b/cli/src/invocation/cmd/mod.rs @@ -1,6 +1,7 @@ //! Responsible for executing commands as parsed by [`crate::argparse`]. pub(crate) mod add; +pub(crate) mod config; pub(crate) mod gc; pub(crate) mod help; pub(crate) mod info; diff --git a/cli/src/invocation/mod.rs b/cli/src/invocation/mod.rs index 997440b2e..a9723fe9a 100644 --- a/cli/src/invocation/mod.rs +++ b/cli/src/invocation/mod.rs @@ -35,6 +35,10 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> anyhow::Result<()> subcommand: Subcommand::Help { summary }, command_name, } => return cmd::help::execute(&mut w, command_name, summary), + Command { + subcommand: Subcommand::Config { config_operation }, + .. + } => return cmd::config::execute(&mut w, config_operation, &settings), Command { subcommand: Subcommand::Version, .. @@ -90,6 +94,10 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> anyhow::Result<()> subcommand: Subcommand::Help { .. }, .. } => unreachable!(), + Command { + subcommand: Subcommand::Config { .. }, + .. + } => unreachable!(), Command { subcommand: Subcommand::Version, .. diff --git a/cli/src/settings/settings.rs b/cli/src/settings/settings.rs index 935e5e8ab..592874e6a 100644 --- a/cli/src/settings/settings.rs +++ b/cli/src/settings/settings.rs @@ -1,7 +1,7 @@ use super::util::table_with_keys; use super::{Column, Property, Report, Sort, SortBy}; use crate::argparse::{Condition, Filter}; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; use std::collections::HashMap; use std::convert::TryFrom; use std::env; @@ -9,6 +9,7 @@ use std::fs; use std::path::PathBuf; use taskchampion::Status; use toml::value::Table; +use toml_edit::Document; #[derive(Debug, PartialEq)] pub(crate) struct Settings { @@ -46,7 +47,7 @@ impl Settings { /// Get the default filename for the configuration, or None if that cannot /// be determined. - pub(crate) fn default_filename() -> Option { + fn default_filename() -> Option { if let Some(dir) = dirs_next::config_dir() { Some(dir.join("taskchampion.toml")) } else { @@ -54,7 +55,9 @@ impl Settings { } } - fn load_from_file(config_file: PathBuf, required: bool) -> Result { + /// Update this settings object with the contents of the given TOML file. Top-level settings + /// are overwritten, and reports are overwritten by name. + pub(crate) fn load_from_file(config_file: PathBuf, required: bool) -> Result { let mut settings = Self::default(); let config_toml = match fs::read_to_string(config_file.clone()) { @@ -62,6 +65,7 @@ impl Settings { return if required { Err(e.into()) } else { + settings.filename = Some(config_file); Ok(settings) }; } @@ -141,6 +145,40 @@ impl Settings { Ok(()) } + + /// Set a value in the config file, modifying it in place. Returns the filename. + pub(crate) fn set(&self, key: &str, value: &str) -> Result { + let allowed_keys = [ + "data_dir", + "server_client_key", + "server_origin", + "encryption_secret", + "server_dir", + // reports is not allowed, since it is not a string + ]; + if !allowed_keys.contains(&key) { + bail!("No such configuration key {}", key); + } + + let filename = if let Some(ref f) = self.filename { + f.clone() + } else { + Settings::default_filename() + .ok_or_else(|| anyhow!("Could not determine config file name"))? + }; + + let mut document = fs::read_to_string(filename.clone()) + .context("Could not read existing configuration file")? + .parse::() + .context("Could not parse existing configuration file")?; + + document[key] = toml_edit::value(value); + + fs::write(filename.clone(), document.to_string()) + .context("Could not write updated configuration file")?; + + Ok(filename) + } } impl Default for Settings { @@ -247,9 +285,13 @@ mod test { #[test] fn test_load_from_file_not_required() { let cfg_dir = TempDir::new().unwrap(); + let cfg_file = cfg_dir.path().join("foo.toml"); - let settings = Settings::load_from_file(cfg_dir.path().join("foo.toml"), false).unwrap(); - assert_eq!(settings, Settings::default()); + let settings = Settings::load_from_file(cfg_file.clone(), false).unwrap(); + + let mut expected = Settings::default(); + expected.filename = Some(cfg_file.clone()); + assert_eq!(settings, expected); } #[test] @@ -302,4 +344,21 @@ mod test { assert!(settings.reports.get("foo").is_some()); // beyond existence of this report, we can rely on Report's unit tests } + + #[test] + fn test_set_valid_key() { + let cfg_dir = TempDir::new().unwrap(); + let cfg_file = cfg_dir.path().join("foo.toml"); + fs::write(cfg_file.clone(), "server_dir = \"/srv\"").unwrap(); + + let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap(); + assert_eq!(settings.filename, Some(cfg_file.clone())); + settings.set("data_dir", "/data").unwrap(); + + // load the file again and see the change + let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap(); + assert_eq!(settings.data_dir, PathBuf::from("/data")); + assert_eq!(settings.server_dir, PathBuf::from("/srv")); + assert_eq!(settings.filename, Some(cfg_file)); + } }