1use std::collections::HashMap;
11use std::sync::Arc;
12
13use packset_core::record::AtomError;
14use serde_json::{json, Map, Value};
15use tiny_http::{Header, Method, Request, Response, Server};
16
17use crate::cards::WriteError;
18use crate::service::Service;
19
20pub const LOOPBACK: &str = "127.0.0.1";
22pub const DEFAULT_PORT: u16 = 8761;
24pub const DEFAULT_WORKERS: usize = 4;
26pub const MAX_WORKERS: usize = 8;
29
30#[must_use]
37pub fn worker_count() -> usize {
38 if let Some(raw) = std::env::var_os("PACKSET_WORKERS") {
39 if let Some(n) = raw.to_str().and_then(|s| s.trim().parse::<usize>().ok()) {
40 if n > 0 {
41 return n.min(MAX_WORKERS);
42 }
43 }
44 }
45 DEFAULT_WORKERS
46}
47
48struct Answer {
50 code: u16,
51 body: Value,
52}
53
54impl Answer {
55 fn ok(body: Value) -> Self {
56 Self { code: 200, body }
57 }
58
59 fn err(code: u16, message: impl std::fmt::Display) -> Self {
60 Self {
61 code,
62 body: json!({ "error": message.to_string() }),
63 }
64 }
65}
66
67pub fn serve(
73 service: Arc<Service>,
74 panel: packset_core::Panel,
75 host: &str,
76 port: u16,
77) -> anyhow::Result<()> {
78 if host != LOOPBACK {
79 anyhow::bail!("packsetd listens on {LOOPBACK} only");
80 }
81 let server = Arc::new(
82 Server::http((host, port))
83 .map_err(|e| anyhow::anyhow!("cannot bind {host}:{port}: {e}"))?,
84 );
85 let workers = worker_count();
86 eprintln!("packsetd: listening on http://{host}:{port} with {workers} workers");
87 let panel = Arc::new(panel);
88
89 let mut handles = Vec::with_capacity(workers);
92 for _ in 0..workers {
93 let server = Arc::clone(&server);
94 let service = Arc::clone(&service);
95 let panel = Arc::clone(&panel);
96 handles.push(std::thread::spawn(move || {
99 while let Ok(request) = server.recv() {
100 handle(&service, &panel, request);
101 }
102 }));
103 }
104 for handle in handles {
105 let _ = handle.join();
106 }
107 Ok(())
108}
109
110fn handle(service: &Service, panel: &packset_core::Panel, mut request: Request) {
111 let url = request.url().to_string();
112 let (path, query) = split_query(&url);
113 let method = request.method().clone();
114
115 if method == Method::Get && path == "/health" {
116 let response = Response::from_string("packsetd ok").with_header(text_plain());
117 let _ = request.respond(response);
118 return;
119 }
120
121 let body = if matches!(method, Method::Post | Method::Put) {
122 match read_json(&mut request) {
123 Ok(map) => map,
124 Err(message) => {
125 respond(request, &Answer::err(400, message));
126 return;
127 }
128 }
129 } else {
130 Map::new()
131 };
132
133 let answer = route(service, panel, &method, path, &query, &body);
134 respond(request, &answer);
135}
136
137fn route(
138 service: &Service,
139 panel: &packset_core::Panel,
140 method: &Method,
141 path: &str,
142 query: &HashMap<String, String>,
143 body: &Map<String, Value>,
144) -> Answer {
145 match (method, path) {
146 (Method::Get, "/__inside_memd/health") => Answer::err(404, "not found"),
149 (Method::Get, "/v1/status") => {
150 let workspace = query.get("workspace").filter(|w| !w.is_empty());
151 answer(service.status(workspace.map(String::as_str), panel))
152 }
153 (Method::Get, "/v1/workspaces") => match service.store().workspaces() {
154 Ok(found) => Answer::ok(json!({
155 "workspaces": found
156 .into_iter()
157 .map(|(name, live)| json!({"name": name, "live": live}))
158 .collect::<Vec<_>>()
159 })),
160 Err(e) => Answer::err(400, e),
161 },
162 (Method::Get, "/v1/pin") => match required(query, "workspace") {
163 Err(a) => a,
164 Ok(workspace) => answer(service.pin_payload(&workspace)),
165 },
166 (Method::Get, "/v1/pack") => match required(query, "workspace") {
167 Err(a) => a,
168 Ok(workspace) => {
169 let set = query.get("set").filter(|s| !s.is_empty());
170 answer(service.pack(&workspace, set.map(String::as_str)))
171 }
172 },
173 (Method::Get, "/v1/set") => match required(query, "workspace") {
174 Err(a) => a,
175 Ok(workspace) => match required(query, "name") {
176 Err(a) => a,
177 Ok(name) => answer(service.pack(&workspace, Some(&name))),
178 },
179 },
180 (Method::Get, "/v1/atoms") => match required(query, "workspace") {
181 Err(a) => a,
182 Ok(workspace) => match as_of_stamp(query) {
183 Err(a) => a,
184 Ok(Some(at)) => answer(service.as_of(&workspace, &at)),
185 Ok(None) => match service.store().live(&workspace) {
186 Ok(atoms) => match query.get("kind").map(String::as_str) {
189 Some(kind) if !kind.is_empty() => Answer::ok(json!({
190 "atoms": atoms
191 .iter()
192 .filter(|a| a.get("kind").and_then(Value::as_str) == Some(kind))
193 .collect::<Vec<_>>()
194 })),
195 _ => Answer::ok(json!({ "atoms": atoms.as_ref() })),
196 },
197 Err(e) => Answer::err(400, e),
198 },
199 },
200 },
201 (Method::Get, "/v1/accessions") => match required(query, "workspace") {
206 Err(a) => a,
207 Ok(workspace) => match service.accessions(&workspace) {
208 Ok(found) => Answer::ok(json!({
209 "workspace": workspace,
210 "accessions": found,
211 })),
212 Err(e) => Answer::err(400, e),
213 },
214 },
215 (Method::Get, "/v1/citers") => match required(query, "workspace") {
217 Err(a) => a,
218 Ok(workspace) => match required(query, "accession") {
219 Err(a) => a,
220 Ok(accession) => match service.citers(&workspace, &accession) {
221 Ok(found) => Answer::ok(json!({
222 "workspace": workspace,
223 "accession": accession,
224 "atoms": found,
225 })),
226 Err(e) => Answer::err(400, e),
227 },
228 },
229 },
230 (Method::Get, _) if path.starts_with("/v1/atoms/") => {
231 let id = &path["/v1/atoms/".len()..];
232 if id.is_empty() || id.contains('/') {
233 return Answer::err(404, "not found");
234 }
235 match required(query, "workspace") {
236 Err(a) => a,
237 Ok(workspace) => match service.store().get(&workspace, id) {
238 Err(e) => Answer::err(400, e),
239 Ok(None) => Answer::err(404, "no atom"),
240 Ok(Some(atom)) => match as_of_stamp(query) {
241 Err(a) => a,
242 Ok(at) => {
243 let dated = at.is_some();
244 let now = at.unwrap_or_else(packset_core::clock::utcnow);
245 let live = if dated {
246 packset_core::record::is_live_at(&atom, &now)
247 } else {
248 packset_core::record::is_live(&atom, &now)
249 };
250 if live {
251 Answer::ok(Value::Object(atom))
252 } else {
253 Answer::err(404, "no atom")
254 }
255 }
256 },
257 },
258 }
259 }
260 (Method::Get, "/v1/identity") => {
261 let cwd = query.get("cwd").cloned().unwrap_or_else(|| ".".into());
262 let harness = query
263 .get("harness")
264 .filter(|h| !h.is_empty())
265 .cloned()
266 .unwrap_or_else(|| "any".into());
267 match crate::workspace::identity(
268 std::path::Path::new(&cwd),
269 packset_core::identity::Strategy::PerRepo,
270 &harness,
271 None,
272 None,
273 0,
274 None,
275 ) {
276 Ok(value) => Answer::ok(value),
277 Err(message) => Answer::err(400, message),
278 }
279 }
280 (Method::Get, "/v1/rules") => {
281 let cwd = query.get("cwd").cloned().unwrap_or_else(|| ".".into());
282 let with_body = truthy(query.get("body").map(String::as_str));
283 Answer::ok(crate::context::rules_payload(
284 std::path::Path::new(&cwd),
285 &service.home().user_path(),
286 with_body,
287 ))
288 }
289 (Method::Get, "/v1/skills") => {
290 let cwd = query.get("cwd").cloned().unwrap_or_else(|| ".".into());
291 let name = query.get("name").filter(|n| !n.is_empty());
292 let home = std::env::var_os("HOME")
295 .map_or_else(|| std::path::PathBuf::from("."), std::path::PathBuf::from);
296 Answer::ok(crate::context::skills_payload(
297 std::path::Path::new(&cwd),
298 &home,
299 name.map(String::as_str),
300 ))
301 }
302 (Method::Get, "/v1/map") => {
303 let cwd = query.get("cwd").cloned().unwrap_or_else(|| ".".into());
304 Answer::ok(crate::context::repo_map(std::path::Path::new(&cwd)))
305 }
306 (Method::Post, "/v1/sweep") => match required(body, "workspace") {
307 Err(a) => a,
308 Ok(workspace) => answer(service.sweep(&workspace)),
309 },
310 (Method::Post, "/v1/consolidate") => match required(body, "workspace") {
311 Err(a) => a,
312 Ok(workspace) => {
313 let apply = body.get("apply").and_then(Value::as_bool).unwrap_or(false);
314 answer(service.consolidate(&workspace, apply))
315 }
316 },
317 (Method::Post, "/v1/fire") => match required(body, "workspace") {
318 Err(a) => a,
319 Ok(workspace) => {
320 let ids: Vec<String> = body
321 .get("ids")
322 .and_then(Value::as_array)
323 .map(|a| {
324 a.iter()
325 .filter_map(|v| v.as_str().map(str::to_string))
326 .collect()
327 })
328 .unwrap_or_default();
329 if ids.len() < 2 {
330 return Answer::err(400, "ids: two or more claims that fired together");
331 }
332 let lens = body
333 .get("as")
334 .and_then(Value::as_str)
335 .filter(|s| !s.trim().is_empty());
336 answer(service.fire_as(&workspace, &ids, lens))
337 }
338 },
339 (Method::Get, "/v1/islands") => match required(query, "workspace") {
340 Err(a) => a,
341 Ok(workspace) => answer(service.islands(&workspace)),
342 },
343 (Method::Get, "/v1/hubs") => match required(query, "workspace") {
344 Err(a) => a,
345 Ok(workspace) => {
346 let limit = query
347 .get("limit")
348 .and_then(|l| l.parse::<usize>().ok())
349 .unwrap_or(10);
350 answer(service.hubs(&workspace, limit))
351 }
352 },
353 (Method::Get, "/v1/activate") => match required(query, "workspace") {
354 Err(a) => a,
355 Ok(workspace) => {
356 let limit = query
357 .get("limit")
358 .and_then(|l| l.parse::<usize>().ok())
359 .unwrap_or(24);
360 let q = query.get("q").cloned().unwrap_or_default();
361 if q.trim().is_empty() {
362 return Answer::err(400, "q required: the cue that activates");
363 }
364 let fire = crate::embed::requested(query.get("fire").map(String::as_str));
365 let lens = query
367 .get("as")
368 .map(String::as_str)
369 .filter(|s| !s.trim().is_empty());
370 answer(service.activate_as(&workspace, &q, limit, panel, fire, lens))
371 }
372 },
373 (Method::Get, "/v1/search") => match required(query, "workspace") {
374 Err(a) => a,
375 Ok(workspace) => {
376 let limit = match query.get("limit").filter(|l| !l.is_empty()) {
377 None => 16usize,
378 Some(raw) => match raw.parse::<i64>() {
379 Ok(v) => v.max(0) as usize,
380 Err(_) => return Answer::err(400, "limit must be an integer"),
381 },
382 };
383 let q = query.get("q").cloned().unwrap_or_default();
384 let set = query.get("set").filter(|s| !s.is_empty());
385 let rerank = crate::embed::requested(query.get("rerank").map(String::as_str));
386 match as_of_stamp(query) {
387 Err(a) => a,
388 Ok(at) => answer(service.search(
389 &workspace,
390 &q,
391 limit,
392 set.map(String::as_str),
393 panel,
394 at.as_deref(),
395 rerank,
396 )),
397 }
398 }
399 },
400 (Method::Get, "/v1/recall") => match required(query, "workspace") {
401 Err(a) => a,
402 Ok(workspace) => {
403 let limit = match query.get("limit").filter(|l| !l.is_empty()) {
404 None => None,
405 Some(raw) => match raw.parse::<i64>() {
406 Ok(v) => Some(v),
407 Err(_) => return Answer::err(400, "limit must be an integer"),
408 },
409 };
410 let seeds: Vec<String> = query
411 .get("seed")
412 .map(|raw| {
413 raw.split(',')
414 .filter(|s| !s.is_empty())
415 .map(str::to_string)
416 .collect()
417 })
418 .unwrap_or_default();
419 let hints = packset_core::recall::Hints {
420 text: query.get("q").cloned().unwrap_or_default(),
421 entities: Vec::new(),
422 };
423 match service.store().live(&workspace) {
424 Err(e) => Answer::err(400, e),
425 Ok(atoms) => Answer::ok(json!({
426 "atoms": packset_core::recall::recall(
427 &atoms,
428 &seeds,
429 &hints,
430 limit,
431 &packset_core::clock::utcnow(),
432 )
433 })),
434 }
435 }
436 },
437 (Method::Get, "/v1/attach") => match required(query, "workspace") {
438 Err(a) => a,
439 Ok(workspace) => {
440 let peek = truthy(query.get("peek").map(String::as_str));
441 let slot = if peek {
442 service.peek_attach(&workspace)
443 } else {
444 service.take_attach(&workspace)
445 };
446 let slot = slot.unwrap_or_default();
447 Answer::ok(json!({
448 "workspace": workspace,
449 "text": slot.text,
450 "label": slot.label,
451 }))
452 }
453 },
454 (Method::Put, "/v1/pin") => match required(body, "workspace") {
455 Err(a) => a,
456 Ok(workspace) => {
457 let name = body.get("set").and_then(Value::as_str).unwrap_or("");
458 match service.set_pin(&workspace, name) {
459 Ok(pinned) => Answer::ok(json!({"workspace": workspace, "set": pinned})),
460 Err(e) => Answer::err(400, e),
461 }
462 }
463 },
464 (Method::Put, "/v1/set") => match required(body, "workspace") {
465 Err(a) => a,
466 Ok(workspace) => {
467 let name = body
468 .get("name")
469 .or_else(|| body.get("set"))
470 .and_then(Value::as_str)
471 .unwrap_or("");
472 match service.write_set(&workspace, name, body) {
473 Err(WriteError::Overflow(o)) => Answer::err(413, o),
474 Err(other) => Answer::err(400, other),
475 Ok(stored) => answer(service.pack(&workspace, Some(&stored))),
476 }
477 }
478 },
479 (Method::Put, "/v1/user") => {
480 let text = body.get("text").and_then(Value::as_str).unwrap_or("");
481 card_answer(service.set_user(text))
482 }
483 (Method::Put, "/v1/memory") => {
484 let workspace = body.get("workspace").and_then(Value::as_str).unwrap_or("");
489 let text = body.get("text").and_then(Value::as_str).unwrap_or("");
490 card_answer(service.set_memory(workspace, text))
491 }
492 (Method::Get, "/v1/proposals") => match required(query, "workspace") {
493 Err(a) => a,
494 Ok(workspace) => Answer::ok(json!({"proposals": service.proposals(&workspace)})),
495 },
496 (Method::Post, "/v1/proposals") => cheap_answer(service.propose(body)),
497 (Method::Post, "/v1/proposals/accept") => {
498 let workspace = body.get("workspace").and_then(Value::as_str).unwrap_or("");
499 let id = body.get("id").and_then(Value::as_str).unwrap_or("");
500 if workspace.is_empty() || id.is_empty() {
501 return Answer::err(400, "workspace and id required");
502 }
503 cheap_answer(service.accept(workspace, id).map(Value::Object))
504 }
505 (Method::Post, "/v1/compact") => match required(body, "workspace") {
506 Err(a) => a,
507 Ok(workspace) => {
508 let day = body
509 .get("day")
510 .and_then(Value::as_str)
511 .filter(|d| !d.is_empty());
512 let transcript = body.get("transcript").and_then(Value::as_str);
513 cheap_answer(service.compact(&workspace, day, transcript))
514 }
515 },
516 (Method::Post, "/v1/atoms") => answer(service.add(body.clone())),
517 (Method::Post, "/v1/atoms/update") => {
518 let (Some(workspace), Some(id)) = (
519 body.get("workspace").and_then(Value::as_str),
520 body.get("id").and_then(Value::as_str),
521 ) else {
522 return Answer::err(400, "workspace and id required");
523 };
524 let empty = Map::new();
525 let fields = body
526 .get("fields")
527 .and_then(Value::as_object)
528 .unwrap_or(&empty);
529 answer(service.update(workspace, id, fields))
530 }
531 (Method::Post, "/v1/atoms/delete") => {
532 let (Some(workspace), Some(id)) = (
533 body.get("workspace").and_then(Value::as_str),
534 body.get("id").and_then(Value::as_str),
535 ) else {
536 return Answer::err(400, "workspace and id required");
537 };
538 let why = body.get("why").and_then(Value::as_str);
539 answer(service.delete_atom(workspace, id, why).map(Value::Object))
540 }
541 (Method::Post, "/v1/grade") => {
542 let workspace = body.get("workspace").and_then(Value::as_str).unwrap_or("");
543 let id = body.get("id").and_then(Value::as_str).unwrap_or("");
544 if workspace.is_empty() || id.is_empty() {
545 return Answer::err(400, "workspace and id required");
546 }
547 let recalled = match body.get("recalled") {
548 None | Some(Value::Null) => true,
549 Some(Value::Bool(b)) => *b,
550 Some(Value::String(s)) => {
551 !matches!(s.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no")
552 }
553 Some(other) => other.as_i64().unwrap_or(1) != 0,
554 };
555 answer(service.grade(workspace, id, recalled))
556 }
557 (Method::Post, "/v1/attach") => match required(body, "workspace") {
558 Err(a) => a,
559 Ok(workspace) => {
560 let text = match body.get("text") {
561 Some(Value::String(s)) => s.clone(),
562 Some(Value::Null) | None => crate::context::read_attach_source(
565 body.get("path").and_then(Value::as_str).unwrap_or(""),
566 crate::context::ATTACH_CAP,
567 ),
568 Some(other) => other.to_string(),
569 };
570 let label = body.get("label").and_then(Value::as_str).unwrap_or("");
571 Answer::ok(service.put_attach(&workspace, &text, label))
572 }
573 },
574 _ => Answer::err(404, "not found"),
575 }
576}
577
578fn answer<T: Into<Value>>(result: anyhow::Result<T>) -> Answer {
580 match result {
581 Ok(value) => Answer::ok(value.into()),
582 Err(e) => Answer::err(400, root_message(&e)),
583 }
584}
585
586fn root_message(err: &anyhow::Error) -> String {
588 if let Some(atom) = err.downcast_ref::<AtomError>() {
589 return atom.0.clone();
590 }
591 err.to_string()
592}
593
594fn cheap_answer<T: Into<Value>>(result: anyhow::Result<T>) -> Answer {
597 match result {
598 Ok(value) => Answer::ok(value.into()),
599 Err(e) => {
600 if let Some(cheap) = e.downcast_ref::<crate::proposals::CheapError>() {
601 Answer::err(403, &cheap.0)
602 } else {
603 Answer::err(400, root_message(&e))
604 }
605 }
606 }
607}
608
609fn card_answer(result: Result<(), WriteError>) -> Answer {
612 match result {
613 Ok(()) => Answer::ok(json!({"ok": true})),
614 Err(WriteError::Overflow(o)) => Answer::err(413, o),
615 Err(other) => Answer::err(400, other),
616 }
617}
618
619trait Lookup {
620 fn lookup(&self, key: &str) -> Option<String>;
621}
622
623impl Lookup for HashMap<String, String> {
624 fn lookup(&self, key: &str) -> Option<String> {
625 self.get(key).filter(|v| !v.is_empty()).cloned()
626 }
627}
628
629impl Lookup for Map<String, Value> {
630 fn lookup(&self, key: &str) -> Option<String> {
631 self.get(key)
632 .and_then(Value::as_str)
633 .filter(|v| !v.is_empty())
634 .map(str::to_string)
635 }
636}
637
638fn as_of_stamp(query: &HashMap<String, String>) -> Result<Option<String>, Answer> {
644 match query.get("as_of").filter(|s| !s.is_empty()) {
645 None => Ok(None),
646 Some(raw) => match packset_core::clock::canonicalize(raw) {
647 Some(at) => Ok(Some(at)),
648 None => Err(Answer::err(400, "as_of must be a timestamp")),
649 },
650 }
651}
652
653fn required<L: Lookup>(source: &L, key: &str) -> Result<String, Answer> {
654 source
655 .lookup(key)
656 .ok_or_else(|| Answer::err(400, format!("{key} required")))
657}
658
659fn truthy(raw: Option<&str>) -> bool {
660 matches!(raw, Some("1" | "true" | "yes"))
661}
662
663fn read_json(request: &mut Request) -> Result<Map<String, Value>, String> {
664 let mut raw = String::new();
665 request
666 .as_reader()
667 .read_to_string(&mut raw)
668 .map_err(|e| e.to_string())?;
669 if raw.trim().is_empty() {
670 return Ok(Map::new());
671 }
672 let value: Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
673 value
674 .as_object()
675 .cloned()
676 .ok_or_else(|| "JSON object required".to_string())
677}
678
679fn split_query(url: &str) -> (&str, HashMap<String, String>) {
680 let Some((path, raw)) = url.split_once('?') else {
681 return (url, HashMap::new());
682 };
683 let mut out = HashMap::new();
684 for pair in raw.split('&').filter(|p| !p.is_empty()) {
685 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
686 out.entry(percent_decode(key))
688 .or_insert_with(|| percent_decode(value));
689 }
690 (path, out)
691}
692
693fn percent_decode(raw: &str) -> String {
694 let bytes = raw.as_bytes();
695 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
696 let mut i = 0usize;
697 while i < bytes.len() {
698 match bytes[i] {
699 b'%' if i + 2 < bytes.len() => {
700 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
701 match u8::from_str_radix(hex, 16) {
702 Ok(byte) => {
703 out.push(byte);
704 i += 3;
705 }
706 Err(_) => {
707 out.push(bytes[i]);
708 i += 1;
709 }
710 }
711 }
712 b'+' => {
713 out.push(b' ');
714 i += 1;
715 }
716 byte => {
717 out.push(byte);
718 i += 1;
719 }
720 }
721 }
722 String::from_utf8_lossy(&out).into_owned()
723}
724
725fn text_plain() -> Header {
726 Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).expect("static header")
727}
728
729fn application_json() -> Header {
730 Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).expect("static header")
731}
732
733fn respond(request: Request, answer: &Answer) {
734 let body = serde_json::to_string(&answer.body).unwrap_or_else(|_| "{}".into());
735 let response = Response::from_string(body)
736 .with_status_code(answer.code)
737 .with_header(application_json());
738 let _ = request.respond(response);
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 #[test]
746 fn default_workers_is_four_not_core_count() {
747 assert_eq!(DEFAULT_WORKERS, 4);
748 assert_eq!(MAX_WORKERS, 8);
749 }
750
751 #[test]
752 fn a_query_splits_and_decodes() {
753 let (path, query) = split_query("/v1/pack?workspace=git%3Agithub.com%2FHaoZeke%2Fvissue");
754 assert_eq!(path, "/v1/pack");
755 assert_eq!(
756 query.get("workspace").map(String::as_str),
757 Some("git:github.com/HaoZeke/vissue")
758 );
759 }
760
761 #[test]
762 fn a_path_with_no_query_is_left_alone() {
763 let (path, query) = split_query("/v1/workspaces");
764 assert_eq!(path, "/v1/workspaces");
765 assert!(query.is_empty());
766 }
767
768 #[test]
769 fn the_first_value_of_a_repeated_key_wins() {
770 let (_, query) = split_query("/v1/pack?workspace=a&workspace=b");
771 assert_eq!(query.get("workspace").map(String::as_str), Some("a"));
772 }
773
774 #[test]
775 fn a_flag_reads_the_three_spellings_and_nothing_else() {
776 assert!(truthy(Some("1")));
777 assert!(truthy(Some("true")));
778 assert!(truthy(Some("yes")));
779 assert!(!truthy(Some("on")));
780 assert!(!truthy(Some("")));
781 assert!(!truthy(None));
782 }
783
784 #[test]
785 fn an_as_of_stamp_is_checked() {
786 let mut q = HashMap::new();
787 assert!(matches!(as_of_stamp(&q), Ok(None)));
788 q.insert("as_of".into(), "2024-06-01T00:00:00.000Z".into());
789 assert!(matches!(
790 as_of_stamp(&q),
791 Ok(Some(ref s)) if s == "2024-06-01T00:00:00.000Z"
792 ));
793 q.insert("as_of".into(), "not-a-date".into());
794 assert!(matches!(as_of_stamp(&q), Err(a) if a.code == 400));
795 }
796
797 #[test]
798 fn a_plus_offset_as_of_agrees_with_the_store_form() {
799 let mut q = HashMap::new();
800 q.insert("as_of".into(), "2024-06-01T00:00:00+00:00".into());
801 assert!(matches!(
802 as_of_stamp(&q),
803 Ok(Some(ref s)) if s == "2024-06-01T00:00:00.000Z"
804 ));
805 q.insert("as_of".into(), "2024-06-01 00:00:00".into());
806 assert!(matches!(
807 as_of_stamp(&q),
808 Ok(Some(ref s)) if s == "2024-06-01T00:00:00.000Z"
809 ));
810 q.insert("as_of".into(), "2024-06-01 00:00:00.000Z".into());
811 assert!(matches!(
812 as_of_stamp(&q),
813 Ok(Some(ref s)) if s == "2024-06-01T00:00:00.000Z"
814 ));
815 }
816
817 #[test]
818 fn a_plus_is_a_space_and_a_stray_percent_survives() {
819 assert_eq!(percent_decode("a+b"), "a b");
820 assert_eq!(percent_decode("100%"), "100%");
821 assert_eq!(percent_decode("%zz"), "%zz");
822 }
823}