Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
8c318a7
refactor: replace settings management with a new structure and file-b…
Mathious6 Nov 4, 2025
5a3daf8
refactor: reorganize settings management into a dedicated module and …
Mathious6 Nov 4, 2025
39825d0
refactor: simplify FileSettingsStore initialization and enhance proje…
Mathious6 Nov 4, 2025
d795ae2
refactor: introduce constants for application settings and update set…
Mathious6 Nov 4, 2025
461dc14
feat: add logging configuration with tracing and tracing-appender sup…
Mathious6 Nov 4, 2025
53e3d91
refactor: replace LoggerBuilder with init_logger function for streaml…
Mathious6 Nov 4, 2025
a24323b
feat: implement AuthService with login and MFA handling, and add Cred…
Mathious6 Nov 6, 2025
f9ea1e2
refactor: rename settings module to store and implement FileSettingsS…
Mathious6 Nov 6, 2025
fa3f1ce
feat: implement account management, configuration saving, and transfe…
Mathious6 Nov 6, 2025
1e6238a
refactor: update CLI argument names and types for improved clarity an…
Mathious6 Nov 6, 2025
d1ca91f
refactor: clean up auth module imports and simplify mod.rs exports
Mathious6 Nov 6, 2025
b3a2875
refactor: replace Arc with Box for settings store in commands and aut…
Mathious6 Nov 6, 2025
7748d42
refactor: streamline module exports and update main function to use s…
Mathious6 Nov 6, 2025
38654c8
feat: add TextProgressBar for enhanced transfer progress visualizatio…
Mathious6 Nov 7, 2025
235a4fd
chore: update dependencies in Cargo.toml and Cargo.lock
Mathious6 Nov 7, 2025
1f00ae0
chore: bump version to 1.0.0 in Cargo.toml and Cargo.lock
Mathious6 Nov 7, 2025
dd05c70
chore: add workspace configuration to Cargo.toml for improved project…
Mathious6 Nov 7, 2025
3e8df0e
refactor: update CLI argument type for credentials to PathBuf for bet…
Mathious6 Nov 11, 2025
d7e05ba
feat: implement JsonFileSettingsStore for JSON-based settings management
Mathious6 Nov 11, 2025
2f14fd0
feat: integrate JsonFileSettingsStore into AppCtx for enhanced settin…
Mathious6 Nov 11, 2025
a0c97fe
refactor: change AuthService to use a reference for SettingsStore to …
Mathious6 Nov 11, 2025
33954e1
refactor: update command handlers to accept AppCtx for improved setti…
Mathious6 Nov 11, 2025
040cbe8
feat: introduce ValueError enum and ClientNumber struct for enhanced …
Mathious6 Nov 9, 2025
d763f43
chore: add thiserror dependency and update types.rs error messages fo…
Mathious6 Nov 9, 2025
d85c3fc
refactor: update ValueError enum to use thiserror for improved error …
Mathious6 Nov 9, 2025
b859a9a
feat: add AccountId struct for better validation
Mathious6 Nov 9, 2025
46c0672
feat: introduce SymbolId struct for better validation
Mathious6 Nov 9, 2025
ae1060e
feat: add OrderQuantity struct for better validation
Mathious6 Nov 9, 2025
c000697
feat: add MoneyAmount struct for better validation
Mathious6 Nov 9, 2025
2b58728
feat: add TransferReason struct for better validation
Mathious6 Nov 9, 2025
58f33b0
feat: add QuoteLength enum for better validation
Mathious6 Nov 11, 2025
1f8743e
feat: add QuotePeriod struct for better validation
Mathious6 Nov 11, 2025
594ed69
feat: add MfaCode struct for better validation
Mathious6 Nov 11, 2025
3f1e538
feat: add Password struct for better validation
Mathious6 Nov 11, 2025
88dc88d
feat: enhance ClientNumber and Password structs with serialization su…
Mathious6 Nov 11, 2025
73f3a5c
feat: update CLI argument types to use new validation structs for imp…
Mathious6 Nov 11, 2025
ce61000
feat: implement TryFrom and From traits for MfaCode to enhance type c…
Mathious6 Nov 11, 2025
6a26dd7
feat: refactor argument handling in quote, transfer, and order comman…
Mathious6 Nov 11, 2025
37288b8
fix: correct logic in QuotePeriod creation to ensure valid initializa…
Mathious6 Nov 11, 2025
27ca7e0
feat: enhance authentication flow by introducing MFA code handling an…
Mathious6 Nov 11, 2025
73dde0d
fix: update error message for QuotePeriod to specify valid value as 0
Mathious6 Nov 11, 2025
ade0e7c
refactor: replace FileSettingsStore and JsonFileSettingsStore with Fs…
Mathious6 Nov 13, 2025
5effad7
refactor: streamline command handling by qualifying command variants …
Mathious6 Nov 13, 2025
e1c4c38
refactor: replace fs::create_dir_all with a direct import for cleaner…
Mathious6 Nov 13, 2025
b0c5147
refactor: update AuthService initialization to use references for set…
Mathious6 Nov 20, 2025
11ae4f0
refactor: integrate derive_more for enhanced type conversions and str…
Mathious6 Nov 20, 2025
322510f
refactor: introduce OrderSide enum and streamline order argument hand…
Mathious6 Nov 20, 2025
b6767f1
fix: read password issue
Mathious6 Nov 20, 2025
b98de70
refactor: renamed `consts` to `constants`
Mathious6 Nov 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,052 changes: 578 additions & 474 deletions Cargo.lock

