cookcli-core 0.34.0

Recipe, shopping list, pantry and report operations for Cooklang, extracted from CookCLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
//! `cook pantry`: what is in stock, and changing it.
//!
//! [`load`] reads the configuration [`Context::pantry`] points at — a file, or
//! text an editor is holding — and the queries answer questions about it:
//! everything in it ([`list`]), what is running out ([`depleted`]), what is
//! about to go off ([`expiring`]), and which recipes it can already cook
//! ([`recipes`]).
//!
//! [`plan`] is the odd one out: it answers "what should I stock?" by looking at
//! the recipe collection alone, and never reads the pantry at all.
//!
//! [`add`], [`remove`] and [`update`] change the pantry and write it back.
//! They are the only functions in this crate that write to a file the user
//! owns, so read [`write_atomically`] and **[what a write
//! touches](#what-a-write-touches)** before calling them.
//!
//! # What a write touches
//!
//! **Only the entry asked for.** A change is applied to the file as a TOML
//! document, so everything else is left byte for byte as it was: comments,
//! blank lines, indentation, key order, the choice between `x = "1%kg"` and
//! `x = { quantity = "1%kg" }`, attributes `cooklang` does not model, and
//! values that are not strings.
//!
//! It did not always work this way. Every change used to re-parse the whole
//! file into `cooklang`'s model, apply itself, and serialise that model back —
//! so anything the model did not carry was gone the first time anything was
//! added, removed or updated, silently, on a file people hand-write. A
//! top-level item written with attributes fared worst: the parser reads
//!
//! ```toml
//! salt = { quantity = "1%kg", expire = "2027-01-01" }
//! ```
//!
//! as a *section* named `salt` holding items `quantity` and `expire`, and the
//! rewrite emitted it as one — destroying the item and inventing two, on a
//! command that had nothing to do with it
//! (<https://github.com/cooklang/cookcli/issues/429>).
//!
//! What a write still normalises, because it is what the writer must choose:
//!
//! - A **new** item is written `name = "quantity"` when that is all it has, and
//!   `name = { … }` when it carries more. An item added to `general` is always
//!   written in the short form, because a top-level inline table would be read
//!   back as a section header — the very shape above.
//! - An item **updated** with an attribute it has no room for grows from the
//!   short form into a table, keeping the quantity it had.
//! - A section emptied by [`remove`] is removed, matching what `cooklang` does
//!   with an empty section when it reads the file back.
//!
//! [`update`] refuses, rather than guesses, when an item's value is neither a
//! quantity nor a set of attributes — a hand-written `salt = 3`. Attributes
//! `cooklang` does not model are kept untouched; its own parse still reports
//! them as unknown fields, which is about what `cook pantry list` can show
//! rather than about anything being lost.

use crate::{
    diagnostic::parse_failure,
    find::{build_tree, listed_ingredients, parse_or_skip, walk},
    fs_atomic::write_atomically,
    parser::collect_diagnostics,
    ConfigSource, Context, CoreError, Diagnostic, Outcome,
};
use camino::{Utf8Path, Utf8PathBuf};
use chrono::{Local, NaiveDate};
use cooklang_find::RecipeEntry;
use regex::Regex;
use std::{
    cmp::Reverse,
    collections::{BTreeMap, BTreeSet},
    sync::LazyLock,
};

/// How [`ExpiringItem::expire_date`] is written, whatever the file used.
const ISO_DATE: &str = "%Y-%m-%d";

// ---------------------------------------------------------------------------
// What a pantry holds
// ---------------------------------------------------------------------------

/// One item in the pantry.
///
/// Every field is carried as it was written, without interpretation: a
/// quantity is `"500%g"` rather than a number and a unit, and a date is
/// whatever spelling the file used. The methods below are where interpretation
/// happens.
///
/// `#[non_exhaustive]` because this is an output type consumers read rather
/// than construct.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PantryItem {
    /// The ingredient's name, as written.
    pub name: String,
    /// The section it was written under. `cooklang` collects items written
    /// above the first section header into a section called `general`, so an
    /// item always has one.
    pub section: String,
    /// How much is in stock — `"500%g"`, `"2"` — or `None` for an item written
    /// without a quantity.
    pub quantity: Option<String>,
    /// When it was bought, if the file says.
    pub bought: Option<String>,
    /// When it expires, if the file says. See [`expiring`] for the spellings
    /// that can be read as a date.
    pub expire: Option<String>,
    /// The quantity at or below which this item counts as low, if the file
    /// sets one. See [`is_low`](PantryItem::is_low).
    pub low: Option<String>,
}

impl PantryItem {
    /// True when the stock has fallen to or below this item's *own* `low`
    /// threshold.
    ///
    /// False unless [`quantity`](PantryItem::quantity) and
    /// [`low`](PantryItem::low) are both set, both parse as a number with an
    /// optional unit, and those units are equal: a threshold written in
    /// different units from the stock is not compared, and neither is a
    /// quantity that is not a number. Such an item is not "not low" so much as
    /// unanswerable, and [`depleted`] falls back to its built-in thresholds
    /// for it.
    ///
    /// Computed rather than stored, so it cannot disagree with the fields it
    /// reads. The comparison is `cooklang`'s own, reached by handing it an
    /// equivalent item, so that there is one definition of it rather than two
    /// that can drift.
    pub fn is_low(&self) -> bool {
        cooklang::pantry::PantryItem::WithAttributes(cooklang::pantry::ItemWithAttributes {
            name: self.name.clone(),
            bought: None,
            expire: None,
            quantity: self.quantity.clone(),
            low: self.low.clone(),
        })
        .is_low()
    }

