Skip to main content

handshake_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(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
29/// Opens the user's actual source, including comments and invalid TOML, creating defaults if absent.
30///
31/// # Errors
32/// Returns an error if the user config directory cannot be found or the starter file cannot be created.
33pub fn read_user_file() -> Result<FilePreview, ConfigEditError> {
34    Config::prepare_user()?;
35    read_file(&Config::user_path().ok_or(ConfigEditError::MissingDirectory)?)
36}
37
38/// Saves only the user config file, preserving the file editor's version and conflict semantics.
39///
40/// # Errors
41/// Returns an error for a different path, an unavailable config directory, or a failed file save.
42pub 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    // Config directories can be symlinks (including /tmp on macOS). Pin their resolved path,
52    // while the file access layer still rejects a symlink in place of config.toml itself.
53    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        // SAFETY: The NUL-terminated path lives through this call; mkfifo retains no pointers.
169        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}