Large diffs are not rendered by default.

16 changes: 10 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "bourso-cli"
version = "0.3.2"
version = "1.0.0"
edition = "2021"
authors = ["@azerpas"]
description = "BoursoBank/Boursorama CLI"
Expand All @@ -10,16 +10,20 @@ repository = "https://github.com/azerpas/bourso-api"

[dependencies]
bourso_api = { path = "./src/bourso_api" }
tokio = { version = "1.33.0", features = ["full"] }
anyhow = { version = "1.0.75" }
tokio = { version = "1.48.0", features = ["full"] }
anyhow = { version = "1.0.100" }
clap = { version = "4.5.51", features = ["derive"] }
rpassword = { version = "7.2.0" }
directories = { version = "5.0.1" }
rpassword = { version = "7.4.0" }
directories = { version = "6.0.0" }
serde = { version = "1.0.189", features = ["derive"] }
serde_json = { version = "1.0.107" }
serde_json = { version = "1.0.145" }
tracing = { version = "0.1.41" }
tracing-appender = { version = "0.2.3" }
tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter", "json"] }
futures-util = { version = "0.3.31" }

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }

[workspace]
members = [".", "src/bourso_api"]
2 changes: 2 additions & 0 deletions src/bourso_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ chrono = { version = "0.4.39" }
tracing = { version = "0.1.41" }
futures-util = { version = "0.3.31" }
async-stream = { version = "0.3.6" }
thiserror = { version = "2.0.17" }
derive_more = { version = "2.0.1", features = ["from", "into", "as_ref"] }

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
10 changes: 1 addition & 9 deletions src/bourso_api/src/client/trade/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use tracing::{debug, info};
use crate::{
account::{Account, AccountKind},
client::config::Config,
types::OrderSide,
};

use super::{get_trading_base_url, BoursoWebClient};
Expand Down Expand Up @@ -526,15 +527,6 @@ pub enum OrderKind {
TradeAtLast,
}

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default, clap::ValueEnum)]
pub enum OrderSide {
#[default]
#[serde(rename = "B")]
Buy,
#[serde(rename = "S")]
Sell,
}

/// Order data submitted to the `/ordersimple/check` endpoint
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct OrderData {
Expand Down
3 changes: 2 additions & 1 deletion src/bourso_api/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
pub mod account;
pub mod client;
pub mod constants;
pub mod types;

#[cfg(not(tarpaulin_include))]
pub fn get_client() -> client::BoursoWebClient {
client::BoursoWebClient::new()
}
}
254 changes: 254 additions & 0 deletions src/bourso_api/src/types.rs
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added some tests for this file and I think some our validations are a bit broken:

