30 Commits
Author SHA1 Message Date
cinco e4cc280f28 🐛 fix: bump version to 0.4.2 to fix self-update version mismatch
continuous-integration/drone/tag Build is failing
The Cargo.toml was still at 0.4.0 while the release was tagged as 0.4.1,
causing the --update command to always think there's a new version available.

Bumping to 0.4.2 ensures the binary version matches the release tag.
2026-03-01 01:13:21 -03:00
cinco 868540b92a force update
continuous-integration/drone/tag Build is failing
2026-03-01 01:04:15 -03:00
cinco 0af46bd6a5 🔧 ci: update release trigger to match tags without v prefix
continuous-integration/drone/tag Build is failing
2026-03-01 00:58:53 -03:00
cinco ba3f923781 feat: add self-update capability
Add `tmuxido --update` command to update binary from latest release.
- New `self_update` module with version comparison
- Atomic binary replacement with backup/rollback
- Fetches latest release from Gitea API
- Downloads correct binary for system architecture
2026-03-01 00:34:42 -03:00
cinco fcb7c7f6a6 🐛 fix(ci): read DRONE_TAG via awk ENVIRON to fix empty release body
continuous-integration/drone/tag Build is failing
Drone CI substitutes ${VAR} in commands before the shell runs.
Since TAG is a local shell variable (not a Drone var), ${TAG} was
replaced with an empty string, causing the awk pattern to match
nothing and the release body to be empty.

Fix: read DRONE_TAG directly inside awk via ENVIRON["DRONE_TAG"]
and strip the v-prefix with gsub — no intermediate shell variable needed.

Also translate CHANGELOG.md to English.
2026-03-01 00:19:10 -03:00
cinco 3c78a5afe3 🐛 fix(ci): corrigir pipeline release para tags v*
- Filtro de ref corrigido: [0-9]* → v* para bater em tags "v0.3.0"
- awk agora strip o prefixo 'v' de DRONE_TAG antes de buscar no CHANGELOG
- Adiciona entrada 0.3.0 no CHANGELOG.md
2026-03-01 00:01:29 -03:00
cinco 4dcac9a6aa feat: verificar dependências fzf e tmux ao iniciar
Adiciona módulo `deps` que, antes de qualquer operação, verifica se
fzf e tmux estão instalados no sistema. Caso faltem, detecta o
gerenciador de pacotes da distro (apt, pacman, dnf, yum, zypper,
emerge, xbps, apk), informa ao usuário e oferece instalar com o
comando adequado.

- `src/deps.rs`: Dep, PackageManager, BinaryChecker (trait injetável),
  check_missing(), detect_package_manager(), ensure_dependencies()
- `src/main.rs`: chama ensure_dependencies() antes do fluxo principal
- `tests/deps.rs`: 11 testes de integração com SystemBinaryChecker real
- `tests/docker/`: Dockerfile multi-stage + suite de 15 testes em
  container Ubuntu 24.04 simulando novo usuário (sem fzf/tmux)
