summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/error.rs49
-rw-r--r--src/lib.rs52
-rw-r--r--src/main.rs193
-rw-r--r--src/model/config.rs110
-rw-r--r--src/model/mod.rs3
-rw-r--r--src/model/plugin.rs283
-rw-r--r--src/model/repo.rs123
-rw-r--r--src/parse/de.rs291
-rw-r--r--src/parse/mod.rs2
-rw-r--r--src/parse/ser.rs62
-rw-r--r--src/path.rs69
11 files changed, 1237 insertions, 0 deletions
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..65e7aff
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,49 @@
+//! Definition of an iris [error][Error].
+
+use std::fmt;
+use std::io;
+
+macro_rules! error {
+ ($($arg:tt)*) => {
+ $crate::error::Error::new(format!($($arg)*))
+ };
+}
+
+pub(crate) use error;
+
+/// Errors that can occur in iris
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+ /// Occurs when a file could not be read, or could not be written to.
+ #[error(transparent)]
+ Io(#[from] io::Error),
+ /// Occurs when an error was raised from the local git repository
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ /// Occurs when iris failed to convert its internal structures into the
+ /// config string representation.
+ #[error(transparent)]
+ De(#[from] crate::parse::de::Error),
+ /// Occurs when a provided string representation of an iris structure is
+ /// invalid and could not be converted/parsed.
+ #[error(transparent)]
+ Ser(#[from] crate::parse::ser::Error),
+ /// Generic error message, occurs anywhere
+ #[error("{0}")]
+ Custom(String),
+}
+
+impl Error {
+ pub fn new(fmt: impl fmt::Display) -> Self {
+ Self::Custom(fmt.to_string())
+ }
+}
+
+impl From<&str> for Error {
+ fn from(msg: &str) -> Self {
+ Self::new(msg)
+ }
+}
+
+/// iris result type
+pub type Result<T> = std::result::Result<T, Error>;
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..27d4e6c
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,52 @@
+//! # Iris
+//! A locking plugin manager for vim
+
+mod model;
+pub use model::{config::Config, plugin::Plugin, repo::Repository};
+
+mod parse;
+
+mod error;
+pub use error::{Error, Result};
+
+pub mod path;
+
+use std::{
+ fs::File,
+ io::{BufWriter, Write},
+ path::Path,
+};
+
+/// Iris copyright header prepended to all generated files
+const COPYRIGHT_HEADER: &str = "\
+IRIS - A locking plugin manager for vim
+Copyright © 2025 Freya Murphy <contact@freyacat.org>
+
+This file is part of IRIS
+
+IRIS is free software; you can redistribute it and/or modify it
+under the terms of the GNU General Public License as published by
+the Free Software Foundation; either version 3 of the License, or (at
+your option) any later version.
+
+IRIS is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with IRIS. If not, see <http://www.gnu.org/licenses/>.";
+
+/// Opens file writer at `path` with copyright header already written
+pub(crate) fn get_writer<P>(path: P, c: char) -> Result<BufWriter<File>>
+where
+ P: AsRef<Path>,
+{
+ let file = File::create(path)?;
+ let mut w = BufWriter::new(file);
+ for line in COPYRIGHT_HEADER.lines() {
+ writeln!(w, "{c}{c}{c} {line}")?;
+ }
+ writeln!(w)?;
+ Ok(w)
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..86cd716
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,193 @@
+//! # Iris
+//! A locking plugin manager for vim
+
+use iris::{Config, Result};
+use log::info;
+use std::{env, process::exit};
+
+const USAGE: &str = "\
+Usage: iris [OPTION]... COMMAND [FILE}";
+
+const HELP: &str = "\
+A locking plugin manager for vim
+
+Commands:
+ switch checkout plugins using their current refs and generate autoload
+ file
+ lock update plugin refs to point to the latest commit
+
+Options:
+ -v, --verbose enable verbose output
+ -q, --quiet disable output
+ -h, --help print the help message
+ -V, --version print the program version
+";
+
+/// print the program version
+fn version() -> ! {
+ println!("iris {}", env!("CARGO_PKG_VERSION"));
+ exit(0);
+}
+
+/// print the program's usage
+fn usage() -> ! {
+ eprintln!("{USAGE}");
+ exit(1);
+}
+
+/// print the program's help message
+fn help() -> ! {
+ println!("{USAGE}\n{HELP}");
+ exit(0);
+}
+
+struct Options {
+ /// the command to run
+ command: String,
+ /// the file to load instead of defaults
+ file: Option<String>,
+ /// print verbose output
+ verbose: bool,
+ /// silence output
+ quiet: bool,
+}
+
+// parse options
+fn opts() -> Options {
+ let mut args = env::args().into_iter().peekable();
+ let mut quiet = false;
+ let mut verbose = false;
+
+ // skip program name
+ _ = args.next();
+
+ // options
+ while let Some(arg) = args.peek() {
+ // arg is not an option
+ if !arg.starts_with("-") {
+ break;
+ };
+ // grab next
+ let arg = args.next().expect("no arg?!");
+
+ match arg.as_str() {
+ // stop parsing options
+ "--" => break,
+ // quiet
+ "-q" | "--quiet" => {
+ quiet = true;
+ }
+ // verbose
+ "-v" | "--verbose" => {
+ verbose = true;
+ }
+ // version
+ "-V" | "--version" => version(),
+ // help
+ "-h" | "--help" => help(),
+ // invalid
+ _ => {
+ eprintln!("invalid options: '{arg}'");
+ exit(1);
+ }
+ };
+ }
+
+ // command
+ let Some(command) = args.next() else {
+ usage();
+ };
+
+ // file
+ let file = args.next();
+
+ // force end of arguments
+ if args.next().is_some() {
+ usage();
+ };
+
+ Options {
+ command,
+ file,
+ quiet,
+ verbose,
+ }
+}
+
+fn log(opts: &Options) {
+ if !opts.quiet {
+ let level = if opts.verbose {
+ log::LevelFilter::Trace
+ } else {
+ log::LevelFilter::Info
+ };
+
+ env_logger::Builder::from_default_env()
+ .filter_level(level)
+ .format_timestamp(None)
+ .format_module_path(false)
+ .format_source_path(false)
+ .init();
+ }
+}
+
+fn switch(opts: &Options) -> Result<()> {
+ let cfg = match &opts.file {
+ Some(path) => Config::read_from_file(path),
+ None => Config::read_from_lock_file()
+ .or_else(|_| Config::read_from_plugin_file()),
+ }?;
+
+ for plugin in &cfg.plugins {
+ plugin.switch()?;
+ info!("switched {}", plugin.id);
+ }
+
+ cfg.write_autoload_file()?;
+
+ Ok(())
+}
+
+fn lock(opts: &Options) -> Result<()> {
+ let mut cfg = match &opts.file {
+ Some(path) => Config::read_from_file(path),
+ None => Config::read_from_plugin_file(),
+ }?;
+
+ for plugin in &mut cfg.plugins {
+ plugin.lock()?;
+ info!("locked {}", plugin.id);
+ }
+
+ // save lock file
+ cfg.write_to_lock_file()?;
+
+ Ok(())
+}
+
+fn inner() -> Result<()> {
+ // parse opts
+ let opts = opts();
+
+ // initalize logger (if not quiet)
+ log(&opts);
+
+ // handle command
+ match opts.command.as_str() {
+ "switch" => switch(&opts)?,
+ "lock" => lock(&opts)?,
+ cmd => {
+ eprintln!("unknown command: '{cmd}'");
+ exit(1);
+ }
+ };
+
+ Ok(())
+}
+
+fn main() {
+ if let Err(err) = inner() {
+ log::error!("{err}");
+ exit(1);
+ }
+}
diff --git a/src/model/config.rs b/src/model/config.rs
new file mode 100644
index 0000000..844609d
--- /dev/null
+++ b/src/model/config.rs
@@ -0,0 +1,110 @@
+//! Definition of an iris [config][Config].
+
+use crate::{get_writer, path, Plugin, Result};
+
+use log::trace;
+use std::{fs, io::Write, path::Path};
+
+/// Iris config
+#[derive(Debug)]
+pub struct Config {
+ /// List of iris plugin specifications
+ pub plugins: Vec<Plugin>,
+}
+
+impl Config {
+ /// Read an iris config from the file system
+ pub fn read_from_file<P>(path: P) -> Result<Self>
+ where
+ P: AsRef<Path>,
+ {
+ trace!("reading: {}", path.as_ref().display());
+ let contents = fs::read_to_string(&path)?;
+ let iris = Self::parse(&contents)?;
+ Ok(iris)
+ }
+
+ /// Read an iris config from the plugin file path
+ pub fn read_from_plugin_file() -> Result<Self> {
+ trace!("read from plugin file");
+ Self::read_from_file(path::plugin_file())
+ }
+
+ /// Read an iris config from the lock file path
+ pub fn read_from_lock_file() -> Result<Self> {
+ trace!("read from lock file");
+ Self::read_from_file(path::lock_file())
+ }
+
+ /// Write the iris config to the file system.
+ pub fn write_to_file<P>(&self, path: P) -> Result<()>
+ where
+ P: AsRef<Path>,
+ {
+ trace!("writing: {}", path.as_ref().display());
+ let contents = self.to_string()?;
+ let mut write = get_writer(&path, '#')?;
+ write.write_all(contents.as_bytes())?;
+ Ok(())
+ }
+
+ /// Write the iris config to the plugins file.
+ pub fn write_to_plugin_file(&self) -> Result<()> {
+ self.write_to_file(path::plugin_file())
+ }
+
+ /// Write the iris config to the lock file.
+ pub fn write_to_lock_file(&self) -> Result<()> {
+ self.write_to_file(path::lock_file())
+ }
+
+ /// Write the vim autoload file formed from each plugin.
+ pub fn write_autoload_file(&self) -> Result<()> {
+ let path = path::autoload_file();
+ trace!("writing: {}", path.display());
+
+ let mut f = get_writer(&path, '"')?;
+
+ // BEGIN iris#load
+ writeln!(f, "function! iris#load()")?;
+ // add each plugin to load path
+ for plugin in &self.plugins {
+ // runtime path
+ {
+ let path = plugin.runtime_path().display();
+ writeln!(f, "set runtimepath+={path}")?;
+ }
+ // lua package path
+ if let Some(path) = plugin.lua_package_path() {
+ let path = path.display();
+ writeln!(f, "lua package.path = package.path .. \";{path}\"")?;
+ }
+ // vim source file
+ if let Some(path) = plugin.vim_source_file() {
+ let path = path.display();
+ writeln!(f, "source {path}")?;
+ }
+ // lua source file
+ if let Some(path) = plugin.lua_source_file() {
+ let path = path.display();
+ writeln!(f, "lua dofile('{path}')")?;
+ }
+ }
+ for plugin in &self.plugins {
+ // vim source after file
+ if let Some(path) = plugin.vim_source_after_file() {
+ let path = path.display();
+ writeln!(f, "source {path}")?;
+ }
+ // lua source after file
+ if let Some(path) = plugin.lua_source_after_file() {
+ let path = path.display();
+ writeln!(f, "lua dofile('{path}')")?;
+ }
+ }
+ writeln!(f, "endfunction")?;
+ // END iris#load
+
+ Ok(())
+ }
+}
diff --git a/src/model/mod.rs b/src/model/mod.rs
new file mode 100644
index 0000000..41088af
--- /dev/null
+++ b/src/model/mod.rs
@@ -0,0 +1,3 @@
+pub mod config;
+pub mod plugin;
+pub mod repo;
diff --git a/src/model/plugin.rs b/src/model/plugin.rs
new file mode 100644
index 0000000..bb097c6
--- /dev/null
+++ b/src/model/plugin.rs
@@ -0,0 +1,283 @@
+//! Defines of an iris [plugin][Plugin].
+
+use crate::{error::error, path, Repository, Result};
+
+use log::{debug, trace, warn};
+use std::{borrow::Cow, cmp::Ordering, collections::HashSet, path::PathBuf};
+use url::Url;
+
+/// Returns the url to the fallback git server. If a repository url is provided
+/// and it does not contain a git server (just author and repo), this url will
+/// be used in its absence.
+fn fallback_git_server() -> &'static Url {
+ use std::sync::OnceLock;
+ static FALLBACK: OnceLock<Url> = OnceLock::new();
+ FALLBACK.get_or_init(|| {
+ Url::parse("https://github.com")
+ .expect("!! IRIS BUG !! invalid fallback git server url")
+ })
+}
+
+/// # Plugin
+///
+/// A structure representing an iris plugin specification.
+///
+/// An iris plugin has two parts.
+///
+/// - [id], must be a unique identifier for this plugin and the plugins
+/// module name
+/// - [args], a set of arguments to configure this plugin
+///
+/// [id]: Self::id
+/// [args]: Self::args
+#[derive(Debug, Eq)]
+pub struct Plugin {
+ /// The plugins unique identifier. Used for the vim module name and
+ /// repository checkout foler.
+ pub id: String,
+ /// The commit oid (if set) that the local repository will be forced to
+ /// track. If not set then the repository will track the latest commit in
+ /// the current tracking branch.
+ pub commit: Option<String>,
+ /// The branch name (if set) that the local repository will be forced to
+ /// track. If not set then the repository will track the repositories
+ /// default branch.
+ pub branch: Option<String>,
+ /// Set of plugin ids this plugin should load before
+ pub run: Option<String>,
+ /// Handle to the local plugin repository.
+ pub before: HashSet<String>,
+ /// Set of plugin ids this plugin should load after
+ pub after: HashSet<String>,
+ /// The command to run when vim starts up
+ repo: Repository,
+}
+
+impl Plugin {
+ /// Create a new iris plugin.
+ ///
+ /// Creates a plugin from the git repo at `url` with the unique id `id`.
+ /// The plugin will be unlocked meaning it will always track the latest
+ /// commit until locked. A handle to a local repository will be created but
+ /// not loaded until called upon.
+ pub fn new(id: String, url: &str) -> Result<Self> {
+ // resolve and parse url
+ let url = {
+ // try url as is
+ Url::parse(url)
+ // try url with fallback
+ .or_else(|_| fallback_git_server().join(url))
+ // invalid git url!
+ .map_err(|_| error!("invalid git url {url}"))
+ }?;
+
+ // get the path to the local repository checkout
+ let repo_path = path::plugin_dir().join(&id);
+
+ // create repository handle
+ let repo = Repository::new(url, repo_path);
+
+ debug!("found plugin '{id}'");
+
+ Ok(Self {
+ id,
+ commit: None,
+ branch: None,
+ run: None,
+ before: HashSet::new(),
+ after: HashSet::new(),
+ repo,
+ })
+ }
+
+ /// The url to the remote git repository.
+ pub const fn url(&self) -> &Url {
+ &self.repo.url
+ }
+
+ /// The path to the local repository
+ pub const fn repo_path(&self) -> &PathBuf {
+ &self.repo.path
+ }
+
+ /// The commit oid that the plugin repository is currently tracking
+ fn commit(&self) -> Result<Cow<'_, str>> {
+ // returned locked commit if set
+ if let Some(commit) = &self.commit {
+ return Ok(Cow::Borrowed(commit));
+ }
+
+ // get commit from repo
+ let branch = self.branch()?;
+ let commit = self.repo.latest_commit(&branch)?;
+ Ok(Cow::Owned(commit))
+ }
+
+ /// The branch name that the plugin repository is currently tracking
+ fn branch(&self) -> Result<Cow<'_, str>> {
+ // return locked branch if set
+ if let Some(branch) = &self.branch {
+ return Ok(Cow::Borrowed(branch));
+ }
+
+ // get branch from repo
+ let branch = self.repo.default_branch()?;
+ Ok(Cow::Owned(branch))
+ }
+
+ /// Fetches latest changes in the local repository but does not update
+ /// checkout.
+ pub fn fetch(&self) -> Result<()> {
+ trace!("fetching {}", self.id);
+ let branch = self.branch()?;
+ self.repo.fetch(&branch)?;
+ Ok(())
+ }
+
+ /// Checkout plugin repository using their current refs
+ pub fn switch(&self) -> Result<()> {
+ trace!("switching {}", self.id);
+ let commit = self.commit()?;
+ self.repo.checkout(&commit)?;
+ Ok(())
+ }
+
+ /// Update plugin ref to point to the latest commit
+ pub fn lock(&mut self) -> Result<()> {
+ trace!("locking {}", self.id);
+ let branch = self.branch()?;
+ self.repo.fetch(&branch)?;
+ let commit = self.repo.latest_commit(&branch)?;
+ self.commit = Some(commit);
+ Ok(())
+ }
+
+ /// Runtime path
+ ///
+ /// This this the plugins root directory, and is used by vim/neovim to
+ /// find the vim/lua runtime scripts.
+ ///
+ /// vim: set runtimepath+=<path>
+ pub fn runtime_path(&self) -> &PathBuf {
+ self.repo_path()
+ }
+
+ /// Lua package path
+ ///
+ /// This is the path to the lua module if the plugin has one. If so
+ /// it needs to be added to the lua package path. The path is located
+ /// in the repos lua directory at /lua/<id>/init.lua or /lua/<id>.lua. If
+ /// both exist the former takes priority.
+ ///
+ /// lua: package.path = package.path .. ";<path>"
+ pub fn lua_package_path(&self) -> Option<PathBuf> {
+ let mut lua = self.repo_path().to_owned();
+ lua.push("lua");
+ lua.push(&self.id);
+ // /lua/<id>/init.lua
+ let module = lua.join("init.lua");
+ if module.exists() {
+ return Some(module.with_file_name("?.lua"));
+ }
+ // /lua/<id>/.lua
+ let module = lua.with_extension("lua");
+ if module.exists() {
+ return Some(module.with_file_name("?.lua"));
+ }
+ // no lua package path found
+ None
+ }
+
+ /// Vim source file
+ ///
+ /// Path to a vim file in the /plugin directory that needs to be
+ /// sourced. This is located at /plugin/<id>.vim
+ ///
+ /// vim: source <path>
+ pub fn vim_source_file(&self) -> Option<PathBuf> {
+ let mut path = self.repo_path().to_owned();
+ path.push("plugin");
+ path.push(&self.id);
+ path.set_extension("vim");
+ Some(path).filter(|path| path.exists())
+ }
+
+ /// Lua source file
+ ///
+ /// Path to a lua file in the /plugin directory that needs to be
+ /// sourced. This is located at /plugin/<id>.lua
+ ///
+ /// lua: dofile('<path>')
+ pub fn lua_source_file(&self) -> Option<PathBuf> {
+ let mut path = self.repo_path().to_owned();
+ path.push("plugin");
+ path.push(&self.id);
+ path.set_extension("lua");
+ Some(path).filter(|path| path.exists())
+ }
+
+ /// Vim source after file
+ ///
+ /// Path to a vim file in the /after/plugin directory that needs to be
+ /// sourced last. This is located at /after/plugin/<id>.vim
+ ///
+ /// vim: source <path>
+ pub fn vim_source_after_file(&self) -> Option<PathBuf> {
+ let mut path = self.repo_path().to_owned();
+ path.push("after/plugin");
+ path.push(&self.id);
+ path.set_extension("vim");
+ Some(path).filter(|path| path.exists())
+ }
+
+ /// Lua source after file
+ ///
+ /// Path to a lua file in the /after/plugin directory that needs to be
+ /// sourced last. This is located at /after/plugin/<id>.lua
+ ///
+ /// lua: dofile('<path>')
+ pub fn lua_source_after_file(&self) -> Option<PathBuf> {
+ let mut path = self.repo_path().to_owned();
+ path.push("after/plugin");
+ path.push(&self.id);
+ path.set_extension("lua");
+ Some(path).filter(|path| path.exists())
+ }
+}
+
+impl PartialEq for Plugin {
+ fn eq(&self, other: &Self) -> bool {
+ self.id.eq(&other.id)
+ }
+}
+
+impl PartialOrd for Plugin {
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+ let is_after =
+ other.before.contains(&self.id) || self.after.contains(&other.id);
+ let is_before =
+ other.after.contains(&self.id) || self.before.contains(&other.id);
+ match (is_before, is_after) {
+ // equal, no ordering
+ (false, false) => Some(Ordering::Equal),
+ // before
+ (true, false) => Some(Ordering::Less),
+ // after
+ (false, true) => Some(Ordering::Greater),
+ // cycle detected
+ (true, true) => {
+ warn!(
+ "plugin dependency cycle detected between '{}' and '{}'",
+ &self.id, &other.id
+ );
+ None
+ }
+ }
+ }
+}
+
+impl Ord for Plugin {
+ fn cmp(&self, other: &Self) -> Ordering {
+ self.partial_cmp(other).unwrap_or(Ordering::Equal)
+ }
+}
diff --git a/src/model/repo.rs b/src/model/repo.rs
new file mode 100644
index 0000000..557efb4
--- /dev/null
+++ b/src/model/repo.rs
@@ -0,0 +1,123 @@
+//! Definition of an iris [repo][Repository].
+
+use crate::{error, Result};
+
+use log::{debug, trace};
+use std::{path::PathBuf, sync::OnceLock};
+use url::Url;
+
+/// A handle to a locally stored git repository.
+pub struct Repository {
+ /// The remote url that the git repository is located at.
+ pub url: Url,
+ /// The path on disk that the this repository is/will be located at.
+ pub path: PathBuf,
+ /// The git2 lib handle to the repo
+ inner: OnceLock<git2::Repository>,
+}
+
+impl Repository {
+ /// Creates a new handle to a local git repository.
+ pub const fn new(url: Url, path: PathBuf) -> Self {
+ Self {
+ url,
+ path,
+ inner: OnceLock::new(),
+ }
+ }
+
+ /// Gets the handle to the local repository
+ fn repo(&self) -> Result<&git2::Repository> {
+ // we only need to initalize once
+ if let Some(repo) = self.inner.get() {
+ return Ok(repo);
+ }
+
+ trace!("loading repo");
+
+ // repository not loaded, try loading it
+ let repo = if self.path.exists() {
+ // repo exists on file system, just open it
+ git2::Repository::open(&self.path)?
+ } else {
+ // repo needs to be cloned
+ git2::Repository::clone(self.url.as_str(), &self.path)?
+ };
+
+ // save and return repo
+ Ok(self.inner.get_or_init(|| repo))
+ }
+
+ /// Gets the current remote in the git repository
+ fn remote(&self) -> Result<git2::Remote> {
+ let repo = self.repo()?;
+ let remote_name = "origin";
+ let mut remote = repo.find_remote(remote_name)?;
+
+ // make sure that out remote is connected before
+ // we do anything with it. otherwise things will
+ // fail!
+ if !remote.connected() {
+ trace!("connecting to remote");
+ remote.connect(git2::Direction::Fetch)?;
+ }
+
+ Ok(remote)
+ }
+
+ /// Fetches the latest commits in the provided branch.
+ pub fn fetch(&self, branch: &str) -> Result<()> {
+ let mut remote = self.remote()?;
+ trace!("fetching '{branch}");
+ remote.fetch(&[branch], None, None)?;
+ Ok(())
+ }
+
+ /// Returns the default branch of the git repositroy.
+ pub fn default_branch(&self) -> Result<String> {
+ let remote = self.remote()?;
+ let buf = remote.default_branch()?;
+ let name = buf
+ .as_str()
+ .and_then(|s| s.strip_prefix("refs/heads/"))
+ .map(String::from)
+ .ok_or_else(|| error::error!("cannot get default branch"))?;
+ debug!("default branch is '{name}'");
+ Ok(name)
+ }
+
+ /// Returns the latest fetched commit in `branch`.
+ pub fn latest_commit(&self, branch: &str) -> Result<String> {
+ let repo = self.repo()?;
+ let refr_name = format!("refs/remotes/origin/{branch}");
+ let refr = repo.find_reference(&refr_name)?;
+ let commit = refr.peel_to_commit()?;
+ let oid = commit.id().to_string();
+ Ok(oid)
+ }
+
+ /// Checks out the local repository to the given commit and detaches
+ /// HEAD to that commit.
+ pub fn checkout(&self, commit: &str) -> Result<()> {
+ let repo = self.repo()?;
+ trace!("checkout to '{commit}'");
+ let oid = git2::Oid::from_str(commit)?;
+ let commit = repo.find_commit(oid)?;
+ repo.reset(commit.as_object(), git2::ResetType::Hard, None)?;
+ Ok(())
+ }
+}
+
+impl std::fmt::Debug for Repository {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "_")
+ }
+}
+
+impl PartialEq for Repository {
+ fn eq(&self, other: &Self) -> bool {
+ self.url.eq(&other.url) && self.path.eq(&other.path)
+ }
+}
+
+impl Eq for Repository {}
diff --git a/src/parse/de.rs b/src/parse/de.rs
new file mode 100644
index 0000000..4b312a4
--- /dev/null
+++ b/src/parse/de.rs
@@ -0,0 +1,291 @@
+//! Deserialize an iris structure from a string.
+
+use crate::{Config, Plugin};
+
+use serde::de;
+use std::collections::HashSet;
+use std::fmt;
+use std::str::FromStr;
+
+/// Errors that can occur when deserializing a type
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+ /// Occurs when toml could not be deserialized
+ #[error(transparent)]
+ Toml(#[from] toml::de::Error),
+}
+
+macro_rules! error {
+ ($($arg:tt)*) => {
+ de::Error::custom(format!($($arg)*))
+ };
+}
+
+struct PluginIDsVisitor;
+impl<'de> de::Visitor<'de> for PluginIDsVisitor {
+ type Value = Vec<String>;
+
+ fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ write!(f, "single or list of plugin ids")
+ }
+
+ fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+ where
+ E: de::Error,
+ {
+ self.visit_string(v.to_string())
+ }
+
+ fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
+ where
+ E: de::Error,
+ {
+ Ok(vec![v])
+ }
+
+ fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+ where
+ A: de::SeqAccess<'de>,
+ {
+ let mut values =
+ seq.size_hint().map_or_else(Vec::new, Vec::with_capacity);
+ while let Some(value) = seq.next_element::<String>()? {
+ values.push(value);
+ }
+ Ok(values)
+ }
+}
+
+struct PluginIDsSeed;
+impl<'de> de::DeserializeSeed<'de> for PluginIDsSeed {
+ type Value = HashSet<String>;
+
+ fn deserialize<D>(self, d: D) -> Result<Self::Value, D::Error>
+ where
+ D: de::Deserializer<'de>,
+ {
+ let values = d.deserialize_any(PluginIDsVisitor)?;
+ Ok(HashSet::from_iter(values))
+ }
+}
+
+// plugin visitor with seeded plugin id
+struct PluginVisitor(Option<String>);
+impl<'de> de::Visitor<'de> for PluginVisitor {
+ type Value = Plugin;
+
+ fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match &self.0 {
+ Some(id) => write!(f, "arguments for plugin '{id}'"),
+ None => write!(f, "plugin id and arguments"),
+ }
+ }
+
+ fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+ where
+ E: de::Error,
+ {
+ self.visit_string(v.to_string())
+ }
+
+ fn visit_borrowed_str<E>(self, v: &'_ str) -> Result<Self::Value, E>
+ where
+ E: de::Error,
+ {
+ self.visit_string(v.to_string())
+ }
+
+ fn visit_string<E>(self, url: String) -> Result<Self::Value, E>
+ where
+ E: de::Error,
+ {
+ let Some(id) = self.0 else {
+ return Err(de::Error::missing_field("id"));
+ };
+ Self::Value::new(id, &url).map_err(E::custom)
+ }
+
+ fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
+ where
+ A: de::MapAccess<'de>,
+ {
+ // parse each map value as a possbile field for the plugin
+ let mut id = self.0; // plugin id (required)
+ let mut url = None; // repository url (required)
+ let mut commit = None; // commit to lock to
+ let mut branch = None; // branch to lock to
+ let mut run = None; // command to run on launch
+ let mut before = HashSet::new(); // plugins to load before
+ let mut after = HashSet::new(); // plugins to load after
+
+ while let Some(key) = map.next_key::<String>()? {
+ match key.as_str() {
+ // plugin id
+ "id" => {
+ id = Some(map.next_value::<String>()?);
+ }
+ // repo url
+ "url" => {
+ url = Some(map.next_value::<String>()?);
+ }
+ // locked commit
+ "commit" => {
+ commit = Some(map.next_value::<String>()?);
+ }
+ // locked branch
+ "branch" => {
+ branch = Some(map.next_value::<String>()?);
+ }
+ // vim command to run on launch
+ "run" => {
+ run = Some(map.next_value::<String>()?);
+ }
+ // plugins to load before
+ "before" => {
+ before = map.next_value_seed(PluginIDsSeed)?;
+ }
+ // plugins to load after
+ "after" => {
+ after = map.next_value_seed(PluginIDsSeed)?;
+ }
+ // invalid key!
+ key => return Err(error!("unknown plugin field '{key}'")),
+ };
+ }
+
+ // id is a required field
+ let Some(id) = id else {
+ return Err(de::Error::missing_field("id"));
+ };
+
+ // url is a required field
+ let Some(url) = url else {
+ return Err(de::Error::missing_field("url"));
+ };
+
+ let mut plugin =
+ Self::Value::new(id, &url).map_err(de::Error::custom)?;
+ plugin.commit = commit;
+ plugin.branch = branch;
+ plugin.run = run;
+ plugin.before = before;
+ plugin.after = after;
+ Ok(plugin)
+ }
+}
+
+// deserialize plugin with possible id
+struct PluginSeed(Option<String>);
+impl<'de> de::DeserializeSeed<'de> for PluginSeed {
+ type Value = Plugin;
+
+ fn deserialize<D>(self, d: D) -> Result<Self::Value, D::Error>
+ where
+ D: de::Deserializer<'de>,
+ {
+ d.deserialize_any(PluginVisitor(self.0))
+ }
+}
+
+// plugins visitor
+struct PluginsVisitor;
+impl<'de> de::Visitor<'de> for PluginsVisitor {
+ type Value = Vec<Plugin>;
+
+ fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str("map of plugin id's to their arguments")
+ }
+
+ fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
+ where
+ A: de::MapAccess<'de>,
+ {
+ let mut plugins =
+ map.size_hint().map_or_else(Vec::new, Vec::with_capacity);
+ while let Some(id) = map.next_key()? {
+ let plugin = map.next_value_seed(PluginSeed(Some(id)))?;
+ plugins.push(plugin);
+ }
+ Ok(plugins)
+ }
+
+ fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+ where
+ A: de::SeqAccess<'de>,
+ {
+ let mut plugins =
+ seq.size_hint().map_or_else(Vec::new, Vec::with_capacity);
+ while let Some(plugin) = seq.next_element_seed(PluginSeed(None))? {
+ plugins.push(plugin);
+ }
+ Ok(plugins)
+ }
+}
+
+// plugins seed
+struct PluginsSeed;
+impl<'de> de::DeserializeSeed<'de> for PluginsSeed {
+ type Value = Vec<Plugin>;
+
+ fn deserialize<D>(self, d: D) -> Result<Self::Value, D::Error>
+ where
+ D: de::Deserializer<'de>,
+ {
+ d.deserialize_any(PluginsVisitor)
+ }
+}
+
+// config visitor
+struct ConfigVisitor;
+impl<'de> de::Visitor<'de> for ConfigVisitor {
+ type Value = Config;
+
+ fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str("iris config value")
+ }
+
+ fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
+ where
+ A: de::MapAccess<'de>,
+ {
+ let mut plugins = None; // list of plugins (required)
+ while let Some(key) = map.next_key::<String>()? {
+ match key.as_str() {
+ "plugins" => {
+ plugins = Some(map.next_value_seed(PluginsSeed)?);
+ }
+ key => return Err(error!("unknown config field '{key}'")),
+ };
+ }
+ // plugins is a required field
+ let Some(mut plugins) = plugins else {
+ return Err(de::Error::missing_field("plugins"));
+ };
+ plugins.sort();
+ Ok(Config { plugins })
+ }
+}
+
+impl<'de> de::Deserialize<'de> for Config {
+ fn deserialize<D>(d: D) -> Result<Self, D::Error>
+ where
+ D: de::Deserializer<'de>,
+ {
+ d.deserialize_map(ConfigVisitor)
+ }
+}
+
+impl Config {
+ pub fn parse(s: &str) -> crate::Result<Self> {
+ toml::from_str(s)
+ .map_err(Error::from)
+ .map_err(crate::Error::from)
+ }
+}
+
+impl FromStr for Config {
+ type Err = crate::Error;
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Self::parse(s)
+ }
+}
diff --git a/src/parse/mod.rs b/src/parse/mod.rs
new file mode 100644
index 0000000..2e6ab62
--- /dev/null
+++ b/src/parse/mod.rs
@@ -0,0 +1,2 @@
+pub mod de;
+pub mod ser;
diff --git a/src/parse/ser.rs b/src/parse/ser.rs
new file mode 100644
index 0000000..3a29fde
--- /dev/null
+++ b/src/parse/ser.rs
@@ -0,0 +1,62 @@
+//! Serialize an iris structure into a string.
+
+use crate::{Config, Plugin};
+
+use serde::ser;
+
+/// Errors that can occur when serializing a type
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+ /// Occurs when toml could not be eserialized
+ #[error(transparent)]
+ Toml(#[from] toml::ser::Error),
+}
+
+impl ser::Serialize for Plugin {
+ fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
+ where
+ S: ser::Serializer,
+ {
+ use ser::SerializeMap;
+ let mut s = s.serialize_map(None)?;
+ s.serialize_entry("id", &self.id)?;
+ s.serialize_entry("url", self.url().as_str())?;
+ if let Some(commit) = &self.commit {
+ s.serialize_entry("commit", commit)?;
+ }
+ if let Some(branch) = &self.branch {
+ s.serialize_entry("branch", branch)?;
+ }
+ if let Some(run) = &self.run {
+ s.serialize_entry("run", run)?;
+ }
+ s.serialize_entry("before", &self.before)?;
+ s.serialize_entry("after", &self.after)?;
+ s.end()
+ }
+}
+
+impl ser::Serialize for Config {
+ fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
+ where
+ S: ser::Serializer,
+ {
+ use ser::SerializeMap;
+ let mut s = s.serialize_map(Some(1))?;
+
+ // plugins
+ s.serialize_key("plugins")?;
+ s.serialize_value(&self.plugins)?;
+
+ s.end()
+ }
+}
+
+impl Config {
+ /// Serializes a string from the iris config
+ pub fn to_string(&self) -> crate::Result<String> {
+ toml::to_string(self)
+ .map_err(Error::from)
+ .map_err(crate::Error::from)
+ }
+}
diff --git a/src/path.rs b/src/path.rs
new file mode 100644
index 0000000..b7fdf91
--- /dev/null
+++ b/src/path.rs
@@ -0,0 +1,69 @@
+//! Directory and file paths used by iris.
+
+use std::path::PathBuf;
+
+/// Root of the data directory
+fn base_config_dir() -> PathBuf {
+ dirs::config_local_dir().expect("could not locate config directory")
+}
+
+/// Root of the config directory
+fn base_data_dir() -> PathBuf {
+ dirs::data_local_dir().expect("could not locate data directory")
+}
+
+/// Path to the current diectort
+pub fn current_dir() -> PathBuf {
+ std::env::current_dir().expect("could not locate current directory")
+}
+
+/// Path to iris's config directory.
+///
+/// This is the directory where iris will look and save it's config files.
+pub fn config_dir() -> PathBuf {
+ base_config_dir().join("iris")
+}
+
+/// Default path to iris's plugin file
+pub fn default_plugin_file() -> PathBuf {
+ config_dir().join("iris.toml")
+}
+
+/// Path to iris's plugin file
+pub fn plugin_file() -> PathBuf {
+ [current_dir(), config_dir()]
+ .into_iter()
+ .map(|dir| dir.join("iris.toml"))
+ .find(|path| path.exists())
+ .unwrap_or_else(default_plugin_file)
+}
+
+/// Path to iris's lock file
+pub fn lock_file() -> PathBuf {
+ plugin_file().with_file_name("iris.lock")
+}
+
+/// Path to iris's data directory.
+pub fn data_dir() -> PathBuf {
+ base_data_dir().join("iris")
+}
+
+/// Path to iris's plugin directory.
+///
+/// This is where each checkout for each plugin repository will be saved.
+pub fn plugin_dir() -> PathBuf {
+ data_dir().join("plugins")
+}
+
+/// Path to iris's transaction lock file.
+///
+/// The transaction lock ensures that only one process is operating on iris's
+/// data directory at a time.
+pub fn transaction_lock_file() -> PathBuf {
+ data_dir().join("lock")
+}
+
+/// Destination path for iris's vim autoload file.
+pub fn autoload_file() -> PathBuf {
+ base_data_dir().join("nvim/site/autoload/iris.vim")
+}