Tests
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_client_number() {
        assert!(ClientNumber::new("12345678").is_ok());
        assert!(ClientNumber::new("1234abcd").is_err());
        assert!(ClientNumber::new("1234567").is_err());
        assert!(ClientNumber::new("123456789").is_err());
    }

    #[test]
    fn test_account_id() {
        assert!(AccountId::new("a1b2c3d4e5f60718293a4b5c6d7e8f90").is_ok());
        assert!(AccountId::new("g1b2c3d4e5f60718293a4b5c6d7e8f90").is_err());
        assert!(AccountId::new("a1b2c3d4e5f60718293a4b5c6d7e8f9").is_err());
        assert!(AccountId::new("a1b2c3d4e5f60718293a4b5c6d7e8f900").is_err());
    }

    #[test]
    fn test_symbol_id() {
        assert!(SymbolId::new("AMZN").is_ok());
        assert!(SymbolId::new("1rPAF").is_ok());
        assert!(SymbolId::new("1rPAIR").is_err());
        assert!(SymbolId::new("1rTWPEA").is_err());
        assert!(SymbolId::new("ABC@123").is_err());
        assert!(SymbolId::new("A").is_err());
        assert!(SymbolId::new("ABCDEFGHIJKLM").is_err());
    }

    #[test]
    fn test_order_quantity() {
        assert!(OrderQuantity::new(10).is_ok());
        assert!(OrderQuantity::new(0).is_err());
    }

    #[test]
    fn test_money_amount() {
        assert!(MoneyAmount::new(100.0).is_ok());
        assert!(MoneyAmount::new(0.0).is_err());
        assert!(MoneyAmount::new(-50.0).is_err());
        assert!(MoneyAmount::new(25.999).is_err());
        assert!(MoneyAmount::new(30.54).is_ok());
    }

    #[test]
    fn test_transfer_reason() {
        assert!(TransferReason::new("Salary").is_ok());
        assert!(TransferReason::new("Bonus2021").is_err());
        assert!(TransferReason::new("ThisReasonIsWayTooLongToBeValidBecauseItExceedsFiftyCharacters").is_err());
    }

    #[test]
    fn test_mfa_code() {
        assert!(MfaCode::new("123456").is_ok());
        assert!(MfaCode::new("12345").is_err());
        assert!(MfaCode::new("123456789012").is_ok());
        assert!(MfaCode::new("1234567890123").is_err());
        assert!(MfaCode::new("12345A").is_err());
    }

    #[test]
    fn test_password() {
        assert!(Password::new("12345678").is_ok());
        assert!(Password::new("").is_err());
        assert!(Password::new("   ").is_err());
    }
}
  • MoneyAmounts are only valid with *.01 or *.02 amounts
  • Validation for symbols starts at 6 when AMZN for instance is at 4

Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
use clap::ValueEnum;
use derive_more::{AsRef, From, Into};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ValueError {
#[error("invalid client number: must be 8 digits (0-9)")]
ClientNumber,
#[error("invalid account id: must be 32 hexadecimal characters (0-9, a-f)")]
AccountId,
#[error("invalid symbol id: must be 6-12 alphanumeric characters (0-9, a-z, A-Z)")]
SymbolId,
#[error("invalid order quantity: must be a positive, non-zero integer")]
OrderQuantity,
#[error("invalid money amount: must be a positive, up to 2 decimal places float")]
MoneyAmount,
#[error("invalid transfer reason: must be 0-50 letters only (a-z, A-Z)")]
TransferReason,
#[error("invalid quote period: must be 0")]
QuotePeriod,
#[error("invalid mfa code: must be 6-12 digits (0-9)")]
MfaCode,
#[error("invalid password: must be a non-empty string")]
Password,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, From, Into)]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are all these derives required? Cause I feel like more derives, more code, more time to compile, bigger file size