- `.dockerignore`: exclui target/, .git/, .claude/ do contexto Docker
2026-02-28 23:58:09 -03:00
cincoandClaude Sonnet 4.6 32c40b7226 ci: migrate coverage badge to orphan badges branch
Replace Gitea packages (served as octet-stream) with a dedicated
orphan branch. Raw branch URL serves SVG with correct content-type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:25:10 -03:00
cincoandClaude Sonnet 4.6 c1adc92c1c fix(ci): delete existing package before PUT to avoid 409
Gitea generic packages returns 409 if file already exists at same
version. Delete the version first, then re-upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:17:56 -03:00
cincoandClaude Sonnet 4.6 e6180b57c3 ci: migrate coverage badge to Gitea Packages
Replace commit-based badge update with a single PUT to the Gitea
generic packages API. Removes the badges/ directory from the repo
and eliminates CI commits on every push.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:16:03 -03:00
cinco 90e3b65820 ci: update coverage badge [CI SKIP] 2026-03-01 01:57:30 +00:00
cincoandClaude Sonnet 4.6 fc2f503886 ci: renomear branch master para main
Atualiza referências de master para main em .drone.yml e README.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:56:07 -03:00
cinco b29baee9c4 🎨 docs: adicionar logo ao README 2026-02-28 22:51:48 -03:00
cinco 6d842b83ba ci: update coverage badge [CI SKIP] 2026-03-01 01:33:39 +00:00
cinco b4877007f2 🐛 fix(ci): gerar badge SVG inline e remover tr com octais 2026-02-28 22:32:19 -03:00
cinco 29ff9535a7 🐛 fix(ci): unificar blocks e sanitizar JSON do Gitea antes do jq 2026-02-28 22:27:08 -03:00
cinco dec566320f ci: add coverage badge [CI SKIP] 2026-03-01 01:22:12 +00:00
cinco 6275c638d9 🔖 chore: atualizar Cargo.lock para versão 0.2.4 2026-02-28 22:20:45 -03:00
cinco 4d6af13134 🔧 chore: adicionar drone-ci-mcp e corrigir formato do hook PostToolUse 2026-02-28 22:20:42 -03:00
cinco b2a0ee5e08 🐛 fix(ci): usar base64 -w 0 e POST/PUT correto no upload do badge 2026-02-28 22:20:40 -03:00
cinco 2bc516ed6d ci: debug base64 and SHA GET response 2026-02-28 21:48:39 -03:00
cinco 7e70157097 ci: add debug output to coverage upload step 2026-02-28 21:44:08 -03:00
cinco ef13e884ba ci: use awk to parse coverage percentage 2026-02-28 21:38:28 -03:00
cinco b6e33c37ac ci: fix coverage parsing and JSON payload construction 2026-02-28 21:34:01 -03:00
cinco 20f516cffb ci: store coverage badge via repo contents API 2026-02-28 21:29:29 -03:00
cinco a5429c3543 chore: bump version to 0.2.4
continuous-integration/drone/tag Build is failing
2026-02-28 21:23:31 -03:00
cinco 8a7df892e8 ci: fix tarpaulin JSON field for coverage percentage 2026-02-28 21:19:56 -03:00
cinco f0949e08c5 ci: restrict release pipeline to version tags only 2026-02-28 21:14:14 -03:00
cinco d80857090e docs: update CI badge label in README.md
continuous-integration/drone/tag Build is failing
Changed the CI badge label from "CI" to "Build Status" for better clarity and consistency with common badge naming conventions.
2026-02-28 21:10:45 -03:00
cinco 19f36060a0 ci: use Gitea generic package registry for coverage badge 2026-02-28 21:08:39 -03:00
17 changed files with 1182 additions and 39 deletions
+7 -3
View File
@@ -3,9 +3,13 @@
"PostToolUse": [ "PostToolUse": [
{ {
"matcher": "Write|Edit", "matcher": "Write|Edit",
"type": "command", "hooks": [
"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 "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
}
]
} }
] ]
} }
+3
View File
@@ -0,0 +1,3 @@
target/
.git/
.claude/
+30 -31
View File
@@ -6,9 +6,6 @@ trigger:
event: event:
- push - push
- pull_request - pull_request
branch:
exclude:
- badges
steps: steps:
- name: test - name: test
@@ -25,33 +22,27 @@ steps:
GITEA_TOKEN: GITEA_TOKEN:
from_secret: gitea_token from_secret: gitea_token
commands: commands:
- apt-get update -qq && apt-get install -y -qq jq git curl
- cargo tarpaulin --out Json 2>/dev/null
- | - |
PCT=$(jq '.coverage | floor' tarpaulin-report.json) apt-get update -qq && apt-get install -y -qq jq curl
if [ "$PCT" -ge 80 ]; then COLOR="brightgreen" PCT=$(cargo tarpaulin 2>&1 | awk '/coverage,/{print int($1)}')
elif [ "$PCT" -ge 60 ]; then COLOR="yellow" [ -z "$PCT" ] && PCT=0
else COLOR="red"; fi if [ "$PCT" -ge 80 ]; then FILL="#4c1"
curl -sf "https://img.shields.io/badge/coverage-${PCT}%25-${COLOR}.svg" -o coverage.svg elif [ "$PCT" -ge 60 ]; then FILL="#dfb317"
- | else FILL="#e05d44"; fi
git config --global user.email "ci@drone" echo "PCT=$PCT FILL=$FILL"
git config --global user.name "Drone CI" 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
REPO="https://cinco:${GITEA_TOKEN}@git.cincoeuzebio.com/cinco/Tmuxido.git" CONTENT=$(base64 coverage.svg | tr -d '\n')
if git clone --branch badges --single-branch "$REPO" _badges 2>/dev/null; then SHA=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
cp coverage.svg _badges/ "https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/contents/coverage.svg?ref=badges" \
cd _badges | jq -r '.sha')
git add coverage.svg jq -n --arg msg "ci: update coverage badge [CI SKIP]" \
git diff --cached --quiet || git commit -m "ci: update coverage badge [CI SKIP]" --arg content "$CONTENT" --arg sha "$SHA" --arg branch "badges" \
git push '{message: $msg, content: $content, sha: $sha, branch: $branch}' \
else | curl -fsSL -X PUT \
mkdir _badges && cp coverage.svg _badges/ -H "Authorization: token $GITEA_TOKEN" \
cd _badges && git init -H "Content-Type: application/json" \
git remote add origin "$REPO" "https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/contents/coverage.svg" \
git checkout --orphan badges -d @-
git add coverage.svg
git commit -m "ci: add coverage badge"
git push origin badges
fi
--- ---
kind: pipeline kind: pipeline
@@ -61,6 +52,8 @@ name: release
trigger: trigger:
event: event:
- tag - tag
ref:
- refs/tags/[0-9]*
steps: steps:
- name: build-x86_64 - name: build-x86_64
@@ -83,8 +76,14 @@ steps:
commands: commands:
- apk add --no-cache curl jq - apk add --no-cache curl jq
- | - |
# Extract changelog entry for this tag (content between this and previous ## heading) # Read DRONE_TAG via ENVIRON inside awk to avoid Drone's ${VAR} substitution
BODY=$(awk "/^## \[${DRONE_TAG}\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md) # 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 \ RELEASE_ID=$(curl -fsSL -X POST \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
+10
View File
@@ -9,6 +9,16 @@
"type": "stdio", "type": "stdio",
"command": "crates-mcp", "command": "crates-mcp",
"args": [] "args": []
},
"drone-ci-mcp": {
"command": "npx",
"args": [
"-y",
"drone-ci-mcp",
"--access-token=${DRONE_TOKEN}",
"--server-url=https://drone.cincoeuzebio.com"
]
} }
} }
} }
+41 -2
View File
@@ -4,16 +4,54 @@ 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/). 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 ## [0.2.2] - 2026-02-28
### Added ### Added
- Coverage badge generated by `cargo-tarpaulin` in CI, hosted on the `badges` branch - Coverage badge generated by `cargo-tarpaulin` in CI, hosted in Gitea Generic Package Registry
- CI status, coverage, version, and Rust edition badges in README - CI status, coverage, version, and Rust edition badges in README
## [0.2.1] - 2026-02-28 ## [0.2.1] - 2026-02-28
### Added ### Added
- Drone CI pipeline (`ci`) that runs `cargo fmt --check`, `cargo clippy`, and `cargo test` on every push and pull request - 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 ## [0.2.0] - 2026-02-28
@@ -33,3 +71,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added ### Added
- Initial release of tmuxido - Initial release of tmuxido
Generated
+1 -1
View File
@@ -542,7 +542,7 @@ dependencies = [
[[package]] [[package]]
name = "tmuxido" name = "tmuxido"
version = "0.2.0" version = "0.4.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tmuxido" name = "tmuxido"
version = "0.2.2" version = "0.4.2"
edition = "2024" edition = "2024"
[dev-dependencies] [dev-dependencies]
+11 -1
View File
@@ -1,6 +1,10 @@
<div align="center">
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
</div>
# tmuxido # tmuxido
[![CI](https://drone.cincoeuzebio.com/api/badges/cinco/Tmuxido/status.svg)](https://drone.cincoeuzebio.com/cinco/Tmuxido) [![Build Status](https://drone.cincoeuzebio.com/api/badges/cinco/Tmuxido/status.svg)](https://drone.cincoeuzebio.com/cinco/Tmuxido)
[![Coverage](https://git.cincoeuzebio.com/cinco/Tmuxido/raw/branch/badges/coverage.svg)](https://drone.cincoeuzebio.com/cinco/Tmuxido) [![Coverage](https://git.cincoeuzebio.com/cinco/Tmuxido/raw/branch/badges/coverage.svg)](https://drone.cincoeuzebio.com/cinco/Tmuxido)
[![Version](https://img.shields.io/gitea/v/release/cinco/Tmuxido?gitea_url=https%3A%2F%2Fgit.cincoeuzebio.com&label=version)](https://git.cincoeuzebio.com/cinco/Tmuxido/releases) [![Version](https://img.shields.io/gitea/v/release/cinco/Tmuxido?gitea_url=https%3A%2F%2Fgit.cincoeuzebio.com&label=version)](https://git.cincoeuzebio.com/cinco/Tmuxido/releases)
![Rust 2024](https://img.shields.io/badge/rust-edition_2024-orange?logo=rust) ![Rust 2024](https://img.shields.io/badge/rust-edition_2024-orange?logo=rust)
@@ -17,6 +21,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
- TOML-based configuration - TOML-based configuration
- Smart caching system for fast subsequent runs - Smart caching system for fast subsequent runs
- Configurable cache TTL - Configurable cache TTL
- Self-update capability (`tmuxido --update`)
- Zero external dependencies (except tmux and fzf) - Zero external dependencies (except tmux and fzf)
## Installation ## Installation
@@ -93,6 +98,11 @@ Check cache status:
tmuxido --cache-status tmuxido --cache-status
``` ```
Update tmuxido to the latest version:
```bash
tmuxido --update
```
View help: View help:
```bash ```bash
tmuxido --help tmuxido --help
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+425
View File
@@ -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()));
}
}
+2
View File
@@ -1,5 +1,7 @@
pub mod cache; pub mod cache;
pub mod config; pub mod config;
pub mod deps;
pub mod self_update;
pub mod session; pub mod session;
use anyhow::Result; use anyhow::Result;
+14
View File
@@ -4,6 +4,8 @@ use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use tmuxido::config::Config; use tmuxido::config::Config;
use tmuxido::deps::ensure_dependencies;
use tmuxido::self_update;
use tmuxido::{get_projects, launch_tmux_session, show_cache_status}; use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -23,11 +25,23 @@ struct Args {
/// Show cache status and exit /// Show cache status and exit
#[arg(long)] #[arg(long)]
cache_status: bool, cache_status: bool,
/// Update tmuxido to the latest version
#[arg(long)]
update: bool,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
let args = Args::parse(); 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 // Ensure config exists
Config::ensure_config_exists()?; Config::ensure_config_exists()?;
+218
View File
@@ -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
View File
@@ -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()
);
}
}
}
+43
View File
@@ -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"]
+185
View File
@@ -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 ]
+54
View File
@@ -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"