use machineid_rs::{Encryption, HWIDComponent, IdBuilder};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use uuid::Uuid;
use crate::shell_init::ShellInitConfig;
use crate::telemetry::TelemetryConfig;
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub(crate) struct CliConfig {
pub settings: Settings,
#[serde(default)]
pub telemetry: TelemetryConfig,
#[serde(default)]
pub shell_init: Option<ShellInitConfig>,
#[serde(default)]
pub mcp: McpPreferences,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct McpPreferences {
#[serde(default)]
pub auto_approve_installs: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub(crate) struct Settings {
#[serde(default = "default_true")]
pub telemetry: bool,
#[serde(default)]
pub fingerprint: Option<String>,
}
fn default_true() -> bool {
true
}
impl Default for Settings {
fn default() -> Self {
Settings {
telemetry: true,
fingerprint: get_hwid_fingerprint().or_else(|| Some(Uuid::now_v7().to_string())),
}
}
}
static GLOBAL_CONFIG: std::sync::OnceLock<std::sync::RwLock<Option<CliConfig>>> =
std::sync::OnceLock::new();
fn config_cache() -> &'static std::sync::RwLock<Option<CliConfig>> {
GLOBAL_CONFIG.get_or_init(|| std::sync::RwLock::new(None))
}
pub(crate) fn invalidate_global_config_cache() {
if let Ok(mut guard) = config_cache().write() {
*guard = None;
}
}
pub(crate) fn initialize() -> CliConfig {
if let Ok(guard) = config_cache().read() {
if let Some(cfg) = guard.as_ref() {
return cfg.clone();
}
}
let fresh = initialize_from_disk();
if let Ok(mut guard) = config_cache().write() {
*guard = Some(fresh.clone());
}
fresh
}
fn initialize_from_disk() -> CliConfig {
if std::env::var("JARVY_INIT_PROBE").as_deref() == Ok("1") {
eprintln!("TEST: initialize called");
}
if std::env::var("JARVY_TEST_MODE").as_deref() == Ok("1") {
return CliConfig::default();
}
let Some(home_dir) = dirs::home_dir() else {
eprintln!("Failed to get home directory");
return CliConfig::default();
};
let jarvy_dir = home_dir.join(".jarvy");
let config_file_path = jarvy_dir.join("config.toml");
if !jarvy_dir.exists() {
if let Err(e) = fs::create_dir(&jarvy_dir) {
eprintln!("Unable to create jarvy config directory: {e}");
return CliConfig::default();
}
println!(
r"
Jarvy tool collects telemetry data to help us improve your experience.
The data collected is anonymized and used solely for analytics purposes.
If you wish to opt-out of telemetry collection, you can disable it by adding the following line to your configuration file located at ~/.jarvy/config.toml:
[settings]
telemetry = false
Thank you for using Jarvy!
"
);
let config = CliConfig {
settings: Settings::default(),
telemetry: TelemetryConfig::default(),
shell_init: None,
mcp: McpPreferences::default(),
};
let toml = toml::to_string(&config).unwrap_or_default();
let mut file = match fs::File::create(&config_file_path) {
Ok(f) => f,
Err(e) => {
eprintln!("Unable to create config file: {e}");
return CliConfig::default();
}
};
if let Err(e) = file.write_all(toml.as_bytes()) {
eprintln!("Unable to write content to config file: {e}");
return CliConfig::default();
}
}
let config: CliConfig = {
let config_content = fs::read_to_string(&config_file_path).unwrap_or_default();
if config_content.trim().is_empty() {
CliConfig::default()
} else {
toml::from_str(&config_content).unwrap_or_default()
}
};
config
}
pub fn save_global_config(config: &CliConfig) -> Result<(), String> {
let path = global_config_path().ok_or_else(|| "no home directory".to_string())?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("failed to create config dir: {e}"))?;
}
let toml =
toml::to_string_pretty(config).map_err(|e| format!("failed to serialize config: {e}"))?;
fs::write(&path, toml).map_err(|e| format!("failed to write config: {e}"))?;
invalidate_global_config_cache();
Ok(())
}
pub fn global_config_path() -> Option<std::path::PathBuf> {
let home = std::env::var("JARVY_TEST_HOME")
.ok()
.map(std::path::PathBuf::from)
.or_else(dirs::home_dir)?;
Some(home.join(".jarvy").join("config.toml"))
}
pub(crate) fn modify_global_config<F>(modify: F) -> Result<(), String>
where
F: FnOnce(&mut CliConfig),
{
let path = global_config_path().ok_or_else(|| "no home directory".to_string())?;
let mut config: CliConfig = if path.exists() {
let content = fs::read_to_string(&path).unwrap_or_default();
if content.trim().is_empty() {
CliConfig::default()
} else {
toml::from_str(&content).unwrap_or_default()
}
} else {
CliConfig::default()
};
modify(&mut config);
save_global_config(&config)
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use super::*;
use std::sync::Mutex;
static HOME_MUTEX: Mutex<()> = Mutex::new(());
fn with_isolated_home<F: FnOnce(&std::path::Path)>(f: F) {
let _guard = HOME_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::TempDir::new().expect("tempdir");
let prev = std::env::var("JARVY_TEST_HOME").ok();
#[allow(unsafe_code)]
unsafe {
std::env::set_var("JARVY_TEST_HOME", tmp.path());
}
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
f(tmp.path());
}));
#[allow(unsafe_code)]
unsafe {
match prev {
Some(v) => std::env::set_var("JARVY_TEST_HOME", v),
None => std::env::remove_var("JARVY_TEST_HOME"),
}
}
if let Err(payload) = res {
std::panic::resume_unwind(payload);
}
}
#[test]
fn save_global_config_creates_jarvy_dir_when_missing() {
with_isolated_home(|home| {
let cfg = CliConfig::default();
save_global_config(&cfg).expect("save");
assert!(home.join(".jarvy").join("config.toml").exists());
});
}
#[test]
fn modify_global_config_updates_existing_field() {
with_isolated_home(|home| {
let mut initial = CliConfig::default();
initial.telemetry.enabled = false;
save_global_config(&initial).expect("seed");
modify_global_config(|cfg| {
cfg.telemetry.enabled = true;
cfg.mcp.auto_approve_installs = true;
})
.expect("modify");
let path = home.join(".jarvy").join("config.toml");
let content = std::fs::read_to_string(path).unwrap();
let reloaded: CliConfig = toml::from_str(&content).expect("reparse");
assert!(reloaded.telemetry.enabled);
assert!(reloaded.mcp.auto_approve_installs);
});
}
#[test]
fn modify_global_config_creates_when_missing() {
with_isolated_home(|home| {
assert!(!home.join(".jarvy").join("config.toml").exists());
modify_global_config(|cfg| {
cfg.mcp.auto_approve_installs = true;
})
.expect("create + modify");
let content = std::fs::read_to_string(home.join(".jarvy").join("config.toml")).unwrap();
assert!(content.contains("auto_approve_installs"));
});
}
#[test]
fn modify_global_config_is_roundtrip_safe() {
with_isolated_home(|_home| {
modify_global_config(|cfg| {
cfg.settings.fingerprint = Some("0123abcd".to_string());
cfg.telemetry.enabled = true;
})
.expect("first");
modify_global_config(|cfg| {
assert_eq!(cfg.settings.fingerprint.as_deref(), Some("0123abcd"));
cfg.settings.fingerprint = None;
})
.expect("second");
modify_global_config(|cfg| {
assert!(cfg.settings.fingerprint.is_none());
assert!(cfg.telemetry.enabled);
})
.expect("third");
});
}
}
fn get_hwid_fingerprint() -> Option<String> {
let mut builder = IdBuilder::new(Encryption::SHA256);
builder
.add_component(HWIDComponent::SystemID) .add_component(HWIDComponent::CPUCores) .add_component(HWIDComponent::OSName) .add_component(HWIDComponent::DriveSerial);
const SALT: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15ac1e289f66085";
builder.build(SALT).ok()
}