use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub theme: ThemeConfig,
#[serde(default)]
pub ai: AIConfig,
#[serde(default)]
pub telemetry: TelemetryConfig,
#[serde(default)]
pub shell: ShellConfig,
#[serde(default)]
pub lessons: LessonsConfig,
#[serde(default)]
pub keybindings: KeybindingsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
#[serde(default)]
pub user_name: Option<String>,
#[serde(default = "default_false")]
pub setup_complete: bool,
#[serde(default = "default_shell")]
pub shell: String,
#[serde(default = "default_history_limit")]
pub history_limit: usize,
#[serde(default = "default_command_timeout")]
pub command_timeout: u64,
#[serde(default = "default_true")]
pub auto_save: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeConfig {
#[serde(default = "default_theme")]
pub default_theme: String,
#[serde(default = "default_true")]
pub enable_colors: bool,
#[serde(default = "default_color_depth")]
pub color_depth: String,
#[serde(default = "default_false")]
pub nerd_fonts: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AIConfig {
#[serde(default = "default_false")]
pub enabled: bool,
#[serde(default = "default_ai_provider")]
pub provider: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default = "default_max_tokens")]
pub max_tokens: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelemetryConfig {
#[serde(default = "default_false")]
pub enabled: bool,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default = "default_false")]
pub usage_stats: bool,
#[serde(default = "default_false")]
pub error_reports: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellConfig {
#[serde(default)]
pub environment: HashMap<String, String>,
#[serde(default)]
pub aliases: HashMap<String, String>,
#[serde(default)]
pub startup_commands: Vec<String>,
#[serde(default = "default_true")]
pub safe_mode: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LessonsConfig {
#[serde(default = "default_practice_mode")]
pub practice_mode: String,
#[serde(default = "default_track")]
pub track: String,
#[serde(default = "default_false")]
pub real_mode_intro_shown: bool,
}
impl LessonsConfig {
pub fn is_real(&self) -> bool {
self.practice_mode == "real"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeybindingsConfig {
#[serde(default)]
pub custom: HashMap<String, String>,
}
impl Config {
pub fn new() -> Self {
Self::default()
}
pub fn load() -> Result<Self> {
let config_path = get_config_file_path()?;
if !config_path.exists() {
let config = Self::default();
config.save()?;
return Ok(config);
}
Self::load_from(&config_path)
}
pub fn load_from(path: &Path) -> Result<Self> {
let config_str = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let mut config: Config = toml::from_str(&config_str)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
config.apply_env_overrides();
Ok(config)
}
pub fn save(&self) -> Result<()> {
let config_path = get_config_file_path()?;
self.save_to(&config_path)
}
pub fn save_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
}
let config_str = toml::to_string_pretty(self)
.context("Failed to serialize config")?;
fs::write(path, config_str)
.with_context(|| format!("Failed to write config file: {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.with_context(|| {
format!("Failed to set permissions on config file: {}", path.display())
})?;
}
Ok(())
}
fn apply_env_overrides(&mut self) {
if let Ok(api_key) = std::env::var("ARCT_AI_API_KEY") {
self.ai.api_key = Some(api_key);
}
if let Ok(provider) = std::env::var("ARCT_AI_PROVIDER") {
self.ai.provider = provider;
}
if let Ok(telemetry) = std::env::var("ARCT_TELEMETRY") {
self.telemetry.enabled = telemetry == "1" || telemetry.to_lowercase() == "true";
}
if let Ok(shell) = std::env::var("ARCT_SHELL") {
self.general.shell = shell;
}
}
pub fn config_path() -> Result<PathBuf> {
get_config_file_path()
}
}
impl Default for Config {
fn default() -> Self {
Self {
general: GeneralConfig::default(),
theme: ThemeConfig::default(),
ai: AIConfig::default(),
telemetry: TelemetryConfig::default(),
shell: ShellConfig::default(),
lessons: LessonsConfig::default(),
keybindings: KeybindingsConfig::default(),
}
}
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
user_name: None,
setup_complete: false,
shell: default_shell(),
history_limit: default_history_limit(),
command_timeout: default_command_timeout(),
auto_save: true,
}
}
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
default_theme: default_theme(),
enable_colors: true,
color_depth: default_color_depth(),
nerd_fonts: false,
}
}
}
impl Default for AIConfig {
fn default() -> Self {
Self {
enabled: false,
provider: default_ai_provider(),
api_key: None,
model: None,
endpoint: None,
max_tokens: default_max_tokens(),
}
}
}
impl Default for TelemetryConfig {
fn default() -> Self {
Self {
enabled: false,
user_id: None,
usage_stats: false,
error_reports: false,
}
}
}
impl Default for ShellConfig {
fn default() -> Self {
Self {
environment: HashMap::new(),
aliases: HashMap::new(),
startup_commands: Vec::new(),
safe_mode: true,
}
}
}
impl Default for LessonsConfig {
fn default() -> Self {
Self {
practice_mode: default_practice_mode(),
track: default_track(),
real_mode_intro_shown: false,
}
}
}
impl Default for KeybindingsConfig {
fn default() -> Self {
Self {
custom: HashMap::new(),
}
}
}
pub fn get_config_file_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?;
let arct_config_dir = config_dir.join("arct");
if !arct_config_dir.exists() {
fs::create_dir_all(&arct_config_dir)
.with_context(|| format!("Failed to create config directory: {}", arct_config_dir.display()))?;
}
Ok(arct_config_dir.join("config.toml"))
}
pub fn generate_default_config() -> String {
let config = Config::default();
toml::to_string_pretty(&config).unwrap_or_else(|_| String::from("# Failed to generate config"))
}
fn default_shell() -> String {
std::env::var("SHELL")
.unwrap_or_else(|_| "bash".to_string())
.split('/')
.last()
.unwrap_or("bash")
.to_string()
}
fn default_history_limit() -> usize {
1000
}
fn default_command_timeout() -> u64 {
5
}
fn default_theme() -> String {
"Arc Academy Orange".to_string()
}
fn default_color_depth() -> String {
"256".to_string()
}
fn default_ai_provider() -> String {
"anthropic".to_string()
}
fn default_max_tokens() -> usize {
4096
}
fn default_practice_mode() -> String {
"simulated".to_string()
}
fn default_track() -> String {
"explorer".to_string()
}
fn default_true() -> bool {
true
}
fn default_false() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.general.history_limit, 1000);
assert_eq!(config.theme.default_theme, "Arc Academy Orange");
assert!(!config.ai.enabled);
assert!(!config.telemetry.enabled);
}
#[test]
fn test_lessons_practice_mode_defaults_to_simulated() {
let config = Config::default();
assert_eq!(config.lessons.practice_mode, "simulated");
assert!(!config.lessons.is_real());
assert!(!config.lessons.real_mode_intro_shown);
let legacy = "[general]\nshell = \"zsh\"\n";
let parsed: Config = toml::from_str(legacy).unwrap();
assert_eq!(parsed.lessons.practice_mode, "simulated");
let toml_str = "[lessons]\npractice_mode = \"real\"\n";
let parsed: Config = toml::from_str(toml_str).unwrap();
assert!(parsed.lessons.is_real());
}
#[test]
fn test_serialize_config() {
let config = Config::default();
let toml_str = toml::to_string(&config).unwrap();
assert!(toml_str.contains("[general]"));
assert!(toml_str.contains("[theme]"));
}
#[test]
fn test_deserialize_config() {
let toml_str = r#"
[general]
shell = "zsh"
history_limit = 500
[theme]
default_theme = "Arc Dark"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.general.shell, "zsh");
assert_eq!(config.general.history_limit, 500);
assert_eq!(config.theme.default_theme, "Arc Dark");
}
struct TempConfig {
dir: PathBuf,
path: PathBuf,
}
impl std::ops::Deref for TempConfig {
type Target = PathBuf;
fn deref(&self) -> &PathBuf {
&self.path
}
}
impl AsRef<std::path::Path> for TempConfig {
fn as_ref(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempConfig {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
fn temp_config_path(name: &str) -> TempConfig {
let dir = std::env::temp_dir().join(format!(
"arct-config-test-{}-{}",
name,
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
let path = dir.join("config.toml");
TempConfig { dir, path }
}
#[test]
fn test_save_to_and_load_from_roundtrip() {
let path = temp_config_path("roundtrip");
let mut config = Config::default();
config.general.user_name = Some("tester".to_string());
config.save_to(&path).unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded.general.user_name.as_deref(), Some("tester"));
}
#[test]
fn test_load_from_missing_path_errors() {
let path = temp_config_path("missing");
assert!(Config::load_from(&path).is_err());
}
#[cfg(unix)]
#[test]
fn test_save_sets_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let path = temp_config_path("perms");
let config = Config::default();
config.save_to(&path).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
config.save_to(&path).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}