From 1ddb5636a459c0d74e4f29d95b5b1a6569b3a24c Mon Sep 17 00:00:00 2001 From: azerpas Date: Tue, 28 Oct 2025 23:14:20 +0100 Subject: [PATCH 1/7] refactor: split transfer_funds in multiple funcs --- src/bourso_api/src/client/transfer/mod.rs | 252 ++++++++++++++++------ 1 file changed, 184 insertions(+), 68 deletions(-) diff --git a/src/bourso_api/src/client/transfer/mod.rs b/src/bourso_api/src/client/transfer/mod.rs index cd38c94..1248a05 100644 --- a/src/bourso_api/src/client/transfer/mod.rs +++ b/src/bourso_api/src/client/transfer/mod.rs @@ -6,43 +6,8 @@ use anyhow::{bail, Context, Result}; mod error; impl BoursoWebClient { - #[cfg(not(tarpaulin_include))] - pub async fn transfer_funds( - &self, - amount: f64, - from_account: Account, - to_account: Account, - reason: Option<&str>, - ) -> Result<()> { - // Minimum amount is 10 EUR - - if amount < 10.0 { - bail!(TransferError::AmountTooLow); - } - - log::debug!( - "Initiating transfer of {:.2} EUR from account {} to account {}", - amount, - from_account.id, - to_account.id - ); - - let transfer_from_banking = from_account.kind == AccountKind::Banking; - - let from_account = &from_account.id; - let to_account = &to_account.id; - - // Default reason if none provided, else use provided reason and - // warn if the reason is too long (> 50 characters) - let transfer_reason = if let Some(r) = reason { - if r.len() > 50 { - bail!(TransferError::ReasonIsTooLong); - } - r.to_string() - } else { - "Virement depuis BoursoBank".to_string() - }; - + /// Initialize the transfer and extract the transfer ID + async fn init_transfer(&self, from_account: &str) -> Result { let init_transfer_url = format!( "{}/compte/cav/{}/virements/immediat/nouveau", BASE_URL, from_account @@ -66,28 +31,41 @@ impl BoursoWebClient { let transfer_id = location .split('/') .nth(7) - .context("Failed to extract transfer id")?; + .context("Failed to extract transfer id")? + .to_string(); - let first_res = self - .client - .get(format!("{}{}", BASE_URL, location)) - .send() - .await?; + Ok(transfer_id) + } - if first_res.status() != 200 { - log::debug!("First transfer step response: {:?}", first_res); + /// Extract the flow instance from the HTML response + async fn extract_flow_instance(&self, url: &str) -> Result { + let res = self.client.get(url).send().await?; + + if res.status() != 200 { + log::debug!("First transfer step response: {:?}", res); bail!(TransferError::TransferInitiationFailed); } - let first_res_text = first_res.text().await?; + let res_text = res.text().await?; let re = regex::Regex::new(r#"name="flow_ImmediateCashTransfer_instance" value="([^"]+)""#) .unwrap(); let flow_instance = re - .captures(&first_res_text) + .captures(&res_text) .and_then(|cap| cap.get(1)) .map(|m| m.as_str()) - .context("Failed to extract flow instance")?; + .context("Failed to extract flow instance")? + .to_string(); + + Ok(flow_instance) + } + /// Set the debit account (step 2) + async fn set_debit_account( + &self, + from_account: &str, + transfer_id: &str, + flow_instance: &str, + ) -> Result<()> { let data = reqwest::multipart::Form::new() .text( "flow_ImmediateCashTransfer_instance", @@ -101,13 +79,25 @@ impl BoursoWebClient { BASE_URL, from_account, transfer_id ); - let second_res = self.client.post(&url).multipart(data).send().await?; + let res = self.client.post(&url).multipart(data).send().await?; - if second_res.status() != 200 { - log::debug!("Set debit account response: {:?}", second_res); + if res.status() != 200 { + log::debug!("Set debit account response: {:?}", res); bail!(TransferError::SetDebitAccountFailed); } + Ok(()) + } + + /// Set the credit account (step 3) + async fn set_credit_account( + &self, + from_account: &str, + to_account: &str, + transfer_id: &str, + flow_instance: &str, + transfer_from_banking: bool, + ) -> Result<()> { let form = if transfer_from_banking { reqwest::multipart::Form::new().text("CreditAccount[newBeneficiary]", "0".to_string()) } else { @@ -127,13 +117,24 @@ impl BoursoWebClient { BASE_URL, from_account, transfer_id ); - let third_res = self.client.post(&url).multipart(data).send().await?; + let res = self.client.post(&url).multipart(data).send().await?; - if third_res.status() != 200 { - log::debug!("Set credit account response: {:?}", third_res); + if res.status() != 200 { + log::debug!("Set credit account response: {:?}", res); bail!(TransferError::SetCreditAccountFailed); } + Ok(()) + } + + /// Set the transfer amount (step 6) + async fn set_transfer_amount( + &self, + from_account: &str, + transfer_id: &str, + flow_instance: &str, + amount: f64, + ) -> Result<()> { let data = reqwest::multipart::Form::new() .text( "flow_ImmediateCashTransfer_instance", @@ -149,13 +150,23 @@ impl BoursoWebClient { BASE_URL, from_account, transfer_id ); - let set_amount_res = self.client.post(&url).multipart(data).send().await?; + let res = self.client.post(&url).multipart(data).send().await?; - if set_amount_res.status() != 200 { - log::debug!("Set amount response: {:?}", set_amount_res); + if res.status() != 200 { + log::debug!("Set amount response: {:?}", res); bail!(TransferError::SetAmountFailed); } + Ok(()) + } + + /// Submit step 7 + async fn submit_step_7( + &self, + from_account: &str, + transfer_id: &str, + flow_instance: &str, + ) -> Result<()> { let data = reqwest::multipart::Form::new() .text("flow_ImmediateCashTransfer_transition", "".to_string()) .text( @@ -165,7 +176,7 @@ impl BoursoWebClient { .text("flow_ImmediateCashTransfer_step", "6".to_string()) .text("submit", "".to_string()); - let submit_res = self + let res = self .client .post(format!( "{}/compte/cav/{}/virements/immediat/nouveau/{}/7", @@ -175,18 +186,29 @@ impl BoursoWebClient { .send() .await?; - if submit_res.status() != 200 { - log::debug!("Submit transfer response: {:?}", submit_res); + if res.status() != 200 { + log::debug!("Submit transfer response: {:?}", res); bail!(TransferError::Step7Failed); } + Ok(()) + } + + /// Set the transfer reason (step 10) + async fn set_transfer_reason( + &self, + from_account: &str, + transfer_id: &str, + flow_instance: &str, + transfer_reason: &str, + ) -> Result<()> { let data = reqwest::multipart::Form::new() .text( "flow_ImmediateCashTransfer_instance", flow_instance.to_string(), ) .text("flow_ImmediateCashTransfer_step", "9".to_string()) - .text("Characteristics[label]", transfer_reason) // Reason for transfer + .text("Characteristics[label]", transfer_reason.to_string()) .text("Characteristics[schedulingType]", "1".to_string()) // 1 = unique .text("flow_ImmediateCashTransfer_transition", "".to_string()) .text("flow_ImmediateCashTransfer_transition", "".to_string()) @@ -197,13 +219,23 @@ impl BoursoWebClient { BASE_URL, from_account, transfer_id ); - let set_reason_res = self.client.post(&url).multipart(data).send().await?; + let res = self.client.post(&url).multipart(data).send().await?; - if set_reason_res.status() != 200 { - log::debug!("Set reason response: {:?}", set_reason_res); + if res.status() != 200 { + log::debug!("Set reason response: {:?}", res); bail!(TransferError::SetReasonFailed); } + Ok(()) + } + + /// Confirm and finalize the transfer (step 12) + async fn confirm_transfer( + &self, + from_account: &str, + transfer_id: &str, + flow_instance: &str, + ) -> Result<()> { let data = reqwest::multipart::Form::new() .text( "flow_ImmediateCashTransfer_instance", @@ -214,7 +246,7 @@ impl BoursoWebClient { .text("flow_ImmediateCashTransfer_transition", "".to_string()) .text("submit", "".to_string()); - let confirm_res = self + let res = self .client .post(format!( "{}/compte/cav/{}/virements/immediat/nouveau/{}/12", @@ -224,12 +256,12 @@ impl BoursoWebClient { .send() .await?; - if confirm_res.status() != 200 { - log::debug!("Confirm transfer response: {:?}", confirm_res); + if res.status() != 200 { + log::debug!("Confirm transfer response: {:?}", res); bail!(TransferError::SubmitTransferFailed); } - let body = confirm_res.text().await?; + let body = res.text().await?; if body.as_str().contains("Confirmation") { Ok(()) @@ -238,4 +270,88 @@ impl BoursoWebClient { bail!(TransferError::InvalidTransfer); } } + + #[cfg(not(tarpaulin_include))] + pub async fn transfer_funds( + &self, + amount: f64, + from_account: Account, + to_account: Account, + reason: Option<&str>, + ) -> Result<()> { + // Minimum amount is 10 EUR + if amount < 10.0 { + bail!(TransferError::AmountTooLow); + } + + log::debug!( + "Initiating transfer of {:.2} EUR from account {} to account {}", + amount, + from_account.id, + to_account.id + ); + + let transfer_from_banking = from_account.kind == AccountKind::Banking; + let from_account_id = &from_account.id; + let to_account_id = &to_account.id; + + // Default reason if none provided, else use provided reason and + // warn if the reason is too long (> 50 characters) + let transfer_reason = if let Some(r) = reason { + if r.len() > 50 { + bail!(TransferError::ReasonIsTooLong); + } + r.to_string() + } else { + "Virement depuis BoursoBank".to_string() + }; + + // Step 1: Initialize transfer and get transfer ID + let transfer_id = self.init_transfer(from_account_id).await?; + + // Extract flow instance + let flow_instance = self + .extract_flow_instance(&format!( + "{}/compte/cav/{}/virements/immediat/nouveau/{}/1", + BASE_URL, from_account_id, transfer_id + )) + .await?; + + // Step 2: Set debit account + self.set_debit_account(from_account_id, &transfer_id, &flow_instance) + .await?; + + // Step 3: Set credit account + self.set_credit_account( + from_account_id, + to_account_id, + &transfer_id, + &flow_instance, + transfer_from_banking, + ) + .await?; + + // Step 6: Set amount + self.set_transfer_amount(from_account_id, &transfer_id, &flow_instance, amount) + .await?; + + // Step 7: Submit + self.submit_step_7(from_account_id, &transfer_id, &flow_instance) + .await?; + + // Step 10: Set reason + self.set_transfer_reason( + from_account_id, + &transfer_id, + &flow_instance, + &transfer_reason, + ) + .await?; + + // Step 12: Confirm transfer + self.confirm_transfer(from_account_id, &transfer_id, &flow_instance) + .await?; + + Ok(()) + } } From 52d49f15e9a626f6b24bbeee7a68982b4e1db104 Mon Sep 17 00:00:00 2001 From: azerpas Date: Tue, 28 Oct 2025 23:26:51 +0100 Subject: [PATCH 2/7] refactor: transfer funds with progress --- Cargo.lock | 38 ++++ Cargo.toml | 1 + src/bourso_api/Cargo.toml | 2 + src/bourso_api/src/client/transfer/error.rs | 2 + src/bourso_api/src/client/transfer/mod.rs | 233 ++++++++++++++------ src/lib.rs | 39 +++- 6 files changed, 243 insertions(+), 72 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d303f94..e89e60d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,28 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -150,6 +172,7 @@ dependencies = [ "bourso_api", "clap", "directories", + "futures-util", "log", "log4rs", "rpassword", @@ -163,9 +186,11 @@ name = "bourso_api" version = "0.3.0" dependencies = [ "anyhow", + "async-stream", "chrono", "clap", "cookie_store", + "futures-util", "lazy_static", "log", "regex", @@ -456,6 +481,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -475,9 +511,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 56b0774..34a6e69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ serde = { version = "1.0.189", features = ["derive"] } serde_json = { version = "1.0.107" } log = { version = "0.4.20" } log4rs = { version = "1.3.0" } +futures-util = "0.3.31" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } diff --git a/src/bourso_api/Cargo.toml b/src/bourso_api/Cargo.toml index 5b5637a..f7ccf6c 100644 --- a/src/bourso_api/Cargo.toml +++ b/src/bourso_api/Cargo.toml @@ -17,6 +17,8 @@ reqwest_cookie_store = "0.8.0" cookie_store = "0.21.1" chrono = { version = "0.4.39" } log = { version = "0.4.20" } +futures-util = "0.3.31" +async-stream = "0.3.6" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } diff --git a/src/bourso_api/src/client/transfer/error.rs b/src/bourso_api/src/client/transfer/error.rs index 465d508..a4b1de6 100644 --- a/src/bourso_api/src/client/transfer/error.rs +++ b/src/bourso_api/src/client/transfer/error.rs @@ -35,3 +35,5 @@ impl fmt::Display for TransferError { } } } + +impl std::error::Error for TransferError {} diff --git a/src/bourso_api/src/client/transfer/mod.rs b/src/bourso_api/src/client/transfer/mod.rs index 1248a05..c24ee6f 100644 --- a/src/bourso_api/src/client/transfer/mod.rs +++ b/src/bourso_api/src/client/transfer/mod.rs @@ -2,9 +2,60 @@ use crate::account::{Account, AccountKind}; use crate::{client::transfer::error::TransferError, client::BoursoWebClient, constants::BASE_URL}; use anyhow::{bail, Context, Result}; +use futures_util::stream::Stream; mod error; +#[derive(Debug, Clone)] +pub enum TransferProgress { + Validating, + InitializingTransfer, + ExtractingFlowInstance, + SettingDebitAccount, + SettingCreditAccount, + SettingAmount, + SubmittingStep7, + SettingReason, + ConfirmingTransfer, + Completed, +} + +impl TransferProgress { + pub fn step_number(&self) -> u8 { + match self { + TransferProgress::Validating => 1, + TransferProgress::InitializingTransfer => 2, + TransferProgress::ExtractingFlowInstance => 3, + TransferProgress::SettingDebitAccount => 4, + TransferProgress::SettingCreditAccount => 5, + TransferProgress::SettingAmount => 6, + TransferProgress::SubmittingStep7 => 7, + TransferProgress::SettingReason => 8, + TransferProgress::ConfirmingTransfer => 9, + TransferProgress::Completed => 10, + } + } + + pub fn total_steps() -> u8 { + 10 + } + + pub fn description(&self) -> &str { + match self { + TransferProgress::Validating => "Validating transfer parameters", + TransferProgress::InitializingTransfer => "Initializing transfer", + TransferProgress::ExtractingFlowInstance => "Extracting flow instance", + TransferProgress::SettingDebitAccount => "Setting debit account", + TransferProgress::SettingCreditAccount => "Setting credit account", + TransferProgress::SettingAmount => "Setting transfer amount", + TransferProgress::SubmittingStep7 => "Submitting intermediate step", + TransferProgress::SettingReason => "Setting transfer reason", + TransferProgress::ConfirmingTransfer => "Confirming transfer", + TransferProgress::Completed => "Transfer completed", + } + } +} + impl BoursoWebClient { /// Initialize the transfer and extract the transfer ID async fn init_transfer(&self, from_account: &str) -> Result { @@ -272,86 +323,130 @@ impl BoursoWebClient { } #[cfg(not(tarpaulin_include))] - pub async fn transfer_funds( + pub fn transfer_funds_with_progress( &self, amount: f64, from_account: Account, to_account: Account, - reason: Option<&str>, - ) -> Result<()> { - // Minimum amount is 10 EUR - if amount < 10.0 { - bail!(TransferError::AmountTooLow); - } - - log::debug!( - "Initiating transfer of {:.2} EUR from account {} to account {}", - amount, - from_account.id, - to_account.id - ); - - let transfer_from_banking = from_account.kind == AccountKind::Banking; - let from_account_id = &from_account.id; - let to_account_id = &to_account.id; - - // Default reason if none provided, else use provided reason and - // warn if the reason is too long (> 50 characters) - let transfer_reason = if let Some(r) = reason { - if r.len() > 50 { - bail!(TransferError::ReasonIsTooLong); + reason: Option, + ) -> impl Stream> + '_ { + async_stream::stream! { + // Validation + yield Ok(TransferProgress::Validating); + + if amount < 10.0 { + yield Err(TransferError::AmountTooLow.into()); + return; } - r.to_string() - } else { - "Virement depuis BoursoBank".to_string() - }; - // Step 1: Initialize transfer and get transfer ID - let transfer_id = self.init_transfer(from_account_id).await?; + log::debug!( + "Initiating transfer of {:.2} EUR from account {} to account {}", + amount, + from_account.id, + to_account.id + ); + + let transfer_from_banking = from_account.kind == AccountKind::Banking; + let from_account_id = from_account.id.clone(); + let to_account_id = to_account.id.clone(); + + // Default reason if none provided, else use provided reason and + // warn if the reason is too long (> 50 characters) + let transfer_reason = if let Some(r) = reason { + if r.len() > 50 { + yield Err(TransferError::ReasonIsTooLong.into()); + return; + } + r + } else { + "Virement depuis BoursoBank".to_string() + }; + + // Step 1: Initialize transfer and get transfer ID + yield Ok(TransferProgress::InitializingTransfer); + let transfer_id = match self.init_transfer(&from_account_id).await { + Ok(id) => id, + Err(e) => { + yield Err(e); + return; + } + }; + + // Extract flow instance + yield Ok(TransferProgress::ExtractingFlowInstance); + let flow_instance = match self + .extract_flow_instance(&format!( + "{}/compte/cav/{}/virements/immediat/nouveau/{}/1", + BASE_URL, &from_account_id, transfer_id + )) + .await { + Ok(flow) => flow, + Err(e) => { + yield Err(e); + return; + } + }; + + // Step 2: Set debit account + yield Ok(TransferProgress::SettingDebitAccount); + if let Err(e) = self.set_debit_account(&from_account_id, &transfer_id, &flow_instance) + .await { + yield Err(e); + return; + } - // Extract flow instance - let flow_instance = self - .extract_flow_instance(&format!( - "{}/compte/cav/{}/virements/immediat/nouveau/{}/1", - BASE_URL, from_account_id, transfer_id - )) - .await?; + // Step 3: Set credit account + yield Ok(TransferProgress::SettingCreditAccount); + if let Err(e) = self.set_credit_account( + &from_account_id, + &to_account_id, + &transfer_id, + &flow_instance, + transfer_from_banking, + ) + .await { + yield Err(e); + return; + } - // Step 2: Set debit account - self.set_debit_account(from_account_id, &transfer_id, &flow_instance) - .await?; + // Step 6: Set amount + yield Ok(TransferProgress::SettingAmount); + if let Err(e) = self.set_transfer_amount(&from_account_id, &transfer_id, &flow_instance, amount) + .await { + yield Err(e); + return; + } - // Step 3: Set credit account - self.set_credit_account( - from_account_id, - to_account_id, - &transfer_id, - &flow_instance, - transfer_from_banking, - ) - .await?; - - // Step 6: Set amount - self.set_transfer_amount(from_account_id, &transfer_id, &flow_instance, amount) - .await?; + // Step 7: Submit + yield Ok(TransferProgress::SubmittingStep7); + if let Err(e) = self.submit_step_7(&from_account_id, &transfer_id, &flow_instance) + .await { + yield Err(e); + return; + } - // Step 7: Submit - self.submit_step_7(from_account_id, &transfer_id, &flow_instance) - .await?; + // Step 10: Set reason + yield Ok(TransferProgress::SettingReason); + if let Err(e) = self.set_transfer_reason( + &from_account_id, + &transfer_id, + &flow_instance, + &transfer_reason, + ) + .await { + yield Err(e); + return; + } - // Step 10: Set reason - self.set_transfer_reason( - from_account_id, - &transfer_id, - &flow_instance, - &transfer_reason, - ) - .await?; - - // Step 12: Confirm transfer - self.confirm_transfer(from_account_id, &transfer_id, &flow_instance) - .await?; + // Step 12: Confirm transfer + yield Ok(TransferProgress::ConfirmingTransfer); + if let Err(e) = self.confirm_transfer(&from_account_id, &transfer_id, &flow_instance) + .await { + yield Err(e); + return; + } - Ok(()) + yield Ok(TransferProgress::Completed); + } } } diff --git a/src/lib.rs b/src/lib.rs index 2e71346..2221ac9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,11 +3,13 @@ use bourso_api::{ account::{Account, AccountKind}, client::{ trade::{order::OrderSide, tick::QuoteTab}, + transfer::TransferProgress, BoursoWebClient, }, get_client, }; use clap::ArgMatches; +use futures_util::StreamExt; use log::{info, warn}; mod settings; @@ -292,9 +294,40 @@ pub async fn parse_matches(matches: ArgMatches) -> Result<()> { .find(|a| a.id == to_account_id) .context("To account not found. Are you sure you have access to it? Run `bourso accounts` to list your accounts")?; - let _ = web_client - .transfer_funds(amount, from_account.clone(), to_account.clone(), reason) - .await?; + let stream = web_client.transfer_funds_with_progress( + amount, + from_account.clone(), + to_account.clone(), + reason.map(|s| s.to_string()), + ); + + use futures_util::pin_mut; + pin_mut!(stream); + + // Track progress and update display + while let Some(progress_result) = stream.next().await { + let progress = progress_result?; + let step = progress.step_number(); + let total = TransferProgress::total_steps(); + let percentage = (step as f32 / total as f32 * 100.0) as u8; + + // Create a simple progress bar + let bar_length = 30; + let filled = (bar_length as f32 * step as f32 / total as f32) as usize; + let bar: String = "█".repeat(filled) + &"░".repeat(bar_length - filled); + + print!( + "\r[{}] {:3}% - {}/{} - {}", + bar, + percentage, + step, + total, + progress.description() + ); + use std::io::Write; + std::io::stdout().flush().unwrap(); + } + println!(); // New line after progress is complete info!( "Transfer of {} from account {} to account {} successful ✅", From 5a3295cd8783ee1538f3e0bf95c7327f6382f2ab Mon Sep 17 00:00:00 2001 From: azerpas Date: Wed, 29 Oct 2025 19:51:57 +0100 Subject: [PATCH 3/7] fix: progress bar clean up --- src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 2221ac9..e68b4dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -316,8 +316,10 @@ pub async fn parse_matches(matches: ArgMatches) -> Result<()> { let filled = (bar_length as f32 * step as f32 / total as f32) as usize; let bar: String = "█".repeat(filled) + &"░".repeat(bar_length - filled); + // Use ANSI escape code to clear the line before printing + // \x1B[2K clears the entire line, \r returns cursor to start print!( - "\r[{}] {:3}% - {}/{} - {}", + "\x1B[2K\r[{}] {:3}% - {}/{} - {}", bar, percentage, step, From 0cb4e0cfd4df4da30603c86c1f4a4802c89e63c4 Mon Sep 17 00:00:00 2001 From: azerpas Date: Wed, 29 Oct 2025 19:53:02 +0100 Subject: [PATCH 4/7] refactor: mv use futures-util --- src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e68b4dd..126d506 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ use bourso_api::{ get_client, }; use clap::ArgMatches; -use futures_util::StreamExt; +use futures_util::{pin_mut, StreamExt}; use log::{info, warn}; mod settings; @@ -301,7 +301,6 @@ pub async fn parse_matches(matches: ArgMatches) -> Result<()> { reason.map(|s| s.to_string()), ); - use futures_util::pin_mut; pin_mut!(stream); // Track progress and update display From 27ea2d50aaa03165e8afb8182f64faf1d844775d Mon Sep 17 00:00:00 2001 From: azerpas Date: Wed, 29 Oct 2025 19:54:00 +0100 Subject: [PATCH 5/7] chore: bump versions --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- src/bourso_api/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e89e60d..ae2103e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,7 +166,7 @@ checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be" [[package]] name = "bourso-cli" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", "bourso_api", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "bourso_api" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 34a6e69..7368bd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bourso-cli" -version = "0.3.1" +version = "0.3.2" edition = "2021" repository = "https://github.com/azerpas/bourso-api" diff --git a/src/bourso_api/Cargo.toml b/src/bourso_api/Cargo.toml index f7ccf6c..314b113 100644 --- a/src/bourso_api/Cargo.toml +++ b/src/bourso_api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bourso_api" -version = "0.3.0" +version = "0.4.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html From fb2ba79e357b6bd6d9472fda02c3f196bf97b82a Mon Sep 17 00:00:00 2001 From: azerpas Date: Wed, 29 Oct 2025 19:59:12 +0100 Subject: [PATCH 6/7] refactor: change method name --- src/bourso_api/src/client/transfer/mod.rs | 12 +++++++++++- src/lib.rs | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/bourso_api/src/client/transfer/mod.rs b/src/bourso_api/src/client/transfer/mod.rs index c24ee6f..1bab4c1 100644 --- a/src/bourso_api/src/client/transfer/mod.rs +++ b/src/bourso_api/src/client/transfer/mod.rs @@ -322,8 +322,18 @@ impl BoursoWebClient { } } + /// Transfer funds from one account to another, yielding progress updates + /// + /// ## Arguments + /// - `amount`: Amount to transfer (must be >= 10.0) + /// - `from_account`: Source account + /// - `to_account`: Destination account + /// - `reason`: Optional reason for the transfer (max 50 characters) + /// + /// ## Returns + /// A stream of progress updates for the transfer. #[cfg(not(tarpaulin_include))] - pub fn transfer_funds_with_progress( + pub fn transfer_funds( &self, amount: f64, from_account: Account, diff --git a/src/lib.rs b/src/lib.rs index 126d506..9876bac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -294,7 +294,7 @@ pub async fn parse_matches(matches: ArgMatches) -> Result<()> { .find(|a| a.id == to_account_id) .context("To account not found. Are you sure you have access to it? Run `bourso accounts` to list your accounts")?; - let stream = web_client.transfer_funds_with_progress( + let stream = web_client.transfer_funds( amount, from_account.clone(), to_account.clone(), From 3b502edb49908666d97750258557230b42718510 Mon Sep 17 00:00:00 2001 From: azerpas Date: Wed, 29 Oct 2025 20:02:37 +0100 Subject: [PATCH 7/7] chore: rm async funcs from tarpaulin checks --- src/bourso_api/src/client/transfer/mod.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/bourso_api/src/client/transfer/mod.rs b/src/bourso_api/src/client/transfer/mod.rs index 1bab4c1..e38c05b 100644 --- a/src/bourso_api/src/client/transfer/mod.rs +++ b/src/bourso_api/src/client/transfer/mod.rs @@ -21,6 +21,7 @@ pub enum TransferProgress { } impl TransferProgress { + #[cfg(not(tarpaulin_include))] pub fn step_number(&self) -> u8 { match self { TransferProgress::Validating => 1, @@ -40,6 +41,7 @@ impl TransferProgress { 10 } + #[cfg(not(tarpaulin_include))] pub fn description(&self) -> &str { match self { TransferProgress::Validating => "Validating transfer parameters", @@ -58,6 +60,7 @@ impl TransferProgress { impl BoursoWebClient { /// Initialize the transfer and extract the transfer ID + #[cfg(not(tarpaulin_include))] async fn init_transfer(&self, from_account: &str) -> Result { let init_transfer_url = format!( "{}/compte/cav/{}/virements/immediat/nouveau", @@ -89,6 +92,7 @@ impl BoursoWebClient { } /// Extract the flow instance from the HTML response + #[cfg(not(tarpaulin_include))] async fn extract_flow_instance(&self, url: &str) -> Result { let res = self.client.get(url).send().await?; @@ -111,6 +115,7 @@ impl BoursoWebClient { } /// Set the debit account (step 2) + #[cfg(not(tarpaulin_include))] async fn set_debit_account( &self, from_account: &str, @@ -141,6 +146,7 @@ impl BoursoWebClient { } /// Set the credit account (step 3) + #[cfg(not(tarpaulin_include))] async fn set_credit_account( &self, from_account: &str, @@ -179,6 +185,7 @@ impl BoursoWebClient { } /// Set the transfer amount (step 6) + #[cfg(not(tarpaulin_include))] async fn set_transfer_amount( &self, from_account: &str, @@ -212,6 +219,7 @@ impl BoursoWebClient { } /// Submit step 7 + #[cfg(not(tarpaulin_include))] async fn submit_step_7( &self, from_account: &str, @@ -246,6 +254,7 @@ impl BoursoWebClient { } /// Set the transfer reason (step 10) + #[cfg(not(tarpaulin_include))] async fn set_transfer_reason( &self, from_account: &str, @@ -281,6 +290,7 @@ impl BoursoWebClient { } /// Confirm and finalize the transfer (step 12) + #[cfg(not(tarpaulin_include))] async fn confirm_transfer( &self, from_account: &str,