handshake_core/config/
editing.rs1use std::fs;
4use std::path::{Path, PathBuf};
5
6use thiserror::Error;
7
8use super::{Config, ConfigDiagnostic, ConfigProblem, default_source, publish_default};
9use crate::files::{self, FileError, FilePreview, FileSaveOutcome, FileSaveRequest};
10
11#[derive(Debug, Error)]
12pub enum ConfigEditError {
13 #[error(transparent)]
14 Migration(#[from] super::ConfigMigrationError),
15 #[error("Cannot find the user config directory.")]
16 MissingDirectory,
17 #[error("Cannot edit feature flags: {0}")]
18 FeatureFlags(String),
19 #[error("Could not open the config file: {0}")]
20 Open(String),
21 #[error("The settings editor can only save the user config file.")]
22 InvalidPath,
23 #[error("Cannot apply settings: {0}")]
24 InvalidConfig(ConfigDiagnostic),
25 #[error(transparent)]
26 Save(#[from] FileError),
27}
28
29pub fn read_user_file() -> Result<FilePreview, ConfigEditError> {
34 Config::prepare_user()?;
35 read_file(&Config::user_path().ok_or(ConfigEditError::MissingDirectory)?)
36}
37
38pub fn save_user_file(request: &FileSaveRequest) -> Result<FileSaveOutcome, ConfigEditError> {
43 Config::prepare_user()?;
44 save_file(
45 &Config::user_path().ok_or(ConfigEditError::MissingDirectory)?,
46 request,
47 )
48}
49
50fn editor_path(path: &Path) -> Result<PathBuf, ConfigEditError> {
51 let parent = path.parent().ok_or(ConfigEditError::InvalidPath)?;
54 let directory =
55 fs::canonicalize(parent).map_err(|error| ConfigEditError::Open(error.to_string()))?;
56 Ok(directory.join(path.file_name().ok_or(ConfigEditError::InvalidPath)?))
57}
58
59pub(super) fn read_file(path: &Path) -> Result<FilePreview, ConfigEditError> {
60 let parent = path.parent().ok_or(ConfigEditError::InvalidPath)?;
61 fs::create_dir_all(parent).map_err(|error| ConfigEditError::Open(error.to_string()))?;
62 let path = editor_path(path)?;
63 let parent = path.parent().ok_or(ConfigEditError::InvalidPath)?;
64 let preview = files::read_preview(parent, &path);
65 if preview.content != files::FileContent::Missing {
66 return Ok(preview);
67 }
68 let source = default_source().map_err(|error| ConfigEditError::Open(error.to_string()))?;
69 publish_default(&path, &source).map_err(|error| ConfigEditError::Open(error.to_string()))?;
70 Ok(files::read_preview(parent, &path))
71}
72
73pub(super) fn save_file(
74 path: &Path,
75 request: &FileSaveRequest,
76) -> Result<FileSaveOutcome, ConfigEditError> {
77 let path = editor_path(path)?;
78 if request.path != path || Some(request.folder.as_path()) != path.parent() {
79 return Err(ConfigEditError::InvalidPath);
80 }
81 let loaded = Config::load_source(&path, &request.text);
82 if let Some(diagnostic) = loaded
83 .diagnostics
84 .into_iter()
85 .find(|diagnostic| diagnostic.problem != ConfigProblem::UnknownKey)
86 {
87 return Err(ConfigEditError::InvalidConfig(diagnostic));
88 }
89 Ok(files::save(request)?)
90}
91
92#[cfg(test)]
93mod tests {
94 use std::os::unix::ffi::OsStrExt;
95
96 use super::*;
97 use crate::files::FileContent;
98
99 #[test]
100 fn invalid_drafts_preserve_the_saved_file_including_import_validation() {
101 let directory = tempfile::tempdir().unwrap();
102 let path = directory.path().join("config.toml");
103 let file = read_file(&path).unwrap();
104 let original = fs::read_to_string(&path).unwrap();
105 let mut request = FileSaveRequest {
106 folder: file.path.parent().unwrap().to_owned(),
107 path: file.path,
108 text: String::new(),
109 expected_version: file.version.unwrap(),
110 overwrite: false,
111 };
112 for source in [
113 "terminal.font_size = 100\n",
114 "terminal.colors.blue = '#invalid'\n",
115 "import = 'missing.toml'\n",
116 "invalid = @\n",
117 ] {
118 request.text = source.into();
119 assert!(matches!(
120 save_file(&path, &request),
121 Err(ConfigEditError::InvalidConfig(_))
122 ));
123 assert_eq!(fs::read_to_string(&path).unwrap(), original);
124 }
125 }
126
127 #[test]
128 fn deleted_config_reports_a_conflict_and_can_be_recreated() {
129 let directory = tempfile::tempdir().unwrap();
130 let path = directory.path().join("config.toml");
131 let file = read_file(&path).unwrap();
132 let mut request = FileSaveRequest {
133 folder: file.path.parent().unwrap().to_owned(),
134 path: file.path,
135 text: "terminal.font_size = 18\n".into(),
136 expected_version: file.version.unwrap(),
137 overwrite: false,
138 };
139 fs::remove_file(&path).unwrap();
140 let FileSaveOutcome::Conflict(conflict) = save_file(&path, &request).unwrap() else {
141 panic!("expected conflict")
142 };
143 assert_eq!(conflict.content, FileContent::Missing);
144 assert!(!path.exists());
145 request.overwrite = true;
146 assert!(matches!(
147 save_file(&path, &request).unwrap(),
148 FileSaveOutcome::Saved(_)
149 ));
150 assert_eq!(fs::read_to_string(path).unwrap(), request.text);
151 }
152
153 #[test]
154 fn existing_files_use_bounded_preview_and_reject_links_and_special_files() {
155 let directory = tempfile::tempdir().unwrap();
156 let path = directory.path().join("config.toml");
157 fs::File::create(&path)
158 .unwrap()
159 .set_len(files::TEXT_LIMIT + 1)
160 .unwrap();
161 assert_eq!(read_file(&path).unwrap().content, FileContent::TooLarge);
162 fs::remove_file(&path).unwrap();
163 std::os::unix::fs::symlink(directory.path().join("target"), &path).unwrap();
164 assert_ne!(read_file(&path).unwrap().content, FileContent::Missing);
165 assert!(!directory.path().join("target").exists());
166 fs::remove_file(&path).unwrap();
167 let fifo = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
168 assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
170 assert_eq!(read_file(&path).unwrap().content, FileContent::Unsupported);
171 }
172
173 #[test]
174 fn editor_resolves_symlinked_config_directories_for_reads_and_saves() {
175 let directory = tempfile::tempdir().unwrap();
176 let real = directory.path().join("real");
177 fs::create_dir(&real).unwrap();
178 let alias = directory.path().join("alias");
179 std::os::unix::fs::symlink(&real, &alias).unwrap();
180 let path = alias.join("config.toml");
181 let file = read_file(&path).unwrap();
182 let request = FileSaveRequest {
183 folder: file.path.parent().unwrap().to_owned(),
184 path: file.path.clone(),
185 text: "# edited\n".into(),
186 expected_version: file.version.unwrap(),
187 overwrite: false,
188 };
189 assert!(matches!(
190 save_file(&path, &request).unwrap(),
191 FileSaveOutcome::Saved(_)
192 ));
193 assert_eq!(
194 fs::read_to_string(real.join("config.toml")).unwrap(),
195 "# edited\n"
196 );
197 }
198
199 #[test]
200 fn editor_creates_defaults_and_preserves_invalid_source() {
201 let directory = tempfile::tempdir().unwrap();
202 let path = directory.path().join("handshake/config.toml");
203 let file = read_file(&path).unwrap();
204 let FileContent::Text(source) = file.content else {
205 panic!("expected text")
206 };
207 assert!(source.contains("[theme]"));
208 assert!(source.contains(
209 "https://standing-desks.github.io/handshake/documentation/guide/#configuration"
210 ));
211 assert!(!source.contains("[terminal.colors]"));
212 fs::write(&path, "# Keep my comments\ninvalid = @\n").unwrap();
213 assert_eq!(
214 read_file(&path).unwrap().content,
215 FileContent::Text("# Keep my comments\ninvalid = @\n".into())
216 );
217 }
218
219 #[test]
220 fn saves_config_without_a_folder_and_detects_external_changes() {
221 let directory = tempfile::tempdir().unwrap();
222 let path = directory.path().join("config.toml");
223 let file = read_file(&path).unwrap();
224 let mut request = FileSaveRequest {
225 folder: file.path.parent().unwrap().to_owned(),
226 path: file.path.clone(),
227 text: "terminal.font_size = 18\n".into(),
228 expected_version: file.version.unwrap(),
229 overwrite: false,
230 };
231 let FileSaveOutcome::Saved(saved) = save_file(&path, &request).unwrap() else {
232 panic!("expected saved")
233 };
234 assert_eq!(Config::load(&path).config.terminal.font_size.points(), 18.0);
235 request.expected_version = saved.version.unwrap();
236 fs::write(&path, "# external\n").unwrap();
237 assert!(matches!(
238 save_file(&path, &request).unwrap(),
239 FileSaveOutcome::Conflict(_)
240 ));
241 assert_eq!(fs::read_to_string(&path).unwrap(), "# external\n");
242 request.overwrite = true;
243 assert!(matches!(
244 save_file(&path, &request).unwrap(),
245 FileSaveOutcome::Saved(_)
246 ));
247 request.path = request.folder.join("other.toml");
248 assert!(matches!(
249 save_file(&path, &request),
250 Err(ConfigEditError::InvalidPath)
251 ));
252 assert!(!request.path.exists());
253 }
254}