Skip to content

Commit 5b63843

Browse files
committed
Add edit command
1 parent c62600a commit 5b63843

File tree

6 files changed

+212
-44
lines changed

6 files changed

+212
-44
lines changed

Cargo.lock

+10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ toml = "0.5.6"
3939
toml_edit = "0.1.5"
4040
url = { version = "2.1.1", features = ["serde"] }
4141
walkdir = "2.3.1"
42+
which = { version = "3.1.1", default-features = false }
4243

4344
[dev-dependencies]
4445
pest = "2.1.3"

src/cli.rs

+7
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ enum RawCommand {
9797
#[structopt(help_message = HELP_MESSAGE)]
9898
Add(Box<Add>),
9999

100+
/// Open up the config file in the default editor.
101+
Edit,
102+
100103
/// Remove a plugin from the config file.
101104
#[structopt(help_message = HELP_MESSAGE)]
102105
Remove {
@@ -182,6 +185,8 @@ struct RawOpt {
182185
pub enum Command {
183186
/// Add a new plugin to the config file.
184187
Add { name: String, plugin: Box<Plugin> },
188+
/// Open up the config file in the default editor.
189+
Edit,
185190
/// Remove a plugin from the config file.
186191
Remove { name: String },
187192
/// Install the plugins sources and generate the lock file.
@@ -312,6 +317,7 @@ impl Opt {
312317
plugin: Box::new(plugin),
313318
}
314319
}
320+
RawCommand::Edit => Command::Edit,
315321
RawCommand::Remove { name } => Command::Remove { name },
316322
RawCommand::Lock { reinstall } => Command::Lock { reinstall },
317323
RawCommand::Source { reinstall, relock } => Command::Source { reinstall, relock },
@@ -404,6 +410,7 @@ OPTIONS:
404410
405411
SUBCOMMANDS:
406412
add Add a new plugin to the config file
413+
edit Open up the config file in the default editor
407414
remove Remove a plugin from the config file
408415
lock Install the plugins sources and generate the lock file
409416
source Generate and print out the script",

src/edit.rs

+20-15
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Edit the configuration file.
22
3-
use std::{fs, path::Path};
3+
use std::{fmt, fs, path::Path};
44

55
use anyhow::{bail, Context as ResultExt, Result};
66

@@ -15,6 +15,7 @@ pub struct Plugin {
1515
/// An editable config.
1616
#[derive(Debug, Default)]
1717
pub struct Config {
18+
/// The parsed TOML version of the config.
1819
doc: toml_edit::Document,
1920
}
2021

@@ -24,14 +25,20 @@ impl From<RawPlugin> for Plugin {
2425
}
2526
}
2627

28+
impl fmt::Display for Config {
29+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30+
write!(f, "{}", self.doc)
31+
}
32+
}
33+
2734
impl Config {
2835
/// Read a `Config` from the given string.
29-
pub fn from_string<S>(s: S) -> Result<Self>
36+
pub fn from_str<S>(s: S) -> Result<Self>
3037
where
31-
S: Into<String>,
38+
S: AsRef<str>,
3239
{
3340
let doc = s
34-
.into()
41+
.as_ref()
3542
.parse::<toml_edit::Document>()
3643
.context("failed to deserialize contents as TOML")?;
3744
Ok(Self { doc })
@@ -43,11 +50,9 @@ impl Config {
4350
P: AsRef<Path>,
4451
{
4552
let path = path.as_ref();
46-
let contents = String::from_utf8(
47-
fs::read(&path).with_context(s!("failed to read from `{}`", path.display()))?,
48-
)
49-
.context("config file contents are not valid UTF-8")?;
50-
Self::from_string(contents)
53+
let contents = fs::read_to_string(path)
54+
.with_context(s!("failed to read from `{}`", path.display()))?;
55+
Self::from_str(contents)
5156
}
5257

5358
/// Add a new plugin.
@@ -94,7 +99,7 @@ impl Config {
9499
P: AsRef<Path>,
95100
{
96101
let path = path.as_ref();
97-
fs::write(path, self.doc.to_string())
102+
fs::write(path, self.to_string())
98103
.with_context(s!("failed to write config to `{}`", path.display()))
99104
}
100105
}
@@ -111,8 +116,8 @@ mod tests {
111116
use url::Url;
112117

113118
#[test]
114-
fn config_from_string_invalid() {
115-
Config::from_string("x = \n").unwrap_err();
119+
fn config_from_str_invalid() {
120+
Config::from_str("x = \n").unwrap_err();
116121
}
117122

118123
#[test]
@@ -142,7 +147,7 @@ tag = "0.1.0"
142147

143148
#[test]
144149
fn config_empty_add_git() {
145-
let mut config = Config::from_string("").unwrap();
150+
let mut config = Config::from_str("").unwrap();
146151
config
147152
.add(
148153
"sheldon-test",
@@ -165,7 +170,7 @@ branch = 'feature'
165170

166171
#[test]
167172
fn config_empty_add_github() {
168-
let mut config = Config::from_string("").unwrap();
173+
let mut config = Config::from_str("").unwrap();
169174
config
170175
.add(
171176
"sheldon-test",
@@ -188,7 +193,7 @@ tag = '0.1.0'
188193

189194
#[test]
190195
fn config_others_add_git() {
191-
let mut config = Config::from_string(
196+
let mut config = Config::from_str(
192197
r#"
193198
# test configuration file
194199
apply = ["PATH", "source"]

src/editor.rs

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//! Open the config file in the default text editor.
2+
3+
use std::{
4+
env, fs,
5+
path::{Path, PathBuf},
6+
process::{self, Command},
7+
};
8+
9+
use anyhow::{anyhow, bail, Context as ResultExt, Result};
10+
11+
use crate::edit;
12+
13+
/// Possible environment variables.
14+
const ENV_VARS: &[&str] = &["VISUAL", "EDITOR"];
15+
16+
/// Possible editors to use.
17+
#[cfg(not(target_os = "windows"))]
18+
const EDITORS: &[&str] = &["code --wait", "nano", "vim", "vi", "emacs"];
19+
20+
/// Possible editors to use.
21+
#[cfg(target_os = "windows")]
22+
const EDITORS: &[&str] = &["code.exe --wait", "notepad.exe"];
23+
24+
/// Holds a temporary file path that is removed when dropped.
25+
struct TempFile {
26+
/// The file path that we will edit.
27+
path: PathBuf,
28+
}
29+
30+
/// Represents the default editor.
31+
pub struct Editor {
32+
/// The path to the editor binary.
33+
bin: PathBuf,
34+
/// Extra args for the editor that might be required.
35+
args: Vec<String>,
36+
}
37+
38+
/// Representation of a running or exited editor process.
39+
pub struct Child {
40+
/// A handle for the editor child process.
41+
child: process::Child,
42+
/// The temporary file that the editor is editing.
43+
file: TempFile,
44+
}
45+
46+
/// Convert a string command to a binary and the rest of the arguments.
47+
fn to_bin_and_args<S>(cmd: S) -> Option<(PathBuf, Vec<String>)>
48+
where
49+
S: AsRef<str>,
50+
{
51+
let mut split = cmd.as_ref().split_whitespace();
52+
let bin: PathBuf = split.next()?.into();
53+
let args: Vec<String> = split.map(Into::into).collect();
54+
Some((bin, args))
55+
}
56+
57+
impl TempFile {
58+
/// Create a new `TempFile`.
59+
fn new(original_path: &Path) -> Self {
60+
let path = original_path.with_extension(format!("tmp-{}.toml", process::id()));
61+
Self { path }
62+
}
63+
}
64+
65+
impl Editor {
66+
/// Create a new default `Editor`.
67+
///
68+
/// This function tries to read from `ENV_VARS` environment variables.
69+
/// Otherwise it will fallback to any of `EDITORS`.
70+
pub fn default() -> Result<Self> {
71+
let (bin, args) = ENV_VARS
72+
.iter()
73+
.filter_map(|e| env::var(e).ok())
74+
.filter_map(to_bin_and_args)
75+
.chain(EDITORS.iter().filter_map(to_bin_and_args))
76+
.find(|(bin, _)| which::which(bin).is_ok())
77+
.ok_or_else(|| anyhow!("failed to determine default editor"))?;
78+
Ok(Self { bin, args })
79+
}
80+
81+
/// Open a file for editing with initial contents.
82+
pub fn edit(self, path: &Path, contents: &str) -> Result<Child> {
83+
let file = TempFile::new(path);
84+
let Self { bin, args } = self;
85+
fs::write(&file.path, &contents).context("failed to write to temporary file")?;
86+
let child = Command::new(bin)
87+
.args(args)
88+
.arg(&file.path)
89+
.spawn()
90+
.context("failed to spawn editor subprocess")?;
91+
Ok(Child { child, file })
92+
}
93+
}
94+
95+
impl Child {
96+
/// Wait for the child process to exit and then update the config file.
97+
pub fn wait_and_update(self, original_contents: &str) -> Result<edit::Config> {
98+
let Self { mut child, file } = self;
99+
let exit_status = child.wait()?;
100+
if exit_status.success() {
101+
let contents =
102+
fs::read_to_string(&file.path).context("failed to read from temporary file")?;
103+
if contents == original_contents {
104+
bail!("Aborted editing!");
105+
} else {
106+
edit::Config::from_str(&contents)
107+
.context("edited config is invalid, not updating config file")
108+
}
109+
} else {
110+
bail!("editor terminated with {}", exit_status)
111+
}
112+
}
113+
}
114+
115+
impl Drop for TempFile {
116+
fn drop(&mut self) {
117+
fs::remove_file(&self.path).ok();
118+
}
119+
}

0 commit comments

Comments
 (0)