krypt_core/
tool_config.rs1#![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#[derive(Debug, Error)]
25pub enum ToolConfigError {
26 #[error("tool config io {path:?}: {source}")]
28 Io {
29 path: PathBuf,
31 #[source]
33 source: io::Error,
34 },
35
36 #[error("tool config parse {path:?}: {source}")]
38 Parse {
39 path: PathBuf,
41 #[source]
43 source: Box<toml::de::Error>,
44 },
45
46 #[error("tool config encode: {0}")]
48 Encode(#[source] Box<toml::ser::Error>),
49
50 #[error("resolving default config path: {0}")]
52 Resolve(#[source] crate::paths::ResolveError),
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct RepoConfig {
61 pub path: PathBuf,
63
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub url: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct ToolConfig {
73 pub repo: RepoConfig,
75}
76
77impl ToolConfig {
78 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 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 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#[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}