    fn from_cooklang(section: &str, item: &cooklang::pantry::PantryItem) -> Self {
        Self {
            name: item.name().to_string(),
            section: section.to_string(),
            quantity: item.quantity().map(ToOwned::to_owned),
            bought: item.bought().map(ToOwned::to_owned),
            expire: item.expire().map(ToOwned::to_owned),
            low: item.low().map(ToOwned::to_owned),
        }
    }
}

/// One section of the pantry, with the items written under it.
///
/// `#[non_exhaustive]` because this is an output type consumers read rather
/// than construct.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PantrySection {
    /// The section's name, as written. Equal to the
    /// [`section`](PantryItem::section) of every item in it, which is carried
    /// on the items too so that a single item taken out of here still says
    /// where it came from.
    pub name: String,
    /// The items written under it, in file order. May be empty, for a section
    /// header with nothing under it.
    pub items: Vec<PantryItem>,
}

/// A whole pantry configuration.
///
/// `#[non_exhaustive]` because this is an output type consumers read rather
/// than construct.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PantryContents {
    /// Every section, in the order the file wrote them.
    pub sections: Vec<PantrySection>,
}

impl PantryContents {
    /// Every item in every section, in file order.
    pub fn items(&self) -> impl Iterator<Item = &PantryItem> {
        self.sections.iter().flat_map(|section| &section.items)
    }

    fn from_conf(conf: &cooklang::pantry::PantryConf) -> Self {
        // `PantryConf::sections` is an `IndexMap`, so this is the file's own
        // order rather than an arbitrary one.
        Self {
            sections: conf
                .sections
                .iter()
                .map(|(name, items)| PantrySection {
                    name: name.clone(),
                    items: items
                        .iter()
                        .map(|item| PantryItem::from_cooklang(name, item))
                        .collect(),
                })
                .collect(),
        }
    }
}

// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------

/// Read and parse the pantry configuration [`Context::pantry`] names.
///
/// Reads through [`ConfigSource`](crate::ConfigSource), so an editor can hand
/// over pantry text it has not saved instead of a path.
///
/// A pantry that parses with warnings — an unknown attribute on an item, say —
/// is a successful load carrying those warnings as [`Outcome::diagnostics`],
/// located in the pantry file when it came from one.
///
/// # Errors
///
/// - [`CoreError::MissingConfig`] if the context carries no pantry at all.
///   Every query below reports this the same way, because a pantry query with
///   no pantry has no answer — unlike `shopping_list::generate`, which simply
///   subtracts nothing.
/// - [`CoreError::Io`] if a path-backed configuration cannot be read.
/// - [`CoreError::Config`] if it cannot be parsed at all, naming the file it
///   came from.
pub fn load(ctx: &Context) -> Result<Outcome<PantryContents>, CoreError> {
    let source = ctx.pantry();
    let Some(text) = source.read()? else {
        return Err(CoreError::MissingConfig {
            kind: "pantry".to_string(),
        });
    };
    let path = source.path();
    tracing::trace!("loading pantry from {:?}", path);

    let parsed = cooklang::pantry::parse_lenient(&text);
    let diagnostics = collect_diagnostics(parsed.report(), path);

    match parsed.output() {
        Some(conf) => Ok(Outcome::with_diagnostics(
            PantryContents::from_conf(conf),
            diagnostics,
        )),
        None => Err(CoreError::Config {
            path: path.map(ToOwned::to_owned),
            message: parse_failure(&diagnostics, "pantry"),
        }),
    }
}

// ---------------------------------------------------------------------------
// list
// ---------------------------------------------------------------------------

/// Which of the pantry to list.
///
/// Not `#[non_exhaustive]`: consumers construct this. `..Default::default()`
/// keeps a literal working if it grows a field.
#[derive(Debug, Clone, Default)]
pub struct ListRequest {
    /// Keep only this section, compared ignoring ASCII case. `None` lists
    /// everything.
    pub section: Option<String>,
}

/// List the pantry, optionally narrowed to one section.
///
/// A filter that matches no section gives an empty list rather than an error:
/// core reports what is there, and whether "you asked for a section that does
/// not exist" deserves an error is the caller's policy. The CLI treats it as
/// one.
///
/// # Errors
///
/// As [`load`].
pub fn list(ctx: &Context, req: ListRequest) -> Result<Outcome<PantryContents>, CoreError> {
    let mut outcome = load(ctx)?;
    if let Some(section) = &req.section {
        outcome
            .value
            .sections
            .retain(|s| s.name.eq_ignore_ascii_case(section));
    }
    Ok(outcome)
}

// ---------------------------------------------------------------------------
// depleted
// ---------------------------------------------------------------------------

