use std::{
fmt::Write as _,
fs,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::{
error::{DotlingError, Result, io_err},
platform::Platform,
};
pub const CONFIG_FILE: &str = ".dotling.toml";
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LinkMethod {
#[default]
Symlink,
Copy,
Encrypted,
}
impl std::fmt::Display for LinkMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Symlink => write!(f, "symlink"),
Self::Copy => write!(f, "copy"),
Self::Encrypted => write!(f, "encrypted"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkEntry {
pub src: String,
pub dest: String,
#[serde(default)]
pub method: LinkMethod,
#[serde(default)]
pub os: Platform,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EncryptionConfig {
#[serde(default)]
pub recipients: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ConfigFile {
#[serde(default)]
encryption: EncryptionConfig,
#[serde(default)]
links: Vec<LinkEntry>,
}
#[derive(Debug, Clone)]
pub struct Config {
path: PathBuf,
pub encryption: EncryptionConfig,
pub entries: Vec<LinkEntry>,
}
impl Config {
pub fn load(repo_root: &Path) -> Result<Self> {
let path = repo_root.join(CONFIG_FILE);
if !path.exists() {
return Ok(Self {
path,
encryption: EncryptionConfig::default(),
entries: Vec::new(),
});
}
let content = fs::read_to_string(&path).map_err(io_err(&path))?;
let file: ConfigFile =
toml::from_str(&content).map_err(|e| DotlingError::ConfigParse(e.to_string()))?;
Ok(Self {
path,
encryption: file.encryption,
entries: file.links,
})
}
pub fn save(&self) -> Result<()> {
let mut output = String::new();
if !self.encryption.recipients.is_empty() {
output.push_str("[encryption]\n");
output.push_str("recipients = [\n");
for recipient in &self.encryption.recipients {
let _ = writeln!(output, " {recipient:?},");
}
output.push_str("]\n\n");
}
for (i, entry) in self.entries.iter().enumerate() {
if i > 0 {
output.push('\n');
}
output.push_str("[[links]]\n");
let _ = writeln!(output, "src = {:?}", entry.src);
let _ = writeln!(output, "dest = {:?}", entry.dest);
if entry.method != LinkMethod::Symlink {
let _ = writeln!(output, "method = \"{}\"", entry.method);
}
if entry.os != Platform::All {
let _ = writeln!(output, "os = \"{}\"", entry.os);
}
}
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).map_err(io_err(parent))?;
}
fs::write(&self.path, &output).map_err(io_err(&self.path))?;
Ok(())
}
pub fn add_entry(&mut self, entry: LinkEntry) -> Result<()> {
if self.entries.iter().any(|e| e.dest == entry.dest) {
return Err(DotlingError::AlreadyTracked(PathBuf::from(&entry.dest)));
}
self.entries.push(entry);
Ok(())
}
pub fn remove_entry(&mut self, dest: &str) -> Result<LinkEntry> {
let idx = self
.entries
.iter()
.position(|e| e.dest == dest)
.ok_or_else(|| DotlingError::NotTracked(PathBuf::from(dest)))?;
Ok(self.entries.remove(idx))
}
pub fn find_by_dest(&self, dest: &str) -> Option<&LinkEntry> {
self.entries.iter().find(|e| e.dest == dest)
}
#[allow(dead_code)]
pub fn find_by_src(&self, src: &str) -> Option<&LinkEntry> {
self.entries.iter().find(|e| e.src == src)
}
pub fn active_entries(&self) -> Vec<&LinkEntry> {
self.entries.iter().filter(|e| e.os.is_active()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_serialize_deserialize() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
path: dir.path().join(CONFIG_FILE),
encryption: EncryptionConfig {
recipients: vec!["age1test".to_string()],
},
entries: vec![
LinkEntry {
src: "config/nvim/init.lua".to_string(),
dest: "~/.config/nvim/init.lua".to_string(),
method: LinkMethod::Symlink,
os: Platform::All,
},
LinkEntry {
src: "shell/zshrc".to_string(),
dest: "~/.zshrc".to_string(),
method: LinkMethod::Copy,
os: Platform::Macos,
},
],
};
config.save().unwrap();
let loaded = Config::load(dir.path()).unwrap();
assert_eq!(loaded.encryption.recipients.len(), 1);
assert_eq!(loaded.encryption.recipients[0], "age1test");
assert_eq!(loaded.entries.len(), 2);
assert_eq!(loaded.entries[0].src, "config/nvim/init.lua");
assert_eq!(loaded.entries[0].dest, "~/.config/nvim/init.lua");
assert_eq!(loaded.entries[0].method, LinkMethod::Symlink);
assert_eq!(loaded.entries[1].src, "shell/zshrc");
assert_eq!(loaded.entries[1].method, LinkMethod::Copy);
assert_eq!(loaded.entries[1].os, Platform::Macos);
}
#[test]
fn duplicate_dest_errors() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config {
path: dir.path().join(CONFIG_FILE),
encryption: EncryptionConfig::default(),
entries: Vec::new(),
};
config
.add_entry(LinkEntry {
src: "shell/zshrc".to_string(),
dest: "~/.zshrc".to_string(),
method: LinkMethod::Symlink,
os: Platform::All,
})
.unwrap();
let result = config.add_entry(LinkEntry {
src: "other/zshrc".to_string(),
dest: "~/.zshrc".to_string(),
method: LinkMethod::Symlink,
os: Platform::All,
});
assert!(result.is_err());
}
#[test]
fn remove_nonexistent_errors() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config {
path: dir.path().join(CONFIG_FILE),
encryption: EncryptionConfig::default(),
entries: Vec::new(),
};
let result = config.remove_entry("~/.nonexistent");
assert!(result.is_err());
}
#[test]
fn load_empty_when_missing() {
let dir = tempfile::tempdir().unwrap();
let config = Config::load(dir.path()).unwrap();
assert!(config.entries.is_empty());
}
}