Skip to main content

krypt_core/
tool_config.rs

1//! Tool-level config at `${XDG_CONFIG}/krypt/config.toml`.
2//!
3//! Records where the dotfiles repo lives and (optionally) where it was
4//! cloned from. Written by `krypt init`; read by future commands that
5//! need to locate the repo without the user passing `--repo-path` each
6//! time.
7
8// `ToolConfigError` wraps `io::Error` + `PathBuf` and (boxed) TOML errors;
9// on Windows the combined enum exceeds clippy's 128-byte threshold.
10#![allow(clippy::result_large_err)]
11
12use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19use crate::paths::Resolver;
20
21// ─── Errors ─────────────────────────────────────────────────────────────────
22
23/// Errors loading, saving, or resolving the tool config.
24#[derive(Debug, Error)]
25pub enum ToolConfigError {
26    /// I/O failure reading or writing the config file.
27    #[error("tool config io {path:?}: {source}")]
28    Io {
29        /// The path involved.
30        path: PathBuf,
31        /// Underlying error.
32        #[source]
33        source: io::Error,
34    },
35
36    /// TOML deserialize failure.
37    #[error("tool config parse {path:?}: {source}")]
38    Parse {
39        /// Path of the bad file.
40        path: PathBuf,
41        /// Underlying serde error (boxed to keep the enum variant small).
42        #[source]
43        source: Box<toml::de::Error>,
44    },
45
46    /// TOML serialize failure.
47    #[error("tool config encode: {0}")]
48    Encode(#[source] Box<toml::ser::Error>),
49
50    /// XDG path resolution failed.
51    #[error("resolving default config path: {0}")]
52    Resolve(#[source] crate::paths::ResolveError),
53}
54
55// ─── Schema ─────────────────────────────────────────────────────────────────
56
57/// `[repo]` section of `config.toml`.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct RepoConfig {
61    /// Absolute path to the cloned (or bare-initialised) dotfiles repo.
62    pub path: PathBuf,
63
64    /// Remote URL the repo was cloned from. Absent in `--bare` mode.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub url: Option<String>,
67}
68
69/// Top-level `config.toml` schema.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct ToolConfig {
73    /// Repository location + origin.
74    pub repo: RepoConfig,
75}
76
77impl ToolConfig {
78    /// Resolve the default path: `${XDG_CONFIG}/krypt/config.toml`.
79    pub fn default_path() -> Result<PathBuf, ToolConfigError> {
80        let r = Resolver::new();
81        let xdg = r
82            .resolve_var("XDG_CONFIG")
83            .map_err(ToolConfigError::Resolve)?;
84        Ok(PathBuf::from(xdg).join("krypt").join("config.toml"))
85    }
86
87    /// Load from disk. Returns `Ok(None)` if the file does not exist.
88    pub fn load(path: &Path) -> Result<Option<Self>, ToolConfigError> {
89        let text = match fs::read_to_string(path) {
90            Ok(s) => s,
91            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
92            Err(e) => {
93                return Err(ToolConfigError::Io {
94                    path: path.to_path_buf(),
95                    source: e,
96                });
97            }
98        };
99        let cfg: ToolConfig = toml::from_str(&text).map_err(|source| ToolConfigError::Parse {
100            path: path.to_path_buf(),
101            source: Box::new(source),
102        })?;
103        Ok(Some(cfg))
104    }
105
106    /// Atomically write to disk. Creates parent directories.
107    pub fn save(&self, path: &Path) -> Result<(), ToolConfigError> {
108        let mk_io = |source: io::Error| ToolConfigError::Io {
109            path: path.to_path_buf(),
110            source,
111        };
112
113        if let Some(parent) = path.parent() {
114            fs::create_dir_all(parent).map_err(mk_io)?;
115        }
116
117        let text =
118            toml::to_string_pretty(self).map_err(|e| ToolConfigError::Encode(Box::new(e)))?;
119
120        let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
121        tmp_name.push(format!(".krypt-tmp-{}", std::process::id()));
122        let tmp = path.with_file_name(tmp_name);
123        let _ = fs::remove_file(&tmp);
124        fs::write(&tmp, text.as_bytes()).map_err(mk_io)?;
125        fs::rename(&tmp, path).map_err(mk_io)?;
126        Ok(())
127    }
128}
129
130// ─── Tests ──────────────────────────────────────────────────────────────────
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use tempfile::tempdir;
136
137    #[test]
138    fn load_missing_returns_none() {
139        let dir = tempdir().unwrap();
140        assert!(
141            ToolConfig::load(&dir.path().join("config.toml"))
142                .unwrap()
143                .is_none()
144        );
145    }
146
147    #[test]
148    fn save_then_load_roundtrips_with_url() {
149        let dir = tempdir().unwrap();
150        let path = dir.path().join("config.toml");
151        let cfg = ToolConfig {
152            repo: RepoConfig {
153                path: PathBuf::from("/home/x/.config/krypt/repo"),
154                url: Some("https://github.com/me/dotfiles".into()),
155            },
156        };
157        cfg.save(&path).unwrap();
158        let loaded = ToolConfig::load(&path).unwrap().unwrap();
159        assert_eq!(loaded.repo.path, cfg.repo.path);
160        assert_eq!(loaded.repo.url, cfg.repo.url);
161    }
162
163    #[test]
164    fn save_then_load_roundtrips_bare() {
165        let dir = tempdir().unwrap();
166        let path = dir.path().join("config.toml");
167        let cfg = ToolConfig {
168            repo: RepoConfig {
169                path: PathBuf::from("/home/x/.config/krypt/repo"),
170                url: None,
171            },
172        };
173        cfg.save(&path).unwrap();
174        let text = fs::read_to_string(&path).unwrap();
175        assert!(!text.contains("url"), "url should be omitted when None");
176        let loaded = ToolConfig::load(&path).unwrap().unwrap();
177        assert!(loaded.repo.url.is_none());
178    }
179
180    #[test]
181    fn unknown_field_rejected() {
182        let dir = tempdir().unwrap();
183        let path = dir.path().join("config.toml");
184        fs::write(&path, "[repo]\npath = \"/x\"\nunknown_field = true\n").unwrap();
185        assert!(matches!(
186            ToolConfig::load(&path),
187            Err(ToolConfigError::Parse { .. })
188        ));
189    }
190}