zenops 0.1.0

Declarative system configuration management for shell config and dotfiles.
use std::{
    borrow::Cow,
    fmt,
    path::{Path, PathBuf},
    sync::Arc,
};

use indexmap::IndexMap;

use crate::{
    error::Error,
    output::{AppliedAction, FileStatus, Output, ResolvedConfigFilePath, Status, SymlinkStatus},
};
use zenops_safe_relative_path::SafeRelativePath;

#[derive(Clone, PartialEq)]
pub enum ConfigFilePath {
    Home(Arc<SafeRelativePath>),
    DotConfig(Arc<SafeRelativePath>),
    Zenops(Arc<SafeRelativePath>),
}

impl ConfigFilePath {
    pub fn in_home(path: impl AsRef<SafeRelativePath>) -> Self {
        Self::Home(Arc::from(path.as_ref()))
    }

    pub fn _in_dot_config(path: impl AsRef<SafeRelativePath>) -> Self {
        Self::DotConfig(Arc::from(path.as_ref()))
    }

    pub fn resolved(&self, dirs: &ConfigFileDirs) -> PathBuf {
        match self {
            Self::Home(path) => path.to_full_path(&dirs.home),
            Self::DotConfig(path) => path.to_full_path(&dirs.config),
            Self::Zenops(path) => path.to_full_path(&dirs.zenops),
        }
    }

    pub fn human_path(&self) -> Cow<'_, str> {
        let (base, path) = match self {
            Self::Home(path) => ("~", path),
            Self::DotConfig(path) => ("~/.config", path),
            Self::Zenops(path) => ("~/.config/zenops", path),
        };
        if path.as_str().is_empty() {
            Cow::Borrowed(base)
        } else {
            Cow::Owned(format!("{base}/{path}"))
        }
    }

    pub fn parent(&self) -> Option<Self> {
        match self {
            Self::Home(path) => Some(Self::Home(Arc::from(path.safe_parent()?))),
            Self::DotConfig(path) => Some(Self::DotConfig(Arc::from(path.safe_parent()?))),
            Self::Zenops(path) => Some(Self::Zenops(Arc::from(path.safe_parent()?))),
        }
    }
}

impl std::fmt::Debug for ConfigFilePath {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        fmt::Debug::fmt(&self.human_path(), f)
    }
}

struct FileEntry {
    path: ConfigFilePath,
    src: ResolvedFileSource,
}

pub enum ConfigFileSource {
    Generated(String),
    SymlinkFrom(ConfigFilePath),
}

impl ConfigFileSource {
    fn into_resolved(self, config_files: &ConfigFiles<'_>) -> ResolvedFileSource {
        match self {
            ConfigFileSource::Generated(data) => ResolvedFileSource::Generated(Arc::from(data)),
            ConfigFileSource::SymlinkFrom(rel) => ResolvedFileSource::SymlinkFrom {
                full: Arc::from(rel.resolved(config_files.dirs)),
                rel,
            },
        }
    }
}

enum ResolvedFileSource {
    Generated(Arc<str>),
    SymlinkFrom {
        full: Arc<Path>,
        rel: ConfigFilePath,
    },
}

pub struct ConfigFileDirs {
    home: PathBuf,
    config: PathBuf,
    zenops: PathBuf,
}

impl ConfigFileDirs {
    pub fn load(home: PathBuf) -> Self {
        assert!(home.is_absolute(), "{home:?}");
        let config = home.join(".config");
        let zenops = home.join(".config/zenops");
        Self {
            home,
            config,
            zenops,
        }
    }

    pub fn home(&self) -> &Path {
        &self.home
    }

    pub fn zenops(&self) -> &Path {
        &self.zenops
    }
}

pub struct ConfigFiles<'dirs> {
    dirs: &'dirs ConfigFileDirs,
    files: IndexMap<Arc<Path>, FileEntry>,
}

impl<'dirs> ConfigFiles<'dirs> {
    pub fn new(dirs: &'dirs ConfigFileDirs) -> Self {
        Self {
            dirs,
            files: IndexMap::new(),
        }
    }

    pub fn add(&mut self, path: ConfigFilePath, src: ConfigFileSource) {
        self.files.insert(
            Arc::from(path.resolved(self.dirs)),
            FileEntry {
                path,
                src: src.into_resolved(self),
            },
        );
    }

