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                    // `kind` narrows the answer to one kind, so a roster of
187                    // personas does not carry every lesson's embedding.
188                    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        // The deed accessions a workspace's live atoms cite, so `deedar
202        // evidence -` and `deedar current -` cover a pack the way they cover a
203        // tracker. Plain strings rather than atoms: the caller wants the join
204        // key, and asking for /v1/atoms to get it means shipping every body.
205        (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        // The other direction: one accession, and the live atoms that cite it.
216        (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            // Global skills live under the seat's own home, not the pack home:
293            // a pack can be moved between seats and a skill catalog cannot.
294            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                // `as` is a persona's lens: its weights on the way in and out.
366                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            // No workspace required, which is the writer being replaced: an
485            // empty name slugs to `workspace` and the card lands there rather
486            // than being refused. Odd, and load-bearing for anything already
487            // sending one.
488            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                    // No text at all means the body names a file to read, so a
563                    // client can hand over a log without carrying it.
564                    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
578/// Turn a service result into an answer, keeping the store's own message.
579fn 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
586/// The innermost message, which is the one a client can act on.
587fn 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
594/// A refused cheap-model job answers 403: the caller is not wrong about the
595/// request, it is asking at a point in the cycle where the job does not run.
596fn 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
609/// Overflow answers 413, because the client can shorten and retry; anything
610/// else about a card is a refusal it has to fix.
611fn 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
638/// A dated retrieve stamp, or none when the caller asked for live-now.
639///
640/// The query may spell the instant the way parse_millis already accepts
641/// (`+00:00`, a space instead of `T`, missing millis). The window compare
642/// is a string compare, so the value that leaves here is the store form.
643fn 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        // First wins, matching a parse that takes element zero of the list.
687        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}