#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::env;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Channel {
#[default]
Stable,
Beta,
Nightly,
}
impl Channel {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"stable" => Some(Channel::Stable),
"beta" => Some(Channel::Beta),
"nightly" => Some(Channel::Nightly),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Channel::Stable => "stable",
Channel::Beta => "beta",
Channel::Nightly => "nightly",
}
}
pub fn matches_version(&self, version: &str) -> bool {
match self {
Channel::Stable => {
!version.contains("-alpha")
&& !version.contains("-beta")
&& !version.contains("-rc")
&& !version.contains("-nightly")
}
Channel::Beta => {
version.contains("-beta")
|| version.contains("-rc")
|| self.matches_version_stable(version)
}
Channel::Nightly => true, }
}
fn matches_version_stable(&self, version: &str) -> bool {
!version.contains("-alpha")
&& !version.contains("-beta")
&& !version.contains("-rc")
&& !version.contains("-nightly")
}
}
impl std::fmt::Display for Channel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AutoInstallPolicy {
Never,
PatchOnly,
#[default]
PatchAndMinor,
All,
}
impl AutoInstallPolicy {
pub fn should_auto_install(&self, current: &semver::Version, new: &semver::Version) -> bool {
match self {
AutoInstallPolicy::Never => false,
AutoInstallPolicy::PatchOnly => {
current.major == new.major && current.minor == new.minor
}
AutoInstallPolicy::PatchAndMinor => current.major == new.major,
AutoInstallPolicy::All => true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub channel: Channel,
#[serde(default = "default_check_interval", with = "humantime_serde")]
pub check_interval: Duration,
#[serde(default)]
pub auto_install: AutoInstallPolicy,
#[serde(default)]
pub patch_only: bool,
#[serde(default)]
pub install_method: Option<String>,
#[serde(default)]
pub pinned_version: Option<String>,
#[serde(default = "default_enabled")]
pub show_notifications: bool,
}
fn default_enabled() -> bool {
true
}
fn default_check_interval() -> Duration {
Duration::from_secs(24 * 60 * 60) }
impl Default for UpdateConfig {
fn default() -> Self {
Self {
enabled: true,
channel: Channel::Stable,
check_interval: default_check_interval(),
auto_install: AutoInstallPolicy::PatchAndMinor,
patch_only: false,
install_method: None,
pinned_version: None,
show_notifications: true,
}
}
}
impl UpdateConfig {
pub fn load() -> Self {
let mut config = Self::load_from_file().unwrap_or_default();
config.apply_env_overrides();
config
}
fn load_from_file() -> Option<Self> {
let config_path = dirs::home_dir()?.join(".jarvy").join("config.toml");
if !config_path.exists() {
return None;
}
let content = std::fs::read_to_string(&config_path).ok()?;
#[derive(Deserialize)]
struct GlobalConfig {
#[serde(default)]
update: UpdateConfig,
}
let global: GlobalConfig = toml::from_str(&content).ok()?;
Some(global.update)
}
fn apply_env_overrides(&mut self) {
if let Ok(val) = env::var("JARVY_UPDATE") {
self.enabled = !matches!(val.as_str(), "0" | "false" | "no" | "off");
}
if let Ok(val) = env::var("JARVY_UPDATE_CHANNEL") {
if let Some(channel) = Channel::from_str(&val) {
self.channel = channel;
}
}
if let Ok(val) = env::var("JARVY_PINNED_VERSION") {
if !val.is_empty() {
self.pinned_version = Some(val);
}
}
if is_ci_environment() && env::var("JARVY_UPDATE").is_err() {
self.enabled = false;
}
}
pub fn is_disabled(&self) -> bool {
!self.enabled || self.pinned_version.is_some()
}
pub fn effective_pinned_version(&self) -> Option<&str> {
self.pinned_version.as_deref()
}
pub fn force_check_requested() -> bool {
matches!(
env::var("JARVY_UPDATE_CHECK").as_deref(),
Ok("1" | "true" | "yes")
)
}
pub fn save(&self) -> std::io::Result<()> {
let config_dir = dirs::home_dir()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "No home directory"))?
.join(".jarvy");
std::fs::create_dir_all(&config_dir)?;
let config_path = config_dir.join("config.toml");
let mut existing: toml::Table = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
toml::from_str(&content).unwrap_or_default()
} else {
toml::Table::new()
};
let update_value = toml::Value::try_from(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
existing.insert("update".to_string(), update_value);
let content = toml::to_string_pretty(&existing)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&config_path, content)
}
}
pub fn is_ci_environment() -> bool {
env::var("CI").is_ok()
|| env::var("GITHUB_ACTIONS").is_ok()
|| env::var("GITLAB_CI").is_ok()
|| env::var("JENKINS_URL").is_ok()
|| env::var("CIRCLECI").is_ok()
|| env::var("TRAVIS").is_ok()
|| env::var("BUILDKITE").is_ok()
|| env::var("BITBUCKET_BUILD_NUMBER").is_ok()
|| env::var("AZURE_PIPELINES").is_ok()
|| env::var("TF_BUILD").is_ok()
|| env::var("TEAMCITY_VERSION").is_ok()
}
pub fn is_interactive() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}
mod humantime_serde {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let secs = duration.as_secs();
if secs % (24 * 60 * 60) == 0 {
serializer.serialize_str(&format!("{}d", secs / (24 * 60 * 60)))
} else if secs % (60 * 60) == 0 {
serializer.serialize_str(&format!("{}h", secs / (60 * 60)))
} else if secs % 60 == 0 {
serializer.serialize_str(&format!("{}m", secs / 60))
} else {
serializer.serialize_str(&format!("{}s", secs))
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
parse_duration(&s).map_err(serde::de::Error::custom)
}
fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty duration string".to_string());
}
if let Ok(secs) = s.parse::<u64>() {
return Ok(Duration::from_secs(secs));
}
let (num_str, suffix) = s.split_at(s.len() - 1);
let num: u64 = num_str
.trim()
.parse()
.map_err(|_| format!("invalid duration: {}", s))?;
match suffix {
"s" => Ok(Duration::from_secs(num)),
"m" => Ok(Duration::from_secs(num * 60)),
"h" => Ok(Duration::from_secs(num * 60 * 60)),
"d" => Ok(Duration::from_secs(num * 24 * 60 * 60)),
_ => Err(format!("unknown duration suffix: {}", suffix)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_channel_from_str() {
assert_eq!(Channel::from_str("stable"), Some(Channel::Stable));
assert_eq!(Channel::from_str("BETA"), Some(Channel::Beta));
assert_eq!(Channel::from_str("Nightly"), Some(Channel::Nightly));
assert_eq!(Channel::from_str("invalid"), None);
}
#[test]
fn test_channel_matches_version() {
assert!(Channel::Stable.matches_version("1.2.3"));
assert!(!Channel::Stable.matches_version("1.2.3-beta.1"));
assert!(!Channel::Stable.matches_version("1.2.3-rc.1"));
assert!(Channel::Beta.matches_version("1.2.3"));
assert!(Channel::Beta.matches_version("1.2.3-beta.1"));
assert!(Channel::Beta.matches_version("1.2.3-rc.1"));
assert!(!Channel::Beta.matches_version("1.2.3-alpha.1"));
assert!(Channel::Nightly.matches_version("1.2.3"));
assert!(Channel::Nightly.matches_version("1.2.3-nightly.123"));
}
#[test]
fn test_auto_install_policy() {
let v1_0_0 = semver::Version::new(1, 0, 0);
let v1_0_1 = semver::Version::new(1, 0, 1);
let v1_1_0 = semver::Version::new(1, 1, 0);
let v2_0_0 = semver::Version::new(2, 0, 0);
assert!(!AutoInstallPolicy::Never.should_auto_install(&v1_0_0, &v1_0_1));
assert!(AutoInstallPolicy::PatchOnly.should_auto_install(&v1_0_0, &v1_0_1));
assert!(!AutoInstallPolicy::PatchOnly.should_auto_install(&v1_0_0, &v1_1_0));
assert!(AutoInstallPolicy::PatchAndMinor.should_auto_install(&v1_0_0, &v1_0_1));
assert!(AutoInstallPolicy::PatchAndMinor.should_auto_install(&v1_0_0, &v1_1_0));
assert!(!AutoInstallPolicy::PatchAndMinor.should_auto_install(&v1_0_0, &v2_0_0));
assert!(AutoInstallPolicy::All.should_auto_install(&v1_0_0, &v2_0_0));
}
#[test]
fn test_default_config() {
let config = UpdateConfig::default();
assert!(config.enabled);
assert_eq!(config.channel, Channel::Stable);
assert_eq!(config.check_interval, Duration::from_secs(24 * 60 * 60));
assert_eq!(config.auto_install, AutoInstallPolicy::PatchAndMinor);
assert!(!config.patch_only);
assert!(config.pinned_version.is_none());
}
#[test]
fn test_is_disabled() {
let mut config = UpdateConfig::default();
assert!(!config.is_disabled());
config.enabled = false;
assert!(config.is_disabled());
config.enabled = true;
config.pinned_version = Some("1.0.0".to_string());
assert!(config.is_disabled());
}
}