Skip to main content

packset_daemon/
http.rs

1//! The `/v1` surface.
2//!
3//! Loopback only, and `127.0.0.1` rather than `localhost`: the name resolves
4//! to whatever the resolver says, which on a dual-stack seat is not always the
5//! interface the writer bound. The address is the contract.
6//!
7//! This layer decodes and encodes and nothing else. What a verb means lives in
8//! [`crate::service`].
9
10use 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
20/// The address the writer will bind, and no other.
21pub const LOOPBACK: &str = "127.0.0.1";
22/// The port the clients look for.
23pub const DEFAULT_PORT: u16 = 8761;
24/// Workers when the machine will not say how many cores it has.
25pub const DEFAULT_WORKERS: usize = 4;
26/// The ceiling on workers. 32 threads each kept a 64 MB malloc arena
27/// (2 GB idle) on a 32-thread laptop.
28pub const MAX_WORKERS: usize = 8;
29
30/// How many requests this writer will answer at once.
31///
32/// A thread per connection is fine until something loops on the socket, and
33/// then it is an unbounded number of threads on a seat that has other work to
34/// do. A fixed pool pulling from one queue answers the same requests and makes
35/// a burst wait instead of a machine swap.
36#[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
48/// What a route decided to answer.
49struct 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
67/// Serve until the process is stopped.
68///
69/// # Errors
70///
71/// Fails when the address cannot be bound.
72pub 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    // Every worker pulls from the server's own queue, so the pool is the
90    // balance: a burst waits in the queue rather than becoming threads.
91    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        // The loop ends when the listener is gone, which is how this process
97        // stops.
98        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        // The old name answered here once; a client still asking for it is
147        // reading a store this writer does not serve.
148        (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        // The deed accessions a workspace's live atoms cite, so `deedar
192        // evidence -` and `deedar current -` cover a pack the way they cover a
193        // tracker. Plain strings rather than atoms: the caller wants the join
194        // key, and asking for /v1/atoms to get it means shipping every body.
195        (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        // The other direction: one accession, and the live atoms that cite it.
206        (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            // Global skills live under the seat's own home, not the pack home:
283            // a pack can be moved between seats and a skill catalog cannot.
284            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            // No workspace required, which is the writer being replaced: an
462            // empty name slugs to `workspace` and the card lands there rather
463            // than being refused. Odd, and load-bearing for anything already
464            // sending one.
465            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                    // No text at all means the body names a file to read, so a
540                    // client can hand over a log without carrying it.
541                    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
555/// Turn a service result into an answer, keeping the store's own message.
556fn 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
563/// The innermost message, which is the one a client can act on.
564fn 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
571/// A refused cheap-model job answers 403: the caller is not wrong about the
572/// request, it is asking at a point in the cycle where the job does not run.
573fn 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
586/// Overflow answers 413, because the client can shorten and retry; anything
587/// else about a card is a refusal it has to fix.
588fn 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
615/// A dated retrieve stamp, or none when the caller asked for live-now.
616///
617/// The query may spell the instant the way parse_millis already accepts
618/// (`+00:00`, a space instead of `T`, missing millis). The window compare
619/// is a string compare, so the value that leaves here is the store form.
620fn 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        // First wins, matching a parse that takes element zero of the list.
664        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}