Skip to main content

primase_core/
read_scope.rs

1//! Caller-stated reader scopes: declarations reuse pass options, never introduce reader defaults.
2//!
3//! A costly read may load only stated data families, levels of detail or forms. The reader retains
4//! its unloaded alternatives as metadata and reports the scope. Selection and lowering remain
5//! registered passes; this declaration supplies no value for an absent caller choice.
6use serde::Serialize;
7
8use crate::convert::{Notice, NoticeKind};
9use crate::pass::{OptionSpec, Options};
10use crate::{JsonError, Resource, World};
11
12/// The named scope options a reader accepts. Examples document values; they never fill a choice.
13#[derive(Clone, Copy, Debug)]
14pub struct ReadScopeSpec {
15    /// The reader's stable name, independent of an application's source aliases.
16    pub name: &'static str,
17    /// Required choices, one-of groups, or optional values with a stated meaning for absence.
18    pub options: &'static [OptionSpec],
19}
20
21impl ReadScopeSpec {
22    /// Check caller choices using the same declarations and rules as registered pass options.
23    pub fn check(&self, choices: &Options) -> Result<(), String> {
24        crate::pass_options::validate_options(self.name, self.options);
25        crate::pass_options::check_options(self.name, self.options, choices)
26    }
27
28    /// Retain the caller's exact choices. Missing required values are refused, not inferred.
29    pub fn resolve(&self, choices: Options) -> Result<ReadScope, String> {
30        self.check(&choices)?;
31        Ok(ReadScope {
32            reader: self.name.into(),
33            choices,
34            unloaded: Vec::new(),
35        })
36    }
37
38    /// Record a source alternative that was not loaded under this scope, without inventing payload.
39    /// `source` is its source identity (a member, prototype or named family), not a local machine path.
40    pub fn record_unloaded(
41        &self,
42        scope: &mut ReadScope,
43        option: &str,
44        value: &str,
45        source: impl Into<String>,
46    ) -> Result<(), String> {
47        if scope.reader != self.name {
48            return Err("unloaded alternative belongs to another reader scope".into());
49        }
50        let declared = self
51            .options
52            .iter()
53            .find(|item| item.key() == option)
54            .ok_or_else(|| format!("unloaded alternative names undeclared scope option {option}"))?;
55        if declared.values().is_some_and(|values| !values.contains(&value)) {
56            return Err(format!("unloaded alternative has unclassified {option}={value}"));
57        }
58        let alternative = UnloadedAlternative {
59            option: option.into(),
60            value: value.into(),
61            source: source.into(),
62        };
63        if alternative.source.trim().is_empty() || value.trim().is_empty() {
64            return Err("unloaded alternative needs a value and source identity".into());
65        }
66        if !scope.unloaded.contains(&alternative) {
67            scope.unloaded.push(alternative);
68        }
69        Ok(())
70    }
71}
72
73/// A combination of scope choices a reader's crate offers by name: hand-picked and labelled so, with what is known of
74/// why. Nothing applies it: a caller names the preset it wants, and the choices are then the caller's, stated.
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
76pub struct ReadPreset {
77    /// The preset's name, stable within its reader.
78    pub name: &'static str,
79    /// The reader scope ([`ReadScopeSpec::name`]) whose options it states.
80    pub reader: &'static str,
81    /// Option and value, each an option the scope declares and a value it accepts.
82    pub choices: &'static [(&'static str, &'static str)],
83    /// What the choices are and the evidence for them, saying where they are observed and where only picked.
84    pub note: &'static str,
85}
86
87impl ReadPreset {
88    /// The choices as options, checked against `spec`'s declarations.
89    pub fn options(&self, spec: &ReadScopeSpec) -> Result<Options, String> {
90        if spec.name != self.reader {
91            return Err(format!(
92                "preset {} belongs to the {} scope, not {}",
93                self.name, self.reader, spec.name
94            ));
95        }
96        let options: Options = self
97            .choices
98            .iter()
99            .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
100            .collect();
101        for key in options.keys() {
102            if !spec.options.iter().any(|item| item.key() == key) {
103                return Err(format!("preset {} states undeclared option {key}", self.name));
104            }
105        }
106        Ok(options)
107    }
108}
109
110/// Metadata for a source candidate the caller's scope did not load.
111#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
112pub struct UnloadedAlternative {
113    /// The declared scope dimension (for example a form or data family).
114    pub option: String,
115    /// The candidate's value in that dimension.
116    pub value: String,
117    /// The source identity retained without loading its payload.
118    pub source: String,
119}
120
121/// A reader's effective scope and the alternatives it left unloaded.
122#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
123pub struct ReadScope {
124    /// The stable reader name.
125    pub reader: String,
126    /// Exactly the caller's checked choices; absent optional values remain absent.
127    pub choices: Options,
128    /// Source candidates not loaded under that scope, retained as metadata.
129    pub unloaded: Vec<UnloadedAlternative>,
130}
131
132impl ReadScope {
133    /// Retain this scope in the world without replacing scopes from other readers or earlier reads.
134    pub fn record_in(&self, world: &mut World) {
135        let mut scopes = world.resource::<ReadScopes>().cloned().unwrap_or_default();
136        if !scopes.0.contains(self) {
137            scopes.0.push(self.clone());
138        }
139        world.insert_resource(scopes);
140    }
141
142    /// The scope as a generic selection notice; diagnostics never select content for the reader.
143    pub fn notice(&self) -> Notice {
144        let choices = self
145            .choices
146            .iter()
147            .map(|(key, value)| format!("{key}={value}"))
148            .collect::<Vec<_>>()
149            .join(", ");
150        let mut notice = Notice::new(
151            &self.reader,
152            NoticeKind::Selection,
153            format!(
154                "caller-stated read scope {{{choices}}}; {} alternatives retained unloaded",
155                self.unloaded.len()
156            ),
157            1,
158        );
159        for alternative in &self.unloaded {
160            notice = notice.example(&alternative.source);
161        }
162        notice
163    }
164}
165
166/// Read scopes retained in the world as portable metadata, including unloaded source identities.
167#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
168pub struct ReadScopes(pub Vec<ReadScope>);
169
170impl Resource for ReadScopes {
171    const NAME: &'static str = "core.read_scopes";
172    fn to_json(&self) -> Result<Option<serde_json::Value>, JsonError> {
173        crate::json::to_value(self).map(Some)
174    }
175    fn verify(&self, _world: &World) -> Result<(), String> {
176        for scope in &self.0 {
177            if scope.reader.trim().is_empty() {
178                return Err("read scope needs a reader name".into());
179            }
180            for alternative in &scope.unloaded {
181                if alternative.option.trim().is_empty()
182                    || alternative.value.trim().is_empty()
183                    || alternative.source.trim().is_empty()
184                {
185                    return Err(
186                        "unloaded alternative needs a scope option, value and source identity".into(),
187                    );
188                }
189            }
190        }
191        Ok(())
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::pass::Choice;
199
200    #[derive(Clone, Copy, Debug, PartialEq)]
201    enum Payload {
202        List,
203        Load,
204    }
205    impl Choice for Payload {
206        const VALUES: &'static [(&'static str, Self)] = &[("list", Self::List), ("load", Self::Load)];
207    }
208    const SPEC: ReadScopeSpec = ReadScopeSpec {
209        name: "test-reader",
210        options: &[
211            OptionSpec::choice::<Payload>("payload", "Which payloads the caller asks to load"),
212            OptionSpec::optional("forms", "all", "Named forms", "No form restriction"),
213        ],
214    };
215
216    #[test]
217    fn declarations_never_fill_required_or_optional_choices() {
218        assert!(SPEC.resolve(Options::new()).is_err());
219        assert!(
220            SPEC.resolve(Options::from([("payload".into(), "guess".into())]))
221                .is_err()
222        );
223        let scope = SPEC
224            .resolve(Options::from([("payload".into(), "list".into())]))
225            .unwrap();
226        assert_eq!(scope.choices.len(), 1);
227        assert!(!scope.choices.contains_key("forms"));
228    }
229
230    #[test]
231    fn unloaded_source_identities_and_scope_survive_portable_reporting() {
232        let mut scope = SPEC
233            .resolve(Options::from([("payload".into(), "list".into())]))
234            .unwrap();
235        SPEC.record_unloaded(&mut scope, "payload", "load", "prototype-a")
236            .unwrap();
237        assert!(
238            SPEC.record_unloaded(&mut scope, "other", "load", "prototype-b")
239                .is_err()
240        );
241        assert_eq!(scope.notice().kind, NoticeKind::Selection);
242        assert_eq!(scope.notice().examples, ["prototype-a"]);
243        let resource = ReadScopes(vec![scope]);
244        assert!(resource.verify(&World::new()).is_ok());
245        let json = resource.to_json().unwrap().unwrap();
246        assert_eq!(json[0]["choices"]["payload"], "list");
247        assert_eq!(json[0]["unloaded"][0]["source"], "prototype-a");
248    }
249}