/// Which items count as running out.
///
/// Not `#[non_exhaustive]`: consumers construct this.
#[derive(Debug, Clone, Default)]
pub struct DepletedRequest {
    /// Also return items whose stock cannot be judged at all — no quantity, or
    /// a quantity that is not a number, or a `low` threshold in units that
    /// cannot be compared with it.
    pub all: bool,
}

/// The items that are low or out of stock, in file order.
///
/// An item is returned when [`PantryItem::is_low`] says so. When it does not,
/// the answer depends on what there is to go on:
///
/// - No quantity at all: only with [`DepletedRequest::all`], since there is
///   nothing to compare.
/// - A quantity, and a `low` threshold in the same units: `is_low` has already
///   compared them and said no, so only with [`DepletedRequest::all`].
/// - A quantity, and either no threshold or one in units that do not match:
///   the built-in thresholds decide — at or below 100 for `g` and `ml`, below
///   0.5 for `kg` and `l`, at or below 1 for anything else, including a bare
///   count.
///
/// # Errors
///
/// As [`load`].
pub fn depleted(
    ctx: &Context,
    req: DepletedRequest,
) -> Result<Outcome<Vec<PantryItem>>, CoreError> {
    let outcome = load(ctx)?;
    let items = outcome
        .value
        .items()
        .filter(|item| is_depleted(item, req.all))
        .cloned()
        .collect();
    Ok(Outcome::with_diagnostics(items, outcome.diagnostics))
}

/// The rule documented on [`depleted`].
fn is_depleted(item: &PantryItem, all: bool) -> bool {
    if item.is_low() {
        return true;
    }
    match &item.quantity {
        None => all,
        Some(quantity) => match &item.low {
            // A threshold in matching units has already been compared by
            // `is_low` above, and it said no.
            Some(low) if units_match(quantity, low) => all,
            _ => is_low_quantity(quantity),
        },
    }
}

// ---------------------------------------------------------------------------
// expiring
// ---------------------------------------------------------------------------

/// How far ahead to look for expiring items.
///
/// Not `#[non_exhaustive]`: consumers construct this.
#[derive(Debug, Clone)]
pub struct ExpiringRequest {
    /// How many days ahead to look. `0` returns only what has expired or
    /// expires today.
    pub days: u32,
    /// Also return items with no readable expiry date, which carry no
    /// [`ExpiringItem::days_until_expiry`].
    pub include_unknown: bool,
}

impl Default for ExpiringRequest {
    /// A week ahead, which is `cook pantry expiring`'s default, and only items
    /// that say when they expire.
    fn default() -> Self {
        Self {
            days: 7,
            include_unknown: false,
        }
    }
}

/// A pantry item that is expiring, with the arithmetic already done.
///
/// `#[non_exhaustive]` because this is an output type consumers read rather
/// than construct.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpiringItem {
    /// The item itself, exactly as [`load`] read it.
    pub item: PantryItem,
    /// Its expiry date normalised to ISO 8601 (`2025-06-01`), whichever
    /// spelling the file used. `None` only for an item included by
    /// [`ExpiringRequest::include_unknown`].
    pub expire_date: Option<String>,
    /// Days from today until it expires: `0` today, negative once it has
    /// expired. `None` alongside an absent `expire_date`.
    pub days_until_expiry: Option<i64>,
}

/// The items expiring within [`ExpiringRequest::days`] of today, soonest
/// first.
///
/// Already-expired items are included, and sort first because their
/// [`days_until_expiry`](ExpiringItem::days_until_expiry) is negative. Items
/// with no readable date sort last, and are only present at all with
/// [`ExpiringRequest::include_unknown`].
///
/// # Dates
///
/// A date is read as `%Y-%m-%d`, `%d.%m.%Y`, `%d/%m/%Y`, `%m/%d/%Y`,
/// `%Y.%m.%d` or `%d-%m-%Y`, in that order — so `01/02/2025` is the 1st of
/// February, not the 2nd of January. Anything else counts as no date at all.
///
/// "Today" is the local date of the machine this runs on.
///
/// # Errors
///
/// As [`load`].
pub fn expiring(
    ctx: &Context,
    req: ExpiringRequest,
) -> Result<Outcome<Vec<ExpiringItem>>, CoreError> {
    let outcome = load(ctx)?;
    let items = expiring_on(&outcome.value, &req, Local::now().date_naive());
    Ok(Outcome::with_diagnostics(items, outcome.diagnostics))
}

