Skip to main content

twine_core/config/
editing.rs

1//! Versioned settings-file access, independent of an open folder or application runtime.
2
3use 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
25/// Opens the user's actual source, including comments and invalid TOML, creating defaults if absent.
26///
27/// # Errors
28/// Returns an error if the user config directory cannot be found or the starter file cannot be created.
29pub fn read_user_file() -> Result<FilePreview, ConfigEditError> {
30    read_file(&Config::user_path().ok_or(ConfigEditError::MissingDirectory)?)
31}
32
33/// Saves only the user config file, preserving the file editor's version and conflict semantics.
34///
35/// # Errors
36/// Returns an error for a different path, an unavailable config directory, or a failed file save.
37pub 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    // Config directories can be symlinks (including /tmp on macOS). Pin their resolved path,
46    // while the file access layer still rejects a symlink in place of config.toml itself.
47    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        // SAFETY: The NUL-terminated path lives through this call; mkfifo retains no pointers.
160        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}