Skip to content

Commit 3614e14

Browse files
committed
Implement config resolution with file discovery and path expansion
The implementation walks up the directory to find .tomb.toml file, falling back to home dir and XDG config dir. Resolves tilde and relative paths against the config file location. Also expands glob patterns and deduplicates resolved files.
1 parent 9617764 commit 3614e14

5 files changed

Lines changed: 283 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tomb-cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ chrono = { version = "0.4.44", features = ["serde"] }
1313
clap = { version = "4.6.0", features = ["derive", "string"] }
1414
crossterm = "0.29.0"
1515
etcetera = "0.11.0"
16+
glob = "0.3.3"
1617
pulldown-cmark = { path = "../pulldown-cmark-task-marker", version = "0.13.1", default-features = false }
1718
rand = "0.10.0"
1819
ratatui = "0.30.0"
@@ -21,6 +22,7 @@ thiserror = "2.0.18"
2122
toml = "1.0.6"
2223

2324
[dev-dependencies]
25+
indoc = "2.0.7"
2426
insta = "1.46.3"
2527
pretty_assertions = "1.4.1"
2628
tempfile = "3.27.0"

tomb-cli/src/config.rs

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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+
}

tomb-cli/src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use thiserror::Error;
44
pub enum TombError {
55
#[error("I/O error: {0}")]
66
Io(#[from] std::io::Error),
7+
#[error("Config error: {0}")]
8+
Config(String),
79
}
810

911
pub type Result<T> = std::result::Result<T, TombError>;

tomb-cli/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod cli;
2+
pub mod config;
23
pub mod error;
34
pub mod model;
45
pub mod version;

0 commit comments

Comments
 (0)