/// [`expiring`] against a given date, so that the arithmetic can be tested
/// without the answer depending on the day the tests are run.
fn expiring_on(
    contents: &PantryContents,
    req: &ExpiringRequest,
    today: NaiveDate,
) -> Vec<ExpiringItem> {
    // A `days` big enough to run off the end of the calendar means every date
    // is within it. Saturating rather than panicking matters in a crate a NAPI
    // addon calls: `cook pantry expiring -d 4294967295` used to panic here.
    let threshold = today
        .checked_add_signed(chrono::Duration::days(i64::from(req.days)))
        .unwrap_or(NaiveDate::MAX);

    let mut items: Vec<ExpiringItem> = contents
        .items()
        .filter_map(|item| match item.expire.as_deref().and_then(parse_date) {
            Some(date) if date <= threshold => Some(ExpiringItem {
                item: item.clone(),
                expire_date: Some(date.format(ISO_DATE).to_string()),
                days_until_expiry: Some((date - today).num_days()),
            }),
            // Expires, but not yet.
            Some(_) => None,
            None if req.include_unknown => Some(ExpiringItem {
                item: item.clone(),
                expire_date: None,
                days_until_expiry: None,
            }),
            None => None,
        })
        .collect();

    // Stable, so items expiring on the same day stay in file order.
    items.sort_by_key(|item| item.days_until_expiry.unwrap_or(i64::MAX));
    items
}

// ---------------------------------------------------------------------------
// recipes
// ---------------------------------------------------------------------------

/// How complete a match has to be to be worth reporting.
///
/// Not `#[non_exhaustive]`: consumers construct this.
#[derive(Debug, Clone)]
pub struct RecipesRequest {
    /// The lowest percentage of a recipe's ingredients that may be in stock
    /// for it to count as a partial match, as a whole number out of 100.
    pub threshold: u8,
}

impl Default for RecipesRequest {
    /// 75%, which is `cook pantry recipes`'s default.
    fn default() -> Self {
        Self { threshold: 75 }
    }
}

/// A recipe most of whose ingredients are in stock.
///
/// `#[non_exhaustive]` because this is an output type consumers read rather
/// than construct.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct PartialMatch {
    /// The recipe's title, or its file stem when it has none.
    pub name: String,
    /// What percentage of its ingredients are in stock, rounded down.
    pub percentage: usize,
    /// The ingredients that are not, lowercased as they were compared, in
    /// alphabetical order.
    pub missing: Vec<String>,
}

/// What the pantry can cook.
///
/// `#[non_exhaustive]` because this is an output type consumers read rather
/// than construct.
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RecipeMatches {
    /// Recipes every one of whose ingredients is in stock, by title, in
    /// alphabetical order.
    pub full: Vec<String>,
    /// Recipes that are only partly covered, at or above
    /// [`RecipesRequest::threshold`], in alphabetical order.
    pub partial: Vec<PartialMatch>,
}

/// Work out which recipes under [`Context::base_path`] the pantry can cook.
///
/// An ingredient counts as in stock when its name matches a pantry item's,
/// compared lowercased and otherwise exactly — no unit or quantity is
/// considered, so a recipe needing a kilo of flour matches a pantry holding a
/// gram of it. References to other recipes are ignored, and a recipe left with
/// no ingredients at all matches nothing. See [`listed_ingredients`] for what
/// else is left out.
///
/// Recipes are found by walking the collection, `.menu` files included. A
/// recipe that cannot be read or parsed is left out, with a warning in
/// [`Outcome::diagnostics`] naming it — it is not counted as a match or a
/// miss. Warnings from recipes that *did* parse are not reported here, because
/// they cannot change the answer; `doctor::validate` is the command for those.
///
/// # Errors
///
/// - As [`load`], since this needs the pantry.
/// - [`CoreError::Search`] if the collection cannot be walked, and
///   [`CoreError::Io`] if a file in it cannot be listed — as
///   `doctor::validate`.
pub fn recipes(ctx: &Context, req: RecipesRequest) -> Result<Outcome<RecipeMatches>, CoreError> {
    let loaded = load(ctx)?;
    let mut diagnostics = loaded.diagnostics;
    let stocked: BTreeSet<String> = loaded
        .value
        .items()
        .map(|item| item.name.to_lowercase())
        .collect();

    let tree = build_tree(ctx.base_path())?;
    let mut matches = RecipeMatches::default();

    for entry in walk(&tree) {
        let Some(recipe) = parse_or_skip(entry, &mut diagnostics) else {
            continue;
        };
        // Lowercased into the set, so a recipe naming `Salt` and `salt` wants
        // one ingredient rather than two.
        let wanted: BTreeSet<String> = listed_ingredients(&recipe)
            .iter()
            .map(|name| name.to_lowercase())
            .collect();
        if wanted.is_empty() {
            continue;
        }

        let available = wanted.iter().filter(|name| stocked.contains(*name)).count();
        let percentage = available * 100 / wanted.len();
        let name = recipe_name(entry);

        if available == wanted.len() {
            matches.full.push(name);
        } else if percentage >= usize::from(req.threshold) {
            matches.partial.push(PartialMatch {
                name,
                percentage,
                // From a `BTreeSet`, so already in order.
                missing: wanted
                    .iter()
                    .filter(|name| !stocked.contains(*name))
                    .cloned()
                    .collect(),
            });
        }
    }

    // The walk yields directories in a `HashMap`'s order, which changes
    // between runs. Sorting is what makes the answer the same twice running.
    matches.full.sort();
    matches.partial.sort();

    Ok(Outcome::with_diagnostics(matches, diagnostics))
}

// ---------------------------------------------------------------------------
// plan
// ---------------------------------------------------------------------------

