patchwork_csv_utils 0.1.25-aarch64-linux → 0.1.26-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 428e2f4abd57105c275a4391288a71fabfc5cae964ba8c7b825027d71164b446
4
- data.tar.gz: 7f717a0d5577da392d8f11a71e9117c65762b6da973314443378837c56acd97d
3
+ metadata.gz: 48b29376c2100ab16451b75344c24acf4e5262436c9063ca37938ddc7c6f82ec
4
+ data.tar.gz: 1a1a0e368e425b2dbc65fcdfecfa4c871c51914b5c97ee6511f60691f5edfb42
5
5
  SHA512:
6
- metadata.gz: c9d64bc47d070017c3d5311cb1f0083ca82c6892f6ad644938fa38e3b8bb2e5571952a0c097b0069f15a1e25af6c3bb9e7f08e821763633e382682a741a16fe3
7
- data.tar.gz: 0ef2e4c2597e023d8fa85dc6100a1672e54f6250840e668b997c946852cbbb5bec5a7c951cb798f05ba345b7dee97b2d8172681708790a3d1a3215141d414e26
6
+ metadata.gz: be279537c6c0f636394bb8743ea910865a424a369bcdc868f3df536669b678e0bfed456bd89e0560a60dbb3ca22cc3d23bf5f20b1b0e5f8a2aa58162fa327022
7
+ data.tar.gz: e66000aa0bb16e83fb2dc9fdc5dd6145865e27a4d5808591b3ce4c107e2ecbf7100478781d335939c7ff4a26933861abc3447002fb0e026dfce8a875d0bfb191
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- patchwork_csv_utils (0.1.25)
4
+ patchwork_csv_utils (0.1.26)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
@@ -5,7 +5,8 @@ use std::collections::HashMap;
5
5
  use std::fs::File;
6
6
 
7
7
  use crate::utils::shared::datetime::DateTimeProcessor;
8
- use crate::utils::shared::filters::RowFilters;
8
+ use crate::utils::shared::dropped_rows::dropped_to_ruby;
9
+ use crate::utils::shared::filters::{FilterableRecord, RowFilters, SkipReason};
9
10
  use crate::utils::shared::types::{HeaderConfig, MandatoryColumn, ProcessingConfig};
10
11
  use crate::utils::shared::validation::TrustValidator;
