flowsurface_data/chart/kline/indicators/
manifest.rs1use super::{IndicatorId, catalog, language::PackageOrigin};
2use serde::Deserialize;
3use std::{
4 collections::BTreeSet,
5 time::{Duration, Instant},
6};
7
8#[derive(Debug, Clone, Deserialize)]
10pub struct Catalog {
11 schema_version: u32,
12 language_version: u32,
13 source_revision: String,
14 source_base_path: String,
15 pub indicators: Vec<Entry>,
16}
17
18#[derive(Debug, Clone, Deserialize)]
19pub struct Entry {
20 pub id: IndicatorId,
21 pub name: String,
22 pub description: Option<String>,
23 pub author: Option<Author>,
24 pub bases: Vec<String>,
25 pub path: String,
26 pub sha256: String,
27 pub size_bytes: usize,
28}
29
30#[derive(Debug, Clone, Deserialize)]
31pub struct Author {
32 pub name: String,
33}
34
35#[derive(Debug, Clone)]
36pub struct FetchError {
37 pub message: String,
38 retry_at: Option<Instant>,
39}
40
41impl FetchError {
42 pub fn retry_after(message: impl Into<String>, delay: Duration) -> Self {
43 Self {
44 message: message.into(),
45 retry_at: Some(Instant::now() + delay),
46 }
47 }
48}
49
50impl From<String> for FetchError {
51 fn from(message: String) -> Self {
52 Self {
53 message,
54 retry_at: None,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum SourceChange {
61 Installed,
62 Removed,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Installation {
67 Bundled,
68 Installed,
69 NotInstalled,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum Trigger {
74 Open,
76 Refresh,
78}
79
80#[derive(Debug, Clone)]
82pub enum SourceAction {
83 Install { entry: Entry, base: String },
84 Remove(IndicatorId),
85}
86
87#[derive(Debug, Clone)]
88pub enum Message {
89 Open,
90 Refresh,
91 ToggleSource(IndicatorId),
92 Fetched(Result<Catalog, FetchError>),
93 SourceChanged {
94 id: IndicatorId,
95 change: SourceChange,
96 result: Result<(), FetchError>,
97 },
98}
99
100#[derive(Default)]
101pub struct State {
102 catalog: Option<Catalog>,
103 status: Status,
104 pending: BTreeSet<IndicatorId>,
105 operation: SourceOperation,
106}
107
108#[derive(Default)]
109enum Status {
110 #[default]
111 Unopened,
112 Loading,
113 Ready,
114 Failed(FetchError),
115}
116
117#[derive(Default)]
118enum SourceOperation {
119 #[default]
120 Idle,
121 Running,
122 Failed {
123 id: IndicatorId,
124 error: FetchError,
125 },
126}
127
128impl Catalog {
129 pub fn parse(bytes: &[u8]) -> Result<Self, String> {
130 let mut catalog: Self =
131 serde_json::from_slice(bytes).map_err(|error| format!("Invalid catalog: {error}"))?;
132 catalog.validate()?;
133 catalog
134 .indicators
135 .sort_by_cached_key(|entry| entry.name.to_lowercase());
136 Ok(catalog)
137 }
138
139 pub fn source_base_path(&self) -> &str {
140 &self.source_base_path
141 }
142
143 fn validate(&self) -> Result<(), String> {
144 if self.schema_version != 1 || self.language_version != 1 {
145 return Err(
146 "This catalog requires an unsupported format or language version".to_owned(),
147 );
148 }
149
150 if self.source_revision.len() != 40
151 || !self
152 .source_revision
153 .bytes()
154 .all(|byte| byte.is_ascii_hexdigit())
155 || self.source_base_path != format!("/v1/sources/{}/", self.source_revision)
156 {
157 return Err("Invalid catalog source revision".to_owned());
158 }
159
160 let mut ids = BTreeSet::new();
161 for entry in &self.indicators {
162 if !ids.insert(&entry.id) {
163 return Err("Catalog contains duplicate IDs".to_owned());
164 }
165
166 if !entry.path.ends_with(".ind")
167 || entry.path.split('/').any(|part| {
168 part.is_empty()
169 || part == "."
170 || part == ".."
171 || part.contains(['\\', ':', '?', '#', '%'])
172 })
173 {
174 return Err(format!("Invalid source path for {}", entry.id.as_str()));
175 }
176
177 if entry.size_bytes == 0
178 || entry.size_bytes > indicator_engine::CompileLimits::default().max_source_bytes
179 || entry.sha256.len() != 64
180 || !entry.sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
181 {
182 return Err(format!(
183 "Invalid source size or checksum for {}",
184 entry.id.as_str()
185 ));
186 }
187 }
188 Ok(())
189 }
190}
191
192impl State {
193 pub fn catalog(&self) -> Option<&Catalog> {
194 self.catalog.as_ref()
195 }
196
197 pub fn is_loading(&self) -> bool {
198 matches!(self.status, Status::Loading)
199 }
200
201 pub fn error(&self) -> Option<&FetchError> {
202 match &self.status {
203 Status::Failed(error) => Some(error),
204 _ => None,
205 }
206 }
207
208 pub fn error_for(&self, id: &IndicatorId) -> Option<&FetchError> {
209 match &self.operation {
210 SourceOperation::Failed { id: failed, error } if failed == id => Some(error),
211 _ => None,
212 }
213 }
214
215 pub fn installation(&self, entry: &Entry) -> Installation {
216 let origin = catalog()
217 .get(&entry.id)
218 .map(|registration| registration.origin);
219 if origin == Some(PackageOrigin::Bundled) {
220 return Installation::Bundled;
221 }
222
223 let pending = self.pending.contains(&entry.id);
224 let present = match origin {
225 Some(_) => !pending,
226 None => pending,
227 };
228 if present {
229 Installation::Installed
230 } else {
231 Installation::NotInstalled
232 }
233 }
234
235 pub fn restart_pending(&self) -> bool {
236 !self.pending.is_empty()
237 }
238
239 pub fn restart_pending_for(&self, id: &IndicatorId) -> bool {
240 self.pending.contains(id)
241 }
242
243 pub fn can_refresh(&self) -> bool {
244 !self.is_loading() && self.can_request()
245 }
246
247 pub fn can_toggle(&self, installation: Installation) -> bool {
248 !matches!(installation, Installation::Bundled) && !self.is_running() && self.can_request()
249 }
250
251 pub fn begin_load(&mut self, trigger: Trigger) -> bool {
252 let allowed = match trigger {
253 Trigger::Open => self.is_unopened() && self.can_request(),
254 Trigger::Refresh => self.can_refresh(),
255 };
256 if allowed {
257 self.status = Status::Loading;
258 }
259 allowed
260 }
261
262 pub fn loaded(&mut self, catalog: Catalog) {
263 self.catalog = Some(catalog);
264 self.status = Status::Ready;
265 }
266
267 pub fn failed(&mut self, error: FetchError) {
268 self.status = Status::Failed(error);
269 }
270
271 pub fn begin_toggle(&mut self, id: &IndicatorId) -> Option<SourceAction> {
272 let manifest = self.catalog.as_ref()?;
273 let entry = manifest
274 .indicators
275 .iter()
276 .find(|entry| entry.id == *id)?
277 .clone();
278 let base = manifest.source_base_path().to_owned();
279
280 let installation = self.installation(&entry);
281 if !self.can_toggle(installation) {
282 return None;
283 }
284 self.operation = SourceOperation::Running;
285
286 Some(if matches!(installation, Installation::Installed) {
287 SourceAction::Remove(entry.id)
288 } else {
289 SourceAction::Install { entry, base }
290 })
291 }
292
293 pub fn operation_succeeded(&mut self, id: IndicatorId, change: SourceChange) {
294 self.record_change(id, change);
295 self.operation = SourceOperation::Idle;
296 }
297
298 pub fn operation_failed(&mut self, id: IndicatorId, error: FetchError) {
299 self.operation = SourceOperation::Failed { id, error };
300 }
301
302 fn is_unopened(&self) -> bool {
303 matches!(self.status, Status::Unopened)
304 }
305
306 fn is_running(&self) -> bool {
307 matches!(self.operation, SourceOperation::Running)
308 }
309
310 fn can_request(&self) -> bool {
311 let catalog_retry = self.error().and_then(|error| error.retry_at);
312 let operation_retry = match &self.operation {
313 SourceOperation::Failed { error, .. } => error.retry_at,
314 _ => None,
315 };
316 catalog_retry
317 .into_iter()
318 .chain(operation_retry)
319 .max()
320 .is_none_or(|at| Instant::now() >= at)
321 }
322
323 fn record_change(&mut self, id: IndicatorId, change: SourceChange) {
324 let loaded = catalog().get(&id).is_some();
325 let pending = match change {
326 SourceChange::Installed => !loaded,
327 SourceChange::Removed => loaded,
328 };
329 if pending {
330 self.pending.insert(id);
331 } else {
332 self.pending.remove(&id);
333 }
334 }
335}