Skip to main content

speccoder_web/
lib.rs

1//! speccoder's web interface: a host on 127.0.0.1 that
2//! serves the interface — embedded in the binary — and an API over
3//! speccoder's own payloads. The page, a browser tab or the desktop window,
4//! holds no logic of its own: what it shows and offers comes from the
5//! runtime, as for the CLI.
6
7pub mod fixes;
8pub mod jobs;
9pub mod security;
10mod welcome;
11
12use std::net::SocketAddr;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use axum::extract::{Query, Request, State};
17use axum::http::{HeaderValue, StatusCode, header};
18use axum::middleware::Next;
19use axum::response::{IntoResponse, Response};
20use axum::routing::{get, post};
21use axum::{Json, Router};
22use serde::Deserialize;
23use serde_json::{Value, json};
24use speccoder_core::actions::Runnable;
25use speccoder_core::{CommandData, CommandResult};
26
27mod bundle {
28    include!(concat!(env!("OUT_DIR"), "/bundle.rs"));
29}
30
31/// The project a host serves: bound at most once (one host per
32/// project). A host started outside a project serves the welcome until
33/// the person creates, opens, clones or tries one.
34#[derive(Debug, Clone)]
35pub struct Root(Arc<RootCell>);
36
37#[derive(Debug)]
38struct RootCell {
39    start: PathBuf,
40    bound: std::sync::OnceLock<PathBuf>,
41}
42
43impl Root {
44    /// A host's root: `start`, bound now when it is a project.
45    #[must_use]
46    pub fn new(start: &Path, bound: bool) -> Self {
47        let cell = RootCell {
48            start: start.to_path_buf(),
49            bound: std::sync::OnceLock::new(),
50        };
51        if bound {
52            let _ = cell.bound.set(start.to_path_buf());
53        }
54        Self(Arc::new(cell))
55    }
56
57    /// The project (the start folder before a bind).
58    #[must_use]
59    pub fn get(&self) -> PathBuf {
60        self.0
61            .bound
62            .get()
63            .cloned()
64            .unwrap_or_else(|| self.0.start.clone())
65    }
66
67    /// The project, once bound.
68    #[must_use]
69    pub fn bound(&self) -> Option<PathBuf> {
70        self.0.bound.get().cloned()
71    }
72
73    /// Where the host was started.
74    #[must_use]
75    pub fn start(&self) -> PathBuf {
76        self.0.start.clone()
77    }
78
79    /// Binds the host to `project`, once.
80    ///
81    /// # Errors
82    ///
83    /// The host serves a project already.
84    pub fn bind(&self, project: &Path) -> Result<(), String> {
85        self.0.bound.set(project.to_path_buf()).map_err(|_| {
86            format!(
87                "this host serves {} already: open another project with `speccoder ui -C {}`",
88                self.get().display(),
89                project.display()
90            )
91        })
92    }
93}
94
95/// The host's state.
96#[derive(Debug, Clone)]
97pub struct Shared {
98    /// The project, bound at most once.
99    pub root: Root,
100    /// The per-start token.
101    pub token: String,
102    /// The port it listens on.
103    pub port: u16,
104    /// Jobs and events.
105    pub hub: Arc<jobs::Hub>,
106    /// It serves the desktop window (in this process), not a browser tab:
107    /// it may open a terminal for the person.
108    pub window: bool,
109    /// Turns true when the host stops: open event streams end with it.
110    pub closing: tokio::sync::watch::Receiver<bool>,
111    /// A GitHub sign-in waiting for the person to enter its code:
112    /// the host, the app and the pending device code — never sent to the page.
113    pub device: Arc<
114        std::sync::Mutex<
115            Option<(
116                String,
117                String,
118                speccoder_runtime::service::github::auth::Pending,
119            )>,
120        >,
121    >,
122}
123
124/// How long open requests get to finish after ctrl-c.
125const GRACE: std::time::Duration = std::time::Duration::from_secs(2);
126
127/// Runs `f` on a thread of its own: the runtime's calls block and start
128/// runtimes of their own, which the host's workers must not.
129async fn off<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
130    let (tx, rx) = tokio::sync::oneshot::channel();
131    std::thread::spawn(move || {
132        let _ = tx.send(f());
133    });
134    match rx.await {
135        Ok(v) => v,
136        // The work panicked: that request fails (its connection drops), the
137        // host and a running job go on. Never abort the process for one.
138        Err(_) => std::panic::resume_unwind(Box::new("a request's work stopped unexpectedly")),
139    }
140}
141
142/// Refuses what does not come from this host's page or a token holder.
143async fn guard(State(s): State<Shared>, req: Request, next: Next) -> Response {
144    let headers = req.headers();
145    if !security::host_ok(s.port, headers) {
146        return (
147            StatusCode::MISDIRECTED_REQUEST,
148            "this host answers on the loopback address only",
149        )
150            .into_response();
151    }
152    if req.uri().path().starts_with("/api/") {
153        if !security::authorised(&s.token, headers) {
154            return (
155                StatusCode::UNAUTHORIZED,
156                Json(json!({ "error": "unauthorised" })),
157            )
158                .into_response();
159        }
160        if !security::origin_ok(s.port, req.method(), headers) {
161            return (
162                StatusCode::FORBIDDEN,
163                Json(json!({ "error": "cross-origin request refused" })),
164            )
165                .into_response();
166        }
167    }
168    let mut res = next.run(req).await;
169    let h = res.headers_mut();
170    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
171    h.insert(
172        header::REFERRER_POLICY,
173        HeaderValue::from_static("no-referrer"),
174    );
175    h.insert(
176        header::X_CONTENT_TYPE_OPTIONS,
177        HeaderValue::from_static("nosniff"),
178    );
179    h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
180    res
181}
182
183fn content_type(path: &str) -> &'static str {
184    match path.rsplit('.').next() {
185        Some("html") => "text/html; charset=utf-8",
186        Some("js") => "text/javascript; charset=utf-8",
187        Some("css") => "text/css; charset=utf-8",
188        Some("svg") => "image/svg+xml",
189        Some("json") => "application/json",
190        Some("woff2") => "font/woff2",
191        _ => "application/octet-stream",
192    }
193}
194
195/// The interface: the embedded bundle (`SPECCODER_UI` names a `dist/` to
196/// serve instead, for working on the interface).
197async fn asset(uri: axum::http::Uri) -> Response {
198    let want = match uri.path().trim_start_matches('/') {
199        "" => "index.html",
200        p => p,
201    };
202    let csp = [(
203        header::CONTENT_SECURITY_POLICY,
204        security::CONTENT_SECURITY_POLICY,
205    )];
206    if let Some(dir) = std::env::var_os("SPECCODER_UI")
207        && !want.contains("..")
208        && let Ok(bytes) = std::fs::read(Path::new(&dir).join(want))
209    {
210        return (csp, [(header::CONTENT_TYPE, content_type(want))], bytes).into_response();
211    }
212    if bundle::BUNDLE.is_empty() {
213        return (
214            StatusCode::SERVICE_UNAVAILABLE,
215            [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
216            "<!doctype html><meta charset=utf-8><title>speccoder</title><p style=\"font:16px system-ui;max-width:60ch;margin:4rem auto\">This binary was built without its interface (<code>npm run build</code> in <code>crates/speccoder-web/ui</code>). The API and the command line work.",
217        )
218            .into_response();
219    }
220    // Vite emits flat names: a path matches an embedded file or nothing.
221    let name = want.trim_start_matches("assets/");
222    match bundle::BUNDLE
223        .iter()
224        .find(|(p, _)| *p == want || *p == name)
225    {
226        Some((p, bytes)) => {
227            (csp, [(header::CONTENT_TYPE, content_type(p))], *bytes).into_response()
228        }
229        // The interface routes in the page: an unknown path is the page.
230        None => match bundle::BUNDLE.iter().find(|(p, _)| *p == "index.html") {
231            Some((_, bytes)) => (
232                csp,
233                [(header::CONTENT_TYPE, content_type("index.html"))],
234                *bytes,
235            )
236                .into_response(),
237            None => (StatusCode::NOT_FOUND, "no such file").into_response(),
238        },
239    }
240}
241
242#[derive(Debug, Deserialize)]
243struct FeatureQuery {
244    feature: Option<String>,
245    task: Option<String>,
246    commit: Option<String>,
247    /// An attempt ref (`refs/speccoder/attempts/…`): a failed or held
248    /// attempt's code, kept for a person to read.
249    #[serde(rename = "ref")]
250    reference: Option<String>,
251}
252
253/// A commit id, or a ref in speccoder's attempt namespace — nothing else
254/// reaches `git show` (no options, no other refs, no `..`).
255fn revision(q: &FeatureQuery) -> Option<String> {
256    if let Some(c) = q.commit.as_deref() {
257        return (c.len() >= 7 && c.chars().all(|ch| ch.is_ascii_hexdigit())).then(|| c.to_owned());
258    }
259    let r = q.reference.as_deref()?;
260    let rest = r.strip_prefix("refs/speccoder/attempts/")?;
261    let ok = !rest.is_empty()
262        && !r.contains("..")
263        && rest
264            .chars()
265            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '-' | '_' | '.'));
266    ok.then(|| r.to_owned())
267}
268
269/// A project-relative path the file view may show: nothing absolute,
270/// nothing up. Where it leads on disk is checked where it is read — a link
271/// is never followed, the file must be inside the worktree.
272fn file_path_ok(path: &str) -> bool {
273    let p = Path::new(path);
274    !path.is_empty()
275        && !p.is_absolute()
276        && p.components()
277            .all(|c| matches!(c, std::path::Component::Normal(_)))
278}
279
280/// A result as the page gets it: the person's own commands in it signed
281/// for this host's project ([`fixes::Issuer`]).
282fn envelope(s: &Shared, r: &CommandResult) -> Json<Value> {
283    Json(s.hub.issuer.signing(s.root.get(), || {
284        serde_json::to_value(r).unwrap_or(Value::Null)
285    }))
286}
287
288async fn project(State(s): State<Shared>) -> Json<Value> {
289    let root = s.root.get();
290    let window = s.window;
291    Json(
292        off(move || {
293            let words = speccoder_runtime::service::format_words(&root);
294            let features = speccoder_runtime::service::current::list(&root, None);
295            let name = root
296                .file_name()
297                .map(|n| n.to_string_lossy().into_owned())
298                .unwrap_or_default();
299            json!({
300                "name": name,
301                "root": root.display().to_string(),
302                "format": words.name,
303                "unit": words.unit,
304                "features": features.data().and_then(|d| match d {
305                    CommandData::Features(f) => serde_json::to_value(f).ok(),
306                    _ => None,
307                }),
308                "version": env!("CARGO_PKG_VERSION"),
309                "os": format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
310                "window": window,
311                "sandbox": speccoder_runtime::service::person::sandboxed(),
312                "planning": speccoder_runtime::service::planning_words(&root),
313                "sample": welcome::sample_of(&root),
314            })
315        })
316        .await,
317    )
318}
319
320async fn report(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
321    let root = s.root.get();
322    let r = off(move || {
323        speccoder_runtime::service::next::attach(
324            speccoder_runtime::service::status::report(&root, q.feature.as_deref()),
325            q.feature.as_deref(),
326        )
327    })
328    .await;
329    envelope(&s, &r)
330}
331
332async fn review(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
333    let root = s.root.get();
334    let r =
335        off(move || speccoder_runtime::service::review::report(&root, q.feature.as_deref())).await;
336    envelope(&s, &r)
337}
338
339/// The comment tray and the last revision turn.
340async fn review_comments(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
341    let root = s.root.get();
342    let r = off(move || {
343        speccoder_runtime::service::review::comments(&root, q.feature.as_deref(), None)
344    })
345    .await;
346    envelope(&s, &r)
347}
348
349#[derive(Debug, Deserialize)]
350struct RunQuery {
351    run: String,
352}
353
354/// The model's account of a run, turn by turn: its reasoning and its words
355/// (a claim).
356async fn account(State(s): State<Shared>, Query(q): Query<RunQuery>) -> Json<Value> {
357    let root = s.root.get();
358    Json(
359        off(
360            move || match speccoder_runtime::service::evidence::account_of(&root, &q.run) {
361                Ok(turns) => json!({ "turns": turns }),
362                Err(e) => json!({ "error": e }),
363            },
364        )
365        .await,
366    )
367}
368
369async fn inbox(State(s): State<Shared>) -> Json<Value> {
370    let root = s.root.get();
371    let issuer = Arc::clone(&s.hub.issuer);
372    // Its actions' steps are built with them: signed as built.
373    Json(
374        off(move || {
375            issuer.signing(root.clone(), || {
376                serde_json::to_value(speccoder_runtime::service::inbox::inbox(&root, None))
377                    .unwrap_or(Value::Null)
378            })
379        })
380        .await,
381    )
382}
383
384/// A bug by `<unit>/<task>` (`task`): its triad, reproductions and stage.
385async fn bug(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
386    let root = s.root.get();
387    let Some(task) = q.task else {
388        return Json(json!({ "error": "a bug is needed: task=<unit>/<task>" }));
389    };
390    let r = off(move || speccoder_runtime::service::bug::show(&root, &task)).await;
391    envelope(&s, &r)
392}
393
394async fn actions(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
395    let root = s.root.get();
396    let issuer = Arc::clone(&s.hub.issuer);
397    Json(
398        off(move || {
399            // An action's step is built with the action: signed as built.
400            issuer.signing(root.clone(), || {
401                json!(speccoder_runtime::service::actions::available(
402                    &root,
403                    q.feature.as_deref(),
404                    q.task.as_deref()
405                ))
406            })
407        })
408        .await,
409    )
410}
411
412async fn approvals(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
413    let root = s.root.get();
414    let r =
415        off(move || speccoder_runtime::service::approvals::list(&root, q.feature.as_deref())).await;
416    envelope(&s, &r)
417}
418
419async fn evidence(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
420    let root = s.root.get();
421    Json(
422        off(move || {
423            let report = speccoder_runtime::service::status::report(&root, q.feature.as_deref());
424            let Some(CommandData::Report(r)) = report.data() else {
425                return json!({ "error": report.message() });
426            };
427            let Some(t) = r.tasks.iter().find(|t| Some(&t.id) == q.task.as_ref()) else {
428                return json!({ "error": "no such task" });
429            };
430            let statements = t
431                .commit
432                .as_deref()
433                .map(|c| speccoder_runtime::service::evidence::statements(&root, c))
434                .unwrap_or_default();
435            let (mark, words) = speccoder_runtime::service::evidence::banner(&t.state);
436            json!({
437                "task": t,
438                "banner": { "mark": mark, "words": words },
439                "rows": speccoder_runtime::service::evidence::rows(t, &statements, None),
440            })
441        })
442        .await,
443    )
444}
445
446/// The task's evidence tour (payload `evidence_tour`, the same as
447/// `speccoder attest tour --json`), read as recorded: the app shows what
448/// the signed statements and the commit say; a maintainer's verifier
449/// checks the signatures.
450async fn tour(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
451    let root = s.root.get();
452    Json(
453        off(move || {
454            let Some(task) = q.task else {
455                return json!({ "error": "a task is needed" });
456            };
457            let result =
458                speccoder_runtime::service::tour::of_task(&root, q.feature.as_deref(), &task);
459            serde_json::to_value(&result).unwrap_or_else(|e| json!({ "error": e.to_string() }))
460        })
461        .await,
462    )
463}
464
465async fn diff(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Response {
466    let root = s.root.get();
467    let Some(commit) = revision(&q) else {
468        return (
469            StatusCode::BAD_REQUEST,
470            Json(json!({ "error": "a commit id or an attempt ref is needed" })),
471        )
472            .into_response();
473    };
474    let patch = off(move || speccoder_runtime::service::commit_patch(&root, &commit)).await;
475    Json(json!({ "patch": patch })).into_response()
476}
477
478async fn documents(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
479    let root = s.root.get();
480    Json(
481        off(move || {
482            json!(speccoder_runtime::service::documents::of_feature(
483                &root,
484                q.feature.as_deref()
485            ))
486        })
487        .await,
488    )
489}
490
491async fn doctor(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
492    let root = s.root.get();
493    let r = off(move || speccoder_runtime::service::doctor::run(&root, q.feature.as_deref(), None))
494        .await;
495    envelope(&s, &r)
496}
497
498#[derive(Debug, Deserialize)]
499struct AgentsQuery {
500    /// Ask each installed agent for a session (never a prompt); `false`:
501    /// what is known without starting one.
502    #[serde(default = "yes")]
503    probe: bool,
504}
505
506fn yes() -> bool {
507    true
508}
509
510/// `doctor --agents`: every agent's card.
511async fn agents(State(s): State<Shared>, Query(q): Query<AgentsQuery>) -> Json<Value> {
512    let root = s.root.get();
513    let r = off(move || speccoder_runtime::service::doctor::agents(&root, None, q.probe)).await;
514    envelope(&s, &r)
515}
516
517async fn config(State(s): State<Shared>) -> Json<Value> {
518    let root = s.root.get();
519    let r = off(move || speccoder_runtime::service::settings::show(&root)).await;
520    envelope(&s, &r)
521}
522
523#[derive(Debug, Deserialize)]
524struct ConfigBody {
525    changes: Vec<speccoder_core::ConfigChange>,
526}
527
528/// Saves changes to `speccoder.toml`, all or nothing, after every parser
529/// accepts the result. Safe while a job runs: runs read the committed file,
530/// so a saved change is in force only once committed.
531async fn config_save(State(s): State<Shared>, Json(b): Json<ConfigBody>) -> Json<Value> {
532    let root = s.root.get();
533    let r = off(move || speccoder_runtime::service::settings::apply(&root, &b.changes)).await;
534    envelope(&s, &r)
535}
536
537#[derive(Debug, Deserialize)]
538struct ShapeBody {
539    field: String,
540    text: String,
541}
542
543/// Shape the person's words for a field: one model call, judged by code;
544/// nothing is written.
545async fn shape(State(s): State<Shared>, Json(b): Json<ShapeBody>) -> Json<Value> {
546    let root = s.root.get();
547    let r =
548        off(move || speccoder_runtime::service::shape::shape(&root, &b.field, &b.text, None)).await;
549    envelope(&s, &r)
550}
551
552/// The writing check of what the person is typing (no model).
553async fn shape_check(Json(b): Json<ShapeBody>) -> Json<Value> {
554    Json(off(move || json!({ "findings": speccoder_runtime::service::shape::findings(&b.field, &b.text) })).await)
555}
556
557#[derive(Debug, Deserialize)]
558struct JudgeBody {
559    field: String,
560    original: String,
561    proposal: String,
562    #[serde(default)]
563    assumptions: Vec<speccoder_core::shape::Assumption>,
564}
565
566/// The proposal again, as the person changed it in the sheet.
567async fn shape_judge(Json(b): Json<JudgeBody>) -> Json<Value> {
568    Json(
569        off(move || {
570            speccoder_runtime::service::shape::judge(
571                &b.field,
572                &b.original,
573                &b.proposal,
574                &b.assumptions,
575            )
576        })
577        .await,
578    )
579}
580
581#[derive(Debug, Deserialize)]
582struct CoverageQuery {
583    /// `commands.allow_prefix` or `sandbox.network_allow_prefix`.
584    key: String,
585    /// The prefix as a JSON array of strings.
586    prefix: String,
587}
588
589/// What a prefix entry would newly allow, from the project's journal: the
590/// preview a prefix is saved from (`WidenPolicy` through `/api/run`).
591async fn policy_coverage(State(s): State<Shared>, Query(q): Query<CoverageQuery>) -> Json<Value> {
592    let Some(list) = speccoder_core::actions::PolicyList::of_key(&q.key) else {
593        return Json(
594            json!({ "status": "usage_error", "message": format!("`{}` is not a prefix list", q.key) }),
595        );
596    };
597    let Ok(prefix) = serde_json::from_str::<Vec<String>>(&q.prefix) else {
598        return Json(
599            json!({ "status": "usage_error", "message": "the prefix is a JSON list of strings" }),
600        );
601    };
602    let root = s.root.get();
603    let r = off(move || {
604        let state = speccoder_runtime::service::worktree::state_dir();
605        speccoder_runtime::service::settings::coverage(&root, state.as_deref(), list, &prefix)
606    })
607    .await;
608    envelope(&s, &r)
609}
610
611/// Commits `speccoder.toml` alone — not while a job runs (an in-place run
612/// commits in the same checkout).
613async fn config_commit(State(s): State<Shared>) -> Json<Value> {
614    let (root, hub) = (s.root.get(), Arc::clone(&s.hub));
615    let r =
616        off(move || hub.exclusive(|| speccoder_runtime::service::settings::commit(&root))).await;
617    match r {
618        Ok(result) => envelope(&s, &result),
619        Err(e) => Json(json!({ "status": "error", "message": e })),
620    }
621}
622
623async fn cost(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
624    let root = s.root.get();
625    let r =
626        off(move || speccoder_runtime::service::controls::cost(&root, q.feature.as_deref())).await;
627    envelope(&s, &r)
628}
629
630/// What the feature's branch changed: commits, files, the patch.
631async fn changes(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
632    let root = s.root.get();
633    Json(json!(
634        off(move || speccoder_runtime::service::code::changes(&root, q.feature.as_deref())).await
635    ))
636}
637
638/// The files at the feature's revision.
639async fn tree(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
640    let root = s.root.get();
641    let ((rev, files), worktree) = off(move || {
642        (
643            speccoder_runtime::service::code::tree(&root, q.feature.as_deref()),
644            speccoder_runtime::service::code::worktree_view(&root, q.feature.as_deref()),
645        )
646    })
647    .await;
648    Json(json!({ "rev": rev, "files": files, "worktree": worktree }))
649}
650
651#[derive(Debug, Deserialize)]
652struct FileQuery {
653    feature: Option<String>,
654    path: String,
655}
656
657/// One file at the feature's revision, when the tree lists it.
658async fn file(State(s): State<Shared>, Query(q): Query<FileQuery>) -> Json<Value> {
659    let root = s.root.get();
660    // Project paths only: nothing absolute, nothing up, nothing of the
661    // state directory (where drafts, frozen and held-out sets live).
662    if !file_path_ok(&q.path) {
663        return Json(json!({ "error": format!("{} is not a project file", q.path) }));
664    }
665    let markdown = q.path.to_ascii_lowercase().ends_with(".md");
666    // Committed at the revision, else untracked in the worktree.
667    let r = off(move || {
668        speccoder_runtime::service::code::file(&root, q.feature.as_deref(), &q.path).or_else(|e| {
669            speccoder_runtime::service::code::worktree_file(&root, q.feature.as_deref(), &q.path)
670                .map_err(|_| e)
671        })
672    })
673    .await;
674    Json(match r {
675        // Markdown also rendered (raw HTML shown as text), for the preview.
676        Ok(text) if markdown => {
677            json!({ "text": text, "html": speccoder_runtime::service::documents::render(&text) })
678        }
679        Ok(text) => json!({ "text": text }),
680        Err(e) => json!({ "error": e }),
681    })
682}
683
684/// The setup assistant's view: what `doctor` sees, and what `init` would
685/// write.
686async fn setup(State(s): State<Shared>) -> Json<Value> {
687    let root = s.root.get();
688    let (doctor, preview) = off(move || {
689        (
690            speccoder_runtime::service::doctor::run(&root, None, None),
691            speccoder_runtime::service::init::preview(&root),
692        )
693    })
694    .await;
695    Json(json!({ "doctor": doctor, "preview": preview }))
696}
697
698#[derive(Debug, Deserialize)]
699struct SetupBody {
700    provider: Option<String>,
701    model: Option<String>,
702    /// Bedrock's region.
703    region: Option<String>,
704}
705
706/// Writes `speccoder.toml` and runs its checks on the untouched code; the
707/// commit is the person's next click.
708async fn setup_init(State(s): State<Shared>, Json(b): Json<SetupBody>) -> Json<Value> {
709    if s.hub.job().is_some_and(|j| j.running) {
710        return Json(json!({ "status": "error", "message": "a job is running" }));
711    }
712    let root = s.root.get();
713    let r = off(move || {
714        speccoder_runtime::service::init::prepare(
715            &root,
716            b.provider.as_deref(),
717            b.model.as_deref(),
718            b.region.as_deref(),
719            false,
720            false,
721        )
722    })
723    .await;
724    match r {
725        Err(refused) => envelope(&s, &refused),
726        Ok(p) => {
727            let (to_commit, failed) = (p.to_commit.clone(), p.failed.clone());
728            let Json(mut v) = envelope(&s, &speccoder_runtime::service::init::finish(p));
729            v["to_commit"] = json!(to_commit);
730            v["failed"] = json!(failed);
731            Json(v)
732        }
733    }
734}
735
736#[derive(Debug, Deserialize)]
737struct SetupCommit {
738    paths: Vec<String>,
739}
740
741/// Commits what `init` wrote — only files `init` commits.
742async fn setup_commit(State(s): State<Shared>, Json(b): Json<SetupCommit>) -> Json<Value> {
743    let (root, hub) = (s.root.get(), Arc::clone(&s.hub));
744    let r = off(move || {
745        hub.exclusive(|| {
746            speccoder_runtime::service::init::commit_written(
747                &speccoder_runtime::service::init::root_of(&root),
748                &b.paths,
749            )
750        })
751    })
752    .await;
753    Json(match r.and_then(|r| r) {
754        Ok(id) => json!({ "status": "ok", "message": format!("committed as {id}") }),
755        Err(e) => json!({ "status": "error", "message": e }),
756    })
757}
758
759/// GitHub as the person's token sees it: whether it is ready, the open
760/// issues (each with the feature that names it), and the feature's pull
761/// request.
762async fn github(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
763    let root = s.root.get();
764    Json(
765        off(move || {
766            use speccoder_runtime::service::github;
767            let ctx = github::Ctx::from_env();
768            let status = github::status(&ctx, &root);
769            if !status.ready {
770                return json!({ "status": status });
771            }
772            let issues = github::issues(&ctx, &root, &github::started(&root));
773            let branch = match speccoder_runtime::service::status::report(
774                &root,
775                q.feature.as_deref(),
776            )
777            .data()
778            {
779                Some(speccoder_core::CommandData::Report(r)) => r.branch.clone(),
780                _ => None,
781            };
782            let pull = branch.as_deref().map(|b| github::pr_for(&ctx, &root, b));
783            json!({
784                "status": status,
785                "issues": issues.as_ref().ok(),
786                "issues_error": issues.as_ref().err(),
787                "branch": branch,
788                "pull": pull.as_ref().and_then(|p| p.as_ref().ok()).cloned().flatten(),
789                "pull_error": pull.as_ref().and_then(|p| p.as_ref().err()),
790            })
791        })
792        .await,
793    )
794}
795
796#[derive(Debug, Deserialize)]
797struct IssueQuery {
798    number: String,
799}
800
801/// Starts GitHub's device sign-in for the repository's host: the code for
802/// the person to enter, and where.
803async fn github_device(State(s): State<Shared>) -> Json<Value> {
804    let root = s.root.get();
805    let slot = Arc::clone(&s.device);
806    Json(
807        off(move || {
808            use speccoder_runtime::service::github;
809            let host = github::repo(&root, None).map_or_else(|_| "github.com".to_owned(), |r| r.host);
810            let Some(client) = github::auth::client_id(&host) else {
811                return json!({ "error": format!("{host} has no speccoder app: sign in with a token instead") });
812            };
813            match github::auth::start(&github::http::oauth(&host), &client) {
814                Ok(p) => {
815                    let shown = json!({ "user_code": p.user_code, "verification_uri": p.verification_uri, "host": host });
816                    if let Ok(mut d) = slot.lock() {
817                        *d = Some((host, client, p));
818                    }
819                    shown
820                }
821                Err(e) => json!({ "error": e }),
822            }
823        })
824        .await,
825    )
826}
827
828/// Waits for the person to enter the code, then keeps the token in the
829/// keychain; says whether the app can reach this repository.
830async fn github_device_wait(State(s): State<Shared>) -> Json<Value> {
831    let root = s.root.get();
832    let slot = Arc::clone(&s.device);
833    let r = off(move || {
834        use speccoder_runtime::service::github;
835        let Some((host, client, pending)) = slot.lock().ok().and_then(|mut d| d.take()) else {
836            return speccoder_core::CommandResult::new(
837                "auth",
838                speccoder_core::CommandStatus::UsageError,
839                "no sign-in is waiting: start it again",
840            );
841        };
842        let ctx = github::Ctx::from_env();
843        match github::auth::wait(&github::http::oauth(&host), &client, &pending) {
844            Ok(token) => {
845                let signed = github::login_with_token(&ctx, &host, token.expose());
846                match (signed.status(), github::reach(&ctx, &root)) {
847                    (speccoder_core::CommandStatus::Ok, Some(todo)) => {
848                        let message = format!("{}; {todo}", signed.message());
849                        signed.reworded(speccoder_core::CommandStatus::Ok, &message)
850                    }
851                    _ => signed,
852                }
853            }
854            Err(e) => github::refused("auth", &e),
855        }
856    })
857    .await;
858    envelope(&s, &r)
859}
860
861#[derive(Debug, Deserialize)]
862struct TokenBody {
863    token: String,
864}
865
866/// Signs in to the repository's GitHub host with a token the person pasted
867/// (kept in the credential store, never echoed or logged).
868async fn github_login(State(s): State<Shared>, Json(b): Json<TokenBody>) -> Json<Value> {
869    let root = s.root.get();
870    let r = off(move || {
871        let host = speccoder_runtime::service::github::repo(&root, None)
872            .map_or_else(|_| "github.com".to_owned(), |r| r.host);
873        speccoder_runtime::service::github::login_with_token(
874            &speccoder_runtime::service::github::Ctx::from_env(),
875            &host,
876            &b.token,
877        )
878    })
879    .await;
880    envelope(&s, &r)
881}
882
883/// One issue, the description a feature started from it gets, and the
884/// skill that starts it.
885async fn github_issue(State(s): State<Shared>, Query(q): Query<IssueQuery>) -> Json<Value> {
886    let root = s.root.get();
887    Json(
888        off(move || {
889            match speccoder_runtime::service::github::issue(
890                &speccoder_runtime::service::github::Ctx::from_env(),
891                &root,
892                &q.number,
893            ) {
894                Ok(issue) => {
895                    json!({
896                        "description": speccoder_runtime::service::github::description(&issue),
897                        "skill": speccoder_runtime::service::create_skill(&root),
898                        "issue": issue,
899                    })
900                }
901                Err(e) => json!({ "error": e }),
902            }
903        })
904        .await,
905    )
906}
907
908/// What `pr` would do — pushed where, the title, the body — without doing it.
909async fn pr_preview(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
910    let root = s.root.get();
911    let r = off(move || {
912        speccoder_runtime::service::handoff::pr(
913            &root,
914            q.feature.as_deref(),
915            &speccoder_runtime::service::handoff::PrOptions {
916                remote: "origin",
917                base: None,
918                open: false,
919                force: false,
920                github: &speccoder_runtime::service::github::Ctx::from_env(),
921            },
922        )
923    })
924    .await;
925    envelope(&s, &r)
926}
927
928/// The project's current (or last) run, whoever started it: this host's
929/// job, or `speccoder run` in a terminal. `running`: its process lives and
930/// it has not finished; `own`: it is this host's.
931async fn live(State(s): State<Shared>) -> Json<Value> {
932    let root = s.root.get();
933    Json(
934        off(move || {
935            let Some(state) = speccoder_runtime::service::worktree::state_dir() else {
936                return json!({ "live": null });
937            };
938            match speccoder_runtime::service::progress::read_live(&root, &state) {
939                Some((live, running)) => json!({
940                    "live": live,
941                    "running": running,
942                    "own": live.pid == std::process::id(),
943                    "interrupted": live.finished.is_none() && !running,
944                    "now": std::time::SystemTime::now()
945                        .duration_since(std::time::UNIX_EPOCH)
946                        .map_or(0, |d| d.as_secs()),
947                }),
948                None => json!({ "live": null }),
949            }
950        })
951        .await,
952    )
953}
954
955async fn job(State(s): State<Shared>) -> Json<Value> {
956    Json(json!({ "job": s.hub.job(), "waiting": s.hub.waiting() }))
957}
958
959#[derive(Debug, Deserialize)]
960struct Start {
961    run: Runnable,
962    input: Option<String>,
963    feature: Option<String>,
964    command: String,
965    /// Before the host is bound: the folder an Undo is for (a recent project).
966    #[serde(default)]
967    root: Option<String>,
968}
969
970async fn start(State(s): State<Shared>, Json(b): Json<Start>) -> Response {
971    let root = match welcome::job_root(&s.root, &b.run, b.root.as_deref()) {
972        Ok(r) => r,
973        Err(e) => return welcome::no_project_yet(&e),
974    };
975    match s.hub.start(b.run, b.input, b.feature, b.command, root) {
976        Ok(id) => Json(json!({ "id": id })).into_response(),
977        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
978    }
979}
980
981#[derive(Debug, Deserialize)]
982struct AnswerBody {
983    id: u64,
984    answer: Option<speccoder_runtime::service::approvals::Answer>,
985    text: Option<String>,
986}
987
988async fn answer(State(s): State<Shared>, Json(b): Json<AnswerBody>) -> Response {
989    let reply = match b.answer {
990        Some(a) => jobs::Reply::Answer(a),
991        None => jobs::Reply::Text(b.text.filter(|t| !t.trim().is_empty())),
992    };
993    match s.hub.answer(b.id, reply) {
994        Ok(()) => Json(json!({ "ok": true })).into_response(),
995        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
996    }
997}
998
999async fn stop(State(s): State<Shared>) -> Json<Value> {
1000    s.hub.stop();
1001    Json(json!({ "ok": true }))
1002}
1003
1004/// Server-sent events: what the job does, what it asks. Read with `fetch`
1005/// (the token is a header, never in the address).
1006async fn events(State(s): State<Shared>) -> Response {
1007    use futures_util::StreamExt;
1008    let rx = s.hub.subscribe();
1009    let hello = futures_util::stream::once(async {
1010        Ok::<_, std::convert::Infallible>(
1011            axum::response::sse::Event::default().data(json!({ "type": "hello" }).to_string()),
1012        )
1013    });
1014    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|m| async move {
1015        m.ok().map(|text| {
1016            Ok::<_, std::convert::Infallible>(axum::response::sse::Event::default().data(text))
1017        })
1018    });
1019    // An event stream never ends on its own: it ends when the host stops,
1020    // or a graceful shutdown would wait for it forever.
1021    let mut closing = s.closing.clone();
1022    let stream = stream.take_until(async move {
1023        let _ = closing.wait_for(|c| *c).await;
1024    });
1025    axum::response::sse::Sse::new(hello.chain(stream))
1026        .keep_alive(axum::response::sse::KeepAlive::default())
1027        .into_response()
1028}
1029
1030#[derive(Debug, Deserialize)]
1031struct FixBody {
1032    argvs: Vec<Vec<String>>,
1033    token: String,
1034}
1035
1036/// The person's own command — argvs this host issued, exactly — run as
1037/// them in the project folder, as a job (`output` events, then `done`).
1038/// Anything else is refused: argvs the page composed, edited or took from
1039/// elsewhere, and a command that reads the keyboard.
1040async fn fix_run(State(s): State<Shared>, Json(b): Json<FixBody>) -> Response {
1041    let root = s.root.get();
1042    // Signed as a step that does not read the keyboard, or not at all.
1043    if !s.hub.issuer.issued(&root, &b.argvs, false, &b.token) {
1044        return (
1045            StatusCode::FORBIDDEN,
1046            Json(json!({ "error": "speccoder did not issue this command here to run: only a fix it printed runs from the app, and one that asks questions runs in a terminal" })),
1047        )
1048            .into_response();
1049    }
1050    if b.argvs.iter().any(|a| speccoder_core::step::interactive(a)) {
1051        return (
1052            StatusCode::BAD_REQUEST,
1053            Json(json!({ "error": "it asks you questions as it runs: open it in a terminal" })),
1054        )
1055            .into_response();
1056    }
1057    // speccoder's own command is a job of speccoder's, never the
1058    // person's command runner.
1059    let started = match b.argvs.as_slice() {
1060        [one] if one.first().map(String::as_str) == Some("speccoder") => {
1061            let args = one[1..].to_vec();
1062            // A slot a constant line leaves free names a unit of this project.
1063            if let [spec, archive, change, ..] = args.as_slice()
1064                && spec == "spec"
1065                && archive == "archive"
1066                && !change.starts_with('-')
1067            {
1068                let (root2, change2) = (root.clone(), change.clone());
1069                let known = off(move || unit_exists(&root2, &change2)).await;
1070                if !known {
1071                    return (
1072                        StatusCode::FORBIDDEN,
1073                        Json(json!({ "error": format!("`{change}` is no unit of this project") })),
1074                    )
1075                        .into_response();
1076                }
1077            }
1078            s.hub.start_speccoder(args, root)
1079        }
1080        argvs
1081            if argvs
1082                .iter()
1083                .any(|a| a.first().map(String::as_str) == Some("speccoder")) =>
1084        {
1085            return (
1086                StatusCode::FORBIDDEN,
1087                Json(json!({ "error": "speccoder runs on its own, never in a chain" })),
1088            )
1089                .into_response();
1090        }
1091        _ => s.hub.start_fix(b.argvs, root),
1092    };
1093    match started {
1094        Ok(id) => Json(json!({ "id": id })).into_response(),
1095        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1096    }
1097}
1098
1099/// Whether `name` is a unit (a feature, a change) of the project at `root`.
1100fn unit_exists(root: &Path, name: &str) -> bool {
1101    match speccoder_runtime::service::current::list(root, None).data() {
1102        Some(CommandData::Features(f)) => f.features.iter().any(|e| e.name == name),
1103        _ => false,
1104    }
1105}
1106
1107/// A terminal of the person's own at the project folder, for a command
1108/// this host issued that reads the keyboard (the page puts it on the
1109/// clipboard). The desktop window only: a tab copies.
1110async fn terminal(State(s): State<Shared>, Json(b): Json<FixBody>) -> Response {
1111    let root = s.root.get();
1112    if !s.window {
1113        return (
1114            StatusCode::BAD_REQUEST,
1115            Json(json!({ "error": "a browser tab opens no terminal: copy the command" })),
1116        )
1117            .into_response();
1118    }
1119    if !s.hub.issuer.issued(&root, &b.argvs, true, &b.token) {
1120        return (
1121            StatusCode::FORBIDDEN,
1122            Json(json!({ "error": "speccoder did not issue this command here for a terminal" })),
1123        )
1124            .into_response();
1125    }
1126    match off(move || speccoder_runtime::service::person::open_terminal(&root)).await {
1127        Ok(()) => Json(json!({ "ok": true })).into_response(),
1128        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1129    }
1130}
1131
1132/// `doctor --bug-report`: the setup in Markdown, secrets taken out.
1133async fn bug_report(State(s): State<Shared>) -> Json<Value> {
1134    let root = s.root.get();
1135    Json(
1136        off(move || {
1137            let doctor = speccoder_runtime::service::doctor::run(&root, None, None);
1138            json!({ "text": speccoder_runtime::service::copy::bug_report(&doctor, &root) })
1139        })
1140        .await,
1141    )
1142}
1143
1144#[derive(Debug, Deserialize)]
1145struct CodeQuery {
1146    code: String,
1147}
1148
1149/// `explain CODE`: what a code means and how to resolve it.
1150async fn explain(Query(q): Query<CodeQuery>) -> Json<Value> {
1151    use speccoder_core::{CodeInfo, CommandData, ExplainReport};
1152    let r = match speccoder_core::codes::lookup(&q.code.to_ascii_uppercase()) {
1153        Some(e) => CommandResult::ok("explain", e.name).with_data(CommandData::Explain(Box::new(
1154            ExplainReport {
1155                codes: vec![CodeInfo::of(e)],
1156            },
1157        ))),
1158        None => CommandResult::new(
1159            "explain",
1160            speccoder_core::CommandStatus::UsageError,
1161            &format!("no code `{}`", q.code),
1162        )
1163        .with_code("USAGE-UNKNOWN-CODE"),
1164    };
1165    Json(serde_json::to_value(&r).unwrap_or(Value::Null))
1166}
1167
1168/// The routes.
1169pub fn router(s: Shared) -> Router {
1170    Router::new()
1171        .route("/api/project", get(project))
1172        .route("/api/report", get(report))
1173        .route("/api/actions", get(actions))
1174        .route("/api/inbox", get(inbox))
1175        .route("/api/shape", post(shape))
1176        .route("/api/shape/check", post(shape_check))
1177        .route("/api/shape/judge", post(shape_judge))
1178        .route("/api/bug", get(bug))
1179        .route("/api/account", get(account))
1180        .route("/api/review", get(review))
1181        .route("/api/review/comments", get(review_comments))
1182        .route("/api/approvals", get(approvals))
1183        .route("/api/evidence", get(evidence))
1184        .route("/api/tour", get(tour))
1185        .route("/api/diff", get(diff))
1186        .route("/api/documents", get(documents))
1187        .route("/api/doctor", get(doctor))
1188        .route("/api/agents", get(agents))
1189        .route("/api/job", get(job))
1190        .route("/api/live", get(live))
1191        .route("/api/config", get(config).post(config_save))
1192        .route("/api/config/commit", post(config_commit))
1193        .route("/api/policy/coverage", get(policy_coverage))
1194        .route("/api/cost", get(cost))
1195        .route("/api/changes", get(changes))
1196        .route("/api/tree", get(tree))
1197        .route("/api/file", get(file))
1198        .route("/api/setup", get(setup).post(setup_init))
1199        .route("/api/github", get(github))
1200        .route("/api/github/issue", get(github_issue))
1201        .route("/api/github/login", post(github_login))
1202        .route("/api/github/device", post(github_device))
1203        .route("/api/github/device/wait", post(github_device_wait))
1204        .route("/api/pr/preview", get(pr_preview))
1205        .route("/api/setup/commit", post(setup_commit))
1206        .route("/api/events", get(events))
1207        .route("/api/run", post(start))
1208        .route("/api/answer", post(answer))
1209        .route("/api/stop", post(stop))
1210        .route("/api/fix/run", post(fix_run))
1211        .route("/api/terminal", post(terminal))
1212        .route("/api/bug-report", get(bug_report))
1213        .route("/api/explain", get(explain))
1214        .route("/api/welcome", get(welcome::view))
1215        .route("/api/open", post(welcome::open))
1216        .route("/api/recent/forget", post(welcome::forget))
1217        .route("/api/folders", get(welcome::folders))
1218        .route("/api/new/plan", get(welcome::plan))
1219        .route("/api/new/first-feature", post(welcome::first_feature))
1220        .route("/api/similar", get(welcome::similar))
1221        .route("/api/suggest", get(welcome::suggest))
1222        .route("/api/github/repos", get(welcome::repositories))
1223        .route("/api/skill-cost", get(welcome::skill_cost))
1224        .fallback(get(asset))
1225        .layer(axum::middleware::from_fn_with_state(
1226            s.clone(),
1227            welcome::projectless,
1228        ))
1229        .layer(axum::middleware::from_fn_with_state(s.clone(), guard))
1230        .with_state(s)
1231}
1232
1233/// The host's events as a shell in the same process reads them.
1234pub type Events = tokio::sync::broadcast::Receiver<String>;
1235/// Why reading [`Events`] failed: fell behind (`Lagged`) or the host ended.
1236pub use tokio::sync::broadcast::error::RecvError;
1237
1238/// A started host.
1239#[derive(Debug)]
1240pub struct Started {
1241    /// The address to open: the page, with the token in its fragment.
1242    pub url: String,
1243    /// The port.
1244    pub port: u16,
1245    /// What the host's jobs do, as the page's events (JSON lines): for a
1246    /// shell in the same process (the window) to follow without a bridge.
1247    pub events: Option<Events>,
1248    /// The host's project: the window binds it in-process to the folder
1249    /// the person picked (the path never transits the page).
1250    pub root: Root,
1251}
1252
1253/// Serves the project at `root` on `port` (0: any free one) until the
1254/// process ends; `ready` is told the address once it listens. Outside a
1255/// project the host serves the welcome until it is bound to one.
1256pub fn serve(
1257    root: &Path,
1258    port: u16,
1259    window: bool,
1260    ready: impl FnOnce(Started) + Send,
1261) -> Result<(), String> {
1262    let rt = tokio::runtime::Builder::new_multi_thread()
1263        .worker_threads(2)
1264        .enable_all()
1265        .build()
1266        .map_err(|e| e.to_string())?;
1267    rt.block_on(async move {
1268        let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port)))
1269            .await
1270            .map_err(|e| format!("127.0.0.1:{port}: {e}"))?;
1271        let port = listener.local_addr().map_err(|e| e.to_string())?.port();
1272        let (close, closed) = tokio::sync::watch::channel(false);
1273        let token = security::token();
1274        if token.is_empty() {
1275            return Err("no random source for the token".to_owned());
1276        }
1277        let Some(issuer) = fixes::Issuer::new() else {
1278            return Err("no random source for the token".to_owned());
1279        };
1280        let root = Root::new(root, welcome::is_project(root));
1281        let shared = Shared {
1282            root: root.clone(),
1283            token: token.clone(),
1284            port,
1285            hub: jobs::Hub::new(root.clone(), Arc::new(issuer)),
1286            window,
1287            closing: closed,
1288            device: Arc::default(),
1289        };
1290        ready(Started {
1291            url: format!("http://127.0.0.1:{port}/#token={token}"),
1292            port,
1293            events: Some(shared.hub.subscribe()),
1294            root,
1295        });
1296        // ctrl-c: end the event streams, give open requests a moment, then
1297        // stop whatever still holds on.
1298        let (stop, stopping) = tokio::sync::oneshot::channel::<()>();
1299        let server = axum::serve(listener, router(shared)).with_graceful_shutdown(async move {
1300            let _ = stopping.await;
1301        });
1302        let signal = async move {
1303            let _ = tokio::signal::ctrl_c().await;
1304            let _ = close.send(true);
1305            let _ = stop.send(());
1306            tokio::time::sleep(GRACE).await;
1307        };
1308        tokio::select! {
1309            served = server => served.map_err(|e| e.to_string()),
1310            () = signal => Ok(()),
1311        }
1312    })
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317    use super::*;
1318
1319    /// A free slot of a signed speccoder line (`spec archive {change}`)
1320    /// takes only a unit the project has.
1321    #[test]
1322    fn an_archive_names_a_unit_the_project_has() {
1323        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1324        let r = dir.path();
1325        std::fs::create_dir_all(r.join(".specify/memory")).unwrap_or_else(|e| panic!("{e}"));
1326        std::fs::create_dir_all(r.join("specs/001-x")).unwrap_or_else(|e| panic!("{e}"));
1327        std::fs::write(r.join("specs/001-x/spec.md"), "# Spec\n").unwrap_or_else(|e| panic!("{e}"));
1328        assert!(unit_exists(r, "001-x"));
1329        assert!(!unit_exists(r, "002-other"));
1330        assert!(!unit_exists(r, "--yes"));
1331    }
1332
1333    fn q(commit: Option<&str>, reference: Option<&str>) -> FeatureQuery {
1334        FeatureQuery {
1335            feature: None,
1336            task: None,
1337            commit: commit.map(str::to_owned),
1338            reference: reference.map(str::to_owned),
1339        }
1340    }
1341
1342    #[test]
1343    fn only_commits_and_attempt_refs_reach_git_show() {
1344        assert!(revision(&q(Some("e23d4197bb"), None)).is_some());
1345        assert!(revision(&q(Some("e23d"), None)).is_none());
1346        assert!(revision(&q(Some("--output=/tmp/x"), None)).is_none());
1347        let attempt = "refs/speccoder/attempts/001-greet/T001/run_01M5S5VJ";
1348        assert_eq!(revision(&q(None, Some(attempt))).as_deref(), Some(attempt));
1349        for bad in [
1350            "refs/heads/main",
1351            "refs/speccoder/attempts/",
1352            "refs/speccoder/attempts/../../heads/main",
1353            "refs/speccoder/attempts/a b",
1354            "--all",
1355            "HEAD",
1356        ] {
1357            assert!(revision(&q(None, Some(bad))).is_none(), "{bad}");
1358        }
1359    }
1360}