Skip to content

Conversation

@Mathious6
Copy link
Collaborator

@Mathious6 Mathious6 commented Nov 20, 2025

Why?

The CLI had tightly coupled parsing, IO, and domain logic in a single flow, making it hard to test & extend.

What?

  • Version

    • Bump CLI to 1.0.0
    • Turn the repo into a Cargo workspace with bourso-cli and src/bourso_api as members
  • Dependencies

    • Refresh core deps: tokio, anyhow, clap, directories, serde_json, tracing-*, futures-util, rpassword
    • Add tracing-appender on the CLI side
    • Add thiserror + derive_more in bourso_api for typed domain values
  • Architecture

    • New top-level modules in the CLI: commands/, services/, settings/, ux/
    • New AppCtx { settings_store } and run(Cli) entrypoint to decouple CLI wiring from command logic
    • Extract AuthService with CredentialsProvider + ClientFactory to isolate login/MFA handling
    • settings/ split into:
      • consts.rs (app identifiers + filenames)
      • logging.rs (centralized init_logger() using ProjectDirs + tracing-appender)
      • store.rs (SettingsStore trait + FsSettingsStore implementation): ready for Vault support
    • main.rs reduced to: init logger → parse Cli (derive) → run(cli)
  • Domain types (bourso_api::types)

    • New shared module in bourso_api with validated value types:
      • ClientNumber, AccountId, SymbolId
      • OrderQuantity, MoneyAmount, TransferReason
      • MfaCode, Password
      • QuoteLength, QuotePeriod, OrderSide (moved here from trade::order)
    • Central ValueError using thiserror for input/validation errors
    • CLI now uses these types directly via FromStr + value_parser!, reducing primitive obsession and centralizing validation
  • Commands

    • All stateful commands now go through AuthService:
      • accounts
      • trade order new
      • transfer
    • config
      • Persists validated ClientNumber via FsSettingsStore
      • Uses ClientNumber newtype instead of raw String
    • quote
      • Uses typed args:
        • symbol: SymbolId
        • length: QuoteLength (1, 5, 30, 90, 180, 365, 1825, 3650)
        • period: QuotePeriod (currently only 0 is accepted)
    • trade order new
      • account: AccountId
      • side: OrderSide (with default "buy")
      • symbol: SymbolId
      • quantity: OrderQuantity
    • transfer
      • from_account: AccountId, to_account: AccountId
      • amount: MoneyAmount
      • reason: Option<TransferReason>
    • Root command:
      • Cli.credentials is now a PathBuf (parsed via value_parser!(PathBuf)) and feeds FsSettingsStore::from_path when provided
  • UX

    • New reusable ux::TextProgressBar
      • Encapsulates the previous inline progress-bar logic used for transfers
      • Safer math (clamp) and handles total == 0 gracefully
  • Logging

    • Structured JSON log file via tracing-subscriber + tracing-appender::rolling::never
    • Log file under platform data dir (e.g. ~/.local/share/bourso/bourso-cli/bourso.log)
    • Console layer:
      • Compact, no timestamps
      • Respects RUST_LOG
      • Still prints a friendly CLI-style message stream

Main impact?

  • Breaking changes

    • config flag renamed: --username--client-number
    • quote:
      • --interval renamed to --period
      • --length is now a QuoteLength enum (must be one of 1,5,30,90,180,365,1825,3650)
      • --period currently only accepts 0 (validated via QuotePeriod)
    • Stricter validation at the CLI boundary:
      • client_number must be 8 digits
      • account IDs must be 32 hex chars
      • symbol IDs must be 6–12 alphanumeric chars
      • quantity must be a strictly positive integer
      • amount must be positive with up to 2 decimals
      • MFA codes and password are also validated newtypes
      • Invalid inputs now fail early with explicit error messages
  • Paths

    • Settings: platform config dir
      e.g. ~/.config/bourso/bourso-cli/settings.json
    • Logs: platform data dir
      e.g. ~/.local/share/bourso/bourso-cli/bourso.log
  • Maintainability

    • Clear separation of concerns:
      • CLI (argument parsing + help)
      • commands/* (orchestration)
      • services/* (Auth, etc.)
      • bourso_api (HTTP/domain)
    • Easier testing/mocking:
      • AuthService takes boxed CredentialsProvider + ClientFactory
      • SettingsStore trait enables in-memory / custom implementations
    • Shared domain types in bourso_api::types ensure CLI and API use the same invariants
    • Reusable UX components and centralized logging initialization

What next?

  • refactor src/bourso_api
  • move src/bourso_api/types to dedicated domain folder

…d updating password type to improve type safety
…SettingsStore for improved settings management
…tings store and streamline password/MFA handling
@Mathious6 Mathious6 requested a review from azerpas November 20, 2025 03:21
@Mathious6 Mathious6 self-assigned this Nov 20, 2025
@Mathious6 Mathious6 added the enhancement New feature or request label Nov 20, 2025
@Mathious6 Mathious6 linked an issue Nov 20, 2025 that may be closed by this pull request
@Mathious6 Mathious6 changed the title draft: Mathious6/fix/value objects 1.0.0: CLI architecture refactor, typed domain models & new settings/logging stack Nov 20, 2025
@Mathious6 Mathious6 marked this pull request as ready for review November 20, 2025 03:28
@codecov-commenter
Copy link

codecov-commenter commented Nov 20, 2025

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 0% with 142 lines in your changes missing coverage. Please review.
✅ Project coverage is 22.86%. Comparing base (e7ec1e3) to head (b98de70).

Files with missing lines Patch % Lines
src/bourso_api/src/types.rs 0.00% 78 Missing ⚠️
src/services/auth.rs 0.00% 54 Missing ⚠️
src/lib.rs 0.00% 10 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@             Coverage Diff             @@
##              1.0      #56       +/-   ##
===========================================
- Coverage   33.55%   22.86%   -10.69%     
===========================================
  Files           9       12        +3     
  Lines         304      446      +142     
===========================================
  Hits          102      102               
- Misses        202      344      +142     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Owner

@azerpas azerpas left a comment

Choose a reason for hiding this comment

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

A few interrogations on the types. Nonetheless, awesome job, thank you Mathis!

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

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?

Comment on lines +236 to +254
#[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)
}
}
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***)")
}
}

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

}

#[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

types::{ClientNumber, MfaCode, Password},
};

// TODO: does it make sense to have MFA handling in the CLI?
Copy link
Owner

Choose a reason for hiding this comment

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

good question, how do you this handled?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: address primitive obsession with typed value objects

4 participants