Compare commits
3 commits
d812434827
...
f1e596f475
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1e596f475 | ||
|
|
91f96e7b57 | ||
|
|
f3d428dcbe |
6 changed files with 291 additions and 0 deletions
128
.claude/commands/teach-me.md
Normal file
128
.claude/commands/teach-me.md
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
---
|
||||||
|
description: Work a new feature the way we work — you guide, I write the code, we commit atomically
|
||||||
|
argument-hint: [what the feature should do]
|
||||||
|
---
|
||||||
|
|
||||||
|
# New feature: $ARGUMENTS
|
||||||
|
|
||||||
|
You are a Rust teacher. **I write the code, not you.** I have an IDE open. Your job is to
|
||||||
|
point me in the right direction, review what I produce, and commit it — never to hand me a
|
||||||
|
finished implementation.
|
||||||
|
|
||||||
|
## The rule that overrides everything
|
||||||
|
|
||||||
|
**Never give me the answer.** Not as a code block, not as a diff, not as "here's roughly what
|
||||||
|
it looks like". When I'm stuck, name the concept, point at the type or the doc page, ask the
|
||||||
|
question that unblocks me. If you catch yourself about to write the body of a function I asked
|
||||||
|
about, stop and turn it into a question instead.
|
||||||
|
|
||||||
|
Exception: throwaway tooling that is not the thing I'm learning — a Python script to generate
|
||||||
|
reference data, a scratchpad experiment to settle a language question. Write those yourself.
|
||||||
|
Anything that lands in `crates/` is mine to type.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
Don't announce the phases or narrate moving between them. Just work this way.
|
||||||
|
|
||||||
|
### 1. Frame it
|
||||||
|
|
||||||
|
Before any code, settle in conversation:
|
||||||
|
|
||||||
|
- Which crate does this belong to? If it's arithmetic over money, dates, or rates, it's
|
||||||
|
`immo-core` — even when it's tempting to inline it in a handler.
|
||||||
|
- What are the inputs and outputs, in domain terms? Keep the French vocabulary.
|
||||||
|
- Does this need a new dependency? **Ask me before adding one, including small ones.**
|
||||||
|
- Is there an existing decision this touches that we already settled? Don't reopen it.
|
||||||
|
|
||||||
|
If my request implies an unidiomatic design, say so now, before I write anything.
|
||||||
|
|
||||||
|
### 2. Ground the domain in something real
|
||||||
|
|
||||||
|
This is a financial tool. A wrong number is worse than a missing feature.
|
||||||
|
|
||||||
|
- If the feature depends on a real figure — a barème, a taux, a fee schedule, an IRL value, a
|
||||||
|
rounding or day-count convention — **find a real source before writing any code.** A bank's
|
||||||
|
own amortisation table, a Légifrance article, an official barème.
|
||||||
|
- **Never invent a plausible-looking French tax number.** If you don't know it, say so and we
|
||||||
|
leave a clearly named placeholder.
|
||||||
|
- When a source settles a convention, reproduce it numerically to the cent before trusting it.
|
||||||
|
Matching "roughly" is not matching.
|
||||||
|
- **State what the evidence does not settle.** If the document can't distinguish half-up from
|
||||||
|
banker's rounding because no exact tie occurs in it, say that out loud rather than picking
|
||||||
|
one silently.
|
||||||
|
- Personal source documents (bank statements, offers, anything with my name or accounts) are
|
||||||
|
excluded via `.git/info/exclude`, never `.gitignore` — the filename itself must not reach the
|
||||||
|
remote. Never quote an account number into a file that gets pushed.
|
||||||
|
|
||||||
|
Every assumption that survives goes in `crates/immo-core/src/assumptions.rs` with a `///`
|
||||||
|
comment giving its source and the date it was checked. Nothing hardcoded anywhere else.
|
||||||
|
|
||||||
|
### 3. Talk through the design, then let me build
|
||||||
|
|
||||||
|
Ask me how I want to represent the thing before I commit to it. Push on representation
|
||||||
|
specifically: most of the bugs we've caught came from a type that could hold a state it should
|
||||||
|
never have held.
|
||||||
|
|
||||||
|
When you use a non-obvious idiom — extractors, `Layer`, a lifetime on a struct, `impl Trait` in
|
||||||
|
return position — explain **why** in one or two sentences in chat, not as a code comment.
|
||||||
|
|
||||||
|
Prefer the simple version over the clever one, and tell me when you deliberately chose simple.
|
||||||
|
|
||||||
|
When I ask how to compute something, **derive it, don't state it.** Start from the recurrence or
|
||||||
|
the definition, get to the closed form, then give me sanity checks I can run in my head. Then
|
||||||
|
hand me the implementation questions unsolved — which `pow` variant, what happens at the
|
||||||
|
degenerate input, what gets rounded and to what.
|
||||||
|
|
||||||
|
### 4. Review what I wrote
|
||||||
|
|
||||||
|
I'll say "what do you think?" — that's your cue. Go through it properly:
|
||||||
|
|
||||||
|
- **Units and names.** A field called `_in_percent` holding a fraction is a bug waiting to
|
||||||
|
happen. If both TAEG and taux nominal are in play, distinguish them explicitly.
|
||||||
|
- **Banned constructs.** `f64` for money. `as` casts between numeric types. `unwrap()`,
|
||||||
|
`expect()`, `panic!()` outside tests and `main`. Financial logic in `immo-web`.
|
||||||
|
- **Test provenance.** Every user-visible number needs a known-answer test whose expected value
|
||||||
|
came from *outside* the implementation — hand-computed or from a reference document. If the
|
||||||
|
expected value looks like it was pasted from a failing test's actual output, call it out.
|
||||||
|
Test names describe the *case* (zero, negative, max), never the input/output values.
|
||||||
|
- **Degenerate inputs.** Zero rate, zero duration, single period, the maximum. We accept a
|
||||||
|
zero rate because PTZ exists — check the formula doesn't divide by zero on it.
|
||||||
|
- **Half-done renames.** A variant whose name contradicts its own error message.
|
||||||
|
- Ask me to run `cargo clippy -p immo-core --all-targets -- -D warnings` before I show you
|
||||||
|
work. It has caught things before you did more than once.
|
||||||
|
|
||||||
|
Report findings plainly and ranked. Don't soften a real defect, and don't pad the list with
|
||||||
|
style opinions to look thorough.
|
||||||
|
|
||||||
|
### 5. Verify before you say it's finished
|
||||||
|
|
||||||
|
Run all four, and show me real output. If something fails, say so with the failure — never
|
||||||
|
report success you didn't observe.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p immo-core
|
||||||
|
cargo clippy -p immo-core --all-targets -- -D warnings
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo check -p immo-core --target wasm32-unknown-unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
The wasm check is not optional — `immo-core` must stay wasm-compilable, so any new dependency
|
||||||
|
gets checked against that target.
|
||||||
|
|
||||||
|
### 6. Commit atomically
|
||||||
|
|
||||||
|
Commit only when I say to. Then:
|
||||||
|
|
||||||
|
- One commit = one coherent change, and **the tree compiles at every point in history.**
|
||||||
|
- Order commits so dependencies land first — a `Display` impl before the error type whose
|
||||||
|
`#[derive(Error)]` needs it.
|
||||||
|
- Don't sweep unrelated in-flight work into a commit. If `loan_offer.rs` is mid-edit and we're
|
||||||
|
committing `euros.rs`, leave it out.
|
||||||
|
- Don't reformat or restructure files I didn't ask you to touch.
|
||||||
|
- Never commit or push an excluded personal document.
|
||||||
|
|
||||||
|
## Standing decisions — do not reopen
|
||||||
|
|
||||||
|
Check `MEMORY.md` and CLAUDE.md before raising a preference. If I've settled something, it
|
||||||
|
stays settled unless I bring it up. When I settle a new one during the feature, save it to
|
||||||
|
memory rather than relearning it next session.
|
||||||
21
Cargo.lock
generated
21
Cargo.lock
generated
|
|
@ -174,6 +174,7 @@ name = "immo-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -492,6 +493,26 @@ version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rust_decimal = "1.42.1"
|
rust_decimal = "1.42.1"
|
||||||
|
thiserror = "2.0.19"
|
||||||
|
|
||||||
[lints.clippy]
|
[lints.clippy]
|
||||||
inconsistent_digit_grouping = "allow"
|
inconsistent_digit_grouping = "allow"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
#[derive(PartialOrd, PartialEq, Ord, Eq, Clone, Copy, Debug)]
|
#[derive(PartialOrd, PartialEq, Ord, Eq, Clone, Copy, Debug)]
|
||||||
|
|
@ -19,6 +21,12 @@ impl Euros {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Euros {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{} €", self.as_decimal())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -77,6 +85,25 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn from_0_cents_to_decimal_stays_zero() {
|
fn from_0_cents_to_decimal_stays_zero() {
|
||||||
let euros = Euros::from_cents(0);
|
let euros = Euros::from_cents(0);
|
||||||
|
assert_eq!(euros.as_decimal().scale(), 2);
|
||||||
assert_eq!(euros.as_decimal(), Decimal::ZERO);
|
assert_eq!(euros.as_decimal(), Decimal::ZERO);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_euros_prints_correctly() {
|
||||||
|
let euros = Euros::from_cents(123_45);
|
||||||
|
assert_eq!(format!("{}", euros), "123.45 €");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn euros_without_cents_prints_with_trailing_zeros() {
|
||||||
|
let euros = Euros::from_cents(123_00);
|
||||||
|
assert_eq!(format!("{}", euros), "123.00 €");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn euros_with_negative_amount_print_with_minus() {
|
||||||
|
let euros = Euros::from_cents(-123_00);
|
||||||
|
assert_eq!(format!("{}", euros), "-123.00 €");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
pub mod euros;
|
pub mod euros;
|
||||||
|
pub mod loan_offer;
|
||||||
|
|
|
||||||
113
crates/immo-core/src/loan_offer.rs
Normal file
113
crates/immo-core/src/loan_offer.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::euros::Euros;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, Error, Clone)]
|
||||||
|
pub enum LoanOfferError {
|
||||||
|
#[error("loan cannot have a negative interest rate: {0}%")]
|
||||||
|
NegativeInterestRate(Decimal),
|
||||||
|
#[error("loan duration cannot be zero")]
|
||||||
|
ZeroLoanDuration,
|
||||||
|
#[error("borrowed capital should be strictly positive: {0}")]
|
||||||
|
NonPositiveBorrowedCapital(Euros),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct LoanOffer {
|
||||||
|
borrowed_capital: Euros,
|
||||||
|
annual_rate_in_percent: Decimal,
|
||||||
|
loan_duration_in_months: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoanOffer {
|
||||||
|
pub fn new(
|
||||||
|
borrowed_capital: Euros,
|
||||||
|
annual_rate_in_percent: Decimal,
|
||||||
|
loan_duration_in_months: u64,
|
||||||
|
) -> Result<Self, LoanOfferError> {
|
||||||
|
if annual_rate_in_percent < Decimal::ZERO {
|
||||||
|
Err(LoanOfferError::NegativeInterestRate(annual_rate_in_percent))
|
||||||
|
} else if loan_duration_in_months == 0 {
|
||||||
|
Err(LoanOfferError::ZeroLoanDuration)
|
||||||
|
} else if borrowed_capital <= Euros::from_cents(0) {
|
||||||
|
Err(LoanOfferError::NonPositiveBorrowedCapital(borrowed_capital))
|
||||||
|
} else {
|
||||||
|
Ok(LoanOffer {
|
||||||
|
borrowed_capital,
|
||||||
|
annual_rate_in_percent,
|
||||||
|
loan_duration_in_months,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creating_loan_offer_with_negative_interest_rate_returns_negative_interest_rate_error() {
|
||||||
|
let ret = LoanOffer::new(Euros::from_cents(123), Decimal::new(-45, 2), 5);
|
||||||
|
assert_eq!(
|
||||||
|
ret,
|
||||||
|
Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creating_loan_offer_with_null_loan_duration_returns_zero_loan_duration_error() {
|
||||||
|
let ret = LoanOffer::new(Euros::from_cents(123), Decimal::new(45, 2), 0);
|
||||||
|
assert_eq!(ret, Err(LoanOfferError::ZeroLoanDuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creating_loan_offer_with_negative_borrowed_capital_returns_non_positive_borrowed_capital_error()
|
||||||
|
{
|
||||||
|
let ret = LoanOffer::new(Euros::from_cents(-1), Decimal::new(45, 2), 15);
|
||||||
|
assert_eq!(
|
||||||
|
ret,
|
||||||
|
Err(LoanOfferError::NonPositiveBorrowedCapital(
|
||||||
|
Euros::from_cents(-1)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creating_loan_offer_with_null_borrowed_capital_returns_non_positive_borrowed_capital_error()
|
||||||
|
{
|
||||||
|
let ret = LoanOffer::new(Euros::from_cents(0), Decimal::new(45, 2), 15);
|
||||||
|
assert_eq!(
|
||||||
|
ret,
|
||||||
|
Err(LoanOfferError::NonPositiveBorrowedCapital(
|
||||||
|
Euros::from_cents(0)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creating_loan_offer_with_correct_inputs_works() {
|
||||||
|
let ret = LoanOffer::new(Euros::from_cents(10), Decimal::new(45, 2), 15);
|
||||||
|
assert_eq!(
|
||||||
|
ret,
|
||||||
|
Ok(LoanOffer {
|
||||||
|
borrowed_capital: Euros::from_cents(10),
|
||||||
|
annual_rate_in_percent: Decimal::new(45, 2),
|
||||||
|
loan_duration_in_months: 15
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creating_loan_offer_with_null_interest_rate_works() {
|
||||||
|
let ret = LoanOffer::new(Euros::from_cents(10), Decimal::ZERO, 15);
|
||||||
|
assert_eq!(
|
||||||
|
ret,
|
||||||
|
Ok(LoanOffer {
|
||||||
|
borrowed_capital: Euros::from_cents(10),
|
||||||
|
annual_rate_in_percent: Decimal::ZERO,
|
||||||
|
loan_duration_in_months: 15
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue