use std::env;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::process;
use deezconfigs::hooks::Hooks;
use deezconfigs::ui;
pub fn determine_config_root(root: Option<&String>, do_check: bool) -> Result<PathBuf, i32> {
let root = if let Some(root) = get_config_root_from_args(root) {
root
} else {
let mut default = get_default_config_root()?;
if !is_a_config_root(&default) {
if let Some(parent) = find_config_root_in_parents(&default) {
default = parent.to_path_buf();
} else if let Some(root) = get_config_root_from_config() {
default = root;
}
}
default
};
ensure_root_exists(&root)?;
if do_check {
ensure_root_is_a_config_root(&root)?;
}
Ok(root)
}
fn get_config_root_from_args(root: Option<&String>) -> Option<PathBuf> {
if let Some(root) = root
&& !root.is_empty()
{
Some(PathBuf::from(root))
} else {
None
}
}
pub fn get_config_root_from_config() -> Option<PathBuf> {
if let Some(root) = env::var("DEEZ_ROOT").ok()
&& !root.is_empty()
{
Some(PathBuf::from(root))
} else {
None
}
}
fn get_default_config_root() -> Result<PathBuf, i32> {
let Ok(root) = env::current_dir() else {
eprint!(
"\
{fatal}: Could not determine current working directory.
Please provide a Root directory as argument.
",
fatal = ui::Color::error("fatal")
);
return Err(1);
};
Ok(root)
}
fn find_config_root_in_parents(root: &Path) -> Option<&Path> {
const DEPTH_LIMIT: usize = 20;
for (i, candidate) in root.ancestors().skip(1).enumerate() {
if is_a_config_root(candidate) {
return Some(candidate);
}
if i == DEPTH_LIMIT {
break;
}
}
None
}
pub fn ensure_root_exists(root: &Path) -> Result<(), i32> {
if root.is_dir() {
return Ok(());
}
eprintln!(
"{fatal}: Root must be a valid directory.",
fatal = ui::Color::error("fatal")
);
if root.is_file() {
eprintln!("'{}' is a file.", root.display());
} else if !root.exists() {
if root.to_str().is_some_and(str::is_empty) {
eprintln!("No path provided.");
} else {
eprintln!("'{}' does not exist.", root.display());
}
}
Err(1)
}
fn ensure_root_is_a_config_root(root: &Path) -> Result<(), i32> {
if is_a_config_root(root) {
return Ok(());
}
eprint!(
"\
{warning}: `root` is not a configuration root.
To make it a configuration root, create a `.deez` file inside of it.
This is a security feature. `{bin}` doesn't want to mess up your Home
directory if you run it in the wrong root.
Selected root: '{}'.
",
root.display(),
warning = ui::Color::warning("warning"),
bin = env!("CARGO_BIN_NAME"),
);
if ui::ask_confirmation_with_prompt("Proceed?") {
println!();
return Ok(());
}
eprintln!("Aborting.");
Err(2)
}
fn is_a_config_root(root: &Path) -> bool {
root.join(".deez").is_file()
}
pub fn is_git_remote_uri(root: Option<&String>) -> bool {
root.is_some_and(|root| {
["git:", "ssh://", "git@", "https://", "http://", "gh:"]
.iter()
.any(|prefix| root.starts_with(prefix))
})
}
pub fn get_config_root_from_git(uri: &str, verbose: bool) -> Result<PathBuf, i32> {
let uri = if let Some(uri) = uri.strip_prefix("git:") {
uri.to_string()
} else if let Some(uri) = uri.strip_prefix("gh:") {
format!("git@github.com:{uri}")
} else {
uri.to_string()
};
let (uri, sub_root) = extract_sub_root(&uri);
let pid = std::process::id();
let uuid = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("current time > Unix epoch")
.as_millis();
let clone_path = env::temp_dir().join(format!("deez-{pid}-{uuid}"));
if clone_path.is_dir() && fs::remove_dir_all(&clone_path).is_err() {
eprint!(
"\
{fatal}: Could not clone the configuration repository.
The target directory already exists and could not be deleted.
",
fatal = ui::Color::error("fatal")
);
return Err(1);
}
println!("Fetching config files remotely...");
let mut command = process::Command::new("git");
command
.env("LANG", "en_US.UTF-8")
.arg("clone")
.arg("--single-branch")
.arg("--depth=1")
.arg("--no-tags")
.arg(uri)
.arg(&clone_path);
let status = if verbose {
command.status().ok()
} else {
command.arg("--quiet");
command.output().ok().map(|out| out.status)
};
if let Some(status) = status {
if !status.success() {
eprintln!(
"{fatal}: Could not clone the configuration repository.",
fatal = ui::Color::error("fatal")
);
if !verbose {
eprintln!("Retry with `--verbose` for additional detail.");
}
return Err(1);
}
} else {
eprint!(
"\
{fatal}: Could not clone the configuration repository.
Did not find the 'git' executable. Please ensure Git is properly
installed on your machine.
",
fatal = ui::Color::error("fatal")
);
return Err(1);
}
println!("Done.");
if let Some(sub_root) = sub_root {
if !is_sub_root_safe(sub_root) {
eprintln!(
"{fatal}: Sub-root must not contain '..' components: '{sub_root}'.",
fatal = ui::Color::error("fatal")
);
return Err(1);
}
let clone_path = clone_path.join(sub_root);
if !clone_path.is_dir() {
eprintln!(
"{fatal}: Cannot find sub-root inside Git repository: '{sub_root}'.",
fatal = ui::Color::error("fatal")
);
return Err(1);
}
Ok(clone_path)
} else {
Ok(clone_path)
}
}
fn is_sub_root_safe(sub_root: &str) -> bool {
!Path::new(sub_root)
.components()
.any(|c| c == Component::ParentDir)
}
fn extract_sub_root(uri: &str) -> (&str, Option<&str>) {
if let Some((uri, sub_root)) = uri.rsplit_once('[')
&& sub_root.ends_with(']')
{
let sub_root = sub_root
.strip_suffix(']')
.expect("we checked that it ends with ']'")
.trim()
.trim_start_matches('/');
if sub_root.is_empty() {
(uri, None)
} else {
(uri, Some(sub_root))
}
} else {
(uri, None)
}
}
pub fn get_home_directory() -> Result<PathBuf, i32> {
if let Some(home_directory) = std::env::home_dir() {
Ok(home_directory)
} else {
eprintln!(
"{fatal}: Could not read Home directory from environment.",
fatal = ui::Color::error("fatal")
);
Err(1)
}
}
pub fn get_hooks_for_command<'a>(
root: &'a Path,
home: &'a Path,
verbose: bool,
) -> Result<Hooks<'a>, i32> {
match Hooks::for_command(root, home, verbose) {
Ok(hooks) => Ok(hooks),
Err(err) => {
eprintln!("{err}");
Err(1)
}
}
}
pub fn run_hooks(hooks: impl Fn() -> Result<usize, String>) -> Result<usize, i32> {
match hooks() {
Ok(nb_hooks) => Ok(nb_hooks),
Err(err) => {
eprintln!("{err}");
Err(1)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_git_remote_uri() {
fn is_git_uri(uri: &'static str) -> bool {
is_git_remote_uri(Some(&uri.to_string()))
}
assert!(is_git_uri("git:../configs"));
assert!(is_git_uri("git:~/Developer/configs"));
assert!(is_git_uri("ssh://misc/home/misc/configs"));
assert!(is_git_uri("git@github.com:qrichert/configs.git"));
assert!(is_git_uri("https://github.com/qrichert/configs.git"));
assert!(is_git_uri("http://github.com/qrichert/configs.git"));
assert!(is_git_uri("gh:qrichert/configs.git"));
}
#[test]
fn test_is_sub_root_safe() {
assert!(is_sub_root_safe("foo/bar"));
assert!(is_sub_root_safe("foo"));
assert!(is_sub_root_safe("foo/bar/baz"));
assert!(is_sub_root_safe(".hidden"));
assert!(is_sub_root_safe("foo/.hidden/bar"));
assert!(is_sub_root_safe("..tricky"));
assert!(is_sub_root_safe("tri..cky"));
assert!(is_sub_root_safe("tricky.."));
assert!(is_sub_root_safe("..."));
assert!(is_sub_root_safe("foo/..tricky/bar"));
assert!(is_sub_root_safe("foo/tri..cky/bar"));
assert!(is_sub_root_safe("foo/.../bar"));
assert!(!is_sub_root_safe(".."));
assert!(!is_sub_root_safe("../foo"));
assert!(!is_sub_root_safe("foo/.."));
assert!(!is_sub_root_safe("foo/../bar"));
assert!(!is_sub_root_safe("foo/../../etc"));
assert!(!is_sub_root_safe("../../etc/passwd"));
}
#[test]
fn test_extract_sub_root() {
assert_eq!(
extract_sub_root("../configs[foo/bar]"),
("../configs", Some("foo/bar"))
);
assert_eq!(
extract_sub_root("~/Developer/configs[/foo/bar]"),
("~/Developer/configs", Some("foo/bar"))
);
assert_eq!(
extract_sub_root("ssh://misc/home/[misc]/configs[ /foo/bar ]"),
("ssh://misc/home/[misc]/configs", Some("foo/bar"))
);
assert_eq!(
extract_sub_root("git@github.com:qrichert/configs.git"),
("git@github.com:qrichert/configs.git", None)
);
assert_eq!(
extract_sub_root("https://github.com/qrichert/configs.git[]"),
("https://github.com/qrichert/configs.git", None)
);
assert_eq!(
extract_sub_root("http://github.com/qrichert/configs.git[ ]"),
("http://github.com/qrichert/configs.git", None)
);
assert_eq!(
extract_sub_root("qrichert/configs.git[ / ]"),
("qrichert/configs.git", None)
);
}
}