mod file;
mod plugin;
mod script;
pub mod source;
use std::fs;
use std::path::Path;
use std::sync::LazyLock as Lazy;
use anyhow::{Context as ResultExt, Result};
use indexmap::{indexmap, IndexMap};
use itertools::{Either, Itertools};
use rayon::prelude::*;
use crate::config::{Config, MatchesProfile, Plugin, Shell};
use crate::context::Context;
pub use crate::lock::file::LockedConfig;
use crate::lock::file::{LockedExternalPlugin, LockedPlugin};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockMode {
Normal,
Update,
Reinstall,
}
pub fn from_path<P>(path: P) -> Result<LockedConfig>
where
P: AsRef<Path>,
{
let path = path.as_ref();
let locked: LockedConfig = toml::from_str(&String::from_utf8_lossy(
&fs::read(path)
.with_context(|| format!("failed to read locked config from `{}`", path.display()))?,
))
.context("failed to deserialize locked config")?;
Ok(locked)
}
pub fn config(ctx: &Context, config: Config) -> Result<LockedConfig> {
let Config {
shell,
matches,
apply,
templates,
plugins,
} = config;
let templates = {
let mut map = shell
.map(|s| s.default_templates().clone())
.unwrap_or_default();
for (name, template) in templates {
map.insert(name, template);
}
map
};
let (externals, inlines): (Vec<_>, Vec<_>) =
plugins
.into_iter()
.enumerate()
.partition_map(|(index, plugin)| match plugin {
Plugin::External(plugin) => Either::Left((index, plugin)),
Plugin::Inline(plugin) => Either::Right((index, plugin)),
});
let inlines = inlines
.into_iter()
.filter(|(_, p)| p.matches_profile(ctx))
.map(|(i, p)| (i, LockedPlugin::Inline(p)));
let mut map = IndexMap::new();
for (index, plugin) in externals {
map.entry(plugin.source.clone())
.or_insert_with(|| Vec::with_capacity(1))
.push((index, plugin));
}
let matches = matches
.as_deref()
.or_else(|| shell.map(|s| s.default_matches()));
let apply = apply
.as_deref()
.or_else(|| shell.map(|s| s.default_apply()));
let count = map.len();
let mut errors = Vec::new();
let plugins = if count == 0 {
inlines
.into_iter()
.map(|(_, locked)| locked)
.collect::<Vec<_>>()
} else {
map.into_par_iter()
.map(|(source, plugins)| {
let source_name = source.to_string();
let plugins: Vec<_> = plugins
.into_iter()
.filter(|(_, p)| p.matches_profile(ctx))
.collect();
if plugins.is_empty() {
ctx.log_status("Skipped", &source_name);
Ok(vec![])
} else {
let source = source::lock(ctx, source)
.with_context(|| format!("failed to install source `{source_name}`"))?;
let mut locked = Vec::with_capacity(plugins.len());
for (index, plugin) in plugins {
let name = plugin.name.clone();
let plugin = plugin::lock(ctx, source.clone(), matches, apply, plugin)
.with_context(|| format!("failed to install plugin `{name}`"));
locked.push((index, plugin));
}
Ok(locked)
}
})
.collect::<Vec<_>>()
.into_iter()
.filter_map(|result| match result {
Ok(ok) => Some(ok),
Err(err) => {
errors.push(err);
None
}
})
.flatten()
.collect::<Vec<_>>()
.into_iter()
.filter_map(|(index, result)| match result {
Ok(plugin) => Some((index, LockedPlugin::External(plugin))),
Err(err) => {
errors.push(err);
None
}
})
.chain(inlines)
.sorted_by_key(|(index, _)| *index)
.map(|(_, locked)| locked)
.collect::<Vec<_>>()
};
Ok(LockedConfig {
ctx: ctx.clone(),
templates,
errors,
plugins,
})
}
impl Shell {
fn default_matches(&self) -> &'static [String] {
static DEFAULT_MATCHES_BASH: Lazy<Vec<String>> = Lazy::new(|| {
vec_into![
"{{ name }}.plugin.bash",
"{{ name }}.plugin.sh",
"{{ name }}.bash",
"{{ name }}.sh",
"*.plugin.bash",
"*.plugin.sh",
"*.bash",
"*.sh"
]
});
static DEFAULT_MATCHES_ZSH: Lazy<Vec<String>> = Lazy::new(|| {
vec_into![
"{{ name }}.plugin.zsh",
"{{ name }}.zsh",
"{{ name }}.sh",
"{{ name }}.zsh-theme",
"*.plugin.zsh",
"*.zsh",
"*.sh",
"*.zsh-theme"
]
});
match self {
Self::Bash => &DEFAULT_MATCHES_BASH,
Self::Zsh => &DEFAULT_MATCHES_ZSH,
}
}
pub fn default_templates(&self) -> &IndexMap<String, String> {
static DEFAULT_TEMPLATES_BASH: Lazy<IndexMap<String, String>> = Lazy::new(|| {
indexmap_into! {
"PATH" => "export PATH=\"{{ dir }}:$PATH\"",
"source" => "{{ hooks?.pre | nl }}{% for file in files %}source \"{{ file }}\"\n{% endfor %}{{ hooks?.post | nl }}"
}
});
static DEFAULT_TEMPLATES_ZSH: Lazy<IndexMap<String, String>> = Lazy::new(|| {
indexmap_into! {
"PATH" => "export PATH=\"{{ dir }}:$PATH\"",
"path" => "path=( \"{{ dir }}\" $path )",
"fpath" => "fpath=( \"{{ dir }}\" $fpath )",
"source" => "{{ hooks?.pre | nl }}{% for file in files %}source \"{{ file }}\"\n{% endfor %}{{ hooks?.post | nl }}"
}
});
match self {
Self::Bash => &DEFAULT_TEMPLATES_BASH,
Self::Zsh => &DEFAULT_TEMPLATES_ZSH,
}
}
fn default_apply(&self) -> &'static [String] {
static DEFAULT_APPLY: Lazy<Vec<String>> = Lazy::new(|| vec_into!["source"]);
&DEFAULT_APPLY
}
}
impl LockedConfig {
pub fn verify(&self, ctx: &Context) -> bool {
if !is_context_equal(&self.ctx, ctx) {
return false;
}
for plugin in &self.plugins {
match plugin {
LockedPlugin::External(plugin) => {
if !plugin.dir().exists() {
return false;
}
for file in &plugin.files {
if !file.exists() {
return false;
}
}
}
LockedPlugin::Inline(_) => {}
}
}
true
}
}
fn is_context_equal(left: &Context, right: &Context) -> bool {
left.version == right.version
&& left.home == right.home
&& left.config_dir == right.config_dir
&& left.data_dir == right.data_dir
&& left.config_file == right.config_file
&& left.profile == right.profile
}
impl LockedExternalPlugin {
fn dir(&self) -> &Path {
self.plugin_dir.as_ref().unwrap_or(&self.source_dir)
}
}
#[cfg(test)]
mod tests {
use url::Url;
use super::*;
use std::io::prelude::*;
use crate::config::{ExternalPlugin, Source};
use crate::context::Output;
use crate::util::build;
impl Context {
pub fn testing(root: &Path) -> Self {
Self {
version: build::CRATE_RELEASE.to_string(),
home: "/".into(),
config_file: root.join("config.toml"),
lock_file: root.join("config.lock"),
clone_dir: root.join("repos"),
download_dir: root.join("downloads"),
data_dir: root.to_path_buf(),
config_dir: root.to_path_buf(),
profile: Some("profile".into()),
output: Output {
verbosity: crate::context::Verbosity::Quiet,
no_color: true,
},
interactive: true,
lock_mode: None,
}
}
}
#[test]
fn lock_config_empty() {
let temp = tempfile::tempdir().expect("create temporary directory");
let dir = temp.path();
let ctx = Context::testing(dir);
let cfg = Config {
shell: Some(Shell::Zsh),
matches: None,
apply: None,
templates: IndexMap::new(),
plugins: Vec::new(),
};
let locked = config(&ctx, cfg).unwrap();
assert_eq!(locked.ctx, ctx);
assert_eq!(locked.plugins, Vec::new());
assert_eq!(locked.templates, Shell::Zsh.default_templates().clone(),);
assert_eq!(locked.errors.len(), 0);
}
#[test]
fn locked_config_clean() {
let temp = tempfile::tempdir().expect("create temporary directory");
let ctx = Context::testing(temp.path());
let cfg = Config {
shell: Some(Shell::Zsh),
matches: None,
apply: None,
templates: IndexMap::new(),
plugins: vec![Plugin::External(ExternalPlugin {
name: "test".to_string(),
source: Source::Git {
url: Url::parse("https://github.com/rossmacarthur/sheldon-test").unwrap(),
reference: None,
},
dir: None,
uses: None,
apply: None,
profiles: None,
hooks: None,
})],
};
let test_dir = ctx.clone_dir().join("github.com/rossmacarthur/another-dir");
let test_file = test_dir.join("test.txt");
fs::create_dir_all(&test_dir).unwrap();
{
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&test_file)
.unwrap();
}
let mut warnings = Vec::new();
crate::config::clean(&ctx, &mut warnings, &cfg).unwrap();
assert!(warnings.is_empty());
assert!(!test_file.exists());
assert!(!test_dir.exists());
let _locked_cfg = config(&ctx, cfg).unwrap();
assert!(ctx
.clone_dir()
.join("github.com/rossmacarthur/sheldon-test")
.exists());
assert!(ctx
.clone_dir()
.join("github.com/rossmacarthur/sheldon-test/test.plugin.zsh")
.exists());
}
#[test]
fn locked_config_to_and_from_path() {
let mut temp = tempfile::NamedTempFile::new().unwrap();
let content = r#"version = "<version>"
home = "<home>"
config_dir = "<config>"
data_dir = "<data>"
config_file = "<config>/plugins.toml"
plugins = []
[templates]
"#;
temp.write_all(content.as_bytes()).unwrap();
let locked_config = from_path(temp.into_temp_path()).unwrap();
let temp = tempfile::NamedTempFile::new().unwrap();
let path = temp.into_temp_path();
locked_config.to_path(&path).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
}