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) => Answer::ok(json!({ "atoms": atoms.as_ref() })),
187 Err(e) => Answer::err(400, e),
188 },
189 },
190 },
191 (Method::Get, "/v1/accessions") => match required(query, "workspace") {
196 Err(a) => a,
197 Ok(workspace) => match service.accessions(&workspace) {
198 Ok(found) => Answer::ok(json!({
199 "workspace": workspace,
200 "accessions": found,
201 })),
202 Err(e) => Answer::err(400, e),
203 },
204 },
205 (Method::Get, "/v1/citers") => match required(query, "workspace") {
207 Err(a) => a,
208 Ok(workspace) => match required(query, "accession") {
209 Err(a) => a,
210 Ok(accession) => match service.citers(&workspace, &accession) {
211 Ok(found) => Answer::ok(json!({
212 "workspace": workspace,
213 "accession": accession,
214 "atoms": found,
215 })),
216 Err(e) => Answer::err(400, e),
217 },
218 },
219 },
220 (Method::Get, _) if path.starts_with("/v1/atoms/") => {
221 let id = &path["/v1/atoms/".len()..];
222 if id.is_empty() || id.contains('/') {
223 return Answer::err(404, "not found");
224 }
225 match required(query, "workspace") {
226 Err(a) => a,
227 Ok(workspace) => match service.store().get(&workspace, id) {
228 Err(e) => Answer::err(400, e),
229 Ok(None) => Answer::err(404, "no atom"),
230 Ok(Some(atom)) => match as_of_stamp(query) {
231 Err(a) => a,
232 Ok(at) => {
233 let dated = at.is_some();
234 let now = at.unwrap_or_else(packset_core::clock::utcnow);
235 let live = if dated {
236 packset_core::record::is_live_at(&atom, &now)
237 } else {
238 packset_core::record::is_live(&atom, &now)
239 };
240 if live {
241 Answer::ok(Value::Object(atom))
242 } else {
243 Answer::err(404, "no atom")
244 }
245 }
246 },
247 },
248 }
249 }
250 (Method::Get, "/v1/identity") => {
251 let cwd = query.get("cwd").cloned().unwrap_or_else(|| ".".into());
252 let harness = query
253 .get("harness")
254 .filter(|h| !h.is_empty())
255 .cloned()
256 .unwrap_or_else(|| "any".into());
257 match crate::workspace::identity(
258 std::path::Path::new(&cwd),
259 packset_core::identity::Strategy::PerRepo,
260 &harness,
261 None,
262 None,
263 0,
264 None,
265 ) {
266 Ok(value) => Answer::ok(value),
267 Err(message) => Answer::err(400, message),
268 }
269 }
270 (Method::Get, "/v1/rules") => {
271 let cwd = query.get("cwd").cloned().unwrap_or_else(|| ".".into());
272 let with_body = truthy(query.get("body").map(String::as_str));
273 Answer::ok(crate::context::rules_payload(
274 std::path::Path::new(&cwd),
275 &service.home().user_path(),
276 with_body,
277 ))
278 }
279 (Method::Get, "/v1/skills") => {
280 let cwd = query.get("cwd").cloned().unwrap_or_else(|| ".".into());
281 let name = query.get("name").filter(|n| !n.is_empty());
282 let home = std::env::var_os("HOME")
285 .map_or_else(|| std::path::PathBuf::from("."), std::path::PathBuf::from);
286 Answer::ok(crate::context::skills_payload(
287 std::path::Path::new(&cwd),
288 &home,
289 name.map(String::as_str),
290 ))
291 }
292 (Method::Get, "/v1/map") => {
293 let cwd = query.get("cwd").cloned().unwrap_or_else(|| ".".into());
294 Answer::ok(crate::context::repo_map(std::path::Path::new(&cwd)))
295 }
296 (Method::Post, "/v1/consolidate") => match required(body, "workspace") {
297 Err(a) => a,
298 Ok(workspace) => {
299 let apply = body.get("apply").and_then(Value::as_bool).unwrap_or(false);
300 answer(service.consolidate(&workspace, apply))
301 }
302 },
303 (Method::Post, "/v1/fire") => match required(body, "workspace") {
304 Err(a) => a,
305 Ok(workspace) => {
306 let ids: Vec<String> = body
307 .get("ids")
308 .and_then(Value::as_array)
309 .map(|a| {
310 a.iter()
311 .filter_map(|v| v.as_str().map(str::to_string))
312 .collect()
313 })
314 .unwrap_or_default();
315 if ids.len() < 2 {
316 return Answer::err(400, "ids: two or more claims that fired together");
317 }
318 answer(service.fire(&workspace, &ids))
319 }
320 },
321 (Method::Get, "/v1/islands") => match required(query, "workspace") {
322 Err(a) => a,
323 Ok(workspace) => answer(service.islands(&workspace)),
324 },
325 (Method::Get, "/v1/hubs") => match required(query, "workspace") {
326 Err(a) => a,
327 Ok(workspace) => {
328 let limit = query
329 .get("limit")
330 .and_then(|l| l.parse::<usize>().ok())
331 .unwrap_or(10);
332 answer(service.hubs(&workspace, limit))
333 }
334 },
335 (Method::Get, "/v1/activate") => match required(query, "workspace") {
336 Err(a) => a,
337 Ok(workspace) => {
338 let limit = query
339 .get("limit")
340 .and_then(|l| l.parse::<usize>().ok())
341 .unwrap_or(24);
342 let q = query.get("q").cloned().unwrap_or_default();
343 if q.trim().is_empty() {
344 return Answer::err(400, "q required: the cue that activates");
345 }
346 let fire = crate::embed::requested(query.get("fire").map(String::as_str));
347 answer(service.activate(&workspace, &q, limit, panel, fire))
348 }
349 },
350 (Method::Get, "/v1/search") => match required(query, "workspace") {
351 Err(a) => a,
352 Ok(workspace) => {
353 let limit = match query.get("limit").filter(|l| !l.is_empty()) {
354 None => 16usize,
355 Some(raw) => match raw.parse::<i64>() {
356 Ok(v) => v.max(0) as usize,
357 Err(_) => return Answer::err(400, "limit must be an integer"),
358 },
359 };
360 let q = query.get("q").cloned().unwrap_or_default();
361 let set = query.get("set").filter(|s| !s.is_empty());
362 let rerank = crate::embed::requested(query.get("rerank").map(String::as_str));
363 match as_of_stamp(query) {
364 Err(a) => a,
365 Ok(at) => answer(service.search(
366 &workspace,
367 &q,
368 limit,
369 set.map(String::as_str),
370 panel,
371 at.as_deref(),
372 rerank,
373 )),
374 }
375 }
376 },
377 (Method::Get, "/v1/recall") => match required(query, "workspace") {
378 Err(a) => a,
379 Ok(workspace) => {
380 let limit = match query.get("limit").filter(|l| !l.is_empty()) {
381 None => None,
382 Some(raw) => match raw.parse::<i64>() {
383 Ok(v) => Some(v),
384 Err(_) => return Answer::err(400, "limit must be an integer"),
385 },
386 };
387 let seeds: Vec<String> = query
388 .get("seed")
389 .map(|raw| {
390 raw.split(',')
391 .filter(|s| !s.is_empty())
392 .map(str::to_string)
393 .collect()
394 })
395 .unwrap_or_default();
396 let hints = packset_core::recall::Hints {
397 text: query.get("q").cloned().unwrap_or_default(),
398 entities: Vec::new(),
399 };
400 match service.store().live(&workspace) {
401 Err(e) => Answer::err(400, e),
402 Ok(atoms) => Answer::ok(json!({
403 "atoms": packset_core::recall::recall(
404 &atoms,
405 &seeds,
406 &hints,
407 limit,
408 &packset_core::clock::utcnow(),
409 )
410 })),
411 }
412 }
413 },
414 (Method::Get, "/v1/attach") => match required(query, "workspace") {
415 Err(a) => a,
416 Ok(workspace) => {
417 let peek = truthy(query.get("peek").map(String::as_str));
418 let slot = if peek {
419 service.peek_attach(&workspace)
420 } else {
421 service.take_attach(&workspace)
422 };
423 let slot = slot.unwrap_or_default();
424 Answer::ok(json!({
425 "workspace": workspace,
426 "text": slot.text,
427 "label": slot.label,
428 }))
429 }
430 },
431 (Method::Put, "/v1/pin") => match required(body, "workspace") {
432 Err(a) => a,
433 Ok(workspace) => {
434 let name = body.get("set").and_then(Value::as_str).unwrap_or("");
435 match service.set_pin(&workspace, name) {
436 Ok(pinned) => Answer::ok(json!({"workspace": workspace, "set": pinned})),
437 Err(e) => Answer::err(400, e),
438 }
439 }
440 },
441 (Method::Put, "/v1/set") => match required(body, "workspace") {
442 Err(a) => a,
443 Ok(workspace) => {
444 let name = body
445 .get("name")
446 .or_else(|| body.get("set"))
447 .and_then(Value::as_str)
448 .unwrap_or("");
449 match service.write_set(&workspace, name, body) {
450 Err(WriteError::Overflow(o)) => Answer::err(413, o),
451 Err(other) => Answer::err(400, other),
452 Ok(stored) => answer(service.pack(&workspace, Some(&stored))),
453 }
454 }
455 },
456 (Method::Put, "/v1/user") => {
457 let text = body.get("text").and_then(Value::as_str).unwrap_or("");
458 card_answer(service.set_user(text))
459 }
460 (Method::Put, "/v1/memory") => {
461 let workspace = body.get("workspace").and_then(Value::as_str).unwrap_or("");
466 let text = body.get("text").and_then(Value::as_str).unwrap_or("");
467 card_answer(service.set_memory(workspace, text))
468 }
469 (Method::Get, "/v1/proposals") => match required(query, "workspace") {
470 Err(a) => a,
471 Ok(workspace) => Answer::ok(json!({"proposals": service.proposals(&workspace)})),
472 },
473 (Method::Post, "/v1/proposals") => cheap_answer(service.propose(body)),
474 (Method::Post, "/v1/proposals/accept") => {
475 let workspace = body.get("workspace").and_then(Value::as_str).unwrap_or("");
476 let id = body.get("id").and_then(Value::as_str).unwrap_or("");
477 if workspace.is_empty() || id.is_empty() {
478 return Answer::err(400, "workspace and id required");
479 }
480 cheap_answer(service.accept(workspace, id).map(Value::Object))
481 }
482 (Method::Post, "/v1/compact") => match required(body, "workspace") {
483 Err(a) => a,
484 Ok(workspace) => {
485 let day = body
486 .get("day")
487 .and_then(Value::as_str)
488 .filter(|d| !d.is_empty());
489 let transcript = body.get("transcript").and_then(Value::as_str);
490 cheap_answer(service.compact(&workspace, day, transcript))
491 }
492 },
493 (Method::Post, "/v1/atoms") => answer(service.add(body.clone())),
494 (Method::Post, "/v1/atoms/update") => {
495 let (Some(workspace), Some(id)) = (
496 body.get("workspace").and_then(Value::as_str),
497 body.get("id").and_then(Value::as_str),
498 ) else {
499 return Answer::err(400, "workspace and id required");
500 };
501 let empty = Map::new();
502 let fields = body
503 .get("fields")
504 .and_then(Value::as_object)
505 .unwrap_or(&empty);
506 answer(service.update(workspace, id, fields))
507 }
508 (Method::Post, "/v1/atoms/delete") => {
509 let (Some(workspace), Some(id)) = (
510 body.get("workspace").and_then(Value::as_str),
511 body.get("id").and_then(Value::as_str),
512 ) else {
513 return Answer::err(400, "workspace and id required");
514 };
515 let why = body.get("why").and_then(Value::as_str);
516 answer(service.delete_atom(workspace, id, why).map(Value::Object))
517 }
518 (Method::Post, "/v1/grade") => {
519 let workspace = body.get("workspace").and_then(Value::as_str).unwrap_or("");
520 let id = body.get("id").and_then(Value::as_str).unwrap_or("");
521 if workspace.is_empty() || id.is_empty() {
522 return Answer::err(400, "workspace and id required");
523 }
524 let recalled = match body.get("recalled") {
525 None | Some(Value::Null) => true,
526 Some(Value::Bool(b)) => *b,
527 Some(Value::String(s)) => {
528 !matches!(s.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no")
529 }
530 Some(other) => other.as_i64().unwrap_or(1) != 0,
531 };
532 answer(service.grade(workspace, id, recalled))
533 }
534 (Method::Post, "/v1/attach") => match required(body, "workspace") {
535 Err(a) => a,
536 Ok(workspace) => {
537 let text = match body.get("text") {
538 Some(Value::String(s)) => s.clone(),
539 Some(Value::Null) | None => crate::context::read_attach_source(
542 body.get("path").and_then(Value::as_str).unwrap_or(""),
543 crate::context::ATTACH_CAP,
544 ),
545 Some(other) => other.to_string(),
546 };
547 let label = body.get("label").and_then(Value::as_str).unwrap_or("");
548 Answer::ok(service.put_attach(&workspace, &text, label))
549 }
550 },
551 _ => Answer::err(404, "not found"),
552 }
553}
554
555fn answer<T: Into<Value>>(result: anyhow::Result<T>) -> Answer {
557 match result {
558 Ok(value) => Answer::ok(value.into()),
559 Err(e) => Answer::err(400, root_message(&e)),
560 }
561}
562
563fn root_message(err: &anyhow::Error) -> String {
565 if let Some(atom) = err.downcast_ref::<AtomError>() {
566 return atom.0.clone();
567 }
568 err.to_string()
569}
570
571fn cheap_answer<T: Into<Value>>(result: anyhow::Result<T>) -> Answer {
574 match result {
575 Ok(value) => Answer::ok(value.into()),
576 Err(e) => {
577 if let Some(cheap) = e.downcast_ref::<crate::proposals::CheapError>() {
578 Answer::err(403, &cheap.0)
579 } else {
580 Answer::err(400, root_message(&e))
581 }
582 }
583 }
584}
585
586fn card_answer(result: Result<(), WriteError>) -> Answer {
589 match result {
590 Ok(()) => Answer::ok(json!({"ok": true})),
591 Err(WriteError::Overflow(o)) => Answer::err(413, o),
592 Err(other) => Answer::err(400, other),
593 }
594}
595
596trait Lookup {
597 fn lookup(&self, key: &str) -> Option<String>;
598}
599
600impl Lookup for HashMap<String, String> {
601 fn lookup(&self, key: &str) -> Option<String> {
602 self.get(key).filter(|v| !v.is_empty()).cloned()
603 }
604}
605
606impl Lookup for Map<String, Value> {
607 fn lookup(&self, key: &str) -> Option<String> {
608 self.get(key)
609 .and_then(Value::as_str)
610 .filter(|v| !v.is_empty())
611 .map(str::to_string)
612 }
613}
614
615fn as_of_stamp(query: &HashMap<String, String>) -> Result<Option<String>, Answer> {
621 match query.get("as_of").filter(|s| !s.is_empty()) {
622 None => Ok(None),
623 Some(raw) => match packset_core::clock::canonicalize(raw) {
624 Some(at) => Ok(Some(at)),
625 None => Err(Answer::err(400, "as_of must be a timestamp")),
626 },
627 }
628}
629
630fn required<L: Lookup>(source: &L, key: &str) -> Result<String, Answer> {
631 source
632 .lookup(key)
633 .ok_or_else(|| Answer::err(400, format!("{key} required")))
634}
635
636fn truthy(raw: Option<&str>) -> bool {
637 matches!(raw, Some("1" | "true" | "yes"))
638}
639
640fn read_json(request: &mut Request) -> Result<Map<String, Value>, String> {
641 let mut raw = String::new();
642 request
643 .as_reader()
644 .read_to_string(&mut raw)
645 .map_err(|e| e.to_string())?;
646 if raw.trim().is_empty() {
647 return Ok(Map::new());
648 }
649 let value: Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
650 value
651 .as_object()
652 .cloned()
653 .ok_or_else(|| "JSON object required".to_string())
654}
655
656fn split_query(url: &str) -> (&str, HashMap<String, String>) {
657 let Some((path, raw)) = url.split_once('?') else {
658 return (url, HashMap::new());
659 };
660 let mut out = HashMap::new();
661 for pair in raw.split('&').filter(|p| !p.is_empty()) {
662 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
663 out.entry(percent_decode(key))
665 .or_insert_with(|| percent_decode(value));
666 }
667 (path, out)
668}
669
670fn percent_decode(raw: &str) -> String {
671 let bytes = raw.as_bytes();
672 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
673 let mut i = 0usize;
674 while i < bytes.len() {
675 match bytes[i] {
676 b'%' if i + 2 < bytes.len() => {
677 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
678 match u8::from_str_radix(hex, 16) {
679 Ok(byte) => {
680 out.push(byte);
681 i += 3;
682 }
683 Err(_) => {
684 out.push(bytes[i]);
685 i += 1;
686 }
687 }
688 }
689 b'+' => {
690 out.push(b' ');
691 i += 1;
692 }
693 byte => {
694 out.push(byte);
695 i += 1;
696 }
697 }
698 }
699 String::from_utf8_lossy(&out).into_owned()
700}
701
702fn text_plain() -> Header {
703 Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).expect("static header")
704}
705
706fn application_json() -> Header {
707 Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).expect("static header")
708}
709
710fn respond(request: Request, answer: &Answer) {
711 let body = serde_json::to_string(&answer.body).unwrap_or_else(|_| "{}".into());
712 let response = Response::from_string(body)
713 .with_status_code(answer.code)
714 .with_header(application_json());
715 let _ = request.respond(response);
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721
722 #[test]
723 fn default_workers_is_four_not_core_count() {
724 assert_eq!(DEFAULT_WORKERS, 4);
725 assert_eq!(MAX_WORKERS, 8);
726 }
727
728 #[test]
729 fn a_query_splits_and_decodes() {
730 let (path, query) = split_query("/v1/pack?workspace=git%3Agithub.com%2FHaoZeke%2Fvissue");
731 assert_eq!(path, "/v1/pack");
732 assert_eq!(
733 query.get("workspace").map(String::as_str),
734 Some("git:github.com/HaoZeke/vissue")
735 );
736 }
737
738 #[test]
739 fn a_path_with_no_query_is_left_alone() {
740 let (path, query) = split_query("/v1/workspaces");
741 assert_eq!(path, "/v1/workspaces");
742 assert!(query.is_empty());
743 }
744
745 #[test]
746 fn the_first_value_of_a_repeated_key_wins() {
747 let (_, query) = split_query("/v1/pack?workspace=a&workspace=b");
748 assert_eq!(query.get("workspace").map(String::as_str), Some("a"));
749 }
750
751 #[test]
752 fn a_flag_reads_the_three_spellings_and_nothing_else() {
753 assert!(truthy(Some("1")));
754 assert!(truthy(Some("true")));
755 assert!(truthy(Some("yes")));
756 assert!(!truthy(Some("on")));
757 assert!(!truthy(Some("")));
758 assert!(!truthy(None));
759 }
760
761 #[test]
762 fn an_as_of_stamp_is_checked() {
763 let mut q = HashMap::new();
764 assert!(matches!(as_of_stamp(&q), Ok(None)));
765 q.insert("as_of".into(), "2024-06-01T00:00:00.000Z".into());
766 assert!(matches!(
767 as_of_stamp(&q),
768 Ok(Some(ref s)) if s == "2024-06-01T00:00:00.000Z"
769 ));
770 q.insert("as_of".into(), "not-a-date".into());
771 assert!(matches!(as_of_stamp(&q), Err(a) if a.code == 400));
772 }
773
774 #[test]
775 fn a_plus_offset_as_of_agrees_with_the_store_form() {
776 let mut q = HashMap::new();
777 q.insert("as_of".into(), "2024-06-01T00:00:00+00:00".into());
778 assert!(matches!(
779 as_of_stamp(&q),
780 Ok(Some(ref s)) if s == "2024-06-01T00:00:00.000Z"
781 ));
782 q.insert("as_of".into(), "2024-06-01 00:00:00".into());
783 assert!(matches!(
784 as_of_stamp(&q),
785 Ok(Some(ref s)) if s == "2024-06-01T00:00:00.000Z"
786 ));
787 q.insert("as_of".into(), "2024-06-01 00:00:00.000Z".into());
788 assert!(matches!(
789 as_of_stamp(&q),
790 Ok(Some(ref s)) if s == "2024-06-01T00:00:00.000Z"
791 ));
792 }
793
794 #[test]
795 fn a_plus_is_a_space_and_a_stray_percent_survives() {
796 assert_eq!(percent_decode("a+b"), "a b");
797 assert_eq!(percent_decode("100%"), "100%");
798 assert_eq!(percent_decode("%zz"), "%zz");
799 }
800}