use crate::{migrations, platform};
use indexmap::{IndexMap, indexmap};
use serde::{Deserialize, Serialize};
use std::{
fs,
io::ErrorKind,
path::{Path, PathBuf},
};
use thiserror::Error;
const CONFIG_VERSION: &str = "2";
const PROJECTS_DIRECTORY_NAMES: [&str; 13] = [
"Projects",
"Code",
"Dev",
"Development",
"Workspace",
"Workspaces",
"Work",
"Repos",
"Repositories",
"Source",
"Sources",
"Git",
"GitHub",
];
pub fn find_projects_directory() -> PathBuf {
let default_directory = platform::default_projects_dir();
let parent_directory = default_directory.parent().unwrap();
for name in PROJECTS_DIRECTORY_NAMES {
let possible_path = parent_directory.join(name);
if possible_path.is_dir() {
return possible_path;
}
}
parent_directory.join("Projects")
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to write configuration file.")]
WriteFailed,
#[error("failed to format configuration to TOML.")]
FormatFailed,
#[error("cannot read the configuration file.")]
ReadPermissionDenied,
#[error("cannot write to the configuration file.")]
WritePermissionDenied,
#[error("cannot create missing directories.")]
DirectoryCreationPermissionDenied,
#[error("not enough space to write data to configuration file.")]
StorageFull,
#[error("cannot find configuration file.")]
FileNotFound,
#[error("configuration error: {0}.")]
BadConfiguration(String),
#[error("profile '{0}' was not found.")]
ProfileNotFound(String),
#[error("file system error occurred: {0}.")]
FileSystemError(#[from] std::io::Error),
}
#[derive(Deserialize, Serialize, Clone)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub version: String,
pub options: GeneralOptions,
pub profiles: IndexMap<String, Profile>,
pub recent: RecentOptions,
pub autocomplete: AutocompleteOptions,
}
impl Default for Config {
fn default() -> Self {
#[allow(unused_mut)]
let mut editor = platform::default_editor().to_string();
let mut editor_args: Vec<String> = Vec::new();
let mut editor_fork_mode = false;
match editor.as_str() {
"code" | "code-insiders" | "codium" | "code-oss" | "cursor" | "windsurf" | "zed"
| "code.cmd" | "code-insiders.cmd" | "codium.cmd" | "code-oss.cmd" | "windsurf.cmd"
| "cursor.cmd" => {
#[cfg(target_os = "windows")]
{
if editor != "zed" && !editor.ends_with(".cmd") {
editor.push_str(".cmd");
}
}
editor_args.push(".".to_string());
editor_fork_mode = true;
}
_ => {}
}
let shell = platform::default_shell().to_string();
let profiles = indexmap! {
String::from("default") => Profile {
editor,
editor_args,
editor_fork_mode,
shell,
}
};
Self {
version: CONFIG_VERSION.to_string(),
options: GeneralOptions::default(),
profiles,
recent: RecentOptions::default(),
autocomplete: AutocompleteOptions::default(),
}
}
}
#[derive(Deserialize, Serialize, Default, Clone)]
#[serde(default, deny_unknown_fields)]
pub struct Profile {
pub editor: String,
pub editor_args: Vec<String>,
pub editor_fork_mode: bool,
pub shell: String,
}
#[derive(Deserialize, Serialize, Clone)]
#[serde(default, deny_unknown_fields)]
pub struct GeneralOptions {
pub projects_directory: PathBuf,
pub current_profile: String,
pub display_hidden: bool,
}
#[derive(Deserialize, Serialize, Clone)]
#[serde(default, deny_unknown_fields)]
pub struct AutocompleteOptions {
pub enabled: bool,
pub always_accept: bool,
}
impl Default for AutocompleteOptions {
fn default() -> Self {
Self {
enabled: true,
always_accept: true,
}
}
}
#[derive(Deserialize, Serialize, Clone)]
#[serde(default, deny_unknown_fields)]
pub struct RecentOptions {
pub enabled: bool,
pub recent_project: String,
}
impl Default for RecentOptions {
fn default() -> Self {
Self {
enabled: true,
recent_project: String::new(),
}
}
}
impl Default for GeneralOptions {
fn default() -> Self {
Self {
projects_directory: find_projects_directory(),
current_profile: "default".to_string(),
display_hidden: false,
}
}
}
impl Config {
pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let content = fs::read_to_string(&path).map_err(|e| match e.kind() {
ErrorKind::PermissionDenied => ConfigError::ReadPermissionDenied,
ErrorKind::NotFound => ConfigError::FileNotFound,
_ => ConfigError::FileSystemError(e),
})?;
let mut value: toml::Value = toml::from_str(&content)
.map_err(|e: toml::de::Error| ConfigError::BadConfiguration(e.to_string()))?;
let was_migrated = migrations::migrate_config(&mut value)?;
let config: Config = value
.try_into()
.map_err(|e: toml::de::Error| ConfigError::BadConfiguration(e.to_string()))?;
if was_migrated {
config.save(path)?;
}
Ok(config)
}
pub fn save(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
let path = path.as_ref();
if let Some(parent) = path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
return match e.kind() {
ErrorKind::PermissionDenied => Err(ConfigError::DirectoryCreationPermissionDenied),
_ => Err(ConfigError::FileSystemError(e)),
};
}
let content = toml::to_string(self).map_err(|_| ConfigError::FormatFailed)?;
fs::write(path, content).map_err(|e| match e.kind() {
ErrorKind::PermissionDenied => ConfigError::WritePermissionDenied,
ErrorKind::NotFound => ConfigError::FileNotFound,
ErrorKind::StorageFull => ConfigError::StorageFull,
_ => ConfigError::FileSystemError(e),
})
}
pub fn get_profile(&self, name: &str) -> Result<&Profile, ConfigError> {
if let Some(p) = self.profiles.get(name) {
Ok(p)
} else {
Err(ConfigError::ProfileNotFound(name.to_string()))
}
}
pub fn is_profile_exist(&self, name: &str) -> bool {
self.profiles.contains_key(name)
}
pub fn reset(&mut self) {
*self = Self::default();
}
}