Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4cc280f28 | ||
|
|
868540b92a | ||
|
|
0af46bd6a5 | ||
|
|
ba3f923781 | ||
|
|
fcb7c7f6a6 | ||
|
|
3c78a5afe3 | ||
|
|
4dcac9a6aa | ||
|
|
32c40b7226 | ||
|
|
c1adc92c1c | ||
|
|
e6180b57c3 | ||
|
|
90e3b65820 | ||
|
|
fc2f503886 | ||
|
|
b29baee9c4 | ||
|
|
6d842b83ba | ||
|
|
b4877007f2 | ||
|
|
29ff9535a7 | ||
|
|
dec566320f | ||
|
|
6275c638d9 | ||
|
|
4d6af13134 | ||
|
|
b2a0ee5e08 | ||
|
|
2bc516ed6d | ||
|
|
7e70157097 | ||
|
|
ef13e884ba | ||
|
|
b6e33c37ac | ||
|
|
20f516cffb | ||
|
|
a5429c3543 | ||
|
|
8a7df892e8 | ||
|
|
f0949e08c5 | ||
|
|
d80857090e | ||
|
|
19f36060a0 | ||
|
|
2dc12e37c9 | ||
|
|
280a180d3e | ||
|
|
5f281b1f9e |
@@ -3,10 +3,14 @@
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'if [ -f Cargo.toml ]; then if command -v cargo-nextest >/dev/null 2>&1; then cargo nextest run 2>&1; else cargo test 2>&1; fi; fi'",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
.git/
|
||||
.claude/
|
||||
+40
-1
@@ -15,6 +15,35 @@ steps:
|
||||
- cargo clippy -- -D warnings
|
||||
- cargo test
|
||||
|
||||
- name: coverage
|
||||
image: xd009642/tarpaulin
|
||||
privileged: true
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
commands:
|
||||
- |
|
||||
apt-get update -qq && apt-get install -y -qq jq curl
|
||||
PCT=$(cargo tarpaulin 2>&1 | awk '/coverage,/{print int($1)}')
|
||||
[ -z "$PCT" ] && PCT=0
|
||||
if [ "$PCT" -ge 80 ]; then FILL="#4c1"
|
||||
elif [ "$PCT" -ge 60 ]; then FILL="#dfb317"
|
||||
else FILL="#e05d44"; fi
|
||||
echo "PCT=$PCT FILL=$FILL"
|
||||
printf '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20"><rect width="76" height="20" fill="#555"/><rect x="76" width="44" height="20" fill="%s"/><g fill="#fff" text-anchor="middle" font-family="sans-serif" font-size="11"><text x="38" y="14">coverage</text><text x="98" y="14">%s%%</text></g></svg>' "$FILL" "$PCT" > coverage.svg
|
||||
CONTENT=$(base64 coverage.svg | tr -d '\n')
|
||||
SHA=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/contents/coverage.svg?ref=badges" \
|
||||
| jq -r '.sha')
|
||||
jq -n --arg msg "ci: update coverage badge [CI SKIP]" \
|
||||
--arg content "$CONTENT" --arg sha "$SHA" --arg branch "badges" \
|
||||
'{message: $msg, content: $content, sha: $sha, branch: $branch}' \
|
||||
| curl -fsSL -X PUT \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/contents/coverage.svg" \
|
||||
-d @-
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
@@ -23,6 +52,8 @@ name: release
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
ref:
|
||||
- refs/tags/[0-9]*
|
||||
|
||||
steps:
|
||||
- name: build-x86_64
|
||||
@@ -45,11 +76,19 @@ steps:
|
||||
commands:
|
||||
- apk add --no-cache curl jq
|
||||
- |
|
||||
# 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"] }
|
||||
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
|
||||
in_section && /^## \[/ { exit }
|
||||
in_section { print }
|
||||
' CHANGELOG.md)
|
||||
RELEASE_ID=$(curl -fsSL -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases" \
|
||||
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":\"Release $DRONE_TAG\"}" \
|
||||
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":$(echo "$BODY" | jq -Rs .)}" \
|
||||
| jq -r .id)
|
||||
for ASSET in tmuxido-x86_64-linux tmuxido-aarch64-linux; do
|
||||
curl -fsSL -X POST \
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
"type": "stdio",
|
||||
"command": "crates-mcp",
|
||||
"args": []
|
||||
},
|
||||
"drone-ci-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"drone-ci-mcp",
|
||||
"--access-token=${DRONE_TOKEN}",
|
||||
"--server-url=https://drone.cincoeuzebio.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
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
|
||||
- Dependency check for `fzf` and `tmux` at startup, before any operation
|
||||
- Automatic Linux package manager detection (apt, pacman, dnf, yum, zypper, emerge, xbps, apk)
|
||||
- Interactive installation prompt when required tools are missing
|
||||
- `deps` module with injectable `BinaryChecker` trait for unit testing without hitting the real system
|
||||
- Integration tests in `tests/deps.rs` (11 tests using real `SystemBinaryChecker`)
|
||||
- Docker test suite in `tests/docker/` with 15 scenarios simulating a fresh Ubuntu 24.04 user
|
||||
|
||||
### Fixed
|
||||
- Release pipeline `publish` step now reads `DRONE_TAG` via awk `ENVIRON` to prevent Drone's
|
||||
`${VAR}` substitution from wiping local shell variables before the shell runs
|
||||
|
||||
## [0.2.4] - 2026-03-01
|
||||
|
||||
### Fixed
|
||||
- Coverage percentage calculation in CI (correct field from tarpaulin JSON output)
|
||||
- Release pipeline trigger now matches `v*` tag format instead of `[0-9]*`
|
||||
|
||||
## [0.2.2] - 2026-02-28
|
||||
|
||||
### Added
|
||||
- Coverage badge generated by `cargo-tarpaulin` in CI, hosted in Gitea Generic Package Registry
|
||||
- CI status, coverage, version, and Rust edition badges in README
|
||||
|
||||
## [0.2.1] - 2026-02-28
|
||||
|
||||
### Added
|
||||
- Drone CI pipeline (`ci`) running `cargo fmt --check`, `cargo clippy`, and `cargo test` on every push and pull request
|
||||
|
||||
## [0.2.0] - 2026-02-28
|
||||
|
||||
### Added
|
||||
- Unit tests for `cache`, `session`, and `config` modules
|
||||
- Integration tests for scan, session config, and cache lifecycle
|
||||
|
||||
### Changed
|
||||
- Refactored business logic into `lib.rs` for better testability; `main.rs` is now a thin entrypoint
|
||||
|
||||
## [0.1.1] - 2026-02-28
|
||||
|
||||
### Fixed
|
||||
- Removed personal path references from default configuration and examples
|
||||
|
||||
## [0.1.0] - 2026-02-28
|
||||
|
||||
### Added
|
||||
- Initial release of tmuxido
|
||||
|
||||
Generated
+1
-1
@@ -542,7 +542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tmuxido"
|
||||
version = "0.2.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tmuxido"
|
||||
version = "0.2.1"
|
||||
version = "0.4.2"
|
||||
edition = "2024"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
<div align="center">
|
||||
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
|
||||
</div>
|
||||
|
||||
# tmuxido
|
||||
|
||||
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||
[](https://git.cincoeuzebio.com/cinco/Tmuxido/releases)
|
||||

|
||||
|
||||
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
|
||||
|
||||
## Features
|
||||
@@ -12,6 +21,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
|
||||
@@ -88,6 +98,11 @@ Check cache status:
|
||||
tmuxido --cache-status
|
||||
```
|
||||
|
||||
Update tmuxido to the latest version:
|
||||
```bash
|
||||
tmuxido --update
|
||||
```
|
||||
|
||||
View help:
|
||||
```bash
|
||||
tmuxido --help
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
+425
@@ -0,0 +1,425 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::io::{self, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Required external tool dependencies.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Dep {
|
||||
Fzf,
|
||||
Tmux,
|
||||
}
|
||||
|
||||
/// Supported Linux package managers.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PackageManager {
|
||||
Apt,
|
||||
Pacman,
|
||||
Dnf,
|
||||
Yum,
|
||||
Zypper,
|
||||
Emerge,
|
||||
Xbps,
|
||||
Apk,
|
||||
}
|
||||
|
||||
/// Injectable binary availability checker — enables unit testing without hitting the real system.
|
||||
pub trait BinaryChecker {
|
||||
fn is_available(&self, name: &str) -> bool;
|
||||
}
|
||||
|
||||
/// Production implementation: delegates to the system `which` command.
|
||||
pub struct SystemBinaryChecker;
|
||||
|
||||
impl BinaryChecker for SystemBinaryChecker {
|
||||
fn is_available(&self, name: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(name)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl Dep {
|
||||
pub fn all() -> Vec<Self> {
|
||||
vec![Self::Fzf, Self::Tmux]
|
||||
}
|
||||
|
||||
pub fn binary_name(&self) -> &str {
|
||||
match self {
|
||||
Self::Fzf => "fzf",
|
||||
Self::Tmux => "tmux",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn package_name(&self) -> &str {
|
||||
match self {
|
||||
Self::Fzf => "fzf",
|
||||
Self::Tmux => "tmux",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackageManager {
|
||||
/// Ordered list for detection — more specific managers first.
|
||||
pub fn all_ordered() -> Vec<Self> {
|
||||
vec![
|
||||
Self::Apt,
|
||||
Self::Pacman,
|
||||
Self::Dnf,
|
||||
Self::Yum,
|
||||
Self::Zypper,
|
||||
Self::Emerge,
|
||||
Self::Xbps,
|
||||
Self::Apk,
|
||||
]
|
||||
}
|
||||
|
||||
/// Binary used to detect whether this package manager is installed.
|
||||
pub fn detection_binary(&self) -> &str {
|
||||
match self {
|
||||
Self::Apt => "apt",
|
||||
Self::Pacman => "pacman",
|
||||
Self::Dnf => "dnf",
|
||||
Self::Yum => "yum",
|
||||
Self::Zypper => "zypper",
|
||||
Self::Emerge => "emerge",
|
||||
Self::Xbps => "xbps-install",
|
||||
Self::Apk => "apk",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &str {
|
||||
match self {
|
||||
Self::Apt => "apt (Debian/Ubuntu)",
|
||||
Self::Pacman => "pacman (Arch Linux)",
|
||||
Self::Dnf => "dnf (Fedora)",
|
||||
Self::Yum => "yum (RHEL/CentOS)",
|
||||
Self::Zypper => "zypper (openSUSE)",
|
||||
Self::Emerge => "emerge (Gentoo)",
|
||||
Self::Xbps => "xbps-install (Void Linux)",
|
||||
Self::Apk => "apk (Alpine Linux)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the full install command (including `sudo`) for the given packages.
|
||||
pub fn install_command(&self, packages: &[&str]) -> Vec<String> {
|
||||
let mut cmd = vec!["sudo".to_string()];
|
||||
match self {
|
||||
Self::Apt => cmd.extend(["apt", "install", "-y"].map(String::from)),
|
||||
Self::Pacman => cmd.extend(["pacman", "-S", "--noconfirm"].map(String::from)),
|
||||
Self::Dnf => cmd.extend(["dnf", "install", "-y"].map(String::from)),
|
||||
Self::Yum => cmd.extend(["yum", "install", "-y"].map(String::from)),
|
||||
Self::Zypper => cmd.extend(["zypper", "install", "-y"].map(String::from)),
|
||||
Self::Emerge => cmd.extend(["emerge"].map(String::from)),
|
||||
Self::Xbps => cmd.extend(["xbps-install", "-y"].map(String::from)),
|
||||
Self::Apk => cmd.extend(["apk", "add"].map(String::from)),
|
||||
}
|
||||
cmd.extend(packages.iter().map(|&s| s.to_string()));
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the required deps that are not currently installed.
|
||||
pub fn check_missing<C: BinaryChecker>(checker: &C) -> Vec<Dep> {
|
||||
Dep::all()
|
||||
.into_iter()
|
||||
.filter(|dep| !checker.is_available(dep.binary_name()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the first supported package manager found on the system.
|
||||
pub fn detect_package_manager<C: BinaryChecker>(checker: &C) -> Option<PackageManager> {
|
||||
PackageManager::all_ordered()
|
||||
.into_iter()
|
||||
.find(|pm| checker.is_available(pm.detection_binary()))
|
||||
}
|
||||
|
||||
/// Checks for missing dependencies, informs the user, and offers to install them.
|
||||
///
|
||||
/// Returns `Ok(())` if all deps are available (or successfully installed).
|
||||
pub fn ensure_dependencies() -> Result<()> {
|
||||
let checker = SystemBinaryChecker;
|
||||
let missing = check_missing(&checker);
|
||||
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!("The following required tools are not installed:");
|
||||
for dep in &missing {
|
||||
eprintln!(" ✗ {}", dep.binary_name());
|
||||
}
|
||||
eprintln!();
|
||||
|
||||
let pm = detect_package_manager(&checker).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No supported package manager found. Please install {} manually.",
|
||||
missing
|
||||
.iter()
|
||||
.map(|d| d.binary_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and ")
|
||||
)
|
||||
})?;
|
||||
|
||||
let packages: Vec<&str> = missing.iter().map(|d| d.package_name()).collect();
|
||||
let cmd = pm.install_command(&packages);
|
||||
|
||||
eprintln!("Detected package manager: {}", pm.display_name());
|
||||
eprintln!("Install command: {}", cmd.join(" "));
|
||||
eprint!("\nProceed with installation? [Y/n] ");
|
||||
io::stdout().flush().ok();
|
||||
|
||||
let mut answer = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("Failed to read user input")?;
|
||||
|
||||
let answer = answer.trim().to_lowercase();
|
||||
if answer == "n" || answer == "no" {
|
||||
anyhow::bail!(
|
||||
"Installation cancelled. Please install {} manually before running tmuxido.",
|
||||
missing
|
||||
.iter()
|
||||
.map(|d| d.binary_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and ")
|
||||
);
|
||||
}
|
||||
|
||||
let (program, args) = cmd
|
||||
.split_first()
|
||||
.expect("install_command always returns at least one element");
|
||||
|
||||
let status = Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run: {}", cmd.join(" ")))?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"Installation failed. Please install {} manually.",
|
||||
missing
|
||||
.iter()
|
||||
.map(|d| d.binary_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and ")
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!("Installation complete!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct MockChecker {
|
||||
available: Vec<String>,
|
||||
}
|
||||
|
||||
impl MockChecker {
|
||||
fn with(available: &[&str]) -> Self {
|
||||
Self {
|
||||
available: available.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BinaryChecker for MockChecker {
|
||||
fn is_available(&self, name: &str) -> bool {
|
||||
self.available.iter().any(|s| s == name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dep ---
|
||||
|
||||
#[test]
|
||||
fn should_return_fzf_binary_name() {
|
||||
assert_eq!(Dep::Fzf.binary_name(), "fzf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_tmux_binary_name() {
|
||||
assert_eq!(Dep::Tmux.binary_name(), "tmux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_include_fzf_and_tmux_in_all_deps() {
|
||||
let deps = Dep::all();
|
||||
assert!(deps.contains(&Dep::Fzf));
|
||||
assert!(deps.contains(&Dep::Tmux));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_same_package_name_as_binary_for_fzf() {
|
||||
assert_eq!(Dep::Fzf.package_name(), "fzf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_same_package_name_as_binary_for_tmux() {
|
||||
assert_eq!(Dep::Tmux.package_name(), "tmux");
|
||||
}
|
||||
|
||||
// --- check_missing ---
|
||||
|
||||
#[test]
|
||||
fn should_return_empty_when_all_deps_present() {
|
||||
let checker = MockChecker::with(&["fzf", "tmux"]);
|
||||
assert!(check_missing(&checker).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_fzf_as_missing_when_only_tmux_present() {
|
||||
let checker = MockChecker::with(&["tmux"]);
|
||||
let missing = check_missing(&checker);
|
||||
assert_eq!(missing, vec![Dep::Fzf]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_tmux_as_missing_when_only_fzf_present() {
|
||||
let checker = MockChecker::with(&["fzf"]);
|
||||
let missing = check_missing(&checker);
|
||||
assert_eq!(missing, vec![Dep::Tmux]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_both_missing_when_none_present() {
|
||||
let checker = MockChecker::with(&[]);
|
||||
let missing = check_missing(&checker);
|
||||
assert_eq!(missing.len(), 2);
|
||||
assert!(missing.contains(&Dep::Fzf));
|
||||
assert!(missing.contains(&Dep::Tmux));
|
||||
}
|
||||
|
||||
// --- detect_package_manager ---
|
||||
|
||||
#[test]
|
||||
fn should_detect_apt_when_available() {
|
||||
let checker = MockChecker::with(&["apt"]);
|
||||
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_pacman_when_available() {
|
||||
let checker = MockChecker::with(&["pacman"]);
|
||||
assert_eq!(
|
||||
detect_package_manager(&checker),
|
||||
Some(PackageManager::Pacman)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_dnf_when_available() {
|
||||
let checker = MockChecker::with(&["dnf"]);
|
||||
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Dnf));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_xbps_when_xbps_install_available() {
|
||||
let checker = MockChecker::with(&["xbps-install"]);
|
||||
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Xbps));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_apk_when_available() {
|
||||
let checker = MockChecker::with(&["apk"]);
|
||||
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_when_no_pm_detected() {
|
||||
let checker = MockChecker::with(&["ls", "sh"]);
|
||||
assert_eq!(detect_package_manager(&checker), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_prefer_apt_over_pacman_when_both_available() {
|
||||
let checker = MockChecker::with(&["apt", "pacman"]);
|
||||
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apt));
|
||||
}
|
||||
|
||||
// --- PackageManager::install_command ---
|
||||
|
||||
#[test]
|
||||
fn should_build_apt_install_command() {
|
||||
let cmd = PackageManager::Apt.install_command(&["fzf", "tmux"]);
|
||||
assert_eq!(cmd, vec!["sudo", "apt", "install", "-y", "fzf", "tmux"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_pacman_install_command() {
|
||||
let cmd = PackageManager::Pacman.install_command(&["fzf", "tmux"]);
|
||||
assert_eq!(
|
||||
cmd,
|
||||
vec!["sudo", "pacman", "-S", "--noconfirm", "fzf", "tmux"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_dnf_install_command() {
|
||||
let cmd = PackageManager::Dnf.install_command(&["fzf"]);
|
||||
assert_eq!(cmd, vec!["sudo", "dnf", "install", "-y", "fzf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_yum_install_command() {
|
||||
let cmd = PackageManager::Yum.install_command(&["tmux"]);
|
||||
assert_eq!(cmd, vec!["sudo", "yum", "install", "-y", "tmux"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_zypper_install_command() {
|
||||
let cmd = PackageManager::Zypper.install_command(&["fzf", "tmux"]);
|
||||
assert_eq!(cmd, vec!["sudo", "zypper", "install", "-y", "fzf", "tmux"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_emerge_install_command() {
|
||||
let cmd = PackageManager::Emerge.install_command(&["fzf"]);
|
||||
assert_eq!(cmd, vec!["sudo", "emerge", "fzf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_xbps_install_command() {
|
||||
let cmd = PackageManager::Xbps.install_command(&["tmux"]);
|
||||
assert_eq!(cmd, vec!["sudo", "xbps-install", "-y", "tmux"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_apk_install_command() {
|
||||
let cmd = PackageManager::Apk.install_command(&["fzf", "tmux"]);
|
||||
assert_eq!(cmd, vec!["sudo", "apk", "add", "fzf", "tmux"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_command_for_single_package() {
|
||||
let cmd = PackageManager::Apt.install_command(&["fzf"]);
|
||||
assert_eq!(cmd, vec!["sudo", "apt", "install", "-y", "fzf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_include_sudo_for_all_package_managers() {
|
||||
for pm in PackageManager::all_ordered() {
|
||||
let cmd = pm.install_command(&["fzf"]);
|
||||
assert_eq!(
|
||||
cmd.first().map(String::as_str),
|
||||
Some("sudo"),
|
||||
"{} install command should start with sudo",
|
||||
pm.display_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_include_all_packages_in_command() {
|
||||
let cmd = PackageManager::Apt.install_command(&["fzf", "tmux", "git"]);
|
||||
assert!(cmd.contains(&"fzf".to_string()));
|
||||
assert!(cmd.contains(&"tmux".to_string()));
|
||||
assert!(cmd.contains(&"git".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod deps;
|
||||
pub mod self_update;
|
||||
pub mod session;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
+14
@@ -4,6 +4,8 @@ use std::io::Write;
|
||||
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)]
|
||||
@@ -23,11 +25,23 @@ 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()?;
|
||||
|
||||
// Ensure config exists
|
||||
Config::ensure_config_exists()?;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
use tmuxido::deps::{
|
||||
BinaryChecker, Dep, PackageManager, SystemBinaryChecker, check_missing, detect_package_manager,
|
||||
};
|
||||
|
||||
// --- SystemBinaryChecker (real system calls) ---
|
||||
|
||||
#[test]
|
||||
fn system_checker_finds_sh_binary() {
|
||||
let checker = SystemBinaryChecker;
|
||||
assert!(
|
||||
checker.is_available("sh"),
|
||||
"`sh` must be present on any Unix system"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_checker_returns_false_for_nonexistent_binary() {
|
||||
let checker = SystemBinaryChecker;
|
||||
assert!(!checker.is_available("tmuxido_nonexistent_xyz_42"));
|
||||
}
|
||||
|
||||
// --- detect_package_manager on real system ---
|
||||
|
||||
#[test]
|
||||
fn should_detect_some_package_manager_on_linux() {
|
||||
let checker = SystemBinaryChecker;
|
||||
let pm = detect_package_manager(&checker);
|
||||
assert!(
|
||||
pm.is_some(),
|
||||
"Expected to detect at least one package manager on this Linux system"
|
||||
);
|
||||
}
|
||||
|
||||
// --- PackageManager metadata completeness ---
|
||||
|
||||
#[test]
|
||||
fn all_package_managers_have_non_empty_detection_binary() {
|
||||
for pm in PackageManager::all_ordered() {
|
||||
assert!(
|
||||
!pm.detection_binary().is_empty(),
|
||||
"{:?} has empty detection binary",
|
||||
pm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_package_managers_have_non_empty_display_name() {
|
||||
for pm in PackageManager::all_ordered() {
|
||||
assert!(
|
||||
!pm.display_name().is_empty(),
|
||||
"{:?} has empty display name",
|
||||
pm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_command_always_starts_with_sudo() {
|
||||
let packages = &["fzf", "tmux"];
|
||||
for pm in PackageManager::all_ordered() {
|
||||
let cmd = pm.install_command(packages);
|
||||
assert_eq!(
|
||||
cmd.first().map(String::as_str),
|
||||
Some("sudo"),
|
||||
"{} install command should start with sudo",
|
||||
pm.display_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_command_always_contains_requested_packages() {
|
||||
let packages = &["fzf", "tmux"];
|
||||
for pm in PackageManager::all_ordered() {
|
||||
let cmd = pm.install_command(packages);
|
||||
assert!(
|
||||
cmd.contains(&"fzf".to_string()),
|
||||
"{} command missing 'fzf'",
|
||||
pm.display_name()
|
||||
);
|
||||
assert!(
|
||||
cmd.contains(&"tmux".to_string()),
|
||||
"{} command missing 'tmux'",
|
||||
pm.display_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dep completeness ---
|
||||
|
||||
#[test]
|
||||
fn dep_package_names_are_standard() {
|
||||
assert_eq!(Dep::Fzf.package_name(), "fzf");
|
||||
assert_eq!(Dep::Tmux.package_name(), "tmux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_deps_have_matching_binary_and_package_names() {
|
||||
for dep in Dep::all() {
|
||||
assert!(!dep.binary_name().is_empty());
|
||||
assert!(!dep.package_name().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// --- check_missing on real system ---
|
||||
|
||||
#[test]
|
||||
fn check_missing_returns_only_actually_missing_tools() {
|
||||
let checker = SystemBinaryChecker;
|
||||
let missing = check_missing(&checker);
|
||||
// Every item reported as missing must NOT be findable via `which`
|
||||
for dep in &missing {
|
||||
assert!(
|
||||
!checker.is_available(dep.binary_name()),
|
||||
"{} reported as missing but `which` finds it",
|
||||
dep.binary_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_missing_does_not_report_present_tools_as_missing() {
|
||||
let checker = SystemBinaryChecker;
|
||||
let missing = check_missing(&checker);
|
||||
// Every dep NOT in missing list must be available
|
||||
let missing_names: Vec<&str> = missing.iter().map(|d| d.binary_name()).collect();
|
||||
for dep in Dep::all() {
|
||||
if !missing_names.contains(&dep.binary_name()) {
|
||||
assert!(
|
||||
checker.is_available(dep.binary_name()),
|
||||
"{} not in missing list but `which` can't find it",
|
||||
dep.binary_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- Stage 1: Build (Rust stable on Debian slim) ----
|
||||
FROM rust:1-slim AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Copy manifests first so cargo can resolve deps (layer cache friendly)
|
||||
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||
|
||||
# Copy source and build release binary
|
||||
COPY src/ ./src/
|
||||
RUN cargo build --release --locked
|
||||
|
||||
# ---- Stage 2: Test environment (fresh Ubuntu, no fzf/tmux) ----
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install only what's needed to run the test suite itself
|
||||
# (git + sudo so Test 7 can install fzf/tmux via apt)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
sudo \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create an unprivileged user with passwordless sudo
|
||||
# (simulates a regular developer who can install packages)
|
||||
RUN useradd -m -s /bin/bash testuser \
|
||||
&& echo "testuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
|
||||
|
||||
# Install the tmuxido binary built in stage 1
|
||||
COPY --from=builder /src/target/release/tmuxido /usr/local/bin/tmuxido
|
||||
|
||||
# Copy and register the test entrypoint
|
||||
COPY tests/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
USER testuser
|
||||
WORKDIR /home/testuser
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test suite executed inside the Ubuntu container.
|
||||
# Simulates a brand-new user running tmuxido for the first time.
|
||||
set -uo pipefail
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " ✓ $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " ✗ $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
section() {
|
||||
echo ""
|
||||
echo "┌─ $1"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — fzf and tmux are NOT installed yet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ tmuxido — Container Integration Tests (Ubuntu 24.04) ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
|
||||
section "Phase 1: binary basics"
|
||||
|
||||
# T1 — binary is in PATH and executable
|
||||
if command -v tmuxido &>/dev/null; then
|
||||
pass "tmuxido found in PATH ($(command -v tmuxido))"
|
||||
else
|
||||
fail "tmuxido not found in PATH"
|
||||
fi
|
||||
|
||||
# T2 — --help exits 0
|
||||
if tmuxido --help >/dev/null 2>&1; then
|
||||
pass "--help exits with code 0"
|
||||
else
|
||||
fail "--help returned non-zero"
|
||||
fi
|
||||
|
||||
# T3 — --version shows the package name
|
||||
VERSION_OUT=$(tmuxido --version 2>&1 || true)
|
||||
if echo "$VERSION_OUT" | grep -q "tmuxido"; then
|
||||
pass "--version output contains 'tmuxido' → $VERSION_OUT"
|
||||
else
|
||||
fail "--version output unexpected: $VERSION_OUT"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — dependency detection (fzf and tmux absent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
section "Phase 2: dependency detection (fzf and tmux not installed)"
|
||||
|
||||
# Pipe "n" so tmuxido declines to install and exits
|
||||
DEP_OUT=$(echo "n" | tmuxido 2>&1 || true)
|
||||
|
||||
# T4 — fzf reported as missing
|
||||
if echo "$DEP_OUT" | grep -q "fzf"; then
|
||||
pass "fzf detected as missing"
|
||||
else
|
||||
fail "fzf NOT detected as missing. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# T5 — tmux reported as missing
|
||||
if echo "$DEP_OUT" | grep -q "tmux"; then
|
||||
pass "tmux detected as missing"
|
||||
else
|
||||
fail "tmux NOT detected as missing. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# T6 — "not installed" heading appears
|
||||
if echo "$DEP_OUT" | grep -q "not installed"; then
|
||||
pass "User-facing 'not installed' message shown"
|
||||
else
|
||||
fail "'not installed' message missing. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# T7 — apt detected as package manager (Ubuntu 24.04)
|
||||
if echo "$DEP_OUT" | grep -q "apt"; then
|
||||
pass "apt detected as the package manager"
|
||||
else
|
||||
fail "apt NOT detected. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# T8 — install command includes sudo apt install
|
||||
if echo "$DEP_OUT" | grep -q "sudo apt install"; then
|
||||
pass "Install command 'sudo apt install' shown to user"
|
||||
else
|
||||
fail "Install command incorrect. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# T9 — cancellation message when user answers "n"
|
||||
if echo "$DEP_OUT" | grep -q "cancelled\|Cancelled\|manually"; then
|
||||
pass "Graceful cancellation message shown"
|
||||
else
|
||||
fail "Cancellation message missing. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 — install deps and run full workflow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
section "Phase 3: full workflow (after installing fzf, tmux and git)"
|
||||
|
||||
echo " Installing fzf, tmux via apt (this may take a moment)..."
|
||||
sudo apt-get update -qq 2>/dev/null
|
||||
sudo apt-get install -y --no-install-recommends fzf tmux 2>/dev/null
|
||||
|
||||
# T10 — fzf now available
|
||||
if command -v fzf &>/dev/null; then
|
||||
pass "fzf installed successfully ($(fzf --version 2>&1 | head -1))"
|
||||
else
|
||||
fail "fzf still not available after installation"
|
||||
fi
|
||||
|
||||
# T11 — tmux now available
|
||||
if command -v tmux &>/dev/null; then
|
||||
pass "tmux installed successfully ($(tmux -V))"
|
||||
else
|
||||
fail "tmux still not available after installation"
|
||||
fi
|
||||
|
||||
# T12 — tmuxido no longer triggers dependency prompt
|
||||
NO_DEP_OUT=$(echo "" | tmuxido 2>&1 || true)
|
||||
if echo "$NO_DEP_OUT" | grep -q "not installed"; then
|
||||
fail "Dependency prompt still shown after installing deps"
|
||||
else
|
||||
pass "No dependency prompt after deps are installed"
|
||||
fi
|
||||
|
||||
# T13 — set up a minimal git project tree for scanning
|
||||
mkdir -p ~/Projects/demo-app
|
||||
git -C ~/Projects/demo-app init --quiet
|
||||
git -C ~/Projects/demo-app config user.email "test@test.com"
|
||||
git -C ~/Projects/demo-app config user.name "Test"
|
||||
|
||||
mkdir -p ~/.config/tmuxido
|
||||
cat > ~/.config/tmuxido/tmuxido.toml <<'EOF'
|
||||
paths = ["~/Projects"]
|
||||
max_depth = 3
|
||||
cache_enabled = true
|
||||
EOF
|
||||
|
||||
# T13 — --refresh scans and finds our demo project
|
||||
REFRESH_OUT=$(tmuxido --refresh 2>&1 || true)
|
||||
if echo "$REFRESH_OUT" | grep -q "projects\|Projects"; then
|
||||
pass "--refresh scanned and reported projects"
|
||||
else
|
||||
fail "--refresh output unexpected: $REFRESH_OUT"
|
||||
fi
|
||||
|
||||
# T14 — --cache-status reports the cache that was just built
|
||||
CACHE_OUT=$(tmuxido --cache-status 2>&1 || true)
|
||||
if echo "$CACHE_OUT" | grep -qi "cache"; then
|
||||
pass "--cache-status reports cache info"
|
||||
else
|
||||
fail "--cache-status output unexpected: $CACHE_OUT"
|
||||
fi
|
||||
|
||||
# T15 — cache contains our demo project
|
||||
if echo "$CACHE_OUT" | grep -q "Projects cached: [^0]"; then
|
||||
pass "Cache contains at least 1 project"
|
||||
else
|
||||
# Try alternate grep in case format differs
|
||||
if echo "$CACHE_OUT" | grep -q "cached:"; then
|
||||
pass "--cache-status shows cached projects (count check skipped)"
|
||||
else
|
||||
fail "Cache appears empty. Output: $CACHE_OUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
printf "║ Results: %-3d passed, %-3d failed%*s║\n" \
|
||||
"$PASS" "$FAIL" $((24 - ${#PASS} - ${#FAIL})) ""
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the tmuxido Docker test image and run the container integration tests.
|
||||
#
|
||||
# Usage:
|
||||
# ./tests/docker/run.sh # build + run
|
||||
# ./tests/docker/run.sh --no-cache # force rebuild from scratch
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
IMAGE_NAME="tmuxido-test"
|
||||
|
||||
# Propagate --no-cache if requested
|
||||
BUILD_FLAGS=()
|
||||
if [[ "${1:-}" == "--no-cache" ]]; then
|
||||
BUILD_FLAGS+=(--no-cache)
|
||||
fi
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ tmuxido — Docker Integration Test Runner ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Project root : $PROJECT_ROOT"
|
||||
echo "Dockerfile : $SCRIPT_DIR/Dockerfile"
|
||||
echo "Image name : $IMAGE_NAME"
|
||||
echo ""
|
||||
|
||||
# ---- Build ----------------------------------------------------------------
|
||||
echo "Building image (stage 1: rust compile, stage 2: ubuntu test env)..."
|
||||
docker build \
|
||||
"${BUILD_FLAGS[@]}" \
|
||||
--tag "$IMAGE_NAME" \
|
||||
--file "$SCRIPT_DIR/Dockerfile" \
|
||||
"$PROJECT_ROOT"
|
||||
|
||||
echo ""
|
||||
echo "Build complete. Running tests..."
|
||||
echo ""
|
||||
|
||||
# ---- Run ------------------------------------------------------------------
|
||||
docker run \
|
||||
--rm \
|
||||
--name "${IMAGE_NAME}-run" \
|
||||
"$IMAGE_NAME"
|
||||
|
||||
EXIT=$?
|
||||
|
||||
if [ "$EXIT" -eq 0 ]; then
|
||||
echo "All tests passed."
|
||||
else
|
||||
echo "Some tests FAILED (exit $EXIT)."
|
||||
fi
|
||||
|
||||
exit "$EXIT"
|
||||
Reference in New Issue
Block a user