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