use clap::Parser;
use home::home_dir;
use serde::Deserialize;
use std::fmt::Display;
use std::fs::create_dir_all;
use std::fs::remove_file;
use std::os::unix::fs::symlink;
use std::path::Path;
use std::{
env::set_current_dir,
fs::File,
io::{self, BufReader, Read},
path::PathBuf,
};
use yansi::Color;
#[derive(Deserialize)]
struct Config {
// TODO: Switch to a different approach.
// ... The toml file should list directories to link as such.
// ... All files that are NOT children of this directory are linked directly.
// ... This would require walking all file in the repo (see TODO below).
symlinks: Vec<String>,
}
// TODO: show a warning ("unreachable") for any git-tracked file that is not
// symlinked and does not have a symlinked parent.
impl Config {
fn read_from_path<P>(path: P) -> io::Result<Config>
where
P: AsRef<Path>,
{
let file = File::open(path)?;
let mut reader = BufReader::new(file);
// This bit can be simplified one this PR is merged:
// https://github.com/toml-rs/toml/pull/349
let mut buf = Vec::new();
reader.read_to_end(&mut buf)?;
let mut config = toml::de::from_slice::<Config>(&buf)?;
for i in 0..config.symlinks.len() {
if let Some(stripped) = config.symlinks[i].strip_suffix('/') {
config.symlinks[i] = stripped.to_string();
}
}
Ok(config)
}
}
struct Context {
/// Home directory.
home: PathBuf,
/// Directory containing files that should be linked from $HOME.
repo: PathBuf,
/// Current configuration.
config: Config,
}
impl Context {
fn new(repo: &Path, home: &Path) -> io::Result<Context> {
Ok(Context {
home: home.into(),
repo: repo.join("home"),
config: Config::read_from_path(repo.join("dotfiles.toml"))?,
})
}
fn home_path<P>(&self, path: P) -> PathBuf
where
P: AsRef<Path>,
{
self.home.join(path)
}
fn repo_path<P>(&self, path: P) -> PathBuf
where
P: AsRef<Path>,
{
self.repo.join(path)
}
fn get_linked_paths(&self) -> Vec<Link> {
let mut paths = Vec::new();
// TODO: walk source directory:
// if dir && listed -> symlink
// if dir && !listed -> walk
// if !dir -> symlink
for dir in &self.config.symlinks {
paths.push(Link::new(PathBuf::from(dir), self));
}
paths.sort_unstable_by_key(|link| link.path.clone());
paths
}
fn state_for_path<P>(&self, path: P) -> io::Result<PathState>
where
P: AsRef<Path>,
{
let home_path = self.home_path(&path);
let state = if home_path.exists() {
if home_path.canonicalize()? == self.repo.join(path).canonicalize()? {
PathState::Fine
} else if home_path.is_symlink() {
PathState::BadLink
} else {
PathState::Conflict
}
} else if !home_path.is_symlink() {
PathState::Missing
} else {
PathState::Broken
};
Ok(state)
}
}
#[derive(Debug, Ord, PartialOrd, Eq, PartialEq)]
enum PathState {
Fine, // Link points to dotfiles repo.
Missing, // File is missing.
Broken, // Link exists and is broken.
BadLink, // Link exists and points to another file.
Conflict, // Not a link.
Error(String),
}
impl PathState {
fn colour(&self) -> Color {
match self {
PathState::Missing => Color::Cyan,
PathState::Broken => Color::Yellow,
PathState::BadLink => Color::Magenta,
PathState::Conflict => Color::Red,
PathState::Fine => Color::Green,
PathState::Error(_) => Color::Red,
}
}
}
impl Display for PathState {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_fmt(format_args!("{:?}", self.colour().paint(self),))
}
}
impl From<io::Result<PathState>> for PathState {
fn from(item: io::Result<PathState>) -> Self {
match item {
Ok(state) => state,
Err(err) => PathState::Error(err.to_string()),
}
}
}
#[derive(Debug, Ord, PartialOrd, Eq, PartialEq)]
struct Link {
// Sorting happens based on field sorting from top to bottom.
state: PathState,
new_state: Option<PathState>,
path: PathBuf,
}
impl Display for Link {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.new_state {
Some(new_state) => formatter.write_fmt(format_args!(
"{} -> {} {}",
self.state,
new_state,
self.path.to_string_lossy(),
)),
None => formatter.write_fmt(format_args!(
"{} {}",
self.state,
self.path.to_string_lossy(),
)),
}
}
}
impl Link {
fn new(path: PathBuf, context: &Context) -> Link {
let state = context.state_for_path(&path);
Link {
path,
state: state.into(),
new_state: None,
}
}
// Creates the link, if possible.
// Nothing is returned; the link's internal `state` is mutated.
fn create_link(&mut self, context: &Context) {
let link_name = context.home_path(&self.path);
let parent_dir = link_name
.parent()
.expect("symlink file has a parent directory");
self.new_state = Some(match create_dir_all(parent_dir) {
Err(err) => PathState::Error(err.to_string()), // Directory creation failed.
Ok(_) => match symlink(context.repo_path(&self.path), link_name) {
Ok(()) => PathState::Fine,
Err(err) => PathState::Error(err.to_string()),
},
});
}
// Apply necessary changes for this link to be valid.
fn apply(&mut self, context: &Context) {
match self.state {
PathState::Missing => {
self.create_link(context);
}
PathState::Broken | PathState::BadLink => {
let home_path = context.home_path(&self.path);
match remove_file(home_path) {
Ok(()) => self.create_link(context),
Err(err) => self.new_state = Some(PathState::Error(err.to_string())),
};
}
PathState::Conflict => {
// TODO: Is there anything we CAN do here?
// TODO: If the files are byte-to-byte identical, then overwriting with a symlink is safe.
// no-op
}
PathState::Fine | PathState::Error(_) => {
// no-op
}
}
}
}
/// Simple tool that symlinks files from a dotfiles repo into their expected location in $HOME.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct DotSync {
/// Show plan but don't make any changes.
#[arg(short, long)]
dry_run: bool,
}
fn main() {
let matches = DotSync::parse();
let cwd = std::env::current_dir().expect("can determine current directory");
let home = home_dir().expect("could find home dir");
let context = Context::new(&cwd, &home).expect("context initialised");
set_current_dir(&context.repo).expect("change directory into $REPO/home.");
let mut paths = context.get_linked_paths();
// TODO: Warn about paths that are neither linked, nor children of linked directories.
if matches.dry_run {
println!("Dry run: not applying any changes",);
} else {
for path in &mut paths {
path.apply(&context);
}
};
paths.sort();
for path in &paths {
println!("{}", path);
}
// TODO: print a global results (no-op/dry-run/ok/partial/error).
}