#[serde(try_from = "String", into = "String")]
pub struct ClientNumber(String);
impl ClientNumber {
pub fn new(s: &str) -> Result<Self, ValueError> {
let t = s.trim();
if t.len() == 8 && t.chars().all(|c| c.is_ascii_digit()) {
Ok(Self(t.into()))
} else {
Err(ValueError::ClientNumber)
}
}
}
impl FromStr for ClientNumber {
type Err = ValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, AsRef, From, Into)]
pub struct AccountId(String);
impl AccountId {
pub fn new(s: &str) -> Result<Self, ValueError> {
let t = s.trim();
if t.len() == 32 && t.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(Self(t.into()))
} else {
Err(ValueError::AccountId)
}
}
}
impl FromStr for AccountId {
type Err = ValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, AsRef, From, Into)]
pub struct SymbolId(String);
impl SymbolId {
pub fn new(s: &str) -> Result<Self, ValueError> {
let t = s.trim();
if (6..=12).contains(&t.len()) && t.chars().all(|c| c.is_ascii_alphanumeric()) {
Ok(Self(t.into()))
} else {
Err(ValueError::SymbolId)
}
}
}
impl FromStr for SymbolId {
type Err = ValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, AsRef, From, Into)]
pub struct OrderQuantity(u64);
impl OrderQuantity {
pub fn new(v: u64) -> Result<Self, ValueError> {
if v >= 1 {
Ok(Self(v))
} else {
Err(ValueError::OrderQuantity)
}
}
pub fn get(self) -> u64 {
self.0
}
}
impl FromStr for OrderQuantity {
type Err = ValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v: u64 = s.parse().map_err(|_| ValueError::OrderQuantity)?;
Self::new(v)
}
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MoneyAmount(f64);
impl MoneyAmount {
pub fn new(v: f64) -> Result<Self, ValueError> {
if v > 0.0 && v.fract().abs() <= 0.02 {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what the logic is here with v.fract().abs() <= 0.02?

Ok(Self(v))
} else {
Err(ValueError::MoneyAmount)
}
}
pub fn get(self) -> f64 {
self.0
}
}
impl FromStr for MoneyAmount {
type Err = ValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v: f64 = s.parse().map_err(|_| ValueError::MoneyAmount)?;
Self::new(v)
}
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, AsRef, From, Into)]
pub struct TransferReason(String);
impl TransferReason {
pub fn new(s: &str) -> Result<Self, ValueError> {
let t = s.trim();
if t.len() > 50 {
return Err(ValueError::TransferReason);
}
if !t.chars().all(|c| c.is_ascii_alphabetic()) {
return Err(ValueError::TransferReason);
}
Ok(Self(t.into()))
}
}
impl FromStr for TransferReason {
type Err = ValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum QuoteLength {
#[value(name = "1")]
D1,
#[value(name = "5")]
D5,
#[value(name = "30")]
D30,
#[value(name = "90")]
D90,
#[value(name = "180")]
D180,
#[value(name = "365")]
D365,
#[value(name = "1825")]
D1825,
#[value(name = "3650")]
D3650,
}
impl QuoteLength {
pub fn days(self) -> i64 {
match self {
QuoteLength::D1 => 1,
QuoteLength::D5 => 5,
QuoteLength::D30 => 30,
QuoteLength::D90 => 90,
QuoteLength::D180 => 180,
QuoteLength::D365 => 365,
QuoteLength::D1825 => 1825,
QuoteLength::D3650 => 3650,
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ValueEnum)]
pub enum OrderSide {
#[serde(rename = "B")]
Buy,
#[serde(rename = "S")]
Sell,
}

// TODO: support only 0 period for now, add support for other periods
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct QuotePeriod(i64);
impl QuotePeriod {
pub fn new(v: i64) -> Result<Self, ValueError> {
if v == 0 {
Ok(Self(v))
} else {
Err(ValueError::QuotePeriod)
}
}
pub fn value(self) -> i64 {
self.0
}
}
impl FromStr for QuotePeriod {
type Err = ValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v: i64 = s.trim().parse().map_err(|_| ValueError::QuotePeriod)?;
Self::new(v)
}
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, AsRef, From, Into)]
pub struct MfaCode(String);
impl MfaCode {
pub fn new(s: &str) -> Result<Self, ValueError> {
let t = s.trim();
if (6..=12).contains(&t.len()) && t.chars().all(|c| c.is_ascii_digit()) {
Ok(Self(t.into()))
} else {
Err(ValueError::MfaCode)
}
}
}
impl FromStr for MfaCode {
type Err = ValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, From, Into)]
#[serde(try_from = "String", into = "String")]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the try_from logic is a bit buggy. And it goes the same for ClientNumber

Using TryFrom, we'll use auto-generated From which don't use our constructor thus bypass our validation. It can be verified with some tests:

Tests fn password_deserialization_rejects_empty() { let json = r#""""#; // empty string let result: Result = serde_json::from_str(json); assert!(result.is_err(), "Empty password should be rejected during deserialization"); }

We probably need something like

impl TryFrom<String> for Password {
    type Error = ValueError;
    fn try_from(s: String) -> Result<Self, Self::Error> {
        Self::new(&s)
    }
}

Same goes for ClientNumber

pub struct Password(String);
impl Password {
pub fn new(s: &str) -> Result<Self, ValueError> {
let t = s.trim();
if !t.is_empty() {
Ok(Self(t.into()))
} else {
Err(ValueError::Password)
}
}
}
impl FromStr for Password {
type Err = ValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
Comment on lines +236 to +254
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug on Password isn't a good idea IMO, would risk exposing the password to logs. For consistency, let's still implement Debug but with a redacted string.

Suggested change
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, From, Into)]
#[serde(try_from = "String", into = "String")]
pub struct Password(String);
impl Password {
pub fn new(s: &str) -> Result<Self, ValueError> {
let t = s.trim();
if !t.is_empty() {
Ok(Self(t.into()))
} else {
Err(ValueError::Password)
}
}
}
impl FromStr for Password {
type Err = ValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, From, Into)]
#[serde(try_from = "String", into = "String")]
pub struct Password(String);
impl Password {
pub fn new(s: &str) -> Result<Self, ValueError> {
let t = s.trim();
if !t.is_empty() {
Ok(Self(t.into()))
} else {
Err(ValueError::Password)
}
}
}
impl FromStr for Password {
type Err = ValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl std::fmt::Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Password(***REDACTED***)")
}
}

Loading