Skip to content

Commit c1c769d

Browse files
committed
feat: implement ext-id.unique for tep-1018
ref #141 Signed-off-by: 35V LG84 <35vlg84-x4e6b92@e257.fi>
1 parent bb79f7f commit c1c769d

8 files changed

Lines changed: 201 additions & 16 deletions

File tree

docs/tep/tep-1018.adoc

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,15 @@ See also future plans for use case xref:use-case-lookup[lookup].
5555

5656
Changes to conf-settings
5757

58-
* [ ] new configuration table `kernel.ext-id`
59-
** [ ] option to enforce uniqueness `kernel.ext-id.unique = <true | false>`
58+
* [x] new configuration table `kernel.ext-id`
59+
** [x] option to enforce uniqueness `kernel.ext-id.unique = <true | false>`
6060
** [ ] _future_: option to enforce existence of `ext-id` `kernel.ext-id.mandatory = <true | false>`
6161

6262
==== Tackler `init` / `new` commands
6363

6464
Changes to tackler `new` or `init` commands
6565

66-
* [ ] Add the ext-id section, with `unique = false`
66+
* [x] Add the ext-id section, with `unique = false`
6767

6868

6969
=== Filtering Changes
@@ -86,8 +86,6 @@ Changes to filtering logic or implementation
8686

8787
Changes to the tackler core engine (e.g. core implementation)
8888

89-
* [ ] item
90-
9189

9290
==== API Changes
9391

tackler-cli/src/commands/init/tackler_toml.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Tackler-NG 2025
2+
* Tackler-NG 2025-2026
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

@@ -15,6 +15,7 @@ pub(super) const TXT: &str = r#"#
1515
### Valid values are <true|false>
1616
strict = false
1717
audit = { mode = false, hash = "SHA-256" }
18+
ext-id = { unique = false }
1819
timestamp = { default-time = 00:00:00, timezone = { name = "UTC" } }
1920
2021
[kernel.input]

tackler-core/src/config/items.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
*/
55
use crate::config::raw_items::{
66
AccountsPathRaw, AccountsRaw, AuditRaw, BalanceGroupRaw, BalanceRaw, CommoditiesPathRaw,
7-
CommoditiesRaw, ConfigRaw, EquityRaw, ExportRaw, FsRaw, GitRaw, InputRaw, KernelRaw, PriceRaw,
8-
RegisterRaw, ReportRaw, ScaleRaw, TagsPathRaw, TagsRaw, TimestampRaw, TimezoneRaw,
7+
CommoditiesRaw, ConfigRaw, EquityRaw, ExportRaw, ExtIdRaw, FsRaw, GitRaw, InputRaw, KernelRaw,
8+
PriceRaw, RegisterRaw, ReportRaw, ScaleRaw, TagsPathRaw, TagsRaw, TimestampRaw, TimezoneRaw,
99
TransactionRaw,
1010
};
1111
use crate::config::{to_export_targets, to_report_formats, to_report_targets};
@@ -272,6 +272,7 @@ pub(crate) struct Kernel {
272272
pub(crate) strict: bool,
273273
pub(crate) timestamp: Timestamp,
274274
pub(crate) audit: Audit,
275+
pub(crate) extid: ExtId,
275276
pub input: Input,
276277
}
277278
impl Kernel {
@@ -280,6 +281,7 @@ impl Kernel {
280281
strict: k_raw.strict,
281282
timestamp: Timestamp::from(&k_raw.timestamp)?,
282283
audit: Audit::from(&k_raw.audit)?,
284+
extid: ExtId::from(k_raw.extid.as_ref()),
283285
input: Input::try_from(&k_raw.input)?,
284286
};
285287
Ok(k)
@@ -357,6 +359,19 @@ impl Audit {
357359
}
358360
}
359361

362+
#[derive(Debug, Clone, Default)]
363+
pub(crate) struct ExtId {
364+
pub(crate) unique: bool,
365+
}
366+
367+
impl ExtId {
368+
fn from(a_raw: Option<&ExtIdRaw>) -> ExtId {
369+
ExtId {
370+
unique: a_raw.is_some_and(|extid| extid.unique),
371+
}
372+
}
373+
}
374+
360375
#[derive(Debug, Clone, Default)]
361376
pub struct Input {
362377
pub storage: StorageType,

tackler-core/src/config/raw_items.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ pub(super) struct KernelRaw {
2121
pub(super) strict: bool,
2222
pub(super) timestamp: TimestampRaw,
2323
pub(super) audit: AuditRaw,
24+
#[serde(rename = "ext-id")]
25+
pub(super) extid: Option<ExtIdRaw>,
2426
pub(super) input: InputRaw,
2527
}
2628

@@ -46,6 +48,13 @@ pub(super) struct AuditRaw {
4648
pub(super) mode: bool,
4749
}
4850

51+
#[derive(Debug, Clone, Deserialize)]
52+
#[serde(deny_unknown_fields)]
53+
#[serde(rename = "ext-id")]
54+
pub(super) struct ExtIdRaw {
55+
pub(super) unique: bool,
56+
}
57+
4958
#[derive(Debug, Clone, Deserialize)]
5059
#[serde(deny_unknown_fields)]
5160
pub(super) struct InputRaw {

tackler-core/src/kernel/settings.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,12 @@ impl Settings {
217217
..Self::default()
218218
}
219219
}
220+
#[must_use]
221+
pub fn default_extid() -> Self {
222+
let mut s = Settings::default();
223+
s.kernel.extid.unique = true;
224+
s
225+
}
220226
}
221227

