symp 0.5.0

symlink farm manager that utilizes configuration files to define symlink mappings
use std::collections::HashMap;
use std::fs;
use std::fs::File;

use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};

use crate::config::ExistingFilePolicy;
use crate::filesystem::{AbsPath, Symlink, move_to_backup};
use crate::table::LinkData;

#[derive(Debug, Deserialize, Serialize)]
pub struct Lock {
    #[serde(default)]
    pub links: HashMap<String, Vec<Link>>,
    #[serde(skip)]
    pub symp_lock_file: AbsPath,
}

impl Lock {
    pub fn load(symp_lock_file: &AbsPath) -> Result<Self> {
        if !symp_lock_file.exists() {
            if let Some(parent) = symp_lock_file.parent()
                && !parent.exists()
            {
                fs::create_dir_all(parent)?;
            }
            File::create(symp_lock_file)?;
        }
        let mut lock: Lock = toml::from_str(fs::read_to_string(symp_lock_file)?.as_str())?;
        lock.symp_lock_file = symp_lock_file.clone();
        Ok(lock)
    }

    pub fn save(&self) -> Result<()> {
        fs::write(&self.symp_lock_file, toml::to_string(self)?)?;
        Ok(())
    }

    pub fn mark_for_removal<'a>(&mut self, package_names: impl Iterator<Item = &'a String>) {
        for package_name in package_names {
            if let Some(links) = self.links.get_mut(package_name) {
                for link in links.iter_mut() {
                    link.state = State::MarkedForRemoval;
                }
            }
        }
    }

    pub fn add<'a>(&mut self, table_links: impl Iterator<Item = &'a LinkData>) {
        for link_data in table_links {
            self.links
                .entry(link_data.package_data.package_name.clone())
                .and_modify(|links| {
                    let mut match_found = false;
                    for link in links.iter_mut() {
                        if link.matches_link_data(link_data) {
                            match_found = true;
                            link.existing_file_policy = link_data.package_data.existing_file_policy;
                            link.state = match link.symlink.exists() {
                                true => State::Synced,
                                false => State::Added,
                            };
                        }
                    }
                    if !match_found {
                        links.push(Link::from_link_data(link_data));
                    }
                })
                .or_insert(vec![Link::from_link_data(link_data)]);
        }
    }

    pub fn sync(&mut self) -> Result<()> {
        // we do three passes:
        // (1) Raise error for any ExistingFilePolicy::Error violations
        // (2) Remove all State::MarkedForRemoval symlinks
        // (3) Create all other symlinks
        for link in self.links.iter_mut().flat_map(|(_, links)| links) {
            if link.symlink.exists() {
                continue;
            }
            if link.symlink.destination.exists()
                && matches!(link.existing_file_policy, ExistingFilePolicy::Error)
            {
                return Err(anyhow!(
                    "Cannot create symlink to {} (location not empty)",
                    link.symlink.destination.display(),
                ));
            }
        }
        for link in self.links.iter_mut().flat_map(|(_, links)| links) {
            if link.state.eq(&State::MarkedForRemoval) {
                println!("Removing {:?}", link.symlink);
                link.symlink.remove()?;
                println!("{}", link.symlink.exists());
            }
        }
        for link in self.links.iter_mut().flat_map(|(_, links)| links) {
            if link.state.eq(&State::MarkedForRemoval) {
                continue;
            }
            if link.symlink.exists() {
                link.state = State::Synced;
                continue;
            }
            if link.symlink.destination.exists() || link.symlink.is_broken() {
                match link.existing_file_policy {
                    ExistingFilePolicy::Backup => {
                        if link.symlink.is_broken() {
                            link.symlink.remove()?;
                        } else {
                            move_to_backup(&link.symlink.destination)?;
                        }
                    }
                    ExistingFilePolicy::Replace => {
                        if link.symlink.destination.is_file() {
                            fs::remove_file(&link.symlink.destination)?;
                        } else {
                            fs::remove_dir_all(&link.symlink.destination)?;
                        }
                    }
                    ExistingFilePolicy::Skip => continue,
                    // we've already handled the error policy
                    ExistingFilePolicy::Error => {}
                }
            }
            link.symlink.create()?;
            link.state = State::Synced;
        }
        for links in self.links.values_mut() {
            links.retain(|link| link.state.ne(&State::MarkedForRemoval))
        }
        for package_name in self.all_package_names() {
            if self.links.get(&package_name).unwrap().is_empty() {
                self.links.remove_entry(&package_name);
            }
        }
        Ok(())
    }

    pub fn all_package_names(&self) -> Vec<String> {
        self.links.keys().cloned().collect()
    }
}

#[derive(Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct Link {
    pub existing_file_policy: ExistingFilePolicy,
    #[serde(flatten)]
    pub symlink: Symlink,
    pub state: State,
}

impl Link {
    fn from_link_data(link_data: &LinkData) -> Self {
        let symlink = Symlink::new(
            link_data.symlink.source.clone(),
            link_data.symlink.destination.clone(),
        );
        let existing_file_policy = link_data.package_data.existing_file_policy;
        let state = State::Added;
        Link {
            existing_file_policy,
            symlink,
            state,
        }
    }

    fn matches_link_data(&self, link_data: &LinkData) -> bool {
        self.symlink.eq(&link_data.symlink)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub enum State {
    Synced,
    Added,
    MarkedForRemoval,
}

#[cfg(test)]
mod tests {
    use super::*;

    use anyhow;
    use tempfile::tempdir;

    const DUMMY_LOCK_FILE_CONTENT: &str = r#"
[[links.apples]]
existing_file_policy = "backup"
source = "/apples/are/great"
destination = "/the/best/fruit"
state = "Synced"

[[links.pineapples]]
existing_file_policy = "error"
source = "/pineapples/are/yummy"
destination = "/can/you/tell/im/hungry"
state = "Added"

[[links.goo]]
existing_file_policy = "replace"
source = "/its/slimy"
destination = "/eeeeeewwwwwww"
state = "MarkedForRemoval"
    "#;

    #[test]
    fn round_trip() -> anyhow::Result<()> {
        let tmp = tempdir()?;
        let symp_lock_file = AbsPath::from_path(&tmp.path().join("symp.lock"))?;
        let mut lock: Lock = toml::from_str(DUMMY_LOCK_FILE_CONTENT)?;
        lock.symp_lock_file = symp_lock_file.clone();
        lock.save()?;
        let lock2 = Lock::load(&symp_lock_file)?;
        assert_eq!(lock.links, lock2.links);
        assert_eq!(lock.symp_lock_file, lock2.symp_lock_file);
        Ok(())
    }

    #[test]
    fn mark_for_removal() -> anyhow::Result<()> {
        let mut lock: Lock = toml::from_str(DUMMY_LOCK_FILE_CONTENT)?;
        for link in lock.links.get("pineapples").unwrap() {
            assert_ne!(link.state, State::MarkedForRemoval);
        }
        lock.mark_for_removal(["pineapples".to_string()].iter());
        for link in lock.links.get("pineapples").unwrap() {
            assert_eq!(link.state, State::MarkedForRemoval);
        }
        Ok(())
    }
}