diff --git a/Cargo.lock b/Cargo.lock index 49d7017..2db55cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,7 +12,6 @@ dependencies = [ "dialoguer", "fs-err", "futures", - "indicatif", "reqwest", "semver", "serde", @@ -808,19 +807,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indicatif" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" -dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -1027,12 +1013,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "potential_utf" version = "0.1.4" @@ -1790,12 +1770,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - [[package]] name = "untrusted" version = "0.9.0" diff --git a/ampup/Cargo.toml b/ampup/Cargo.toml index 060c59e..5d3bf8c 100644 --- a/ampup/Cargo.toml +++ b/ampup/Cargo.toml @@ -17,7 +17,6 @@ console = "0.16" dialoguer = "0.12" fs-err = "3.0.0" futures = "0.3" -indicatif = "0.18" reqwest = { version = "0.13", default-features = false, features = [ "json", "query", diff --git a/ampup/src/download_manager.rs b/ampup/src/download_manager.rs index 7000984..d752afb 100644 --- a/ampup/src/download_manager.rs +++ b/ampup/src/download_manager.rs @@ -7,7 +7,10 @@ use anyhow::{Context, Result}; use fs_err as fs; use tokio::{sync::Semaphore, task::JoinSet}; -use crate::github::{GitHubClient, ResolvedAsset}; +use crate::{ + github::{GitHubClient, ResolvedAsset}, + progress::ProgressReporter, +}; // --------------------------------------------------------------------------- // Public types @@ -168,6 +171,7 @@ impl DownloadManager { tasks: Vec, version: &str, version_dir: PathBuf, + reporter: Arc, ) -> Result<()> { // Resolve all asset metadata with a single API call so that each // spawned task can download directly without re-fetching the release. @@ -185,13 +189,17 @@ impl DownloadManager { let staging_dir = tempfile::tempdir_in(parent).context("Failed to create staging directory")?; + let names: Vec = tasks.iter().map(|t| t.artifact_name.clone()).collect(); + reporter.set_total(tasks.len(), names); + let semaphore = Arc::new(Semaphore::new(self.max_concurrent)); - let mut join_set: JoinSet> = JoinSet::new(); + let mut join_set: JoinSet> = JoinSet::new(); for (task, asset) in tasks.into_iter().zip(resolved) { let github = self.github.clone(); let sem = semaphore.clone(); let staging_path = staging_dir.path().to_path_buf(); + let reporter = reporter.clone(); join_set.spawn(async move { let _permit = sem @@ -201,29 +209,39 @@ impl DownloadManager { artifact_name: task.artifact_name.clone(), })?; + reporter.component_started(&task.artifact_name); + let data = download_with_retry(&github, &asset).await?; verify_artifact(&task.artifact_name, &data)?; write_to_staging(&staging_path, &task.dest_filename, &data)?; - Ok(()) + Ok(task.artifact_name) }); } // Collect results — fail fast on first error while let Some(result) = join_set.join_next().await { match result { - Ok(Ok(())) => {} + Ok(Ok(artifact_name)) => { + reporter.component_completed(&artifact_name); + } Ok(Err(e)) => { + let artifact_name = download_error_artifact_name(&e); + reporter.component_failed(artifact_name); + reporter.finish(); join_set.shutdown().await; return Err(e.into()); } Err(join_err) => { + reporter.finish(); join_set.shutdown().await; return Err(anyhow::anyhow!("download task panicked: {}", join_err)); } } } + reporter.finish(); + // Set executable permissions on all staged files #[cfg(unix)] set_executable_permissions(staging_dir.path())?; @@ -313,15 +331,13 @@ async fn download_with_retry( github: &GitHubClient, asset: &ResolvedAsset, ) -> std::result::Result, DownloadError> { - // `false` suppresses per-file progress bars — DownloadManager will - // provide aggregate progress reporting in a future PR. - match github.download_resolved_asset(asset, false).await { + match github.download_resolved_asset(asset).await { Ok(data) => Ok(data), Err(first_err) => { crate::ui::warn!("Download failed for {}, retrying once...", asset.name); github - .download_resolved_asset(asset, false) + .download_resolved_asset(asset) .await .map_err(|retry_err| DownloadError::TaskFailed { artifact_name: asset.name.clone(), @@ -357,6 +373,16 @@ fn write_to_staging( }) } +/// Extract the artifact name from a [`DownloadError`]. +fn download_error_artifact_name(err: &DownloadError) -> &str { + match err { + DownloadError::TaskFailed { artifact_name, .. } + | DownloadError::EmptyArtifact { artifact_name } + | DownloadError::StagingWrite { artifact_name, .. } + | DownloadError::SemaphoreClosed { artifact_name } => artifact_name, + } +} + /// Set executable permissions (0o755) on all files in a directory. #[cfg(unix)] fn set_executable_permissions(dir: &Path) -> Result<()> { @@ -548,6 +574,18 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use super::*; + use crate::progress::ProgressReporter; + + /// No-op reporter for tests that don't need progress output. + struct NoopReporter; + + impl ProgressReporter for NoopReporter { + fn set_total(&self, _total: usize, _names: Vec) {} + fn component_started(&self, _name: &str) {} + fn component_completed(&self, _name: &str) {} + fn component_failed(&self, _name: &str) {} + fn finish(&self) {} + } /// Route configuration for the mock HTTP server. #[derive(Clone)] @@ -729,8 +767,9 @@ mod tests { /// Run `download_all` with the given tasks. async fn download(&self, tasks: Vec) -> Result<()> { + let reporter: Arc = Arc::new(NoopReporter); self.manager - .download_all(tasks, "v1.0.0", self.version_dir.clone()) + .download_all(tasks, "v1.0.0", self.version_dir.clone(), reporter) .await } } diff --git a/ampup/src/github.rs b/ampup/src/github.rs index 1b929ac..7c81f45 100644 --- a/ampup/src/github.rs +++ b/ampup/src/github.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use anyhow::{Context, Result}; use futures::StreamExt; -use indicatif::{ProgressBar, ProgressStyle}; use serde::Deserialize; use crate::rate_limiter::GitHubRateLimiter; @@ -353,17 +352,11 @@ impl GitHubClient { /// Download a previously resolved asset without re-fetching release /// metadata. - pub async fn download_resolved_asset( - &self, - asset: &ResolvedAsset, - show_progress: bool, - ) -> Result> { + pub async fn download_resolved_asset(&self, asset: &ResolvedAsset) -> Result> { if self.token.is_some() { - self.download_asset_via_api(asset.id, &asset.name, show_progress) - .await + self.download_asset_via_api(asset.id, &asset.name).await } else { - self.download_asset_direct(&asset.url, &asset.name, show_progress) - .await + self.download_asset_direct(&asset.url, &asset.name).await } } @@ -506,37 +499,21 @@ impl GitHubClient { } /// Download a release asset by name. - /// - /// When `show_progress` is `true`, an indicatif progress bar is rendered for - /// this individual download. Set to `false` when downloads are managed by - /// `DownloadManager` (which will provide aggregate progress in the future). - pub async fn download_release_asset( - &self, - version: &str, - asset_name: &str, - show_progress: bool, - ) -> Result> { + pub async fn download_release_asset(&self, version: &str, asset_name: &str) -> Result> { let release = self.get_tagged_release(version).await?; let asset = self.find_asset(&release, asset_name, version)?; if self.token.is_some() { // For private repositories, we need to use the API to download - self.download_asset_via_api(asset.id, asset_name, show_progress) - .await + self.download_asset_via_api(asset.id, asset_name).await } else { // For public repositories, use direct download URL - self.download_asset_direct(&asset.url, asset_name, show_progress) - .await + self.download_asset_direct(&asset.url, asset_name).await } } /// Download asset via GitHub API (for private repos) - async fn download_asset_via_api( - &self, - asset_id: u64, - asset_name: &str, - show_progress: bool, - ) -> Result> { + async fn download_asset_via_api(&self, asset_id: u64, asset_name: &str) -> Result> { let url = format!( "https://api.github.com/repos/{}/releases/assets/{}", self.repo, asset_id @@ -553,35 +530,24 @@ impl GitHubClient { ) .await?; - self.download_with_progress(response, &url, asset_name, show_progress) - .await + self.download_response(response, &url, asset_name).await } /// Download asset directly (for public repos) - async fn download_asset_direct( - &self, - url: &str, - asset_name: &str, - show_progress: bool, - ) -> Result> { + async fn download_asset_direct(&self, url: &str, asset_name: &str) -> Result> { let response = self .send_with_rate_limit(|| self.client.get(url), "Failed to download asset") .await?; - self.download_with_progress(response, url, asset_name, show_progress) - .await + self.download_response(response, url, asset_name).await } - /// Download with optional progress bar from a response. - /// - /// When `show_progress` is `false`, bytes are collected silently (used by - /// `DownloadManager` which manages its own aggregate progress reporting). - async fn download_with_progress( + /// Stream a response body into a buffer. + async fn download_response( &self, response: reqwest::Response, url: &str, asset_name: &str, - show_progress: bool, ) -> Result> { if !response.status().is_success() { let status = response.status(); @@ -594,47 +560,13 @@ impl GitHubClient { .into()); } - // Setup progress bar (hidden when DownloadManager handles progress) - let pb = if show_progress { - let total_size = response.content_length(); - if let Some(size) = total_size { - let pb = ProgressBar::new(size); - pb.set_style( - ProgressStyle::default_bar() - .template( - "{msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", - ) - .context("Invalid progress bar template")? - .progress_chars("#>-"), - ); - pb.set_message(format!("{} Downloading", console::style("→").cyan())); - pb - } else { - let pb = ProgressBar::new_spinner(); - pb.set_message(format!( - "{} Downloading (size unknown)", - console::style("→").cyan() - )); - pb - } - } else { - ProgressBar::hidden() - }; - // Stream and collect chunks - let mut downloaded: u64 = 0; let mut buffer = Vec::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk.context("Error while downloading file")?; buffer.extend_from_slice(&chunk); - downloaded += chunk.len() as u64; - pb.set_position(downloaded); - } - - if show_progress { - pb.finish_with_message(format!("{} Downloaded", console::style("✓").green().bold())); } Ok(buffer) diff --git a/ampup/src/install.rs b/ampup/src/install.rs index f598ec4..0b4f48e 100644 --- a/ampup/src/install.rs +++ b/ampup/src/install.rs @@ -3,7 +3,7 @@ use anyhow::Result; use crate::{ download_manager::{DownloadManager, DownloadTask}, platform::{Architecture, Platform}, - ui, + progress, ui, version_manager::VersionManager, }; @@ -50,10 +50,11 @@ impl Installer { }, ]; + let reporter = progress::create_reporter(); let version_dir = self.version_manager.config().versions_dir.join(version); self.download_manager - .download_all(tasks, version, version_dir) + .download_all(tasks, version, version_dir, reporter) .await?; // Activation barrier: all downloads succeeded, now create symlinks diff --git a/ampup/src/lib.rs b/ampup/src/lib.rs index 375cbbf..01c9f7c 100644 --- a/ampup/src/lib.rs +++ b/ampup/src/lib.rs @@ -5,6 +5,7 @@ pub mod download_manager; pub mod github; pub mod install; pub mod platform; +pub mod progress; pub mod rate_limiter; pub mod shell; pub mod token; diff --git a/ampup/src/progress.rs b/ampup/src/progress.rs new file mode 100644 index 0000000..ae5cafd --- /dev/null +++ b/ampup/src/progress.rs @@ -0,0 +1,581 @@ +use std::sync::{Arc, Mutex}; + +use console::{Term, style}; + +// --------------------------------------------------------------------------- +// Public trait +// --------------------------------------------------------------------------- + +/// Reports aggregate download progress to the user. +/// +/// Implementations handle TTY vs. non-TTY output formatting. +/// Shared across concurrent tasks via `Arc`. +pub trait ProgressReporter: Send + Sync { + /// Register the components that will be downloaded. + /// + /// Called once before any tasks start. + fn set_total(&self, total: usize, names: Vec); + + /// Mark a component as actively downloading. + /// + /// Called from inside the spawned task after the semaphore permit + /// is acquired, indicating the download has begun. + fn component_started(&self, name: &str); + + /// Mark a component as successfully downloaded. + /// + /// Called from the result collection loop after a task completes. + fn component_completed(&self, name: &str); + + /// Mark a component as failed. + /// + /// Called from the result collection loop when a task returns an error. + fn component_failed(&self, name: &str); + + /// Finalize the progress display. + /// + /// Called after all tasks have completed (or after fail-fast shutdown). + fn finish(&self); +} + +/// Create a progress reporter appropriate for the current terminal. +/// +/// Returns [`TtyProgress`] when stderr is a TTY (interactive terminal), +/// [`CiProgress`] otherwise (piped output, CI environments). +pub fn create_reporter() -> Arc { + let term = Term::stderr(); + if term.is_term() { + Arc::new(TtyProgress::new(term)) + } else { + Arc::new(CiProgress::new()) + } +} + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ComponentStatus { + Pending, + Downloading, + Completed, + Failed, +} + +struct ProgressState { + names: Vec, + statuses: Vec, + completed_count: usize, +} + +impl ProgressState { + fn new() -> Self { + Self { + names: Vec::new(), + statuses: Vec::new(), + completed_count: 0, + } + } + + fn index_of(&self, name: &str) -> Option { + self.names.iter().position(|n| n == name) + } +} + +// --------------------------------------------------------------------------- +// TTY progress reporter +// --------------------------------------------------------------------------- + +/// Interactive progress reporter using in-place terminal line updates. +/// +/// Renders a status line per component, updating in place as downloads +/// progress. Uses stderr so stdout remains clean for piping. +struct TtyProgress { + term: Term, + state: Mutex, + lines_drawn: Mutex, +} + +impl TtyProgress { + fn new(term: Term) -> Self { + Self { + term, + state: Mutex::new(ProgressState::new()), + lines_drawn: Mutex::new(0), + } + } + + /// Redraw all component status lines in place. + /// + /// Terminal write failures are best-effort — progress display failure + /// should not abort downloads. + fn redraw(&self, state: &ProgressState) { + let mut lines_drawn = self.lines_drawn.lock().unwrap_or_else(|e| e.into_inner()); + + // Move cursor back to overwrite previous output + if *lines_drawn > 0 { + let _ = self.term.clear_last_lines(*lines_drawn); + } + + if state.names.is_empty() { + *lines_drawn = 0; + return; + } + + let max_name_len = state.names.iter().map(|n| n.len()).max().unwrap_or(0); + + for (i, name) in state.names.iter().enumerate() { + let status = state.statuses[i]; + let line = format_tty_line(name, max_name_len, status); + let _ = self.term.write_line(&line); + } + + *lines_drawn = state.names.len(); + } +} + +impl ProgressReporter for TtyProgress { + fn set_total(&self, _total: usize, names: Vec) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + let count = names.len(); + state.statuses = vec![ComponentStatus::Pending; count]; + state.names = names; + self.redraw(&state); + } + + fn component_started(&self, name: &str) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(idx) = state.index_of(name) { + state.statuses[idx] = ComponentStatus::Downloading; + } + self.redraw(&state); + } + + fn component_completed(&self, name: &str) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(idx) = state.index_of(name) { + state.statuses[idx] = ComponentStatus::Completed; + state.completed_count += 1; + } + self.redraw(&state); + } + + fn component_failed(&self, name: &str) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(idx) = state.index_of(name) { + state.statuses[idx] = ComponentStatus::Failed; + } + self.redraw(&state); + } + + fn finish(&self) { + // Final redraw to ensure terminal is in a clean state + let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + self.redraw(&state); + } +} + +// --------------------------------------------------------------------------- +// CI progress reporter +// --------------------------------------------------------------------------- + +/// Append-only progress reporter for non-interactive environments. +/// +/// Prints one line per component completion to avoid garbled output +/// in CI pipelines and piped contexts. +struct CiProgress { + state: Mutex, +} + +impl CiProgress { + fn new() -> Self { + Self { + state: Mutex::new(ProgressState::new()), + } + } +} + +impl ProgressReporter for CiProgress { + fn set_total(&self, _total: usize, names: Vec) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + let count = names.len(); + state.statuses = vec![ComponentStatus::Pending; count]; + state.names = names; + } + + fn component_started(&self, _name: &str) { + // No output for CI — only report completions + } + + fn component_completed(&self, name: &str) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(idx) = state.index_of(name) { + state.statuses[idx] = ComponentStatus::Completed; + state.completed_count += 1; + } + let total = state.names.len(); + let completed = state.completed_count; + crate::ui::success!("[{}/{}] Downloaded {}", completed, total, name); + } + + fn component_failed(&self, name: &str) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(idx) = state.index_of(name) { + state.statuses[idx] = ComponentStatus::Failed; + } + let total = state.names.len(); + let completed = state.completed_count; + crate::ui::warn!("[{}/{}] Failed {}", completed, total, name); + } + + fn finish(&self) { + // No cleanup needed for append-only output + } +} + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +fn format_tty_line(name: &str, max_name_len: usize, status: ComponentStatus) -> String { + let padded_name = format!("{:width$}", name, width = max_name_len); + match status { + ComponentStatus::Pending => { + format!(" {} {}", padded_name, style("waiting...").dim()) + } + ComponentStatus::Downloading => { + format!(" {} {} downloading...", padded_name, style("→").cyan()) + } + ComponentStatus::Completed => { + format!( + " {} {} downloaded", + padded_name, + style("✓").green().bold() + ) + } + ComponentStatus::Failed => { + format!(" {} {} failed", padded_name, style("✗").red().bold()) + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + mod progress_state { + use super::*; + + #[test] + fn index_of_with_existing_name_returns_index() { + //* Given + let mut state = ProgressState::new(); + state.names = vec!["ampd".to_string(), "ampctl".to_string()]; + state.statuses = vec![ComponentStatus::Pending; 2]; + + //* When + let result = state.index_of("ampctl"); + + //* Then + assert_eq!(result, Some(1), "should return index of matching name"); + } + + #[test] + fn index_of_with_missing_name_returns_none() { + //* Given + let mut state = ProgressState::new(); + state.names = vec!["ampd".to_string()]; + state.statuses = vec![ComponentStatus::Pending]; + + //* When + let result = state.index_of("nonexistent"); + + //* Then + assert_eq!(result, None, "should return None for missing name"); + } + } + + mod tty_progress { + use super::*; + + /// Helper to create a TtyProgress with a buffered terminal for testing. + fn test_reporter() -> TtyProgress { + TtyProgress::new(Term::buffered_stderr()) + } + + #[test] + fn set_total_with_names_initializes_all_as_pending() { + //* Given + let reporter = test_reporter(); + + //* When + reporter.set_total(2, vec!["ampd".to_string(), "ampctl".to_string()]); + + //* Then + let state = reporter + .state + .lock() + .expect("state lock should not be poisoned in tests"); + assert_eq!(state.names.len(), 2); + assert!( + state + .statuses + .iter() + .all(|s| *s == ComponentStatus::Pending), + "all components should start as Pending" + ); + } + + #[test] + fn component_started_with_valid_name_sets_downloading() { + //* Given + let reporter = test_reporter(); + reporter.set_total(2, vec!["ampd".to_string(), "ampctl".to_string()]); + + //* When + reporter.component_started("ampd"); + + //* Then + let state = reporter + .state + .lock() + .expect("state lock should not be poisoned in tests"); + assert_eq!( + state.statuses[0], + ComponentStatus::Downloading, + "started component should be Downloading" + ); + assert_eq!( + state.statuses[1], + ComponentStatus::Pending, + "other component should remain Pending" + ); + } + + #[test] + fn component_completed_with_valid_name_sets_completed_and_increments_count() { + //* Given + let reporter = test_reporter(); + reporter.set_total(2, vec!["ampd".to_string(), "ampctl".to_string()]); + reporter.component_started("ampd"); + + //* When + reporter.component_completed("ampd"); + + //* Then + let state = reporter + .state + .lock() + .expect("state lock should not be poisoned in tests"); + assert_eq!( + state.statuses[0], + ComponentStatus::Completed, + "completed component should be Completed" + ); + assert_eq!( + state.completed_count, 1, + "completed count should be incremented" + ); + } + + #[test] + fn component_failed_with_valid_name_sets_failed() { + //* Given + let reporter = test_reporter(); + reporter.set_total(1, vec!["ampd".to_string()]); + reporter.component_started("ampd"); + + //* When + reporter.component_failed("ampd"); + + //* Then + let state = reporter + .state + .lock() + .expect("state lock should not be poisoned in tests"); + assert_eq!( + state.statuses[0], + ComponentStatus::Failed, + "failed component should be Failed" + ); + } + + #[test] + fn component_started_with_unknown_name_does_not_panic() { + //* Given + let reporter = test_reporter(); + reporter.set_total(1, vec!["ampd".to_string()]); + + //* When / Then — should not panic + reporter.component_started("nonexistent"); + + let state = reporter + .state + .lock() + .expect("state lock should not be poisoned in tests"); + assert_eq!( + state.statuses[0], + ComponentStatus::Pending, + "existing component should be unchanged" + ); + } + + #[test] + fn set_total_with_zero_components_does_not_panic() { + //* Given + let reporter = test_reporter(); + + //* When / Then — should not panic + reporter.set_total(0, vec![]); + reporter.finish(); + } + } + + mod ci_progress { + use super::*; + + #[test] + fn component_completed_with_valid_name_increments_count() { + //* Given + let reporter = CiProgress::new(); + reporter.set_total(2, vec!["ampd".to_string(), "ampctl".to_string()]); + + //* When + reporter.component_completed("ampd"); + + //* Then + let state = reporter + .state + .lock() + .expect("state lock should not be poisoned in tests"); + assert_eq!( + state.completed_count, 1, + "completed count should be incremented" + ); + assert_eq!( + state.statuses[0], + ComponentStatus::Completed, + "component should be marked Completed" + ); + } + + #[test] + fn component_failed_with_valid_name_sets_failed() { + //* Given + let reporter = CiProgress::new(); + reporter.set_total(1, vec!["ampd".to_string()]); + + //* When + reporter.component_failed("ampd"); + + //* Then + let state = reporter + .state + .lock() + .expect("state lock should not be poisoned in tests"); + assert_eq!( + state.statuses[0], + ComponentStatus::Failed, + "component should be marked Failed" + ); + } + + #[test] + fn component_started_does_not_change_state() { + //* Given + let reporter = CiProgress::new(); + reporter.set_total(1, vec!["ampd".to_string()]); + + //* When + reporter.component_started("ampd"); + + //* Then + let state = reporter + .state + .lock() + .expect("state lock should not be poisoned in tests"); + assert_eq!( + state.statuses[0], + ComponentStatus::Pending, + "CI reporter should not change state on started" + ); + } + } + + mod format_tty_line { + use super::*; + + #[test] + fn format_tty_line_with_pending_status_contains_waiting() { + //* Given / When + let line = format_tty_line("ampd", 10, ComponentStatus::Pending); + + //* Then + assert!( + line.contains("waiting..."), + "pending line should contain 'waiting...', got: {}", + line + ); + } + + #[test] + fn format_tty_line_with_downloading_status_contains_downloading() { + //* Given / When + let line = format_tty_line("ampd", 10, ComponentStatus::Downloading); + + //* Then + assert!( + line.contains("downloading..."), + "downloading line should contain 'downloading...', got: {}", + line + ); + } + + #[test] + fn format_tty_line_with_completed_status_contains_downloaded() { + //* Given / When + let line = format_tty_line("ampd", 10, ComponentStatus::Completed); + + //* Then + assert!( + line.contains("downloaded"), + "completed line should contain 'downloaded', got: {}", + line + ); + } + + #[test] + fn format_tty_line_with_failed_status_contains_failed() { + //* Given / When + let line = format_tty_line("ampd", 10, ComponentStatus::Failed); + + //* Then + assert!( + line.contains("failed"), + "failed line should contain 'failed', got: {}", + line + ); + } + } + + mod create_reporter { + use super::*; + + #[test] + fn create_reporter_returns_reporter_without_panic() { + //* Given / When + let reporter = create_reporter(); + + //* Then — smoke test: call methods without panic + reporter.set_total(1, vec!["test".to_string()]); + reporter.component_started("test"); + reporter.component_completed("test"); + reporter.finish(); + } + } +} diff --git a/ampup/src/updater.rs b/ampup/src/updater.rs index 5b1e86b..cdf48b4 100644 --- a/ampup/src/updater.rs +++ b/ampup/src/updater.rs @@ -40,7 +40,7 @@ impl Updater { let binary_data = self .github - .download_release_asset(version, &artifact_name, true) + .download_release_asset(version, &artifact_name) .await .context("Failed to download ampup binary")?;