Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Add edit command
  • Loading branch information
rossmacarthur committed Apr 28, 2020
1 parent c62600a commit 5b63843
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 44 deletions.
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Expand Up @@ -39,6 +39,7 @@ toml = "0.5.6"
toml_edit = "0.1.5"
url = { version = "2.1.1", features = ["serde"] }
walkdir = "2.3.1"
which = { version = "3.1.1", default-features = false }

[dev-dependencies]
pest = "2.1.3"
Expand Down
7 changes: 7 additions & 0 deletions src/cli.rs
Expand Up @@ -97,6 +97,9 @@ enum RawCommand {
#[structopt(help_message = HELP_MESSAGE)]
Add(Box<Add>),

/// Open up the config file in the default editor.
Edit,

/// Remove a plugin from the config file.
#[structopt(help_message = HELP_MESSAGE)]
Remove {
Expand Down Expand Up @@ -182,6 +185,8 @@ struct RawOpt {
pub enum Command {
/// Add a new plugin to the config file.
Add { name: String, plugin: Box<Plugin> },
/// Open up the config file in the default editor.
Edit,
/// Remove a plugin from the config file.
Remove { name: String },
/// Install the plugins sources and generate the lock file.
Expand Down Expand Up @@ -312,6 +317,7 @@ impl Opt {
plugin: Box::new(plugin),
}
}
RawCommand::Edit => Command::Edit,
RawCommand::Remove { name } => Command::Remove { name },
RawCommand::Lock { reinstall } => Command::Lock { reinstall },
RawCommand::Source { reinstall, relock } => Command::Source { reinstall, relock },
Expand Down Expand Up @@ -404,6 +410,7 @@ OPTIONS:
SUBCOMMANDS:
add Add a new plugin to the config file
edit Open up the config file in the default editor
remove Remove a plugin from the config file
lock Install the plugins sources and generate the lock file
source Generate and print out the script",
Expand Down
35 changes: 20 additions & 15 deletions src/edit.rs
@@ -1,6 +1,6 @@
//! Edit the configuration file.

use std::{fs, path::Path};
use std::{fmt, fs, path::Path};

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

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

Expand All @@ -24,14 +25,20 @@ impl From<RawPlugin> for Plugin {
}
}

impl fmt::Display for Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.doc)
}
}