/// How far to take a pantry plan.
///
/// Not `#[non_exhaustive]`: consumers construct this. The default plans until
/// every recipe is covered.
#[derive(Debug, Clone, Default)]
pub struct PlanRequest {
    /// Stop after this many ingredients. `None` continues until every recipe
    /// is cookable, or until no ingredient is left to add.
    pub max_ingredients: Option<usize>,
    /// Count a recipe as cookable while it is still missing this many
    /// ingredients. `0` means everything it needs must be stocked.
    pub allow_missing: usize,
}

/// One ingredient to buy, and what buying it achieves.
///
/// `#[non_exhaustive]` because this is an output type consumers read rather
/// than construct.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IngredientStep {
    /// The ingredient, as recipes write it.
    pub name: String,
    /// How many more recipes become cookable once it is in stock.
    pub new_recipes_unlocked: usize,
    /// How many recipes are cookable in total by this point — this step and
    /// every step before it.
    pub total_cookable: usize,
}

/// An order to stock a pantry in.
///
/// `#[non_exhaustive]` because this is an output type consumers read rather
/// than construct.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PantryPlan {
    /// The ingredients to buy, most useful first.
    pub steps: Vec<IngredientStep>,
    /// How many recipes the plan was worked out over: every recipe in the
    /// collection that lists at least one ingredient.
    pub total_recipes: usize,
}

impl PantryPlan {
    /// How many recipes are cookable once the whole plan is stocked.
    ///
    /// Read off the last step rather than stored alongside it, so it cannot
    /// disagree with the steps it summarises. Zero for an empty plan.
    pub fn cookable_recipes(&self) -> usize {
        self.steps.last().map_or(0, |step| step.total_cookable)
    }

    /// [`cookable_recipes`](PantryPlan::cookable_recipes) as a percentage of
    /// [`total_recipes`](PantryPlan::total_recipes), rounded down. Zero when
    /// there are no recipes at all.
    pub fn coverage_percentage(&self) -> usize {
        self.cookable_recipes() * 100 / self.total_recipes.max(1)
    }
}

/// Work out which ingredients to stock to cook as much of the collection as
/// possible.
///
/// **The pantry is not consulted.** This answers "what should I buy?" from the
/// recipes under [`Context::base_path`] alone, so it needs no pantry
/// configuration and will happily recommend something already in stock.
///
/// The plan is greedy: at each step it takes the ingredient wanted by the most
/// recipes that are not yet cookable, and ties are broken alphabetically. That
/// is an approximation — a greedy set cover is not guaranteed to be the
/// shortest plan — but it is deterministic, which the tie-break is there for.
///
/// Only `.cook` files are considered; `.menu` files are skipped, and so are
/// recipes that list no ingredients — and, as in [`recipes`], any that cannot
/// be read or parsed, each with a warning in [`Outcome::diagnostics`].
/// Ingredients are those of [`listed_ingredients`], compared exactly as
/// recipes write them, so `Flour` and `flour` are two ingredients — unlike
/// [`recipes`], which lowercases.
///
/// # Errors
///
/// [`CoreError::Search`] if the collection cannot be walked, and
/// [`CoreError::Io`] if a file in it cannot be listed. Never
/// [`CoreError::MissingConfig`].
pub fn plan(ctx: &Context, req: PlanRequest) -> Result<Outcome<PantryPlan>, CoreError> {
    let tree = build_tree(ctx.base_path())?;
    let mut diagnostics = Vec::new();

    // What each recipe still needs. A recipe drops out once it is cookable.
    let mut missing: Vec<BTreeSet<String>> = walk(&tree)
        .into_iter()
        .filter(|entry| !entry.is_menu())
        .filter_map(|entry| parse_or_skip(entry, &mut diagnostics))
        .map(|recipe| listed_ingredients(&recipe))
        .filter(|ingredients| !ingredients.is_empty())
        .collect();

    let total_recipes = missing.len();
    let max_ingredients = req.max_ingredients.unwrap_or(usize::MAX);
    let mut steps: Vec<IngredientStep> = Vec::new();
    let mut cookable = 0;

    while cookable < total_recipes && steps.len() < max_ingredients {
        let Some(best) = most_wanted(&missing) else {
            // Nothing left to choose: every remaining recipe wants nothing,
            // which `allow_missing` cannot satisfy.
            break;
        };

        let mut newly_cookable = 0;
        missing.retain_mut(|wanted| {
            wanted.remove(&best);
            if wanted.len() <= req.allow_missing {
                newly_cookable += 1;
                false
            } else {
                true
            }
        });
        cookable += newly_cookable;

        steps.push(IngredientStep {
            name: best,
            new_recipes_unlocked: newly_cookable,
            total_cookable: cookable,
        });
    }

    Ok(Outcome::with_diagnostics(
        PantryPlan {
            steps,
            total_recipes,
        },
        diagnostics,
    ))
}

/// The ingredient wanted by the most recipes, ties broken alphabetically.
fn most_wanted(missing: &[BTreeSet<String>]) -> Option<String> {
    let mut scores: BTreeMap<&str, usize> = BTreeMap::new();
    for wanted in missing {
        for ingredient in wanted {
            *scores.entry(ingredient.as_str()).or_insert(0) += 1;
        }
    }
    scores
        // Highest count wins; `Reverse` on the name turns "largest" into
        // "alphabetically first" for the tie, which is what makes two runs
        // over the same collection agree.
        .into_iter()
        .max_by_key(|&(name, count)| (count, Reverse(name)))
        .map(|(name, _)| name.to_string())
}

