Compare commits
No commits in common. "f1e596f4752c376a18aa6970d95a136a095911d7" and "d8124348277786c9f198e912c79e89fa99b524fc" have entirely different histories.
f1e596f475
...
d812434827
6 changed files with 0 additions and 291 deletions
|
|
@ -1,128 +0,0 @@
|
||||||
---
|
|
||||||
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,7 +174,6 @@ name = "immo-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"thiserror",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -493,26 +492,6 @@ 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,7 +6,6 @@ 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,5 +1,3 @@
|
||||||
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)]
|
||||||
|
|
@ -21,12 +19,6 @@ 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::*;
|
||||||
|
|
@ -85,25 +77,6 @@ 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,2 +1 @@
|
||||||
pub mod euros;
|
pub mod euros;
|
||||||
pub mod loan_offer;
|
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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