11
12
  use crate::utils::{
@@ -25,7 +26,7 @@ pub fn transform_csv(
25
26
  expected_trust_name: String,
26
27
  is_streamed_file: bool,
27
28
  earliest_start_date: Option<String>,
28
- ) -> magnus::error::Result<()> {
29
+ ) -> magnus::error::Result<RArray> {
29
30
  if !csv_path.has_extension(&["csv"]) {
30
31
  return Err(Error::new(
31
32
  ruby.exception_standard_error(),
@@ -55,7 +56,7 @@ pub fn transform_csv(
55
56
  if let Some(value) =
56
57
  check_mandatory_headers(ruby, &headers_list, &config.mandatory_headers, "csv")
57
58
  {
58
- return value;
59
+ return Err(value);
59
60
  }
60
61
 
61
62
  let header_map = create_header_map(&config.mandatory_headers);
@@ -80,13 +81,21 @@ pub fn transform_csv(
80
81
  let mandatory_records =
81
82
  get_mandatory_records(ruby, &mut csv, &headers_list, &config.mandatory_headers)?;
82
83
 
84
+ let mut dropped: Vec<(String, SkipReason)> = Vec::new();
85
+
83
86
  for (ri, record) in mandatory_records.iter().enumerate() {
84
- if filters.should_skip(
87
+ if let Some(reason) = filters.skip_reason(
85
88
  record,
86
89
  header_config.request_id,
87
90
  header_config.status,
88
91
  header_config.date,
89
92
  ) {
93
+ dropped.push((
94
+ record
95
+ .get_request_id(header_config.request_id)
96
+ .unwrap_or_default(),
97
+ reason,
98
+ ));
90
99
  continue;
91
100
  }
92
101
 
@@ -149,7 +158,7 @@ pub fn transform_csv(
149
158
 
150
159
  wtr.flush().map_err(|e| magnus_err(ruby, e, "flush"))?;
151
160
 
152
- Ok(())
161
+ dropped_to_ruby(ruby, dropped)
153
162
  }
154
163
 
155
164
  fn get_mandatory_records(
@@ -48,13 +48,13 @@ pub fn dedup(
48
48
  &mandatory_headers,
49
49
  "previous_csv",
50
50
  ) {
51
- return err;
51
+ return Err(err);
52
52
  }
53
53
 
54
54
  if let Some(err) =
55
55
  check_mandatory_headers(ruby, &new_headers_list, &mandatory_headers, "new_csv")
56
56
  {
57
- return err;
57
+ return Err(err);
58
58
  }
59
59
 
60
60
  wtr.write_byte_record(&headers_as_byte_record(mandatory_headers.clone()))
@@ -49,14 +49,14 @@ fn check_mandatory_headers(
49
49
  headers: &[String],
50
50
  mandatory_headers: &[String],
51
51
  message: &str,
52
- ) -> Option<magnus::error::Result<()>> {
52
+ ) -> Option<magnus::Error> {
53
53
  let csv_mandatory_headers = filter_headers(headers, mandatory_headers);
54
54
 
55
55
  if csv_mandatory_headers.is_empty() {
56
- return Some(Err(magnus::Error::new(
56
+ return Some(magnus::Error::new(
57
57
  ruby.exception_standard_error(),
58
58
  format!("{} has no mandatory headers", message),
59
- )));
59
+ ));
60
60
  }
61
61
 
62
62
  let csv_mandatory_headers = csv_mandatory_headers.to_owned().clone();
@@ -71,14 +71,14 @@ fn check_mandatory_headers(
71
71
  .iter()
72
72
  .map(|h| h.to_string())
73
73
  .collect::<Vec<String>>();
74
- return Some(Err(magnus::Error::new(
74
+ return Some(magnus::Error::new(
75
75
  ruby.exception_standard_error(),
76
76
  format!(
77
77
  "{} is missing mandatory headers: {}",
78
78
  message,
79
79
  missing_headers.join(", ")
80
80
  ),
81
- )));
81
+ ));
82
82
  }
83
83
  None
84
84
  }
@@ -0,0 +1,25 @@
1
+ use magnus::{RArray, Ruby};
2
+
3
+ use crate::utils::shared::filters::SkipReason;
4
+
5
+ /// Converts dropped rows into `[[request_id, reason], ...]` for Ruby.
6
+ ///
7
+ /// A flat array of pairs rather than a hash: a Request Id can legitimately appear more
8
+ /// than once in an export, and keying by it would silently collapse those into one.
9
+ ///
10
+ /// The id is empty for `empty_row` and `empty_request_id`, because those rows have no
11
+ /// id to report. Consumers should treat those two reasons as counts rather than as
12
+ /// identifiable rows.
13
+ pub fn dropped_to_ruby(
14
+ ruby: &Ruby,
15
+ dropped: Vec<(String, SkipReason)>,
16
+ ) -> magnus::error::Result<RArray> {
17
+ let out = ruby.ary_new_capa(dropped.len());
18
+ for (request_id, reason) in dropped {
19
+ let pair = ruby.ary_new_capa(2);
20
+ pair.push(request_id)?;
21
+ pair.push(reason.as_str())?;
22
+ out.push(pair)?;
23
+ }
24
+ Ok(out)
25
+ }
@@ -10,6 +10,32 @@ pub trait FilterableRecord {
10
10
  fn get_date(&self, index: usize) -> Option<NaiveDateTime>;
11
11
  }
12
12
 
13
+ /// Why a row was dropped.
14
+ ///
15
+ /// The filter used to return a bare bool, so when a shift went missing there was no way
16
+ /// to tell whether Allocate never sent it or Patchwork discarded it, and if discarded,
17
+ /// on which rule. See PLT-2487.
18
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19
+ pub enum SkipReason {
20
+ EmptyRow,
21
+ EmptyRequestId,
22
+ PendingTaskExclusion,
23
+ StatusExclusion,
24
+ EarliestStartDate,
25
+ }
26
+
27
+ impl SkipReason {
28
+ pub fn as_str(self) -> &'static str {
29
+ match self {
30
+ SkipReason::EmptyRow => "empty_row",
31
+ SkipReason::EmptyRequestId => "empty_request_id",
32
+ SkipReason::PendingTaskExclusion => "pending_task_exclusion",
33
+ SkipReason::StatusExclusion => "status_exclusion",
34
+ SkipReason::EarliestStartDate => "earliest_start_date",
35
+ }
36
+ }
37
+ }
38
+
13
39
  pub struct RowFilters {
14
40
  exclusions: Vec<String>,
15
41
  status_exclusions: Vec<String>,
@@ -50,11 +76,36 @@ impl RowFilters {
50
76
  status_index: Option<usize>,
51
77
  date_index: usize,
52
78
  ) -> bool {
53
- record.is_empty()
54
- || record.has_empty_first_column()
55
- || self.should_skip_by_exclusion(record, request_id_index, status_index)
56
- || self.should_skip_by_status(record, status_index)
57
- || self.should_skip_by_date(record, date_index)
79
+ self.skip_reason(record, request_id_index, status_index, date_index)
80
+ .is_some()
81
+ }
82
+
83
+ /// First match wins, preserving the original short-circuit order. A row can satisfy
84
+ /// several rules at once; reporting the first is enough to explain the drop and
85
+ /// avoids evaluating predicates whose answer cannot change the outcome.
86
+ pub fn skip_reason<R: FilterableRecord>(
87
+ &self,
88
+ record: &R,
89
+ request_id_index: usize,
90
+ status_index: Option<usize>,
91
+ date_index: usize,
92
+ ) -> Option<SkipReason> {
93
+ if record.is_empty() {
94
+ return Some(SkipReason::EmptyRow);
95
+ }
96
+ if record.has_empty_first_column() {
97
+ return Some(SkipReason::EmptyRequestId);
98
+ }
99
+ if self.should_skip_by_exclusion(record, request_id_index, status_index) {
100
+ return Some(SkipReason::PendingTaskExclusion);
101
+ }
102
+ if self.should_skip_by_status(record, status_index) {
103
+ return Some(SkipReason::StatusExclusion);
104
+ }
105
+ if self.should_skip_by_date(record, date_index) {
106
+ return Some(SkipReason::EarliestStartDate);
107
+ }
108
+ None
58
109
  }
59
110
 
60
111
  fn should_skip_by_exclusion<R: FilterableRecord>(
@@ -195,4 +246,120 @@ mod tests {
195
246
  2
196
247
  ));
197
248
  }
249
+
250
+ fn reason_for(filters: &RowFilters, cells: &[&str]) -> Option<SkipReason> {
251
+ filters.skip_reason(&row(cells), 0, Some(1), 2)
252
+ }
253
+
254
+ #[test]
255
+ fn reports_which_rule_dropped_the_row() {
256
+ let filters = RowFilters::new(
257
+ vec!["0726154700".to_string()],
258
+ vec!["Cancelled".to_string()],
259
+ Some(
260
+ NaiveDateTime::parse_from_str("2026-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
261
+ ),
262
+ );
263
+
264
+ assert_eq!(
265
+ reason_for(&filters, &["", "", ""]),
266
+ Some(SkipReason::EmptyRow)
267
+ );
268
+ assert_eq!(
269
+ reason_for(&filters, &["", "UnFilled Bank", "2026-07-30 00:00:00"]),
270
+ Some(SkipReason::EmptyRequestId)
271
+ );
272
+ assert_eq!(
273
+ reason_for(
274
+ &filters,
275
+ &["0726154700", "UnFilled Bank", "2026-07-30 00:00:00"]
276
+ ),
277
+ Some(SkipReason::PendingTaskExclusion)
278
+ );
279
+ assert_eq!(
280
+ reason_for(
281
+ &filters,
282
+ &["0726154999", "Cancelled", "2026-07-30 00:00:00"]
283
+ ),
284
+ Some(SkipReason::StatusExclusion)
285
+ );
286
+ assert_eq!(
287
+ reason_for(
288
+ &filters,
289
+ &["0726154999", "UnFilled Bank", "2025-06-01 00:00:00"]
290
+ ),
291
+ Some(SkipReason::EarliestStartDate)
292
+ );
293
+ }
294
+
295
+ #[test]
296
+ fn reports_no_reason_for_a_row_that_survives() {
297
+ let filters = excluding("0726154700");
298
+
299
+ assert_eq!(
300
+ reason_for(
301
+ &filters,
302
+ &["0726154999", "UnFilled Bank", "2026-07-30 00:00:00"]
303
+ ),
304
+ None
305
+ );
306
+ }
307
+
308
+ #[test]
309
+ fn skip_reason_and_should_skip_always_agree() {
310
+ let filters = RowFilters::new(
311
+ vec!["0726154700".to_string()],
312
+ vec!["Cancelled".to_string()],
313
+ None,
314
+ );
315
+
316
+ for cells in [
317
+ vec!["", "", ""],
318
+ vec!["", "UnFilled Bank", "2026-07-30 00:00:00"],
319
+ vec!["0726154700", "UnFilled Bank", "2026-07-30 00:00:00"],
320
+ vec!["0726154999", "Cancelled", "2026-07-30 00:00:00"],
321
+ vec!["0726154999", "UnFilled Bank", "2026-07-30 00:00:00"],
322
+ ] {
323
+ let record = row(&cells);
324
+ assert_eq!(
325
+ filters.should_skip(&record, 0, Some(1), 2),
326
+ filters.skip_reason(&record, 0, Some(1), 2).is_some(),
327
+ "disagreement on {cells:?}"
328
+ );
329
+ }
330
+ }
331
+
332
+ #[test]
333
+ fn reports_the_first_matching_rule_when_several_apply() {
334
+ // Excluded AND status-excluded. The exclusion check runs first, and reporting one
335
+ // reason is enough to explain the drop.
336
+ let filters = RowFilters::new(
337
+ vec!["0726154700".to_string()],
338
+ vec!["Cancelled".to_string()],
339
+ None,
340
+ );
341
+
342
+ assert_eq!(
343
+ reason_for(
344
+ &filters,
345
+ &["0726154700", "Cancelled", "2026-07-30 00:00:00"]
346
+ ),
347
+ Some(SkipReason::PendingTaskExclusion)
348
+ );
349
+ }
350
+
351
+ #[test]
352
+ fn reason_names_are_stable_for_consumers() {
353
+ assert_eq!(SkipReason::EmptyRow.as_str(), "empty_row");
354
+ assert_eq!(SkipReason::EmptyRequestId.as_str(), "empty_request_id");
355
+ assert_eq!(
356
+ SkipReason::PendingTaskExclusion.as_str(),
357
+ "pending_task_exclusion"
358
+ );
359
+ assert_eq!(SkipReason::StatusExclusion.as_str(), "status_exclusion");
360
+ assert_eq!(
361
+ SkipReason::EarliestStartDate.as_str(),
362
+ "earliest_start_date"
363
+ );
364
+ }
198
365
  }
@@ -1,4 +1,5 @@
1
1
  pub mod datetime;
2
+ pub mod dropped_rows;
2
3
  pub mod filters;
3
4
  pub mod types;
4
5
  pub mod validation;
@@ -7,7 +7,8 @@ use chrono::{NaiveDateTime, Timelike, Utc};
7
7
  use magnus::{RArray, Ruby};
8
8
 
9
9
  use crate::utils::shared::datetime::DateTimeProcessor;
10
- use crate::utils::shared::filters::RowFilters;
10
+ use crate::utils::shared::dropped_rows::dropped_to_ruby;
11
+ use crate::utils::shared::filters::{FilterableRecord, RowFilters, SkipReason};
11
12
  use crate::utils::shared::types::{HeaderConfig, MandatoryColumn, ProcessingConfig};
12
13
  use crate::utils::shared::validation::TrustValidator;
13
14
  use crate::utils::{
@@ -26,7 +27,7 @@ pub fn to_csv(
26
27
  expected_trust_name: String,
27
28
  is_streamed_file: bool,
28
29
  earliest_start_date: Option<String>,
29
- ) -> magnus::error::Result<()> {
30
+ ) -> magnus::error::Result<RArray> {
30
31
  if !xls_path.has_extension(&["xls", "xlsx"]) {
31
32
  return Err(magnus::Error::new(
32
33
  ruby.exception_standard_error(),
@@ -72,7 +73,7 @@ pub fn to_csv(
72
73
  if let Some(value) =
73
74
  check_mandatory_headers(ruby, &headers_list, &config.mandatory_headers, "csv")
74
75
  {
75
- return value;
76
+ return Err(value);
76
77
  }
77
78
 
78
79
  let csv_out_file = File::create(&target_path).map_err(|e| {
@@ -84,7 +85,8 @@ pub fn to_csv(
84
85
  })?;
85
86
  let mut dest = BufWriter::new(csv_out_file);
86
87
 
87
- write_csv(ruby, &mut dest, &range, config, headers_list)
88
+ let dropped = write_csv(ruby, &mut dest, &range, config, headers_list)?;
89
+ dropped_to_ruby(ruby, dropped)
88
90
  }
89
91
 
90
92
  fn write_csv<W: Write>(
@@ -93,7 +95,7 @@ fn write_csv<W: Write>(
93
95
  range: &Range<Data>,
94
96
  config: ProcessingConfig,
95
97
  headers_list: Vec<String>,
96
- ) -> magnus::error::Result<()> {
98
+ ) -> magnus::error::Result<Vec<(String, SkipReason)>> {
97
99
  let n = config.mandatory_headers.len() - 1;
98
100
  let header_map: HashMap<String, usize> = config
99
101
  .mandatory_headers
@@ -113,13 +115,20 @@ fn write_csv<W: Write>(
113
115
  let mandatory_rows =
114
116
  get_mandatory_records(ruby, range, &headers_list, &config.mandatory_headers)?;
115
117
 
118
+ let mut dropped: Vec<(String, SkipReason)> = Vec::new();
119
+
116
120
  for (ri, r) in mandatory_rows.into_iter().enumerate() {
117
- if filters.should_skip(
121
+ if let Some(reason) = filters.skip_reason(
118
122
  &r,
119
123
  header_config.request_id,
120
124
  header_config.status,
121
125
  header_config.date,
122
126
  ) {
127
+ dropped.push((
128
+ r.get_request_id(header_config.request_id)
129
+ .unwrap_or_default(),
130
+ reason,
131
+ ));
123
132
  continue;
124
133
  }
125
134
 
@@ -184,7 +193,7 @@ fn write_csv<W: Write>(
184
193
  )
185
194
  })?;
186
195
  }
187
- Ok(())
196
+ Ok(dropped)
188
197
  }
189
198
 
190
199
  fn handle_datetime_iso<W: Write>(
Binary file
Binary file
Binary file
Binary file
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CsvUtils
4
- VERSION = '0.1.25'
4
+ VERSION = '0.1.26'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patchwork_csv_utils
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.25
4
+ version: 0.1.26
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - kingsley.hendrickse
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-24 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Deduplication of CSV files and XLS to CSV conversion.
14
14
  email:
@@ -33,6 +33,7 @@ files:
33
33
  - ext/csv_utils/src/utils/dedup.rs
34
34
  - ext/csv_utils/src/utils/mod.rs
35
35
  - ext/csv_utils/src/utils/shared/datetime.rs
36
+ - ext/csv_utils/src/utils/shared/dropped_rows.rs
36
37
  - ext/csv_utils/src/utils/shared/filters.rs
37
38
  - ext/csv_utils/src/utils/shared/mod.rs
38
39
  - ext/csv_utils/src/utils/shared/types.rs