// ---------------------------------------------------------------------------
// add, remove, update
// ---------------------------------------------------------------------------

/// An item to add to the pantry.
///
/// Not `#[non_exhaustive]`: consumers construct this. `..Default::default()`
/// keeps a literal working if it grows a field.
#[derive(Debug, Clone, Default)]
pub struct AddRequest {
    /// The section to add it under, matched and written exactly as given —
    /// unlike [`ListRequest::section`], case counts, so adding to `Dairy` when
    /// the file says `dairy` makes a second section.
    pub section: String,
    /// The ingredient's name.
    pub name: String,
    /// How much is in stock, as pantry files write it: `"500%g"`, `"2"`.
    pub quantity: Option<String>,
    /// When it was bought.
    pub bought: Option<String>,
    /// When it expires. See [`expiring`] for the spellings that can be read
    /// back as a date.
    pub expire: Option<String>,
    /// The quantity at or below which it counts as low.
    pub low: Option<String>,
}

/// Which item to take out of the pantry.
///
/// Not `#[non_exhaustive]`: consumers construct this.
#[derive(Debug, Clone, Default)]
pub struct RemoveRequest {
    /// The section holding it, matched exactly.
    pub section: String,
    /// The item's name, matched exactly.
    pub name: String,
}

/// What to change about an item already in the pantry.
///
/// Not `#[non_exhaustive]`: consumers construct this. At least one attribute
/// must be set; see [`update`].
#[derive(Debug, Clone, Default)]
pub struct UpdateRequest {
    /// The section holding it, matched exactly.
    pub section: String,
    /// The item's name, matched exactly. Not changed by an update — remove and
    /// add to rename.
    pub name: String,
    /// The new quantity, or `None` to leave it as it is.
    pub quantity: Option<String>,
    /// The new bought date, or `None` to leave it as it is.
    pub bought: Option<String>,
    /// The new expiry date, or `None` to leave it as it is.
    pub expire: Option<String>,
    /// The new low-stock threshold, or `None` to leave it as it is.
    pub low: Option<String>,
}

/// Add an item to the pantry and write it back.
///
/// The section is created if the file has no such section, and the file itself
/// is created if there is none — under `<base_path>/config/pantry.conf`, which
/// is where [`Context::discover`] looks first. That is the one case where this
/// crate invents a path rather than being told one.
///
/// Returns the pantry as it now stands on disk, so a caller need not read it
/// back, together with any warnings from parsing what was there before.
///
/// Only the entry asked for is touched; see [what a write
/// touches](self#what-a-write-touches).
///
/// # Errors
///
/// - [`CoreError::ReadOnlyConfig`] if the context carries the pantry inline.
///   There is nowhere to write it, and inventing a path would put an editor's
///   unsaved buffer on someone's disk.
/// - [`CoreError::PantryEdit`] if the section already holds an item of that
///   name, compared exactly. Nothing is written; [`update`] is how an item is
///   changed.
/// - [`CoreError::Config`] if the existing file cannot be parsed at all, and
///   [`CoreError::Io`] if it cannot be read or the new one cannot be written.
pub fn add(ctx: &Context, req: AddRequest) -> Result<Outcome<PantryContents>, CoreError> {
    let attributes = edit::Attributes {
        quantity: req.quantity,
        bought: req.bought,
        expire: req.expire,
        low: req.low,
    };
    edit::check_general_attributes(&req.section, &req.name, &attributes)?;

    let path = path_to_create(ctx)?;
    let (mut doc, mut diagnostics) = read_document_or_empty(&path)?;

    if edit::item_exists(&doc, &req.section, &req.name) {
        return Err(CoreError::PantryEdit {
            message: format!(
                "item '{}' already exists in section '{}'",
                req.name, req.section
            ),
        });
    }

    diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
    edit::insert(&mut doc, &req.section, &req.name, &attributes);

    save(&path, &doc, diagnostics)
}

/// Take an item out of the pantry and write it back.
///
/// A section left with no items is removed too, because `cooklang` drops empty
/// sections when it reads a file and keeping one would not survive the next
/// read anyway.
///
/// Returns the pantry as it now stands on disk, and any warnings from parsing
/// what was there before.
///
/// Only the entry asked for is touched; see [what a write
/// touches](self#what-a-write-touches).
///
/// # Errors
///
/// - [`CoreError::MissingConfig`] if the context carries no pantry: there is
///   nothing to take an item out of. Unlike [`add`], this does not create one.
/// - [`CoreError::ReadOnlyConfig`] if it carries the pantry inline.
/// - [`CoreError::PantryEdit`] if there is no such section, or no such item in
///   it. Nothing is written.
/// - As [`load`] otherwise, plus [`CoreError::Io`] if the file cannot be
///   written.
pub fn remove(ctx: &Context, req: RemoveRequest) -> Result<Outcome<PantryContents>, CoreError> {
    let path = path_to_edit(ctx)?;
    let (mut doc, mut diagnostics) = read_document(&path)?;

    diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
    if !edit::section_exists(&doc, &req.section) {
        return Err(section_not_found(&req.section));
    }
    if !edit::item_exists(&doc, &req.section, &req.name) {
        return Err(item_not_found(&req.name, &req.section));
    }

    edit::remove(&mut doc, &req.section, &req.name);

    save(&path, &doc, diagnostics)
}