    fn entry_status(&self, full: Arc<Path>, entry: &FileEntry) -> Status {
        let path = ResolvedConfigFilePath {
            path: entry.path.clone(),
            full,
        };

        match &entry.src {
            ResolvedFileSource::Generated(content) => {
                if path.full.exists() {
                    if let Ok(cur_content) = std::fs::read_to_string(&path.full) {
                        let status = if cur_content == content.as_ref() {
                            FileStatus::Ok
                        } else {
                            FileStatus::Modified
                        };
                        Status::Generated {
                            want_content: content.clone(),
                            cur_content: Some(cur_content),
                            path,
                            status,
                        }
                    } else {
                        todo!()
                    }
                } else {
                    Status::Generated {
                        want_content: content.clone(),
                        cur_content: None,
                        path,
                        status: FileStatus::New,
                    }
                }
            }
            ResolvedFileSource::SymlinkFrom { full, rel } => {
                let status = match SymlinkInfo::from_path(&path.full) {
                    SymlinkInfo::LinksTo(link_path) => {
                        if link_path == full.as_ref() {
                            match full.symlink_metadata() {
                                Ok(_) => SymlinkStatus::Ok,
                                Err(e) => match e.kind() {
                                    std::io::ErrorKind::NotFound => {
                                        SymlinkStatus::RealPathIsMissing
                                    }
                                    unk => todo!("{unk:?}"),
                                },
                            }
                        } else {
                            SymlinkStatus::WrongLink(link_path)
                        }
                    }
                    SymlinkInfo::NotFound => {
                        match path.full.parent().map(|v| v.symlink_metadata()) {
                            None | Some(Ok(_)) => SymlinkStatus::New,
                            Some(Err(e)) => match e.kind() {
                                std::io::ErrorKind::NotFound => SymlinkStatus::DstDirIsMissing,
                                unk => todo!("{unk:?}"),
                            },
                        }
                    }
                    SymlinkInfo::NotSymlinkIsFile => todo!(),
                    SymlinkInfo::NotSymlinkIsDir => todo!(),
                };
                Status::Symlink {
                    real: ResolvedConfigFilePath {
                        path: rel.clone(),
                        full: full.clone(),
                    },
                    symlink: path,
                    status,
                }
            }
        }
    }

    pub fn check_status(&self, output: &mut dyn Output) {
        for (path, entry) in &self.files {
            output.push_status(self.entry_status(path.clone(), entry));
        }
    }

    pub fn apply_changes(&self, output: &mut dyn Output) -> Result<(), Error> {
        for (path, entry) in &self.files {
            let status = self.entry_status(path.clone(), entry);
            match status {
                Status::Generated {
                    want_content,
                    cur_content: _,
                    path,
                    status,
                } => {
                    let expect_action = match status {
                        FileStatus::Ok => {
                            log::debug!("Config is already up to date {path}");
                            continue;
                        }
                        FileStatus::Modified => {
                            log::debug!("Updating modified config {path}");
                            AppliedAction::UpdatedFile
                        }
                        FileStatus::New => {
                            log::debug!("Creating new config {path}");
                            AppliedAction::CreatedFile
                        }
                    };

                    std::fs::write(&path.full, want_content.as_bytes())
                        .map_err(|e| Error::FailedToWriteConfig(path.to_owned(), e))?;

                    output.push_applied_action(expect_action(path));
                }
                Status::Symlink {
                    real,
                    symlink,
                    status,
                } => match status {
                    SymlinkStatus::Ok => {
                        log::debug!("Symlink from {symlink} to {real} is already in place",);
                        continue;
                    }
                    SymlinkStatus::WrongLink(_) => todo!(),
                    SymlinkStatus::New => {
                        log::debug!("Creating symlink from {symlink} to {real}",);
                        create_symlink(&real.full, &symlink.full)?;
                        output.push_applied_action(AppliedAction::CreatedSymlink { real, symlink });
                    }
                    SymlinkStatus::IsFile => {
                        return Err(Error::RefusingToOverwriteFileWithSymlink {
                            symlink: symlink.clone(),
                            real: real.clone(),
                        });
                    }
                    SymlinkStatus::RealPathIsMissing => todo!(),
                    SymlinkStatus::DstDirIsMissing => {
                        log::debug!("Creating symlink from {symlink} to {real}",);
                        let dir = symlink.parent().unwrap_or_else(|| {
                            todo!("This should not be possible due to earlier check")
                        });
                        match std::fs::create_dir_all(&dir.full) {
                            Ok(()) => {}
                            Err(e) => return Err(Error::CreateDirectoryError(dir, e)),
                        }
                        output.push_applied_action(AppliedAction::CreatedDir(dir));
                        create_symlink(&real.full, &symlink.full)?;
                        output.push_applied_action(AppliedAction::CreatedSymlink { real, symlink });
                    }
                },
                Status::Git { repo, status } => todo!("{repo}: {status:?}"),
            }
        }

        Ok(())
    }
}

enum SymlinkInfo {
    LinksTo(PathBuf),
    NotFound,
    NotSymlinkIsFile,
    NotSymlinkIsDir,
}

impl SymlinkInfo {
    pub fn from_path(p: impl AsRef<Path>) -> Self {
        let p = p.as_ref();
        match p.symlink_metadata() {
            Ok(meta) => {
                if meta.is_symlink() {
                    match std::fs::read_link(p) {
                        Ok(link_path) => Self::LinksTo(link_path),
                        Err(e) => todo!("{p:?} {e:?}"),
                    }
                } else if meta.is_file() {
                    Self::NotSymlinkIsFile
                } else if meta.is_dir() {
                    Self::NotSymlinkIsDir
                } else {
                    todo!()
                }
            }
            Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => Self::NotFound,
            Err(e) => todo!("{e:?}"),
        }
    }
}

#[cfg(unix)]
fn create_symlink(real_path: &Path, symlink_path: &Path) -> Result<(), Error> {
    use std::io::ErrorKind;

    match std::os::unix::fs::symlink(real_path, symlink_path) {
        Ok(()) => Ok(()),
        Err(e) => match e.kind() {
            ErrorKind::AlreadyExists => todo!(),
            ErrorKind::NotFound => todo!(),
            unk => todo!("{unk:?}"),
        },
    }
}