impl Config {
/// Read a `Config` from the given string.
pub fn from_string<S>(s: S) -> Result<Self>
pub fn from_str<S>(s: S) -> Result<Self>
where
S: Into<String>,
S: AsRef<str>,
{
let doc = s
.into()
.as_ref()
.parse::<toml_edit::Document>()
.context("failed to deserialize contents as TOML")?;
Ok(Self { doc })
Expand All @@ -43,11 +50,9 @@ impl Config {
P: AsRef<Path>,
{
let path = path.as_ref();
let contents = String::from_utf8(
fs::read(&path).with_context(s!("failed to read from `{}`", path.display()))?,
)
.context("config file contents are not valid UTF-8")?;
Self::from_string(contents)
let contents = fs::read_to_string(path)
.with_context(s!("failed to read from `{}`", path.display()))?;
Self::from_str(contents)
}

/// Add a new plugin.
Expand Down Expand Up @@ -94,7 +99,7 @@ impl Config {
P: AsRef<Path>,
{
let path = path.as_ref();
fs::write(path, self.doc.to_string())
fs::write(path, self.to_string())
.with_context(s!("failed to write config to `{}`", path.display()))
}
}
Expand All @@ -111,8 +116,8 @@ mod tests {
use url::Url;

#[test]
fn config_from_string_invalid() {
Config::from_string("x = \n").unwrap_err();
fn config_from_str_invalid() {
Config::from_str("x = \n").unwrap_err();
}

#[test]
Expand Down Expand Up @@ -142,7 +147,7 @@ tag = "0.1.0"

#[test]
fn config_empty_add_git() {
let mut config = Config::from_string("").unwrap();
let mut config = Config::from_str("").unwrap();
config
.add(
"sheldon-test",
Expand All @@ -165,7 +170,7 @@ branch = 'feature'

#[test]
fn config_empty_add_github() {
let mut config = Config::from_string("").unwrap();
let mut config = Config::from_str("").unwrap();
config
.add(
"sheldon-test",
Expand All @@ -188,7 +193,7 @@ tag = '0.1.0'

#[test]
fn config_others_add_git() {
let mut config = Config::from_string(
let mut config = Config::from_str(
r#"
# test configuration file
apply = ["PATH", "source"]
Expand Down
119 changes: 119 additions & 0 deletions src/editor.rs
@@ -0,0 +1,119 @@
//! Open the config file in the default text editor.

use std::{
env, fs,
path::{Path, PathBuf},
process::{self, Command},
};

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

use crate::edit;

/// Possible environment variables.
const ENV_VARS: &[&str] = &["VISUAL", "EDITOR"];

/// Possible editors to use.
#[cfg(not(target_os = "windows"))]
const EDITORS: &[&str] = &["code --wait", "nano", "vim", "vi", "emacs"];

/// Possible editors to use.
#[cfg(target_os = "windows")]
const EDITORS: &[&str] = &["code.exe --wait", "notepad.exe"];

/// Holds a temporary file path that is removed when dropped.
struct TempFile {
/// The file path that we will edit.
path: PathBuf,
}

/// Represents the default editor.
pub struct Editor {
/// The path to the editor binary.
bin: PathBuf,
/// Extra args for the editor that might be required.
args: Vec<String>,
}

/// Representation of a running or exited editor process.
pub struct Child {
/// A handle for the editor child process.
child: process::Child,
/// The temporary file that the editor is editing.
file: TempFile,
}

/// Convert a string command to a binary and the rest of the arguments.
fn to_bin_and_args<S>(cmd: S) -> Option<(PathBuf, Vec<String>)>
where
S: AsRef<str>,
{
let mut split = cmd.as_ref().split_whitespace();
let bin: PathBuf = split.next()?.into();
let args: Vec<String> = split.map(Into::into).collect();
Some((bin, args))
}

impl TempFile {
/// Create a new `TempFile`.
fn new(original_path: &Path) -> Self {
let path = original_path.with_extension(format!("tmp-{}.toml", process::id()));
Self { path }
}
}

impl Editor {
/// Create a new default `Editor`.
///
/// This function tries to read from `ENV_VARS` environment variables.
/// Otherwise it will fallback to any of `EDITORS`.
pub fn default() -> Result<Self> {
let (bin, args) = ENV_VARS
.iter()
.filter_map(|e| env::var(e).ok())
.filter_map(to_bin_and_args)
.chain(EDITORS.iter().filter_map(to_bin_and_args))
.find(|(bin, _)| which::which(bin).is_ok())
.ok_or_else(|| anyhow!("failed to determine default editor"))?;
Ok(Self { bin, args })
}

/// Open a file for editing with initial contents.
pub fn edit(self, path: &Path, contents: &str) -> Result<Child> {
let file = TempFile::new(path);
let Self { bin, args } = self;
fs::write(&file.path, &contents).context("failed to write to temporary file")?;
let child = Command::new(bin)
.args(args)
.arg(&file.path)
.spawn()
.context("failed to spawn editor subprocess")?;
Ok(Child { child, file })
}
}

impl Child {
/// Wait for the child process to exit and then update the config file.
pub fn wait_and_update(self, original_contents: &str) -> Result<edit::Config> {
let Self { mut child, file } = self;
let exit_status = child.wait()?;
if exit_status.success() {
let contents =
fs::read_to_string(&file.path).context("failed to read from temporary file")?;
if contents == original_contents {
bail!("Aborted editing!");
} else {
edit::Config::from_str(&contents)
.context("edited config is invalid, not updating config file")
}
} else {
bail!("editor terminated with {}", exit_status)
}
}
}

impl Drop for TempFile {
fn drop(&mut self) {
fs::remove_file(&self.path).ok();
}
}

0 comments on commit 5b63843

Please sign in to comment.