use crate::settings_file::{self, PARSE_OPTIONS};
use jsonc_parser::cst::{CstObject, CstObjectProp, CstRootNode};
use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SettingsError {
#[error("failed to read {}", path.display())]
Read {
path: PathBuf,
source: io::Error,
},
#[error("failed to parse {}: {message}", path.display())]
Parse {
path: PathBuf,
message: String,
},
#[error("{PLATFORM_KEY} in {} isn't an object", path.display())]
PlatformNotAnObject {
path: PathBuf,
},
#[error("{PLATFORM_KEY} appears more than once in {}", path.display())]
DuplicatePlatformKey {
path: PathBuf,
},
#[error("failed to write {}", path.display())]
Write {
path: PathBuf,
source: io::Error,
},
}
const PLATFORM_KEY: &str = "remote.SSH.remotePlatform";
const PLATFORM: &str = "linux";
const HOST_SUFFIX: &str = ".fbk";
const SETTINGS_FILE: &str = "settings.json";
const EDITORS: [&str; 4] = ["Code", "Code - Insiders", "Cursor", "VSCodium"];
pub fn config_root() -> Option<PathBuf> {
settings_file::config_root_or(platform_config_root)
}
#[cfg(not(target_os = "macos"))]
fn platform_config_root() -> Option<PathBuf> {
settings_file::xdg_config_home()
}
#[cfg(target_os = "macos")]
fn platform_config_root() -> Option<PathBuf> {
std::env::var_os("HOME").map(|home| PathBuf::from(home).join("Library/Application Support"))
}
pub fn sync_settings(config_root: &Path, hostnames: &[String]) -> Vec<SettingsError> {
let _guard = settings_file::lock();
EDITORS
.iter()
.map(|editor| config_root.join(editor).join("User"))
.filter(|user_dir| user_dir.is_dir())
.filter_map(|user_dir| sync_file(&user_dir.join(SETTINGS_FILE), hostnames).err())
.collect()
}
fn sync_file(path: &Path, hostnames: &[String]) -> Result<(), SettingsError> {
let text = settings_file::read(path).map_err(|source| SettingsError::Read {
path: path.to_path_buf(),
source,
})?;
match update_platforms(path, &text, hostnames)? {
Some(updated) => {
settings_file::write(path, &updated).map_err(|source| SettingsError::Write {
path: path.to_path_buf(),
source,
})
}
None => Ok(()),
}
}
fn update_platforms(
path: &Path,
text: &str,
hostnames: &[String],
) -> Result<Option<String>, SettingsError> {
let root = CstRootNode::parse(text, &PARSE_OPTIONS).map_err(|err| SettingsError::Parse {
path: path.to_path_buf(),
message: err.to_string(),
})?;
let Some(platforms) = platforms_object(&root, path, hostnames)? else {
return Ok(None);
};
remove_stale_hosts(&platforms, hostnames);
add_hosts(&platforms, hostnames);
let updated = root.to_string();
Ok((updated != text).then_some(updated))
}
fn platforms_object(
root: &CstRootNode,
path: &Path,
hostnames: &[String],
) -> Result<Option<CstObject>, SettingsError> {
let settings = match root.value() {
None if hostnames.is_empty() => return Ok(None),
None => root.object_value_or_set(),
Some(value) => value.as_object().ok_or_else(|| SettingsError::Parse {
path: path.to_path_buf(),
message: "the settings aren't an object".to_string(),
})?,
};
if settings_file::key_count(&settings, PLATFORM_KEY) > 1 {
return Err(SettingsError::DuplicatePlatformKey {
path: path.to_path_buf(),
});
}
match settings.get(PLATFORM_KEY) {
None if hostnames.is_empty() => Ok(None),
None => Ok(Some(settings.object_value_or_set(PLATFORM_KEY))),
Some(prop) => {
prop.object_value()
.map(Some)
.ok_or_else(|| SettingsError::PlatformNotAnObject {
path: path.to_path_buf(),
})
}
}
}
fn remove_stale_hosts(platforms: &CstObject, hostnames: &[String]) {
for prop in platforms.properties() {
if prop
.decoded_name()
.is_some_and(|name| name.ends_with(HOST_SUFFIX) && !hostnames.contains(&name))
{
prop.remove();
}
}
}
fn add_hosts(platforms: &CstObject, hostnames: &[String]) {
let mut hostnames: Vec<&String> = hostnames.iter().collect();
hostnames.sort();
for hostname in hostnames {
match platforms.get(hostname) {
Some(prop) if maps_to_linux(&prop) => {}
Some(prop) => prop.set_value(PLATFORM.into()),
None => {
platforms.append(hostname, PLATFORM.into());
}
}
}
}
fn maps_to_linux(prop: &CstObjectProp) -> bool {
prop.value()
.and_then(|value| value.as_string_lit())
.and_then(|value| value.decoded_value().ok())
.is_some_and(|value| value == PLATFORM)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn hosts(names: &[&str]) -> Vec<String> {
names.iter().map(|name| name.to_string()).collect()
}
fn vscode_root() -> (TempDir, PathBuf) {
let root = TempDir::new().unwrap();
let user = root.path().join("Code/User");
fs::create_dir_all(&user).unwrap();
(root, user.join("settings.json"))
}
#[test]
fn adds_hosts_and_keeps_comments_and_other_keys() {
let (root, settings) = vscode_root();
fs::write(
&settings,
"{\n // my own comment stays\n \"editor.fontSize\": 14,\n \
\"remote.SSH.remotePlatform\": {\n \"myserver\": \"linux\",\n },\n}\n",
)
.unwrap();
let errors = sync_settings(root.path(), &hosts(&["project.fbk"]));
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(
fs::read_to_string(&settings).unwrap(),
"{\n // my own comment stays\n \"editor.fontSize\": 14,\n \
\"remote.SSH.remotePlatform\": {\n \"myserver\": \"linux\",\n \
\"project.fbk\": \"linux\",\n },\n}\n"
);
}
#[test]
fn removes_stale_hosts_only() {
let (root, settings) = vscode_root();
fs::write(
&settings,
"{\n \"remote.SSH.remotePlatform\": {\n \"old.fbk\": \"linux\",\n \
\"myserver\": \"linux\",\n \"web.fbk\": \"linux\"\n }\n}\n",
)
.unwrap();
sync_settings(root.path(), &hosts(&["web.fbk"]));
assert_eq!(
fs::read_to_string(&settings).unwrap(),
"{\n \"remote.SSH.remotePlatform\": {\n \"myserver\": \"linux\",\n \
\"web.fbk\": \"linux\"\n }\n}\n"
);
}
#[test]
fn creates_missing_settings_file() {
let (root, settings) = vscode_root();
sync_settings(root.path(), &hosts(&["project.fbk"]));
let content = fs::read_to_string(&settings).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&content).unwrap();
assert_eq!(value[PLATFORM_KEY]["project.fbk"], "linux", "{content}");
}
#[test]
fn leaves_missing_settings_file_alone_without_hosts() {
let (root, settings) = vscode_root();
sync_settings(root.path(), &[]);
assert!(!settings.exists());
}
#[test]
fn skips_editors_that_are_not_installed() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("Cursor")).unwrap();
let errors = sync_settings(root.path(), &hosts(&["project.fbk"]));
assert!(errors.is_empty(), "{errors:?}");
assert!(!root.path().join("Cursor/User").exists());
assert!(!root.path().join("Code").exists());
}
#[test]
fn syncs_every_installed_editor() {
let root = TempDir::new().unwrap();
for editor in ["Code - Insiders", "Cursor", "VSCodium"] {
fs::create_dir_all(root.path().join(editor).join("User")).unwrap();
}
sync_settings(root.path(), &hosts(&["project.fbk"]));
for editor in ["Code - Insiders", "Cursor", "VSCodium"] {
let settings = root.path().join(editor).join("User/settings.json");
assert!(
fs::read_to_string(settings)
.unwrap()
.contains("\"project.fbk\": \"linux\""),
"{editor}"
);
}
}
#[test]
fn leaves_invalid_file_unchanged_and_syncs_the_others() {
let (root, settings) = vscode_root();
let invalid = "{ \"editor.fontSize\": 14, oops";
fs::write(&settings, invalid).unwrap();
fs::create_dir_all(root.path().join("Cursor/User")).unwrap();
let errors = sync_settings(root.path(), &hosts(&["project.fbk"]));
assert!(
matches!(errors.as_slice(), [SettingsError::Parse { path, .. }] if *path == settings),
"{errors:?}"
);
assert_eq!(fs::read_to_string(&settings).unwrap(), invalid);
assert!(root.path().join("Cursor/User/settings.json").exists());
}
#[test]
fn leaves_file_with_non_object_platform_unchanged() {
let (root, settings) = vscode_root();
let content = "{ \"remote.SSH.remotePlatform\": \"linux\" }\n";
fs::write(&settings, content).unwrap();
let errors = sync_settings(root.path(), &hosts(&["project.fbk"]));
assert!(
matches!(
errors.as_slice(),
[SettingsError::PlatformNotAnObject { .. }]
),
"{errors:?}"
);
assert_eq!(fs::read_to_string(&settings).unwrap(), content);
}
#[test]
fn keeps_a_symlinked_settings_file_a_symlink() {
let (root, settings) = vscode_root();
let target = root.path().join("dotfiles-settings.json");
fs::write(&target, "{}\n").unwrap();
std::os::unix::fs::symlink(&target, &settings).unwrap();
sync_settings(root.path(), &hosts(&["project.fbk"]));
assert!(fs::symlink_metadata(&settings).unwrap().is_symlink());
assert!(
fs::read_to_string(&target)
.unwrap()
.contains("\"project.fbk\": \"linux\"")
);
}
#[test]
fn leaves_file_vs_code_rejects_unchanged() {
let (root, settings) = vscode_root();
for invalid in [
"{ \"a\": 1 \"b\": 2 }\n",
"{ 'a': 1 }\n",
"{ a: 1 }\n",
"{ \"a\": 0xFF }\n",
] {
fs::write(&settings, invalid).unwrap();
let errors = sync_settings(root.path(), &hosts(&["project.fbk"]));
assert!(
matches!(errors.as_slice(), [SettingsError::Parse { .. }]),
"{invalid}: {errors:?}"
);
assert_eq!(fs::read_to_string(&settings).unwrap(), invalid);
}
}
#[test]
fn leaves_file_with_duplicate_platform_key_unchanged() {
let (root, settings) = vscode_root();
let content = "{\n \"remote.SSH.remotePlatform\": {},\n \
\"remote.SSH.remotePlatform\": {}\n}\n";
fs::write(&settings, content).unwrap();
let errors = sync_settings(root.path(), &hosts(&["project.fbk"]));
assert!(
matches!(
errors.as_slice(),
[SettingsError::DuplicatePlatformKey { .. }]
),
"{errors:?}"
);
assert_eq!(fs::read_to_string(&settings).unwrap(), content);
}
#[test]
fn leaves_dangling_settings_symlink_alone() {
let (root, settings) = vscode_root();
let target = root.path().join("dotfiles/settings.json");
std::os::unix::fs::symlink(&target, &settings).unwrap();
let errors = sync_settings(root.path(), &hosts(&["project.fbk"]));
assert!(
matches!(errors.as_slice(), [SettingsError::Read { .. }]),
"{errors:?}"
);
assert!(fs::symlink_metadata(&settings).unwrap().is_symlink());
assert!(!target.exists());
}
#[test]
fn leaves_up_to_date_file_untouched() {
let (root, settings) = vscode_root();
let content = "{\"remote.SSH.remotePlatform\": {\"project.fbk\": \"linux\"}}";
fs::write(&settings, content).unwrap();
sync_settings(root.path(), &hosts(&["project.fbk"]));
assert_eq!(fs::read_to_string(&settings).unwrap(), content);
}
}