/// Change an item already in the pantry and write it back.
///
/// Only the attributes set on the request are changed; the rest of the item is
/// left as it was. There is no way to clear an attribute — `None` means "leave
/// it", not "remove it" — so an item is cleared by removing and adding it.
///
/// Returns the pantry as it now stands on disk, and any warnings from parsing
/// what was there before.
///
/// Only the entry asked for is touched; see [what a write
/// touches](self#what-a-write-touches).
///
/// # Errors
///
/// - [`CoreError::PantryEdit`] if the request sets no attribute at all, since
///   that could only rewrite the file to what it already said; or if there is
///   no such section, or no such item in it. Nothing is written.
/// - [`CoreError::MissingConfig`] if the context carries no pantry, and
///   [`CoreError::ReadOnlyConfig`] if it carries one inline.
/// - As [`load`] otherwise, plus [`CoreError::Io`] if the file cannot be
///   written.
pub fn update(ctx: &Context, req: UpdateRequest) -> Result<Outcome<PantryContents>, CoreError> {
    let attributes = edit::Attributes {
        quantity: req.quantity,
        bought: req.bought,
        expire: req.expire,
        low: req.low,
    };

    // Checked before anything is read: an update of nothing is a mistake
    // whether or not there is a pantry to make it in.
    if attributes.is_empty() {
        return Err(CoreError::PantryEdit {
            message: format!(
                "no attributes given to update on item '{}' in section '{}'",
                req.name, req.section
            ),
        });
    }

    edit::check_general_attributes(&req.section, &req.name, &attributes)?;

    let path = path_to_edit(ctx)?;
    let (mut doc, mut diagnostics) = read_document(&path)?;

    diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
    if !edit::section_exists(&doc, &req.section) {
        return Err(section_not_found(&req.section));
    }
    if !edit::item_exists(&doc, &req.section, &req.name) {
        return Err(item_not_found(&req.name, &req.section));
    }

    edit::apply(&mut doc, &req.section, &req.name, &attributes)?;

    save(&path, &doc, diagnostics)
}

fn section_not_found(section: &str) -> CoreError {
    CoreError::PantryEdit {
        message: format!("section '{section}' not found"),
    }
}

fn item_not_found(name: &str, section: &str) -> CoreError {
    CoreError::PantryEdit {
        message: format!("item '{name}' not found in section '{section}'"),
    }
}

/// The file [`add`] writes, which need not exist yet.
fn path_to_create(ctx: &Context) -> Result<Utf8PathBuf, CoreError> {
    match ctx.pantry() {
        ConfigSource::Path(path) => Ok(path.clone()),
        // Named with `discover`'s own constants, so that the file this creates
        // stays the one the next `discover` finds.
        ConfigSource::None => Ok(ctx
            .base_path()
            .join(crate::context::LOCAL_CONFIG_DIR)
            .join(crate::context::AUTO_PANTRY)),
        ConfigSource::Inline(_) => Err(read_only()),
    }
}

/// The file [`remove`] and [`update`] write, which must exist: there is
/// nothing to take an item out of, or to change, without one.
fn path_to_edit(ctx: &Context) -> Result<Utf8PathBuf, CoreError> {
    match ctx.pantry() {
        ConfigSource::Path(path) => Ok(path.clone()),
        ConfigSource::None => Err(CoreError::MissingConfig {
            kind: "pantry".to_string(),
        }),
        ConfigSource::Inline(_) => Err(read_only()),
    }
}

fn read_only() -> CoreError {
    CoreError::ReadOnlyConfig {
        kind: "pantry".to_string(),
    }
}

fn parse_conf(
    path: &Utf8Path,
    text: &str,
) -> Result<(cooklang::pantry::PantryConf, Vec<Diagnostic>), CoreError> {
    let parsed = cooklang::pantry::parse_lenient(text);
    let diagnostics = collect_diagnostics(parsed.report(), Some(path));
    match parsed.output() {
        Some(conf) => Ok((conf.clone(), diagnostics)),
        None => Err(CoreError::Config {
            path: Some(path.to_owned()),
            message: parse_failure(&diagnostics, "pantry"),
        }),
    }
}

/// Convert a section written as an array of names into the equivalent table,
/// and say so.
///
/// The array form has nowhere to put a key, so an edit to such a section has to
/// rewrite it. Doing that here, rather than leaving [`edit::insert`] to replace
/// the array wholesale, is what keeps the names already in it.
fn normalise_array_section(
    doc: &mut toml_edit::DocumentMut,
    section: &str,
    path: &Utf8Path,
) -> Vec<Diagnostic> {
    let converted = edit::normalise_array_section(doc, section);
    if converted.is_empty() {
        return Vec::new();
    }
    vec![Diagnostic::warning(format!(
        "section '{section}' was written as a list of names, which cannot hold quantities; \
         rewritten as a [{section}] section keeping {}",
        converted.join(", ")
    ))
    .at_file(path.to_owned())]
}

