git-ents.gitmain
⌘K
foforge
effects.rs238 lines · 8.1 KB · rusthistorycomment on this file
1//! `GET /effects`, `GET /effects/{name}`: the generic list/view pair for
2//! [`ents_model::Effect`], plus a light, genuine use of `ents-query`
3//! (`overview.adoc`'s crate-graph row for this crate names it as a
4//! dependency): the show page re-parses the effect's own trigger text as a
5//! [`ents_query::Query`] and reports whether it still parses, exactly the
6//! tolerance check `git_ents::hook::read_effect` already performs on the
7//! hosted root before running an effect.
8
9use std::sync::Arc;
10
11use axum::Form;
12use axum::extract::{Path, State};
13use axum::response::{IntoResponse, Redirect};
14use ents_model::{Effect, namespace};
15use ents_query::Query;
16use gix_object::{Find, Write};
17use maud::html;
18use serde::Deserialize;
19
20use crate::error::{Error, Result};
21use crate::session::Session;
22use crate::state::AppState;
23
24/// `GET /effects`.
25///
26/// # Errors
27///
28/// Propagates a ref-store or object read failure.
29pub async fn list<O>(
30 State(state): State<Arc<AppState<O>>>,
31 axum::Extension(session): axum::Extension<Session>,
32) -> Result<maud::Markup>
33where
34 O: Find + Write + Send + 'static,
35{
36 let mut rows = Vec::new();
37 let mut failures = Vec::new();
38 for (name, effect) in read_all(&state)? {
39 match effect {
40 Ok(effect) => rows.push((name, effect)),
41 Err(error) => failures.push((format!("refs/meta/effects/{name}"), error)),
42 }
43 }
44 let table = if rows.is_empty() {
45 super::blankslate(
46 "No effects yet",
47 html! { "Define one with the form below." },
48 )
49 } else {
50 crate::render::list_table(&rows, "name", |id| format!("/effects/{id}"))
51 };
52 Ok(super::layout_meta(
53 &super::RepoHeader::from_state(&state),
54 &super::identity_label(&state),
55 "/effects",
56 "Effects",
57 html! {
58 (crate::render::unreadable_disclosure(&failures))
59 (table)
60 div.card {
61 div.card-header { "Define an effect" }
62 (add_form(&session))
63 }
64 },
65 ))
66}
67
68/// The define-effect form (`POST /effects`) -- `git ents effect add`'s
69/// own arguments as form fields.
70fn add_form(session: &Session) -> maud::Markup {
71 html! {
72 form method="post" action="/effects" {
73 (super::csrf_input(session))
74 label { "Name" input type="text" name="name"; }
75 label {
76 "Trigger"
77 input type="text" name="trigger" placeholder="query.grammar trigger";
78 }
79 label { "Run" input type="text" name="run" placeholder="command to run"; }
80 label {
81 "Toolchains"
82 input type="text" name="toolchains" placeholder="rust, node";
83 }
84 button type="submit" { "Define Effect" }
85 }
86 }
87}
88
89/// The form fields `POST /effects` accepts.
90#[derive(Debug, Deserialize)]
91pub struct AddForm {
92 /// Name to record the effect under (`refs/meta/effects/<name>`).
93 name: String,
94 /// The query the effect triggers on (`query.grammar`).
95 trigger: String,
96 /// The command the effect runs.
97 run: String,
98 /// Comma- or whitespace-separated toolchain names.
99 #[serde(default)]
100 toolchains: String,
101 /// The per-session CSRF token (`roots.web-session`).
102 csrf: String,
103}
104
105/// `POST /effects`: define (or replace) an effect as a signed mutation on
106/// `refs/meta/effects/<name>` -- the web counterpart of
107/// `git ents effect add`, sharing its pre-write rule that the trigger must
108/// parse (`ents_receive::reconcile`'s tolerance rule would otherwise
109/// silently skip a malformed one on every future scan).
110///
111/// # Errors
112///
113/// [`crate::Error::BadCsrf`] if `form.csrf` does not match;
114/// [`Error::InvalidArgument`] on an unparsable trigger or an empty name;
115/// otherwise propagates the `receive` proposal's own failures.
116// @relation(roots.web-signing, roots.web-session, scope=function)
117pub async fn create<O>(
118 State(state): State<Arc<AppState<O>>>,
119 axum::Extension(session): axum::Extension<Session>,
120 Form(form): Form<AddForm>,
121) -> Result<impl IntoResponse>
122where
123 O: Find + Write + Send + 'static,
124{
125 super::require_csrf(&session, &form.csrf)?;
126 let _: Query = form.trigger.parse().map_err(|_source| {
127 Error::InvalidArgument(format!("unparsable trigger: {}", form.trigger))
128 })?;
129 let name = form.name.trim();
130 let ref_name = namespace::effect_ref(name)
131 .map_err(|_invalid| Error::InvalidArgument(format!("invalid effect name: {name}")))?;
132 let effect = Effect {
133 name: name.to_owned(),
134 trigger: form.trigger,
135 toolchains: form
136 .toolchains
137 .split([',', ' '])
138 .map(str::trim)
139 .filter(|part| !part.is_empty())
140 .map(str::to_owned)
141 .collect(),
142 run: form.run,
143 };
144 let identity = state.identity.as_ref();
145 let outcome = ents_receive::propose_entity(
146 state.refs.as_ref(),
147 &*state.objects(),
148 state.events.as_ref(),
149 ref_name,
150 &effect,
151 &crate::receive_identity!(identity, crate::pages::member_author(&session)),
152 &format!("Define effect {name}"),
153 state.mode,
154 )?;
155 crate::error::outcome_to_result(outcome)?;
156 Ok(Redirect::to(&format!("/effects/{name}")))
157}
158
159/// `GET /effects/{name}`.
160///
161/// # Errors
162///
163/// [`Error::NotFound`] if `name` has no effect ref at all -- an effect ref
164/// that exists but whose stored tree does not match this build's
165/// [`Effect`] shape degrades to [`crate::render::unreadable`] instead
166/// (`roots.web-agnostic`'s graceful-degradation stance); the trigger-query
167/// parse check is skipped in that case, since there is no [`Effect`] to
168/// check.
169pub async fn show<O>(
170 State(state): State<Arc<AppState<O>>>,
171 Path(name): Path<String>,
172) -> Result<maud::Markup>
173where
174 O: Find + Write + Send + 'static,
175{
176 let (_, effect) = read_all(&state)?
177 .into_iter()
178 .find(|(id, _)| *id == name)
179 .ok_or_else(|| Error::NotFound {
180 what: format!("effect {name}"),
181 })?;
182 let body = match effect {
183 Ok(effect) => {
184 let (label, class) = match effect.trigger.parse::<Query>() {
185 Ok(_) => ("parses".to_owned(), "pass"),
186 Err(error) => (format!("does not parse: {error}"), "fail"),
187 };
188 html! {
189 (crate::render::view(&effect))
190 p {
191 "trigger query: "
192 (super::status_chip_labeled(&label, class))
193 }
194 }
195 }
196 Err(detail) => crate::render::unreadable(&detail),
197 };
198 Ok(super::layout_meta(
199 &super::RepoHeader::from_state(&state),
200 &super::identity_label(&state),
201 "/effects",
202 &name,
203 html! {
204 (super::child_crumbs("effects", "/effects", &name))
205 (body)
206 },
207 ))
208}
209
210/// Every `refs/meta/effects/*` ref, with its tip's tree deserialized as an
211/// [`Effect`] -- `Err(detail)` for a ref this build's `#[derive(Facet)]`
212/// shape could not read back, kept in the listing rather than dropped (see
213/// `crate::pages::members::read_all`'s identical rationale).
214fn read_all<O: Find>(
215 state: &AppState<O>,
216) -> Result<Vec<(String, std::result::Result<Effect, String>)>> {
217 let mut out = Vec::new();
218 for entry in state.refs.iter_prefix("refs/meta/effects/")? {
219 let (name, tip) = entry?;
220 let path = name.as_bstr().to_string();
221 let Some(id) = path.strip_prefix("refs/meta/effects/") else {
222 continue;
223 };
224 // One `state.objects()` lock per iteration, reused for both reads
225 // -- see `crate::pages::members::read_all`'s identical comment for
226 // why a second `state.objects()` within the same statement would
227 // self-deadlock on this non-reentrant `Mutex`.
228 let objects = state.objects();
229 let effect = super::commit_tree(&*objects, tip)
230 .map_err(|error| error.to_string())
231 .and_then(|tree| {
232 facet_git_tree::deserialize::<Effect>(&tree, &*objects)
233 .map_err(|error| error.to_string())
234 });
235 out.push((id.to_owned(), effect));
236 }
237 Ok(out)
238}