kanri 0.11.0

Manage your projects within the terminal.
Documentation
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 {
        // Getting default editor
        #[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;
            }
            _ => {}
        }

        // Getting default shell
        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();
    }
}