/// Read a pantry file as an editable document, and the warnings `cooklang`
/// raises about it.
///
/// Parsed twice, deliberately and cheaply: once as TOML, which is what an edit
/// is applied to, and once through `cooklang`, whose lenient parse is what
/// produces the diagnostics a caller expects and what decides whether the file
/// is a *pantry* rather than merely valid TOML.
fn read_document(path: &Utf8Path) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
    let text = std::fs::read_to_string(path).map_err(|source| CoreError::Io {
        path: path.to_owned(),
        source,
    })?;
    parse_document(path, &text)
}

/// As [`read_document`], but an absent file is an empty document rather than an
/// error — which is what lets [`add`] create one.
///
/// Missing is judged by the read failing rather than by asking whether the
/// file exists first, so that nothing can delete it in between.
fn read_document_or_empty(
    path: &Utf8Path,
) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
    match std::fs::read_to_string(path) {
        Ok(text) => parse_document(path, &text),
        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
            Ok((toml_edit::DocumentMut::new(), Vec::new()))
        }
        Err(source) => Err(CoreError::Io {
            path: path.to_owned(),
            source,
        }),
    }
}

fn parse_document(
    path: &Utf8Path,
    text: &str,
) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
    // `cooklang` first, so that a file it rejects is reported the way every
    // other pantry command reports it, rather than as a TOML error.
    let (_, diagnostics) = parse_conf(path, text)?;
    Ok((edit::parse(text, path)?, diagnostics))
}

/// Write the edited document over `path` and read back what it now says.
///
/// Reading back rather than deriving the result from the edit is what keeps the
/// returned [`PantryContents`] honest: it is the file as the next command will
/// see it, normalisation and all.
fn save(
    path: &Utf8Path,
    doc: &toml_edit::DocumentMut,
    diagnostics: Vec<Diagnostic>,
) -> Result<Outcome<PantryContents>, CoreError> {
    let text = doc.to_string();
    write_atomically(path, &text)?;

    // The re-read is for the value, not for its diagnostics: those are the same
    // ones already collected from reading the file, and reporting them twice
    // per edit is noise.
    let (conf, _) = parse_conf(path, &text)?;
    Ok(Outcome::with_diagnostics(
        PantryContents::from_conf(&conf),
        diagnostics,
    ))
}

/// What to call a recipe in the results: its title, or its file stem.
///
/// The fallback is `cooklang-find`'s job and it always manages one for a
/// file-backed entry, so "unknown" is unreachable through a walk. Kept because
/// dropping a nameless recipe from the results would be worse than naming it
/// badly.
fn recipe_name(entry: &RecipeEntry) -> String {
    entry
        .name()
        .clone()
        .unwrap_or_else(|| "unknown".to_string())
}

// ---------------------------------------------------------------------------
// Reading quantities and dates
// ---------------------------------------------------------------------------

/// A quantity as pantry files write it: a number, an optional `%`, then an
/// optional unit. The unit group matches the empty string, so a bare count
/// parses with no unit rather than failing.
static QUANTITY: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"^(\d+(?:\.\d+)?)\s*%?\s*(.*)$").expect("the quantity pattern is valid")
});

/// The unit of a quantity, lowercased, or `None` if it is not a quantity at
/// all. A bare count has an empty unit.
fn unit_of(quantity: &str) -> Option<String> {
    QUANTITY
        .captures(quantity)
        .map(|captures| captures[2].to_lowercase())
}

/// Whether two quantities are written in the same unit, and so can be
/// compared. False if either is not a quantity.
fn units_match(quantity: &str, low_threshold: &str) -> bool {
    match (unit_of(quantity), unit_of(low_threshold)) {
        (Some(quantity), Some(threshold)) => quantity == threshold,
        _ => false,
    }
}

/// The built-in "running out" thresholds, for items that set none of their
/// own. False for anything that is not a quantity.
fn is_low_quantity(quantity: &str) -> bool {
    let Some(captures) = QUANTITY.captures(quantity) else {
        return false;
    };
    let Ok(amount) = captures[1].parse::<f64>() else {
        return false;
    };

    match captures[2].to_lowercase().as_str() {
        "g" | "ml" => amount <= 100.0,
        "kg" | "l" => amount < 0.5,
        // A bare count, `item`, `items`, and every unit not listed above.
        _ => amount <= 1.0,
    }
}

/// The date spellings a pantry file may use, tried in this order.
const DATE_FORMATS: [&str; 6] = [
    "%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y", "%Y.%m.%d", "%d-%m-%Y",
];

/// Read a date in any of [`DATE_FORMATS`], or `None`.
fn parse_date(date: &str) -> Option<NaiveDate> {
    DATE_FORMATS
        .iter()
        .find_map(|format| NaiveDate::parse_from_str(date, format).ok())
}

mod edit;

#[cfg(test)]
mod tests;