pub mod template;
pub mod vars;
use std::{
fmt, fs,
path::{Path, PathBuf},
};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeployMethod {
Symlink,
Copy,
}
impl DeployMethod {
pub fn as_str(self) -> &'static str {
match self {
Self::Symlink => "symlink",
Self::Copy => "copy",
}
}
fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"symlink" => Some(Self::Symlink),
"copy" => Some(Self::Copy),
_ => None,
}
}
}
impl fmt::Display for DeployMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct Entry {
pub source: String,
pub target: String,
pub method: Option<DeployMethod>,
pub encrypted: bool,
pub directory: bool,
pub template: bool,
pub os: Option<String>,
pub permissions: Option<u32>,
pub before: Option<String>,
pub after: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Settings {
pub method: DeployMethod,
}
impl Default for Settings {
fn default() -> Self {
Self {
method: DeployMethod::Symlink,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Hooks {
pub init: Option<String>,
pub before: Option<String>,
pub after: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Config {
pub settings: Settings,
pub entries: Vec<Entry>,
pub hooks: Hooks,
pub vars: Vec<(String, String)>,
path: PathBuf,
}
impl Config {
pub fn new(path: PathBuf) -> Self {
Self {
settings: Settings::default(),
entries: Vec::new(),
hooks: Hooks::default(),
vars: Vec::new(),
path,
}
}
pub fn load(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path).map_err(|e| Error::io(path, "read config", e))?;
let mut config = parse_config(&content, path)?;
config.path = path.to_path_buf();
Ok(config)
}
pub fn save(&self) -> Result<()> {
let content = serialize_config(self);
crate::fs::atomic_write(&self.path, content.as_bytes())
}
pub fn add_entry(&mut self, entry: Entry) -> Result<()> {
if self.entries.iter().any(|e| e.source == entry.source) {
return Err(Error::User(format!(
"`{}` is already tracked",
entry.source
)));
}
if self.entries.iter().any(|e| e.target == entry.target) {
return Err(Error::User(format!(
"target `{}` is already in use by `{}`",
entry.target,
self.entries
.iter()
.find(|e| e.target == entry.target)
.map_or("?", |e| e.source.as_str()),
)));
}
self.entries.push(entry);
Ok(())
}
pub fn remove_entry(&mut self, source: &str) -> Option<Entry> {
if let Some(i) = self.entries.iter().position(|e| e.source == source) {
Some(self.entries.remove(i))
} else {
None
}
}
pub fn find_entry(&self, query: &str) -> Option<&Entry> {
let resolved_query = crate::path::resolve(Path::new(query)).ok();
let repo_root = self.path.parent();
self.entries.iter().find(|e| {
if e.source == query || e.target == query {
return true;
}
if let Some(rq) = &resolved_query {
if let Ok(resolved_target) = crate::path::resolve(Path::new(&e.target)) {
if rq == &resolved_target {
return true;
}
}
if let Some(root) = repo_root {
if let Ok(resolved_source) = crate::path::resolve(&root.join(&e.source)) {
if rq == &resolved_source {
return true;
}
}
}
}
false
})
}
pub fn find_entry_mut(&mut self, query: &str) -> Option<&mut Entry> {
let resolved_query = crate::path::resolve(Path::new(query)).ok();
let repo_root = self.path.parent();
self.entries.iter_mut().find(|e| {
if e.source == query || e.target == query {
return true;
}
if let Some(rq) = &resolved_query {
if let Ok(resolved_target) = crate::path::resolve(Path::new(&e.target)) {
if rq == &resolved_target {
return true;
}
}
if let Some(root) = repo_root {
if let Ok(resolved_source) = crate::path::resolve(&root.join(&e.source)) {
if rq == &resolved_source {
return true;
}
}
}
}
false
})
}
}
fn parse_config(input: &str, path: &Path) -> Result<Config> {
let mut settings = Settings::default();
let mut entries = Vec::new();
let mut hooks = Hooks::default();
let mut vars: Vec<(String, String)> = Vec::new();
let mut current_section: Option<String> = None;
let mut current_entry: Option<EntryBuilder> = None;
for (line_num, raw_line) in input.lines().enumerate() {
let line_num = line_num + 1; let line = raw_line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
if line.starts_with("[[") && line.ends_with("]]") {
if let Some(builder) = current_entry.take() {
entries.push(builder.build(path, line_num)?);
}
let name = &line[2..line.len() - 2].trim();
if *name == "entries" {
current_entry = Some(EntryBuilder::default());
current_section = Some("entries".into());
} else {
return Err(Error::Config {
message: format!("unknown section `[[{name}]]`"),
line: Some(line_num),
});
}
continue;
}
if line.starts_with('[') && line.ends_with(']') {
if let Some(builder) = current_entry.take() {
entries.push(builder.build(path, line_num)?);
}
let name = &line[1..line.len() - 1].trim();
current_section = Some((*name).to_string());
continue;
}
if let Some((key, value)) = parse_kv(line) {
if current_section.as_deref() == Some("vars") {
vars.push((key.to_string(), value));
} else {
handle_kv(
key,
&value,
current_section.as_deref(),
&mut settings,
&mut current_entry,
&mut hooks,
line_num,
)?;
}
}
}
if let Some(builder) = current_entry.take() {
entries.push(builder.build(path, input.lines().count())?);
}
Ok(Config {
settings,
entries,
hooks,
vars,
path: path.to_path_buf(),
})
}
#[allow(clippy::too_many_arguments)]
fn handle_kv(
key: &str,
value: &str,
current_section: Option<&str>,
settings: &mut Settings,
current_entry: &mut Option<EntryBuilder>,
hooks: &mut Hooks,
line_num: usize,
) -> Result<()> {
match current_section {
Some("settings") => match key {
"method" => {
settings.method = DeployMethod::parse(value).ok_or_else(|| Error::Config {
message: format!("invalid method `{value}`"),
line: Some(line_num),
})?;
}
_ => {
return Err(Error::Config {
message: format!("unknown setting `{key}`"),
line: Some(line_num),
});
}
},
Some("hooks") => match key {
"init" => hooks.init = Some(value.to_string()),
"before" => hooks.before = Some(value.to_string()),
"after" => hooks.after = Some(value.to_string()),
_ => {
return Err(Error::Config {
message: format!("unknown hook `{key}`"),
line: Some(line_num),
});
}
},
Some("entries") => {
let builder = current_entry.as_mut().ok_or_else(|| Error::Config {
message: "key-value outside [[entries]]".into(),
line: Some(line_num),
})?;
match key {
"source" => builder.source = Some(value.to_string()),
"target" => builder.target = Some(value.to_string()),
"method" => builder.method = Some(value.to_string()),
"encrypted" => builder.encrypted = parse_bool(value),
"directory" => builder.directory = parse_bool(value),
"template" => builder.template = parse_bool(value),
"os" => builder.os = Some(value.to_string()),
"permissions" => {
builder.permissions = u32::from_str_radix(value, 8).ok();
if builder.permissions.is_none() {
return Err(Error::Config {
message: format!("invalid permissions `{value}`"),
line: Some(line_num),
});
}
}
"before" => builder.before = Some(value.to_string()),
"after" => builder.after = Some(value.to_string()),
_ => {
return Err(Error::Config {
message: format!("unknown entry field `{key}`"),
line: Some(line_num),
});
}
}
}
_ => {}
}
Ok(())
}
#[derive(Default)]
struct EntryBuilder {
source: Option<String>,
target: Option<String>,
method: Option<String>,
encrypted: bool,
directory: bool,
template: bool,
os: Option<String>,
permissions: Option<u32>,
before: Option<String>,
after: Option<String>,
}
impl EntryBuilder {
fn build(self, path: &Path, line: usize) -> Result<Entry> {
let source = self.source.ok_or_else(|| Error::Config {
message: "entry missing `source`".into(),
line: Some(line),
})?;
let target = self.target.ok_or_else(|| Error::Config {
message: format!("entry `{source}` missing `target`"),
line: Some(line),
})?;
let method = self
.method
.as_deref()
.map(|s| {
DeployMethod::parse(s).ok_or_else(|| Error::Config {
message: format!("invalid method `{s}` for entry `{source}`"),
line: Some(line),
})
})
.transpose()?;
let _ = path;
Ok(Entry {
source: source.clone(),
target,
method,
encrypted: self.encrypted,
directory: self.directory,
template: self.template,
os: self.os,
permissions: self.permissions,
before: self.before,
after: self.after,
})
}
}
fn parse_kv(line: &str) -> Option<(&str, String)> {
let (key, rest) = line.split_once('=')?;
let key = key.trim();
let value = rest.trim();
let value = if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
unescape_string(&value[1..value.len() - 1])
} else {
value.to_string()
};
Some((key, value))
}
fn parse_bool(s: &str) -> bool {
matches!(s.to_ascii_lowercase().as_str(), "true" | "1" | "yes")
}
fn unescape_string(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some(ch @ ('\\' | '"')) => result.push(ch),
Some(other) => {
result.push('\\');
result.push(other);
}
None => result.push('\\'),
}
} else {
result.push(c);
}
}
result
}
fn serialize_config(config: &Config) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(
out,
"# dotling.toml — managed by dotling, safe to hand-edit\n"
);
if config.settings.method != DeployMethod::Symlink {
let _ = writeln!(out, "[settings]");
let _ = writeln!(out, "method = \"{}\"\n", config.settings.method.as_str());
}
if config.hooks.init.is_some() || config.hooks.before.is_some() || config.hooks.after.is_some()
{
let _ = writeln!(out, "[hooks]");
if let Some(ref init) = config.hooks.init {
let _ = writeln!(out, "init = \"{}\"", escape_string(init));
}
if let Some(ref before) = config.hooks.before {
let _ = writeln!(out, "before = \"{}\"", escape_string(before));
}
if let Some(ref after) = config.hooks.after {
let _ = writeln!(out, "after = \"{}\"", escape_string(after));
}
let _ = writeln!(out);
}
if !config.vars.is_empty() {
let _ = writeln!(out, "[vars]");
let _ = writeln!(
out,
"# Shared defaults — override in ~/.dotling/vars.toml on each machine"
);
for (key, value) in &config.vars {
let _ = writeln!(out, "{key} = \"{}\"", escape_string(value));
}
let _ = writeln!(out);
}
for entry in &config.entries {
let _ = writeln!(out, "[[entries]]");
let _ = writeln!(out, "source = \"{}\"", escape_string(&entry.source));
let _ = writeln!(out, "target = \"{}\"", escape_string(&entry.target));
if let Some(method) = entry.method {
let _ = writeln!(out, "method = \"{}\"", method.as_str());
}
if entry.encrypted {
let _ = writeln!(out, "encrypted = true");
}
if entry.directory {
let _ = writeln!(out, "directory = true");
}
if entry.template {
let _ = writeln!(out, "template = true");
}
if let Some(ref os) = entry.os {
let _ = writeln!(out, "os = \"{os}\"");
}
if let Some(perms) = entry.permissions {
let _ = writeln!(out, "permissions = \"{perms:04o}\"");
}
if let Some(ref before) = entry.before {
let _ = writeln!(out, "before = \"{}\"", escape_string(before));
}
if let Some(ref after) = entry.after {
let _ = writeln!(out, "after = \"{}\"", escape_string(after));
}
let _ = writeln!(out);
}
out
}
fn escape_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\t', "\\t")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_config() {
let config = parse_config("", Path::new("test.toml")).unwrap();
assert!(config.entries.is_empty());
assert_eq!(config.settings.method, DeployMethod::Symlink);
}
#[test]
fn parse_basic_config() {
let input = r#"
# dotling.toml
[settings]
method = "symlink"
[[entries]]
source = "shell/zshrc"
target = "~/.zshrc"
[[entries]]
source = "config/nvim"
target = "~/.config/nvim"
directory = true
method = "copy"
os = "macos"
"#;
let config = parse_config(input, Path::new("test.toml")).unwrap();
assert_eq!(config.settings.method, DeployMethod::Symlink);
assert_eq!(config.entries.len(), 2);
assert_eq!(config.entries[0].source, "shell/zshrc");
assert_eq!(config.entries[0].target, "~/.zshrc");
assert!(!config.entries[0].directory);
assert!(config.entries[0].method.is_none());
assert_eq!(config.entries[1].source, "config/nvim");
assert_eq!(config.entries[1].target, "~/.config/nvim");
assert!(config.entries[1].directory);
assert_eq!(config.entries[1].method, Some(DeployMethod::Copy));
assert_eq!(config.entries[1].os.as_deref(), Some("macos"));
}
#[test]
fn serialize_roundtrip() {
let config = Config {
settings: Settings {
method: DeployMethod::Symlink,
},
entries: vec![
Entry {
source: "shell/zshrc".into(),
target: "~/.zshrc".into(),
method: None,
encrypted: false,
directory: false,
template: false,
os: None,
permissions: None,
before: Some("echo 'entry before'".into()),
after: Some("echo 'entry after'".into()),
},
Entry {
source: "config/nvim".into(),
target: "~/.config/nvim".into(),
method: Some(DeployMethod::Copy),
encrypted: true,
directory: true,
template: false,
os: Some("linux".into()),
permissions: Some(0o600),
before: None,
after: None,
},
],
hooks: Hooks {
init: Some("echo 'init'".into()),
before: Some("echo 'global before'".into()),
after: Some("echo 'global after'".into()),
},
vars: vec![],
path: PathBuf::from("test.toml"),
};
let serialized = serialize_config(&config);
let parsed = parse_config(&serialized, Path::new("test.toml")).unwrap();
assert_eq!(parsed.entries.len(), 2);
assert_eq!(parsed.entries[0].source, "shell/zshrc");
assert_eq!(
parsed.entries[0].before.as_deref(),
Some("echo 'entry before'")
);
assert_eq!(
parsed.entries[0].after.as_deref(),
Some("echo 'entry after'")
);
assert!(parsed.entries[1].encrypted);
assert!(parsed.entries[1].directory);
assert_eq!(parsed.entries[1].permissions, Some(0o600));
assert_eq!(parsed.hooks.init.as_deref(), Some("echo 'init'"));
assert_eq!(parsed.hooks.before.as_deref(), Some("echo 'global before'"));
assert_eq!(parsed.hooks.after.as_deref(), Some("echo 'global after'"));
}
#[test]
fn duplicate_source_rejected() {
let mut config = Config::new(PathBuf::from("test.toml"));
config
.add_entry(Entry {
source: "a".into(),
target: "~/.a".into(),
method: None,
encrypted: false,
directory: false,
template: false,
os: None,
permissions: None,
before: None,
after: None,
})
.unwrap();
let err = config
.add_entry(Entry {
source: "a".into(),
target: "~/.b".into(),
method: None,
encrypted: false,
directory: false,
template: false,
os: None,
permissions: None,
before: None,
after: None,
})
.unwrap_err();
assert!(err.to_string().contains("already tracked"));
}
#[test]
fn duplicate_target_rejected() {
let mut config = Config::new(PathBuf::from("test.toml"));
config
.add_entry(Entry {
source: "a".into(),
target: "~/.a".into(),
method: None,
encrypted: false,
directory: false,
template: false,
os: None,
permissions: None,
before: None,
after: None,
})
.unwrap();
let err = config
.add_entry(Entry {
source: "b".into(),
target: "~/.a".into(),
method: None,
encrypted: false,
directory: false,
template: false,
os: None,
permissions: None,
before: None,
after: None,
})
.unwrap_err();
assert!(err.to_string().contains("already in use"));
}
#[test]
fn find_by_source_or_target() {
let mut config = Config::new(PathBuf::from("test.toml"));
config
.add_entry(Entry {
source: "shell/zshrc".into(),
target: "~/.zshrc".into(),
method: None,
encrypted: false,
directory: false,
template: false,
os: None,
permissions: None,
before: None,
after: None,
})
.unwrap();
assert!(config.find_entry("shell/zshrc").is_some());
assert!(config.find_entry("~/.zshrc").is_some());
assert!(config.find_entry("nope").is_none());
}
#[test]
fn remove_entry() {
let mut config = Config::new(PathBuf::from("test.toml"));
config
.add_entry(Entry {
source: "a".into(),
target: "~/.a".into(),
method: None,
encrypted: false,
directory: false,
template: false,
os: None,
permissions: None,
before: None,
after: None,
})
.unwrap();
assert!(config.remove_entry("a").is_some());
assert!(config.entries.is_empty());
assert!(config.remove_entry("a").is_none());
}
}