diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 8dbb4d6..fc877a0 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -1620,6 +1620,42 @@ impl Ledger { } } + /// Change an asset's description (what it is, as the register and its + /// label print it). Its accounting doesn't change. + /// + /// ```text + /// GOAL: keep the register's words right when they go stale (a + /// corrected entry, a better name). + /// SOURCE: DECISION (Stephen, 2026-10-15). + /// SCENARIOS: + /// 1. IF the asset is unknown, or the description is blank: SHOULD + /// refuse, changing nothing. + /// ``` + pub fn set_asset_description( + &mut self, + id: AssetId, + description: &str, + ) -> Result<(), LedgerError> { + self.check_asset_description(id, description)?; + self.assets.get_mut(&id).expect("checked").description = description.to_string(); + Ok(()) + } + + /// The validation behind [`Self::set_asset_description`]. + pub fn check_asset_description( + &self, + id: AssetId, + description: &str, + ) -> Result<(), LedgerError> { + if !self.assets.contains_key(&id) { + return Err(LedgerError::UnknownAsset(id)); + } + if description.trim().is_empty() { + return Err(LedgerError::EmptyAssetDescription); + } + Ok(()) + } + /// The asset whose current tag is `tag`, ignoring case. pub fn asset_by_tag(&self, tag: &str) -> Option<&Asset> { let id = self.asset_tags.get(&assets::tag_key(tag))?; diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index e7a9fcc..21c23d5 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -267,6 +267,15 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { }; ev(icon::BARCODE, text, asset_id.map(Route::Asset)) } + "asset.described" => ev( + icon::PENCIL_SIMPLE, + format!( + "Renamed asset {} to {}", + s(p, "previous_description"), + s(p, "description") + ), + asset_id.map(Route::Asset), + ), "asset.acquisition_linked" => { let entry = p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0); Some(Event { diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 9e234bc..9116ebd 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -2561,7 +2561,7 @@ impl BooksServer { #[tool( name = "books.label_asset", - description = "Link a fixed asset to the entry that acquired it (acquisition_entry), or set or change its tag (the label on the thing: letters, digits, - _ . /, at most 32; unique ignoring case and never reused by another asset, even after a retag or disposal) and its manufacturer serial number. The asset is its id or current tag. Journaled. Print labels with books.asset_labels." + description = "Change a fixed asset's description, link it to the entry that acquired it (acquisition_entry), or set or change its tag (the label on the thing: letters, digits, - _ . /, at most 32; unique ignoring case and never reused by another asset, even after a retag or disposal) and its manufacturer serial number. The asset is its id or current tag. Journaled. Print labels with books.asset_labels." )] async fn label_asset( &self, @@ -2574,11 +2574,23 @@ impl BooksServer { if clear && p.serial.is_some() { return Err("pass serial or clear_serial, not both".into()); } - if p.tag.is_none() && p.serial.is_none() && !clear && p.acquisition_entry.is_none() { + if p.tag.is_none() + && p.serial.is_none() + && !clear + && p.acquisition_entry.is_none() + && p.description.is_none() + { return Err( - "nothing to change: pass tag, serial, clear_serial, or acquisition_entry".into(), + "nothing to change: pass tag, serial, clear_serial, description, or acquisition_entry" + .into(), ); } + if let Some(d) = &p.description { + store + .ledger() + .check_asset_description(id, d) + .map_err(|e| e.to_string())?; + } if let Some(entry) = p.acquisition_entry { store .ledger() @@ -2612,12 +2624,18 @@ impl BooksServer { .set_asset_acquisition(id, entry) .map_err(|e| e.to_string())?; } + if let Some(d) = &p.description { + store + .set_asset_description(id, d) + .map_err(|e| e.to_string())?; + } let a = store.ledger().get_asset(id).expect("resolved"); Ok(Json(LabelAssetOut { asset_id: id, tag: a.tag.clone(), serial: a.serial.clone(), acquisition_entry: a.acquired_by, + description: a.description.clone(), })) } diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 2cea06f..6aba607 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -1537,6 +1537,9 @@ pub struct LabelAssetParams { pub serial: Option, /// True to clear the serial number. pub clear_serial: Option, + /// A new description (what it is, as the register and label print it). + /// Omit to keep it. + pub description: Option, /// Link the posted entry that put it on the books (purchase or /// contribution; it must debit the asset's cost account and not be /// reversed). Use after correcting that entry, to link the replacement. @@ -1554,6 +1557,8 @@ pub struct LabelAssetOut { pub serial: Option, /// The entry that acquired it, if linked. pub acquisition_entry: Option, + /// Its description now. + pub description: String, } /// Parameters for `books.asset_labels`. diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 515227c..47e198e 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -725,6 +725,28 @@ impl Store { Ok(()) } + /// Change an asset's description (see `Ledger::set_asset_description`). + /// Journaled as `asset.described`, with the description it had. + pub fn set_asset_description( + &mut self, + id: AssetId, + description: &str, + ) -> Result<(), StoreError> { + self.ledger.check_asset_description(id, description)?; + let previous = self + .ledger + .get_asset(id) + .map(|a| a.description.clone()) + .unwrap_or_default(); + let line = self.append_journal( + "asset.described", + json!({"asset_id": id, "description": description, "previous_description": previous}), + )?; + self.ledger.set_asset_description(id, description)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + /// Link an asset to the entry that put it on the books (see /// `Ledger::set_asset_acquisition`). Journaled as /// `asset.acquisition_linked`. @@ -3430,6 +3452,9 @@ pub(crate) fn replay_line_into_ledger( "asset.tagged" => { ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?; } + "asset.described" => { + ledger.set_asset_description(get_u64(p, "asset_id")?, get_str(p, "description")?)?; + } "asset.acquisition_linked" => { ledger.set_asset_acquisition(get_u64(p, "asset_id")?, get_u64(p, "entry_id")?)?; } @@ -3904,6 +3929,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr params![id, tag], )?; } + "asset.described" => { + conn.execute( + "UPDATE assets SET description = ? WHERE id = ?", + params![get_str(p, "description")?, get_u64(p, "asset_id")? as i64], + )?; + } "asset.acquisition_linked" => { conn.execute( "UPDATE assets SET acquisition_entry = ? WHERE id = ?", diff --git a/crates/ledger-store/tests/asset_tags.rs b/crates/ledger-store/tests/asset_tags.rs index b5fd533..e75459f 100644 --- a/crates/ledger-store/tests/asset_tags.rs +++ b/crates/ledger-store/tests/asset_tags.rs @@ -499,3 +499,28 @@ fn verify_compares_amounts_by_value() { Store::open(&dir).unwrap().verify().unwrap(); } } + +/// A description change is journaled with the old one, replays on reopen +/// and from the journal alone, and verifies; a blank one is refused and +/// journals nothing. +#[test] +fn a_description_change_replays_and_verifies() { + let (dir, mut s, _, x, _) = fixture("described"); + let before = journal(&dir).len(); + assert!(s.set_asset_description(x, " ").is_err()); + assert_eq!(journal(&dir).len(), before, "a refusal journals nothing"); + s.set_asset_description(x, "Laptop, contributed jointly") + .unwrap(); + let (kind, p) = journal(&dir).pop().unwrap(); + assert_eq!(kind, "asset.described"); + assert_eq!(p["description"], "Laptop, contributed jointly"); + s.verify().unwrap(); + drop(s); + std::fs::remove_file(dir.join(DB_FILE)).unwrap(); + let s = Store::open(&dir).unwrap(); + assert_eq!( + s.ledger().get_asset(x).unwrap().description, + "Laptop, contributed jointly" + ); + s.verify().unwrap(); +}