Add a ta config set subcommand

This uses `toml_edit` to edit the config file in-place.  For the moment,
it only supports top-level arguments, but can be extended to do other
things later.
This commit is contained in:
Dustin J. Mitchell 2021-05-05 14:18:17 -04:00
parent a778423cbc
commit fd62c8327b
9 changed files with 261 additions and 6 deletions

46
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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<ArgList, ConfigOperation> {
fn set_to_op(input: (&str, &str, &str)) -> Result<ConfigOperation, ()> {
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 <key> <value>",
summary: "Set a configuration value",
description: "Update Taskchampion configuration file to set key = value",
});
}
}

View file

@ -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;

View file

@ -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<ArgList, Subcommand> {
fn to_subcommand(input: (&str, ConfigOperation)) -> Result<Subcommand, ()> {
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 {

View file

@ -0,0 +1,62 @@
use crate::argparse::ConfigOperation;
use crate::settings::Settings;
use termcolor::{ColorSpec, WriteColor};
pub(crate) fn execute<W: WriteColor>(
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"
);
}
}

View file

@ -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;

View file

@ -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,
..

View file

@ -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<PathBuf> {
fn default_filename() -> Option<PathBuf> {
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<Self> {
/// 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<Self> {
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<PathBuf> {
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::<Document>()
.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));
}
}