|
| 1 | +use std::{ |
| 2 | + collections::HashSet, |
| 3 | + fs, |
| 4 | + path::{Path, PathBuf}, |
| 5 | +}; |
| 6 | + |
| 7 | +use etcetera::{choose_base_strategy, home_dir, BaseStrategy}; |
| 8 | +use serde::Deserialize; |
| 9 | + |
| 10 | +use crate::error::{Result, TombError}; |
| 11 | + |
| 12 | +#[derive(Debug, Clone, PartialEq, Deserialize)] |
| 13 | +#[serde(untagged)] |
| 14 | +pub enum FileEntry { |
| 15 | + Path { path: PathBuf, context: Option<String> }, |
| 16 | + Glob { glob: String, context: Option<String> }, |
| 17 | +} |
| 18 | + |
| 19 | +#[derive(Debug, Clone, PartialEq, Deserialize)] |
| 20 | +pub struct InboxEntry { |
| 21 | + pub path: PathBuf, |
| 22 | +} |
| 23 | + |
| 24 | +#[derive(Debug, Default, Clone, PartialEq, Deserialize)] |
| 25 | +pub struct Config { |
| 26 | + pub files: Vec<FileEntry>, |
| 27 | + pub inbox: Vec<InboxEntry>, |
| 28 | + /// Directory containing the config file, used as base for relative paths |
| 29 | + #[serde(skip)] |
| 30 | + pub root_dir: Option<PathBuf>, |
| 31 | +} |
| 32 | + |
| 33 | +/// Intermediate struct for TOML deserialization (allows missing sections) |
| 34 | +#[derive(Clone, Debug, PartialEq, Deserialize, Default)] |
| 35 | +struct TomlConfig { |
| 36 | + #[serde(default)] |
| 37 | + files: Vec<FileEntry>, |
| 38 | + #[serde(default)] |
| 39 | + inbox: Vec<InboxEntry>, |
| 40 | +} |
| 41 | + |
| 42 | +const TOMB_CONFIG_FILE_NAME: &str = ".tomb.toml"; |
| 43 | +const TOMB_CONFIG_FILE_NAME_XDG: &str = "tomb/config.toml"; |
| 44 | + |
| 45 | +/// Resolve a config by searching for config files. |
| 46 | +/// |
| 47 | +/// Search order: |
| 48 | +/// 1. Walk up from `start_dir` looking for `.tomb.toml` |
| 49 | +/// - stop at root marker |
| 50 | +/// 2. `~/.tomb.toml` |
| 51 | +/// 3. `~/.config/tomb/config.toml` (XDG/platform config dir) |
| 52 | +/// 4. If nothing found, return a default empty config |
| 53 | +pub fn resolve_config(start_dir: &Path, root_markers: &[&str]) -> Result<Config> { |
| 54 | + // Find the first existing config file path from the search locations. |
| 55 | + // Use Path::ancestors() to walk up from start_dir. |
| 56 | + let repo_config = start_dir |
| 57 | + .ancestors() |
| 58 | + .take_while(|dir| { |
| 59 | + !root_markers.iter().any(|marker| dir.join(marker).exists()) || dir.join(TOMB_CONFIG_FILE_NAME).is_file() |
| 60 | + }) |
| 61 | + .find_map(|dir| { |
| 62 | + let candidate = dir.join(TOMB_CONFIG_FILE_NAME); |
| 63 | + candidate.is_file().then_some(candidate) |
| 64 | + }); |
| 65 | + |
| 66 | + let home_config = home_dir().map(|home_dir| home_dir.join(TOMB_CONFIG_FILE_NAME)).ok(); |
| 67 | + let xdg_config = choose_base_strategy() |
| 68 | + .map(|strategy| strategy.config_dir().join(TOMB_CONFIG_FILE_NAME_XDG)) |
| 69 | + .ok(); |
| 70 | + |
| 71 | + let config_path = [repo_config, home_config, xdg_config] |
| 72 | + .into_iter() |
| 73 | + .flatten() |
| 74 | + .find(|path| path.is_file()) |
| 75 | + .ok_or(TombError::Config(String::from("Could not resolve config")))?; |
| 76 | + |
| 77 | + toml::from_str::<TomlConfig>(&fs::read_to_string(&config_path)?) |
| 78 | + .map(|config| Config { |
| 79 | + files: config.files, |
| 80 | + inbox: config.inbox, |
| 81 | + root_dir: config_path.parent().map(|parent| parent.to_path_buf()), |
| 82 | + }) |
| 83 | + .map_err(|err| TombError::Config(err.message().to_string())) |
| 84 | +} |
| 85 | + |
| 86 | +/// Expand `~` prefix to the user's home directory and resolve relative |
| 87 | +/// paths against `base`. |
| 88 | +/// |
| 89 | +/// - `~/foo` becomes `/home/user/foo` |
| 90 | +/// - `foo/bar` becomes `base/foo/bar` |
| 91 | +/// - `/absolute/path` stays as-is |
| 92 | +pub fn resolve_path(base: &Path, raw: &Path) -> PathBuf { |
| 93 | + if let Ok(suffix) = raw.strip_prefix("~") { |
| 94 | + home_dir() |
| 95 | + .map(|home_dir| home_dir.join(suffix)) |
| 96 | + .unwrap_or(raw.to_path_buf()) |
| 97 | + } else if raw.is_relative() { |
| 98 | + base.join(raw) |
| 99 | + } else { |
| 100 | + raw.to_path_buf() |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +/// Apply path resolution to all entries in a parsed config. |
| 105 | +/// Call resolve_path on every FileEntry::Path and InboxEntry path |
| 106 | +/// using config.root_dir as the base. |
| 107 | +pub fn resolve_paths(config: &mut Config) { |
| 108 | + let base = config.root_dir.clone().unwrap_or_default(); |
| 109 | + |
| 110 | + for entry in &mut config.files { |
| 111 | + if let FileEntry::Path { path, .. } = entry { |
| 112 | + *path = resolve_path(&base, path) |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + for entry in &mut config.inbox { |
| 117 | + entry.path = resolve_path(&base, &entry.path) |
| 118 | + } |
| 119 | +} |
| 120 | + |
| 121 | +impl Config { |
| 122 | + /// Return the first configured inbox entry, or None. |
| 123 | + pub fn inbox(&self) -> Option<&InboxEntry> { |
| 124 | + self.inbox.first() |
| 125 | + } |
| 126 | + |
| 127 | + /// Expand all file entries into resolved paths. |
| 128 | + /// Explicit Path entries are resolved directly. |
| 129 | + /// Glob entries are expanded using the glob crate. |
| 130 | + /// Returns a deduplicated list where explicit entries win over glob matches. |
| 131 | + pub fn resolve_files(&self) -> Vec<ResolvedFile> { |
| 132 | + let mut files = self |
| 133 | + .files |
| 134 | + .iter() |
| 135 | + .flat_map(|entry| match entry { |
| 136 | + FileEntry::Path { path, context } => { |
| 137 | + vec![ResolvedFile { |
| 138 | + path: path.clone(), |
| 139 | + context: context.clone(), |
| 140 | + }] |
| 141 | + } |
| 142 | + FileEntry::Glob { glob: pattern, context } => glob::glob(pattern) |
| 143 | + .into_iter() |
| 144 | + .flatten() |
| 145 | + .filter_map(|p| p.ok()) |
| 146 | + .map(|path| ResolvedFile { |
| 147 | + path, |
| 148 | + context: context.clone(), |
| 149 | + }) |
| 150 | + .collect(), |
| 151 | + }) |
| 152 | + .collect::<Vec<_>>(); |
| 153 | + |
| 154 | + let mut seen = HashSet::new(); |
| 155 | + files.retain(|f| seen.insert(f.path.clone())); |
| 156 | + files |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +#[derive(Debug, Clone, PartialEq)] |
| 161 | +pub struct ResolvedFile { |
| 162 | + pub path: PathBuf, |
| 163 | + pub context: Option<String>, |
| 164 | +} |
| 165 | + |
| 166 | +#[cfg(test)] |
| 167 | +mod tests { |
| 168 | + use super::*; |
| 169 | + use std::result; |
| 170 | + use tempfile::tempdir; |
| 171 | + |
| 172 | + #[test] |
| 173 | + fn finds_tomb_toml_in_ancestor_directory() -> result::Result<(), Box<dyn std::error::Error>> { |
| 174 | + let tmp_dir = tempdir()?; |
| 175 | + let root_dir = tmp_dir.path(); |
| 176 | + let start_dir = root_dir.join("a/b/c"); |
| 177 | + |
| 178 | + fs::write( |
| 179 | + root_dir.join(".tomb.toml"), |
| 180 | + r#" |
| 181 | + [[files]] |
| 182 | + path = "notes.md" |
| 183 | +
|
| 184 | + [[files]] |
| 185 | + path = "~/home_notes.md" |
| 186 | +
|
| 187 | + [[files]] |
| 188 | + glob = "*.md" |
| 189 | + context = "docs" |
| 190 | +
|
| 191 | + [[inbox]] |
| 192 | + path = "inbox.md" |
| 193 | + "#, |
| 194 | + )?; |
| 195 | + fs::create_dir_all(&start_dir)?; |
| 196 | + |
| 197 | + let config = resolve_config(&start_dir, &[])?; |
| 198 | + |
| 199 | + assert_eq!(config.root_dir.unwrap(), root_dir); |
| 200 | + assert_eq!( |
| 201 | + config.files, |
| 202 | + vec![ |
| 203 | + FileEntry::Path { |
| 204 | + path: PathBuf::from("notes.md"), |
| 205 | + context: None |
| 206 | + }, |
| 207 | + FileEntry::Path { |
| 208 | + path: PathBuf::from("~/home_notes.md"), |
| 209 | + context: None |
| 210 | + }, |
| 211 | + FileEntry::Glob { |
| 212 | + glob: String::from("*.md"), |
| 213 | + context: Some(String::from("docs")) |
| 214 | + } |
| 215 | + ] |
| 216 | + ); |
| 217 | + assert_eq!( |
| 218 | + config.inbox, |
| 219 | + vec![InboxEntry { |
| 220 | + path: PathBuf::from("inbox.md") |
| 221 | + }] |
| 222 | + ); |
| 223 | + |
| 224 | + Ok(()) |
| 225 | + } |
| 226 | + |
| 227 | + #[test] |
| 228 | + fn resolve_files_expands_paths_and_globs() -> result::Result<(), Box<dyn std::error::Error>> { |
| 229 | + let tmp = tempdir()?; |
| 230 | + let root = tmp.path(); |
| 231 | + |
| 232 | + fs::write(root.join("notes.md"), "")?; |
| 233 | + fs::write(root.join("todo.md"), "")?; |
| 234 | + fs::write(root.join("readme.txt"), "")?; |
| 235 | + |
| 236 | + let config = Config { |
| 237 | + files: vec![ |
| 238 | + FileEntry::Path { |
| 239 | + path: root.join("notes.md"), |
| 240 | + context: Some("personal".into()), |
| 241 | + }, |
| 242 | + FileEntry::Glob { |
| 243 | + glob: root.join("*.md").to_string_lossy().into(), |
| 244 | + context: None, |
| 245 | + }, |
| 246 | + ], |
| 247 | + inbox: vec![], |
| 248 | + root_dir: Some(root.to_path_buf()), |
| 249 | + }; |
| 250 | + |
| 251 | + let files = config.resolve_files(); |
| 252 | + |
| 253 | + assert_eq!(files.iter().filter(|f| f.path == root.join("notes.md")).count(), 1); |
| 254 | + assert!(files.iter().any(|f| f.path == root.join("notes.md"))); |
| 255 | + assert!(files.iter().any(|f| f.path == root.join("todo.md"))); |
| 256 | + assert!(!files.iter().any(|f| f.path.extension().is_some_and(|e| e == "txt"))); |
| 257 | + let notes = files.iter().find(|f| f.path == root.join("notes.md")).unwrap(); |
| 258 | + assert_eq!(notes.context, Some("personal".into())); |
| 259 | + |
| 260 | + Ok(()) |
| 261 | + } |
| 262 | + |
| 263 | + #[test] |
| 264 | + fn tilde_expansion() { |
| 265 | + let path = resolve_path(Path::new("/tmp"), Path::new("~/foo")); |
| 266 | + assert!(!path.starts_with("~")); |
| 267 | + assert!(path.starts_with(home_dir().unwrap())); |
| 268 | + assert!(path.ends_with("foo")) |
| 269 | + } |
| 270 | +} |
0 commit comments