lib_efo/
epoch.rs

1//! # Epoch configuration and state
2//!
3//! The *epoch* system is deliberately split into two tiny, serialisable
4//! structures so that the binary can quickly read/write its “current
5//! position” without pulling the whole world into memory.
6//!
7//! * `EpochConfig` - lives in `.epochconfig`.  It only remembers
8//!   which epoch folder should be considered “current” and a few
9//!   convenience fields (tags, path).  It never holds any event or
10//!   world data.
11//!
12//! * `EpochInfo` - stored inside `epoch/<epoch_id>/.epochinfo`.
13//!   This file contains the *metadata* for that particular epoch
14//!   (seed, selected events, parent link, etc.).  Again, it does **not**
15//!   hold the full world; that is handled elsewhere in the engine.
16//!
17//! Keeping these two structs tiny means that switching epochs, logging,
18//! or resetting can be done by reading/writing a handful of JSON/YAML
19//! files, just like a git repository tracks its commits and
20//! `HEAD` pointer.
21#![warn(missing_docs, clippy::missing_docs_in_private_items)]
22use clap::error::ErrorKind;
23use derive_more::Display;
24use serde::{Deserialize, Serialize};
25use std::{
26    ops::{Deref, DerefMut},
27    str::FromStr,
28};
29
30use crate::error::EpochError;
31pub mod epoch_config;
32pub use epoch_config::EpochConfig;
33
34pub mod epoch_info;
35pub use epoch_info::EpochInfo;
36pub mod epoch_traits;
37
38// TODO(5): Try to load from a config and have it inserted as a backup
39/// The subfolder naming convention
40static SUBFOLDER_NAMING: &str = "epoch";
41/// The main name of the epoch
42static MAIN_NAME: &str = ".epoch";
43/// The subname of the epochinfo
44static SUB_NAME: &str = "epochinfo.json";
45
46/// A lightweight wrapper around a list of tags.
47///
48/// The wrapper exists mainly to give the collection a semantic type
49/// and to hide the internal representation.  
50#[derive(Debug, Clone, Serialize, Deserialize, Display)]
51#[display("{inner:?}")]
52pub struct TagList {
53    /// The inner vector of tags
54    inner: Vec<String>,
55}
56
57impl TagList {
58    /// Create a new empty tag list
59    fn new() -> Self {
60        Self { inner: Vec::new() }
61    }
62}
63
64impl Deref for TagList {
65    type Target = Vec<String>;
66
67    fn deref(&self) -> &Self::Target {
68        &self.inner
69    }
70}
71impl DerefMut for TagList {
72    fn deref_mut(&mut self) -> &mut Self::Target {
73        &mut self.inner
74    }
75}
76
77/// Which save mode to use
78#[allow(missing_docs)]
79pub enum SaveMode {
80    DoNotErase,
81    Erase,
82}
83
84/// Thin type to handle EpochIds
85#[derive(
86    Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display,
87)]
88#[display("{id}")]
89pub struct EpochId {
90    /// The underlying numeric value.
91    pub id: u64,
92}
93
94impl FromStr for EpochId {
95    type Err = EpochError;
96
97    fn from_str(s: &str) -> Result<Self, Self::Err> {
98        Ok(EpochId { id: s.parse()? })
99    }
100}
101
102impl EpochId {
103    /// Creates a new `EpochId` from a numeric value.
104    pub fn new(id: u64) -> Self {
105        Self { id }
106    }
107
108    /// Return the next epochId
109    pub fn next(&self) -> Self {
110        Self { id: self.id + 1 }
111    }
112
113    /// Return a new epoch Id derived from this one
114    pub fn add_to_epoch(&self, other: i64) -> Self {
115        Self {
116            id: self.id.saturating_add_signed(other),
117        }
118    }
119}
120
121impl std::ops::Deref for EpochId {
122    type Target = u64;
123
124    fn deref(&self) -> &Self::Target {
125        &self.id
126    }
127}
128
129/// A flexible identifier for *any* epoch - the world may be
130/// addressed by a numeric ID or by a friendly tag (e.g. `"winter-b"`).
131///
132/// The enum is `untagged` so that serialising/deserialising does
133/// **not** add a wrapper field, e.g. `{"Tag":"foo"}`.  
134/// Instead the JSON representation is exactly the inner value:
135///   * `"42"`  → `EpochIdentifier::Id(EpochId::new(42))`
136///   * `"foo"` → `EpochIdentifier::Tag("foo".into())`
137#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
138#[serde(untagged)] // <-- no “type” wrapper is written to JSON
139pub enum EpochIdentifier {
140    /// A textual tag that maps to a numeric epoch ID in `EpochConfig::tags`.
141    Tag(String),
142
143    /// The raw numeric identifier of the epoch.
144    Id(EpochId),
145}
146
147impl FromStr for EpochIdentifier {
148    type Err = clap::Error; // any `std::error::Error` works
149
150    /// Try to parse either a decimal number (-> `Id`) or any non-empty
151    /// string (-> `Tag`).
152    ///
153    /// Errors are converted to a Clap error so that the CLI shows a
154    /// nice message.
155    fn from_str(s: &str) -> Result<Self, Self::Err> {
156        if let Ok(num) = s.parse::<u64>() {
157            Ok(EpochIdentifier::Id(EpochId::new(num)))
158        } else if !s.is_empty() {
159            Ok(EpochIdentifier::Tag(s.to_owned()))
160        } else {
161            Err(clap::Error::raw(
162                ErrorKind::InvalidValue,
163                format!("{} is not a valid epoch id or tag", s),
164            ))
165        }
166    }
167}
168
169#[cfg(test)]
170#[allow(clippy::unwrap_used)]
171mod test {
172    use insta::assert_debug_snapshot;
173    // tests/epoch.rs
174    use serde_json::to_string_pretty;
175
176    use crate::epoch::{EpochId, EpochIdentifier, SaveMode, TagList};
177
178    #[test]
179    fn epoch_id_arithmetic() {
180        let id = EpochId::new(42);
181        assert_eq!(*id, 42);
182
183        let next = id.next();
184        assert_eq!(*next, 43);
185
186        let added = id.add_to_epoch(5);
187        assert_eq!(*added, 47);
188
189        let negative = id.add_to_epoch(-10);
190        assert_eq!(*negative, 32);
191
192        // saturating behaviour
193        let zero = EpochId::new(0);
194        let under = zero.add_to_epoch(-1);
195        assert_eq!(*under, 0);
196    }
197
198    #[test]
199    fn epoch_identifier_parsing() {
200        // numeric string → Id
201        let parsed: EpochIdentifier = "7".parse().unwrap();
202        assert!(matches!(parsed, EpochIdentifier::Id(_)));
203
204        // non-empty string → Tag
205        let parsed: EpochIdentifier = "winter-b".parse().unwrap();
206        assert!(matches!(parsed, EpochIdentifier::Tag(_)));
207
208        // empty string → error
209        assert!("".parse::<EpochIdentifier>().is_err());
210    }
211
212    #[test]
213    fn epoch_identifier_serialisation() {
214        // Id variant should serialize to a number
215        let id = EpochIdentifier::Id(EpochId::new(13));
216        let json = to_string_pretty(&id).unwrap();
217        assert_debug_snapshot!("epoch_identifier_id", json);
218
219        // Tag variant should serialize to a string
220        let tag = EpochIdentifier::Tag("alpha".into());
221        let json = to_string_pretty(&tag).unwrap();
222        assert_debug_snapshot!("epoch_identifier_tag", json);
223    }
224
225    #[test]
226    fn tag_list_deref_and_mut() {
227        let mut list = TagList::new();
228        list.push("one".into());
229        list.push("two".into());
230
231        // Deref to Vec<String>
232        assert_eq!(list[0], "one");
233        assert_eq!(list[1], "two");
234
235        // Mutating through DerefMut
236        list[1] = "TWO".into();
237        assert_eq!(list[1], "TWO");
238    }
239
240    #[test]
241    fn save_mode_variants() {
242        // Just a quick check that the variants exist and can be constructed
243        let _do_not = SaveMode::DoNotErase;
244        let _erase = SaveMode::Erase;
245    }
246}