Asset descriptions can change: books.label_asset description, journaled as asset.described (with the old one), replayed, projected, shown in the journal
check / check (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 12:43:28 -06:00
1 parent 2e14c702c0
commit 49c49aef33
6 files changed
+127 -3

No files matched your search

+36
View File
@@ -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. /// The asset whose current tag is `tag`, ignoring case.
pub fn asset_by_tag(&self, tag: &str) -> Option<&Asset> { pub fn asset_by_tag(&self, tag: &str) -> Option<&Asset> {
let id = self.asset_tags.get(&assets::tag_key(tag))?; let id = self.asset_tags.get(&assets::tag_key(tag))?;
+9
View File
@@ -267,6 +267,15 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
}; };
ev(icon::BARCODE, text, asset_id.map(Route::Asset)) 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" => { "asset.acquisition_linked" => {
let entry = p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0); let entry = p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0);
Some(Event { Some(Event {
+21 -3
View File
@@ -2561,7 +2561,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.label_asset", 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( async fn label_asset(
&self, &self,
@@ -2574,11 +2574,23 @@ impl BooksServer {
if clear && p.serial.is_some() { if clear && p.serial.is_some() {
return Err("pass serial or clear_serial, not both".into()); 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( 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 { if let Some(entry) = p.acquisition_entry {
store store
.ledger() .ledger()
@@ -2612,12 +2624,18 @@ impl BooksServer {
.set_asset_acquisition(id, entry) .set_asset_acquisition(id, entry)
.map_err(|e| e.to_string())?; .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"); let a = store.ledger().get_asset(id).expect("resolved");
Ok(Json(LabelAssetOut { Ok(Json(LabelAssetOut {
asset_id: id, asset_id: id,
tag: a.tag.clone(), tag: a.tag.clone(),
serial: a.serial.clone(), serial: a.serial.clone(),
acquisition_entry: a.acquired_by, acquisition_entry: a.acquired_by,
description: a.description.clone(),
})) }))
} }
+5
View File
@@ -1537,6 +1537,9 @@ pub struct LabelAssetParams {
pub serial: Option<String>, pub serial: Option<String>,
/// True to clear the serial number. /// True to clear the serial number.
pub clear_serial: Option<bool>, pub clear_serial: Option<bool>,
/// A new description (what it is, as the register and label print it).
/// Omit to keep it.
pub description: Option<String>,
/// Link the posted entry that put it on the books (purchase or /// Link the posted entry that put it on the books (purchase or
/// contribution; it must debit the asset's cost account and not be /// contribution; it must debit the asset's cost account and not be
/// reversed). Use after correcting that entry, to link the replacement. /// reversed). Use after correcting that entry, to link the replacement.
@@ -1554,6 +1557,8 @@ pub struct LabelAssetOut {
pub serial: Option<String>, pub serial: Option<String>,
/// The entry that acquired it, if linked. /// The entry that acquired it, if linked.
pub acquisition_entry: Option<u64>, pub acquisition_entry: Option<u64>,
/// Its description now.
pub description: String,
} }
/// Parameters for `books.asset_labels`. /// Parameters for `books.asset_labels`.
+31
View File
@@ -725,6 +725,28 @@ impl Store {
Ok(()) 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 /// Link an asset to the entry that put it on the books (see
/// `Ledger::set_asset_acquisition`). Journaled as /// `Ledger::set_asset_acquisition`). Journaled as
/// `asset.acquisition_linked`. /// `asset.acquisition_linked`.
@@ -3430,6 +3452,9 @@ pub(crate) fn replay_line_into_ledger(
"asset.tagged" => { "asset.tagged" => {
ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?; 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" => { "asset.acquisition_linked" => {
ledger.set_asset_acquisition(get_u64(p, "asset_id")?, get_u64(p, "entry_id")?)?; 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], 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" => { "asset.acquisition_linked" => {
conn.execute( conn.execute(
"UPDATE assets SET acquisition_entry = ? WHERE id = ?", "UPDATE assets SET acquisition_entry = ? WHERE id = ?",
+25
View File
@@ -499,3 +499,28 @@ fn verify_compares_amounts_by_value() {
Store::open(&dir).unwrap().verify().unwrap(); 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();
}