use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::env::ShellType;
use crate::tools;
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ShellInitConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub tools: Option<Vec<String>>,
#[serde(default)]
pub versions: Option<HashMap<String, String>>,
#[serde(default = "default_true")]
pub background: bool,
#[serde(default = "default_24")]
pub check_interval: u64,
}
impl Default for ShellInitConfig {
fn default() -> Self {
Self {
enabled: false,
tools: None,
versions: None,
background: true,
check_interval: 24,
}
}
}
fn default_true() -> bool {
true
}
fn default_24() -> u64 {
24
}
impl ShellInitConfig {
pub fn config_hash(&self) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
if let Some(t) = self.tools.as_ref() {
let mut idx: Vec<&str> = t.iter().map(String::as_str).collect();
idx.sort_unstable();
for (i, name) in idx.iter().enumerate() {
if i > 0 {
hasher.update(b",");
}
hasher.update(name.as_bytes());
}
hasher.update(b";");
}
if let Some(v) = self.versions.as_ref() {
let mut keys: Vec<&str> = v.keys().map(String::as_str).collect();
keys.sort_unstable();
for k in keys {
hasher.update(k.as_bytes());
hasher.update(b"=");
if let Some(val) = v.get(k) {
hasher.update(val.as_bytes());
}
hasher.update(b";");
}
}
format!("sha256:{}", hex::encode(hasher.finalize()))
}
pub fn tool_tasks(&self) -> Vec<(&str, &str)> {
let Some(tools) = self.tools.as_ref() else {
return Vec::new();
};
let versions = self.versions.as_ref();
tools
.iter()
.map(|name| {
let hint = versions
.and_then(|v| v.get(name))
.map(String::as_str)
.unwrap_or("");
(name.as_str(), hint)
})
.collect()
}
}
#[derive(Deserialize, Serialize, Debug)]
pub struct EnsureStamp {
pub config_hash: String,
pub last_check: u64,
pub tools_installed: Vec<String>,
pub jarvy_version: String,
}
impl EnsureStamp {
fn path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".jarvy").join("ensure.stamp"))
}
pub fn load() -> Option<Self> {
let path = Self::path()?;
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn save(&self) -> Result<(), std::io::Error> {
let path = Self::path().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory")
})?;
let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
fs::create_dir_all(dir)?;
let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
let tmp = tempfile::NamedTempFile::new_in(dir)?;
fs::write(tmp.path(), &json)?;
tmp.persist(&path).map_err(|e| e.error)?;
Ok(())
}
pub fn is_fresh(&self, config_hash: &str, interval_hours: u64) -> bool {
if self.config_hash != config_hash {
return false;
}
if interval_hours == 0 {
return false;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let elapsed_hours = (now.saturating_sub(self.last_check)) / 3600;
elapsed_hours < interval_hours
}
}
pub fn generate_rc_snippet(shell: ShellType) -> String {
match shell {
ShellType::Fish => "if command -q jarvy\n jarvy ensure --quiet\nend".to_string(),
ShellType::PowerShell => {
"if (Get-Command jarvy -ErrorAction SilentlyContinue) {\n jarvy ensure --quiet\n}"
.to_string()
}
_ => {
"if command -v jarvy &> /dev/null; then\n jarvy ensure --quiet\nfi".to_string()
}
}
}
#[cfg(unix)]
fn refuse_if_config_is_world_or_group_writable() -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let Some(path) = crate::init::global_config_path() else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let Ok(meta) = std::fs::metadata(&path) else {
return Ok(());
};
let mode = meta.permissions().mode();
if mode & 0o022 != 0 {
return Err(format!(
"Refusing to run `jarvy ensure`: {} is writable by group/other ({:o}). \
Run `chmod 600 ~/.jarvy/config.toml` and try again.",
crate::network::redact_home(&path.display().to_string()),
mode & 0o777
));
}
Ok(())
}
#[cfg(not(unix))]
fn refuse_if_config_is_world_or_group_writable() -> Result<(), String> {
Ok(())
}
pub fn run_ensure(config: &ShellInitConfig, force: bool, quiet: bool) -> Result<(), String> {
refuse_if_config_is_world_or_group_writable()?;
let config_hash = config.config_hash();
let start = std::time::Instant::now();
if !force {
if let Some(stamp) = EnsureStamp::load() {
if stamp.is_fresh(&config_hash, config.check_interval) {
tracing::debug!(event = "ensure.fast_path", reason = "stamp_fresh");
return Ok(());
}
}
}
tools::register_all();
let tasks = config.tool_tasks();
let mut installed: Vec<String> = Vec::new();
let mut failed_count: u32 = 0;
for (name, hint) in &tasks {
if tools::has(name) && hint.is_empty() {
installed.push((*name).to_string());
continue;
}
if !quiet {
eprintln!("jarvy ensure: installing {}...", name);
}
tracing::info!(
event = "ensure.tool.start",
tool = %name,
hint = %hint,
);
match tools::add(name, hint) {
Ok(_) => {
if !quiet {
eprintln!("jarvy ensure: {} installed", name);
}
tracing::info!(event = "ensure.tool.success", tool = %name);
installed.push((*name).to_string());
}
Err(e) => {
if !quiet {
eprintln!("jarvy ensure: {} failed: {}", name, e);
}
tracing::warn!(
event = "ensure.tool.failed",
tool = %name,
error = %e,
);
failed_count += 1;
}
}
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let stamp = EnsureStamp {
config_hash,
last_check: now,
tools_installed: installed.clone(),
jarvy_version: env!("CARGO_PKG_VERSION").to_string(),
};
if let Err(e) = stamp.save() {
if !quiet {
eprintln!("jarvy ensure: failed to write stamp: {}", e);
}
tracing::warn!(event = "ensure.stamp.write_failed", error = %e);
}
tracing::info!(
event = "ensure.run.complete",
tasks = tasks.len(),
installed = installed.len(),
failed = failed_count,
duration_ms = start.elapsed().as_millis() as u64,
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_shell_init_config_default() {
let config = ShellInitConfig::default();
assert!(!config.enabled);
assert!(config.background);
assert_eq!(config.check_interval, 24);
}
#[test]
fn test_config_hash_deterministic() {
let config = ShellInitConfig {
enabled: true,
tools: Some(vec!["git".into(), "docker".into()]),
versions: None,
background: true,
check_interval: 24,
};
let h1 = config.config_hash();
let h2 = config.config_hash();
assert_eq!(h1, h2);
}
#[test]
fn test_config_hash_changes_with_tools() {
let c1 = ShellInitConfig {
tools: Some(vec!["git".into()]),
..Default::default()
};
let c2 = ShellInitConfig {
tools: Some(vec!["docker".into()]),
..Default::default()
};
assert_ne!(c1.config_hash(), c2.config_hash());
}
#[test]
fn test_tool_tasks() {
let config = ShellInitConfig {
tools: Some(vec!["node".into(), "git".into()]),
versions: Some(HashMap::from([("node".into(), "20".into())])),
..Default::default()
};
let tasks = config.tool_tasks();
assert_eq!(tasks.len(), 2);
assert!(tasks.contains(&("node", "20")));
assert!(tasks.contains(&("git", "")));
}
#[test]
fn config_hash_format_is_sha256_prefixed_hex() {
let config = ShellInitConfig {
tools: Some(vec!["git".into()]),
..Default::default()
};
let h = config.config_hash();
assert!(h.starts_with("sha256:"));
let hex = &h["sha256:".len()..];
assert_eq!(hex.len(), 64);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn config_hash_is_independent_of_tool_order() {
let a = ShellInitConfig {
tools: Some(vec!["git".into(), "node".into(), "docker".into()]),
..Default::default()
};
let b = ShellInitConfig {
tools: Some(vec!["node".into(), "docker".into(), "git".into()]),
..Default::default()
};
assert_eq!(a.config_hash(), b.config_hash());
}
#[test]
fn test_stamp_freshness() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let stamp = EnsureStamp {
config_hash: "sha256:abc".into(),
last_check: now,
tools_installed: vec![],
jarvy_version: "0.2".into(),
};
assert!(stamp.is_fresh("sha256:abc", 24));
assert!(!stamp.is_fresh("sha256:def", 24));
assert!(!stamp.is_fresh("sha256:abc", 0));
}
#[test]
fn test_stamp_expired() {
let old_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- (25 * 3600);
let stamp = EnsureStamp {
config_hash: "sha256:abc".into(),
last_check: old_time,
tools_installed: vec![],
jarvy_version: "0.2".into(),
};
assert!(!stamp.is_fresh("sha256:abc", 24));
}
#[test]
fn test_generate_rc_snippet_bash() {
let snippet = generate_rc_snippet(ShellType::Bash);
assert!(snippet.contains("command -v jarvy"));
assert!(snippet.contains("jarvy ensure --quiet"));
}
#[test]
fn test_generate_rc_snippet_fish() {
let snippet = generate_rc_snippet(ShellType::Fish);
assert!(snippet.contains("command -q jarvy"));
assert!(snippet.contains("end"));
}
#[test]
fn test_generate_rc_snippet_powershell() {
let snippet = generate_rc_snippet(ShellType::PowerShell);
assert!(snippet.contains("Get-Command"));
assert!(snippet.contains("jarvy ensure --quiet"));
}
}