Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
437584aac7 | ||
|
|
960724685c | ||
|
|
639bcdf643 | ||
|
|
ddb4b70234 | ||
|
|
32155bc1d2 | ||
|
|
e4cc280f28 | ||
|
|
868540b92a | ||
|
|
0af46bd6a5 | ||
|
|
ba3f923781 |
+2
-2
@@ -53,7 +53,7 @@ trigger:
|
||||
event:
|
||||
- tag
|
||||
ref:
|
||||
- refs/tags/v*
|
||||
- refs/tags/[0-9]*
|
||||
|
||||
steps:
|
||||
- name: build-x86_64
|
||||
@@ -79,7 +79,7 @@ steps:
|
||||
# Read DRONE_TAG via ENVIRON inside awk to avoid Drone's ${VAR} substitution
|
||||
# which would replace ${TAG} with an empty string before the shell runs.
|
||||
BODY=$(awk '
|
||||
BEGIN { tag = ENVIRON["DRONE_TAG"]; gsub(/^v/, "", tag) }
|
||||
BEGIN { tag = ENVIRON["DRONE_TAG"] }
|
||||
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
|
||||
in_section && /^## \[/ { exit }
|
||||
in_section { print }
|
||||
|
||||
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [0.4.2] - 2026-03-01
|
||||
|
||||
### Fixed
|
||||
- Version mismatch: bumped Cargo.toml version to match release tag, fixing `--update` false positive
|
||||
|
||||
## [0.4.1] - 2026-03-01
|
||||
|
||||
### Added
|
||||
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
|
||||
|
||||
## [0.4.0] - 2026-03-01
|
||||
|
||||
### Added
|
||||
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
|
||||
- New `self_update` module with version comparison and atomic binary replacement
|
||||
- `--update` CLI flag for in-place binary updates
|
||||
- Backup and rollback mechanism if update fails
|
||||
|
||||
## [0.3.0] - 2026-03-01
|
||||
|
||||
### Added
|
||||
@@ -53,3 +71,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
### Added
|
||||
- Initial release of tmuxido
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -542,7 +542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tmuxido"
|
||||
version = "0.2.4"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tmuxido"
|
||||
version = "0.2.4"
|
||||
version = "0.4.3"
|
||||
edition = "2024"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<div align="center">
|
||||
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
|
||||
</div>
|
||||
|
||||
# tmuxido
|
||||
<div align="center">
|
||||
|
||||
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||
[](https://git.cincoeuzebio.com/cinco/Tmuxido/releases)
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
# tmuxido
|
||||
|
||||
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
|
||||
|
||||
@@ -21,6 +24,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
|
||||
- TOML-based configuration
|
||||
- Smart caching system for fast subsequent runs
|
||||
- Configurable cache TTL
|
||||
- Self-update capability (`tmuxido --update`)
|
||||
- Zero external dependencies (except tmux and fzf)
|
||||
|
||||
## Installation
|
||||
@@ -97,6 +101,11 @@ Check cache status:
|
||||
tmuxido --cache-status
|
||||
```
|
||||
|
||||
Update tmuxido to the latest version:
|
||||
```bash
|
||||
tmuxido --update
|
||||
```
|
||||
|
||||
View help:
|
||||
```bash
|
||||
tmuxido --help
|
||||
@@ -161,3 +170,15 @@ Each window can have multiple panes with commands that run automatically:
|
||||
- First pane is the main window pane
|
||||
- Additional panes are created by splitting
|
||||
- Empty panes array = just open the window in the project directory
|
||||
|
||||
## Author
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/cinco">
|
||||
<img src="https://github.com/cinco.png" width="100" height="100" style="border-radius: 50%;" alt="Cinco avatar"/>
|
||||
</a>
|
||||
<br><br>
|
||||
<strong>Cinco</strong>
|
||||
<br>
|
||||
<a href="https://github.com/cinco">@cinco</a>
|
||||
</div>
|
||||
|
||||
+270
-8
@@ -1,6 +1,7 @@
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod deps;
|
||||
pub mod self_update;
|
||||
pub mod session;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -37,28 +38,45 @@ pub fn show_cache_status(config: &Config) -> Result<()> {
|
||||
}
|
||||
|
||||
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
|
||||
get_projects_internal(
|
||||
config,
|
||||
force_refresh,
|
||||
&ProjectCache::load,
|
||||
&|cache| cache.save(),
|
||||
&scan_all_roots,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn get_projects_internal(
|
||||
config: &Config,
|
||||
force_refresh: bool,
|
||||
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
|
||||
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
|
||||
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
if !config.cache_enabled || force_refresh {
|
||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
||||
let (projects, fingerprints) = scanner(config)?;
|
||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||
cache.save()?;
|
||||
cache_saver(&cache)?;
|
||||
eprintln!("Cache updated with {} projects", projects.len());
|
||||
return Ok(projects);
|
||||
}
|
||||
|
||||
if let Some(mut cache) = ProjectCache::load()? {
|
||||
if let Some(mut cache) = cache_loader()? {
|
||||
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
|
||||
if cache.dir_mtimes.is_empty() {
|
||||
eprintln!("Upgrading cache, scanning for projects...");
|
||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
||||
let (projects, fingerprints) = scanner(config)?;
|
||||
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||
new_cache.save()?;
|
||||
cache_saver(&new_cache)?;
|
||||
eprintln!("Cache updated with {} projects", projects.len());
|
||||
return Ok(projects);
|
||||
}
|
||||
|
||||
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
||||
if changed {
|
||||
cache.save()?;
|
||||
cache_saver(&cache)?;
|
||||
eprintln!(
|
||||
"Cache updated incrementally ({} projects)",
|
||||
cache.projects.len()
|
||||
@@ -71,9 +89,9 @@ pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>
|
||||
|
||||
// Sem cache ainda — scan completo inicial
|
||||
eprintln!("No cache found, scanning for projects...");
|
||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
||||
let (projects, fingerprints) = scanner(config)?;
|
||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||
cache.save()?;
|
||||
cache_saver(&cache)?;
|
||||
eprintln!("Cache updated with {} projects", projects.len());
|
||||
Ok(projects)
|
||||
}
|
||||
@@ -161,3 +179,247 @@ pub fn launch_tmux_session(selected: &Path, config: &Config) -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn create_test_config(cache_enabled: bool) -> Config {
|
||||
Config {
|
||||
paths: vec!["/tmp/test".to_string()],
|
||||
max_depth: 3,
|
||||
cache_enabled,
|
||||
cache_ttl_hours: 24,
|
||||
default_session: session::SessionConfig { windows: vec![] },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_scan_when_cache_disabled() {
|
||||
let config = create_test_config(false);
|
||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||
let fingerprints = HashMap::new();
|
||||
let expected_projects = projects.clone();
|
||||
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_called = RefCell::new(false);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| panic!("should not load cache when disabled"),
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert!(saver_called.into_inner());
|
||||
assert_eq!(result.unwrap(), projects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_scan_when_force_refresh() {
|
||||
let config = create_test_config(true);
|
||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||
let fingerprints = HashMap::new();
|
||||
let expected_projects = projects.clone();
|
||||
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_called = RefCell::new(false);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
true,
|
||||
&|| panic!("should not load cache when force refresh"),
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert!(saver_called.into_inner());
|
||||
assert_eq!(result.unwrap(), projects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_do_initial_scan_when_no_cache_exists() {
|
||||
let config = create_test_config(true);
|
||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||
let fingerprints = HashMap::new();
|
||||
let expected_projects = projects.clone();
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_called = RefCell::new(false);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
Ok(None)
|
||||
},
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(loader_called.into_inner());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert!(saver_called.into_inner());
|
||||
assert_eq!(result.unwrap(), projects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_upgrade_old_cache_format() {
|
||||
let config = create_test_config(true);
|
||||
let old_projects = vec![PathBuf::from("/old/project")];
|
||||
let new_projects = vec![
|
||||
PathBuf::from("/new/project1"),
|
||||
PathBuf::from("/new/project2"),
|
||||
];
|
||||
let new_fingerprints = HashMap::from([(PathBuf::from("/new"), 12345u64)]);
|
||||
|
||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||
let old_cache = RefCell::new(Some(ProjectCache::new(old_projects, HashMap::new())));
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_count = RefCell::new(0);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
// Take the cache out of the RefCell
|
||||
Ok(old_cache.borrow_mut().take())
|
||||
},
|
||||
&|_| {
|
||||
*saver_count.borrow_mut() += 1;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((new_projects.clone(), new_fingerprints.clone()))
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(loader_called.into_inner());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert_eq!(*saver_count.borrow(), 1);
|
||||
assert_eq!(result.unwrap(), new_projects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_cached_projects_when_nothing_changed() {
|
||||
let config = create_test_config(true);
|
||||
let cached_projects = vec![
|
||||
PathBuf::from("/nonexistent/project1"),
|
||||
PathBuf::from("/nonexistent/project2"),
|
||||
];
|
||||
// Use a path that doesn't exist - validate_and_update will skip rescan
|
||||
// because it can't check mtime of non-existent directory
|
||||
let cached_fingerprints =
|
||||
HashMap::from([(PathBuf::from("/definitely_nonexistent_path_xyz"), 12345u64)]);
|
||||
|
||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||
let cache = RefCell::new(Some(ProjectCache::new(
|
||||
cached_projects.clone(),
|
||||
cached_fingerprints,
|
||||
)));
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_count = RefCell::new(0);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
// Take the cache out of the RefCell
|
||||
Ok(cache.borrow_mut().take())
|
||||
},
|
||||
&|_| {
|
||||
*saver_count.borrow_mut() += 1;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
panic!("should not do full scan when cache is valid")
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(loader_called.into_inner());
|
||||
// Note: When the directory in dir_mtimes doesn't exist, validate_and_update
|
||||
// treats it as "changed" and removes projects under that path.
|
||||
// This test verifies the flow completes - the specific behavior of
|
||||
// validate_and_update is tested separately in cache.rs
|
||||
let result_projects = result.unwrap();
|
||||
// Projects were removed because the tracked directory doesn't exist
|
||||
assert!(result_projects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_update_incrementally_when_cache_changed() {
|
||||
let config = create_test_config(true);
|
||||
let initial_projects = vec![PathBuf::from("/nonexistent/project1")];
|
||||
// Use a path that doesn't exist - validate_and_update will treat missing
|
||||
// directory as a change (unwrap_or(true) in the mtime check)
|
||||
let mut dir_mtimes = HashMap::new();
|
||||
dir_mtimes.insert(PathBuf::from("/definitely_nonexistent_path_abc"), 0u64);
|
||||
|
||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||
let cache = RefCell::new(Some(ProjectCache::new(initial_projects, dir_mtimes)));
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
let saver_called = RefCell::new(false);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
// Take the cache out of the RefCell
|
||||
Ok(cache.borrow_mut().take())
|
||||
},
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| panic!("full scan should not happen with incremental update"),
|
||||
);
|
||||
|
||||
// validate_and_update is called internally. Since the directory doesn't exist,
|
||||
// it treats it as "changed" and will try to rescan using scan_from_root.
|
||||
// We verify the flow completes without panicking.
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(loader_called.into_inner());
|
||||
// Note: The saver may or may not be called depending on whether
|
||||
// validate_and_update detects changes (missing dir = change)
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -5,6 +5,7 @@ use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use tmuxido::config::Config;
|
||||
use tmuxido::deps::ensure_dependencies;
|
||||
use tmuxido::self_update;
|
||||
use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -24,11 +25,20 @@ struct Args {
|
||||
/// Show cache status and exit
|
||||
#[arg(long)]
|
||||
cache_status: bool,
|
||||
|
||||
/// Update tmuxido to the latest version
|
||||
#[arg(long)]
|
||||
update: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Handle self-update before anything else
|
||||
if args.update {
|
||||
return self_update::self_update();
|
||||
}
|
||||
|
||||
// Check that fzf and tmux are installed; offer to install if missing
|
||||
ensure_dependencies()?;
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
const REPO: &str = "cinco/Tmuxido";
|
||||
const BASE_URL: &str = "https://git.cincoeuzebio.com";
|
||||
|
||||
/// Check if running from cargo (development mode)
|
||||
fn is_dev_build() -> bool {
|
||||
option_env!("CARGO_PKG_NAME").is_none()
|
||||
}
|
||||
|
||||
/// Get current version from cargo
|
||||
pub fn current_version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
/// Detect system architecture
|
||||
fn detect_arch() -> Result<&'static str> {
|
||||
let arch = std::env::consts::ARCH;
|
||||
match arch {
|
||||
"x86_64" => Ok("x86_64-linux"),
|
||||
"aarch64" => Ok("aarch64-linux"),
|
||||
_ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch latest release tag from Gitea API
|
||||
fn fetch_latest_tag() -> Result<String> {
|
||||
let url = format!("{}/api/v1/repos/{}/releases?limit=1&page=1", BASE_URL, REPO);
|
||||
|
||||
let output = Command::new("curl")
|
||||
.args(["-fsSL", &url])
|
||||
.output()
|
||||
.context("Failed to execute curl. Make sure curl is installed.")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to fetch latest release: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let response = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse JSON response to extract tag_name
|
||||
let tag: serde_json::Value =
|
||||
serde_json::from_str(&response).context("Failed to parse release API response")?;
|
||||
|
||||
tag.get(0)
|
||||
.and_then(|r| r.get("tag_name"))
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|t| t.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not extract tag_name from release"))
|
||||
}
|
||||
|
||||
/// Get path to current executable
|
||||
fn get_current_exe() -> Result<PathBuf> {
|
||||
std::env::current_exe().context("Failed to get current executable path")
|
||||
}
|
||||
|
||||
/// Download binary to a temporary location
|
||||
fn download_binary(tag: &str, arch: &str, temp_path: &std::path::Path) -> Result<()> {
|
||||
let url = format!("{}/{}/releases/download/{}/{}", BASE_URL, REPO, tag, arch);
|
||||
|
||||
println!("Downloading {}...", url);
|
||||
|
||||
let output = Command::new("curl")
|
||||
.args(["-fsSL", &url, "-o", &temp_path.to_string_lossy()])
|
||||
.output()
|
||||
.context("Failed to execute curl for download")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to download binary: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
// Make executable
|
||||
let mut perms = std::fs::metadata(temp_path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(temp_path, perms)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform self-update
|
||||
pub fn self_update() -> Result<()> {
|
||||
if is_dev_build() {
|
||||
println!("Development build detected. Skipping self-update.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current = current_version();
|
||||
println!("Current version: {}", current);
|
||||
|
||||
let latest = fetch_latest_tag()?;
|
||||
let latest_clean = latest.trim_start_matches('v');
|
||||
println!("Latest version: {}", latest);
|
||||
|
||||
// Compare versions (simple string comparison for semver without 'v' prefix)
|
||||
if latest_clean == current {
|
||||
println!("Already up to date!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if latest is actually newer
|
||||
match version_compare(latest_clean, current) {
|
||||
std::cmp::Ordering::Less => {
|
||||
println!("Current version is newer than release. Skipping update.");
|
||||
return Ok(());
|
||||
}
|
||||
std::cmp::Ordering::Equal => {
|
||||
println!("Already up to date!");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let arch = detect_arch()?;
|
||||
let exe_path = get_current_exe()?;
|
||||
|
||||
// Create temporary file in same directory as target (for atomic rename)
|
||||
let exe_dir = exe_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine executable directory"))?;
|
||||
let temp_path = exe_dir.join(".tmuxido.new");
|
||||
|
||||
println!("Downloading update...");
|
||||
download_binary(&latest, arch, &temp_path)?;
|
||||
|
||||
// Verify the downloaded binary works
|
||||
let verify = Command::new(&temp_path).arg("--version").output();
|
||||
if let Err(e) = verify {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Downloaded binary verification failed: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
|
||||
// Atomic replace: rename old to .old, rename new to target
|
||||
let backup_path = exe_path.with_extension("old");
|
||||
|
||||
// Remove old backup if exists
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
|
||||
// Rename current to backup
|
||||
std::fs::rename(&exe_path, &backup_path)
|
||||
.context("Failed to backup current binary (is tmuxido running?)")?;
|
||||
|
||||
// Move new to current location
|
||||
if let Err(e) = std::fs::rename(&temp_path, &exe_path) {
|
||||
// Restore backup on failure
|
||||
let _ = std::fs::rename(&backup_path, &exe_path);
|
||||
return Err(anyhow::anyhow!("Failed to install new binary: {}", e));
|
||||
}
|
||||
|
||||
// Remove backup on success
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
|
||||
println!("Successfully updated to {}!", latest);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare two semver versions
|
||||
fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let parse = |s: &str| {
|
||||
s.split('.')
|
||||
.filter_map(|n| n.parse::<u32>().ok())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let a_parts = parse(a);
|
||||
let b_parts = parse(b);
|
||||
|
||||
for (a_part, b_part) in a_parts.iter().zip(b_parts.iter()) {
|
||||
match a_part.cmp(b_part) {
|
||||
std::cmp::Ordering::Equal => continue,
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
|
||||
a_parts.len().cmp(&b_parts.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_detect_current_version() {
|
||||
let version = current_version();
|
||||
// Version should be non-empty and contain dots
|
||||
assert!(!version.is_empty());
|
||||
assert!(version.contains('.'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_compare_versions_correctly() {
|
||||
assert_eq!(
|
||||
version_compare("0.3.0", "0.2.4"),
|
||||
std::cmp::Ordering::Greater
|
||||
);
|
||||
assert_eq!(version_compare("0.2.4", "0.3.0"), std::cmp::Ordering::Less);
|
||||
assert_eq!(version_compare("0.3.0", "0.3.0"), std::cmp::Ordering::Equal);
|
||||
assert_eq!(
|
||||
version_compare("1.0.0", "0.9.9"),
|
||||
std::cmp::Ordering::Greater
|
||||
);
|
||||
assert_eq!(
|
||||
version_compare("0.10.0", "0.9.0"),
|
||||
std::cmp::Ordering::Greater
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user