1use serde::Serialize;
7
8use crate::convert::{Notice, NoticeKind};
9use crate::pass::{OptionSpec, Options};
10use crate::{JsonError, Resource, World};
11
12#[derive(Clone, Copy, Debug)]
14pub struct ReadScopeSpec {
15 pub name: &'static str,
17 pub options: &'static [OptionSpec],
19}
20
21impl ReadScopeSpec {
22 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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
76pub struct ReadPreset {
77 pub name: &'static str,
79 pub reader: &'static str,
81 pub choices: &'static [(&'static str, &'static str)],
83 pub note: &'static str,
85}
86
87impl ReadPreset {
88 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#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
112pub struct UnloadedAlternative {
113 pub option: String,
115 pub value: String,
117 pub source: String,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
123pub struct ReadScope {
124 pub reader: String,
126 pub choices: Options,
128 pub unloaded: Vec<UnloadedAlternative>,
130}
131
132impl ReadScope {
133 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 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#[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}