222228
impl Settings {
@@ -396,6 +402,10 @@ impl Settings {
396402
}
397403
}
398404

405+
pub(crate) fn is_extid_unique(&self) -> bool {
406+
self.kernel.extid.unique
407+
}
408+
399409
pub(crate) fn get_txn_account(
400410
&self,
401411
name: &str,

tackler-core/src/model/txn_data.rs

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
use crate::kernel::Predicate;
76
use crate::kernel::hash::Hash;
7+
use crate::kernel::{Predicate, Settings};
88
use crate::model::{TxnRefs, Txns, transaction};
99
use crate::tackler;
1010
use itertools::Itertools;
@@ -18,6 +18,7 @@ pub struct TxnData {
1818
metadata: Option<Metadata>,
1919
txns: Txns,
2020
hash: Option<Hash>,
21+
unique_extid: bool,
2122
}
2223

2324
pub struct TxnSet<'a> {
@@ -54,21 +55,25 @@ impl TxnData {
5455
pub fn try_from(
5556
mdi_opt: Option<MetadataItem>,
5657
txns: Txns,
57-
hash: &Option<Hash>,
58+
settings: &Settings,
5859
) -> Result<TxnData, tackler::Error> {
5960
let metadata = mdi_opt.map(Metadata::from_mdi);
6061

61-
if hash.is_some() {
62+
if settings.audit_mode {
6263
check_uuids(&txns)?;
6364
}
65+
if settings.is_extid_unique() {
66+
check_extid(&txns)?;
67+
}
6468

6569
let mut t = txns;
6670
t.sort_by(transaction::ord_by_txn);
6771

6872
Ok(TxnData {
6973
metadata,
7074
txns: t,
71-
hash: hash.clone(),
75+
hash: settings.get_hash().clone(),
76+
unique_extid: settings.is_extid_unique(),
7277
})
7378
}
7479

@@ -86,6 +91,9 @@ impl TxnData {
8691
if self.hash.is_some() {
8792
check_uuids(&self.txns)?;
8893
}
94+
if self.unique_extid {
95+
check_extid(&self.txns)?;
96+
}
8997

9098
let metadata =
9199
TxnData::make_metadata(self.hash.as_ref(), None, &self.txns.iter().collect())?;
@@ -150,6 +158,34 @@ impl TxnData {
150158
}
151159
}
152160

161+
fn check_extid(txns: &Txns) -> Result<(), tackler::Error> {
162+
let dups: Vec<&String> = txns
163+
.iter()
164+
.filter_map(|txn| txn.header.extid.as_ref())
165+
.duplicates()
166+
.collect();
167+
168+
if dups.is_empty() {
169+
Ok(())
170+
} else {
171+
let dups_count = dups.len();
172+
let msg = if dups_count < 10 {
173+
format!(
174+
"Found {} duplicate external ids.\nDuplicate ext-ids are:\n{}",
175+
dups.len(),
176+
dups.iter().join(",\n")
177+
)
178+
} else {
179+
format!(
180+
"Found {} duplicate external is.\nFirst ten duplicate ext-ids are:\n{}",
181+
dups.len(),
182+
dups[0..10].iter().join(",\n")
183+
)
184+
};
185+
Err(msg.into())
186+
}
187+
}
188+
153189
fn check_uuids(txns: &Txns) -> Result<(), tackler::Error> {
154190
if txns.iter().any(|txn| txn.header.uuid.is_none()) {
155191
let msg =

tackler-core/src/parser/tackler_txns.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ pub fn string_to_txns(
3131
// feature: a94d4a60-40dc-4ec0-97a3-eeb69399f01b
3232
// coverage: "sorted" tested by 200aad57-9275-4d16-bdad-2f1c484bcf17
3333

34-
TxnData::try_from(None, txns, &settings.get_hash())
34+
TxnData::try_from(None, txns, settings)
3535
}
3636

3737
/// # Errors
@@ -46,7 +46,7 @@ pub fn paths_to_txns(
4646
.flatten_ok()
4747
.collect();
4848

49-
TxnData::try_from(None, txns?, &settings.get_hash())
49+
TxnData::try_from(None, txns?, settings)
5050
}
5151

5252
/// # Errors
@@ -204,6 +204,9 @@ pub fn git_to_txns(
204204
// perf: let ts_end = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(/*:test:*/);
205205
// perf: eprintln!("total time: {}ms, parse time: {}ms, git: {}ms", (ts_end.as_millis() - ts_start.as_millis()), ts_par_total, (ts_end.as_millis() - ts_start.as_millis())-ts_par_total);
206206

207-
let hash = &settings.get_hash();
208-
TxnData::try_from(Some(MetadataItem::GitInputReference(gitmd)), txns?, hash)
207+
TxnData::try_from(
208+
Some(MetadataItem::GitInputReference(gitmd)),
209+
txns?,
210+
settings,
211+
)
209212
}

tackler-core/tests/txns_extid.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Tackler-NG 2026
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
mod txns_extid {
7+
use indoc::formatdoc;
8+
use std::fmt::Write;
9+
use tackler_core::kernel::Settings;
10+
use tackler_core::parser;
11+
use tackler_rs::IndocUtils;
12+
13+
const EXT_ID_01: &str = "ext-id #001";
14+
const EXT_ID_02: &str = "ext-id #002";
15+
const EXT_ID_03: &str = "ext-id #003";
16+
17+
#[rustfmt::skip]
18+
fn make_extids() -> String {
19+
formatdoc!(
20+
"2026-07-19 'txn01
21+
| # ext-id: {EXT_ID_01}
22+
| e 1
23+
| a
24+
|
25+
|2026-07-19 'txn02
26+
| # ext-id: {EXT_ID_02}
27+
| e 1
28+
| a
29+
|
30+
|2026-07-19 'txn03
31+
| # ext-id: {EXT_ID_03}
32+
| e 1
33+
| a
34+
|"
35+
).strip_margin()
36+
}
37+
38+
#[rustfmt::skip]
39+
fn dup_extid() -> String {
40+
formatdoc!(
41+
"2026-07-19 'txn04
42+
| # ext-id: {EXT_ID_02}
43+
| e 1
44+
| a
45+
|"
46+
).strip_margin()
47+
}
48+
49+
#[rustfmt::skip]
50+
fn make_dups_extid() -> String {
51+
let mut s = make_extids();
52+
_ = writeln!(s);
53+
_ = writeln!(s, "{}", dup_extid());
54+
s
55+
}
56+
57+
#[test]
58+
// test: f3c3f4fb-2c58-47d8-82a6-82b04a752e2e
59+
// desc: try_from accepts duplicate ext-ids
60+
fn txns_try_from_accepts_dup_extid() {
61+
let txns =
62+
parser::string_to_txns(&mut make_dups_extid().as_str(), &mut Settings::default());
63+
64+
assert!(txns.is_ok());
65+
}
66+
67+
#[test]
68+
// test: c4905afd-ea7a-460f-8f0b-ab46803f63be
69+
// desc: try_from detects duplicate ext-ids
70+
fn txns_try_from_detects_dup_extid() {
71+
let txns = parser::string_to_txns(
72+
&mut make_dups_extid().as_str(),
73+
&mut Settings::default_extid(),
74+
);
75+
76+
let err_msg = txns.expect_err("test case went wonky").to_string();
77+
78+
assert!(err_msg.contains("Found 1 duplicate"));
79+
assert!(err_msg.contains("ext-id #002"));
80+
}
81+
82+
#[test]
83+
// test: 843948ac-6d7a-402e-9fe3-931615fd9565
84+
// desc: append accepts duplicate ext-ids
85+
fn txns_append_accepts_dup_extid() {
86+
let mut txns = parser::string_to_txns(
87+
&mut make_extids().as_str(), &mut Settings::default()).unwrap(/*:test:*/);
88+
89+
let mut txns_dup = parser::string_to_txns(
90+
&mut dup_extid().as_str(), &mut Settings::default()).unwrap(/*:test:*/);
91+
92+
let res = txns.append(&mut txns_dup);
93+
94+
assert!(res.is_ok());
95+
}
96+
97+
#[test]
98+
// test: 1c8f8fb1-d96b-4661-8597-7ddda75194d5
99+
// desc: append detects duplicate ext-ids
100+
fn txns_append_detects_dup_extid() {
101+
let mut txns = parser::string_to_txns(
102+
&mut make_extids().as_str(), &mut Settings::default_extid()).unwrap(/*:test:*/);
103+
104+
let mut txns_dup = parser::string_to_txns(
105+
&mut dup_extid().as_str(), &mut Settings::default()).unwrap(/*:test:*/);
106+
107+
let err = txns.append(&mut txns_dup);
108+
let err_msg = err.expect_err("test case went wonky").to_string();
109+
110+
assert!(err_msg.contains("Found 1 duplicate"));
111+
assert!(err_msg.contains("ext-id #002"));
112+
}
113+
}

0 commit comments

Comments
 (0)