git-ents.gitmain
⌘K
foforge
commit 2da0244
roots: add the issues surface with threads as context queries

Issues join the meta tab’s registry (META_SECTIONS) like every other entity family. GET /issues lists each issue’s title, state, assignees, and labels; GET /issues/{id} shows one issue, an edit form, and its discussion — the comments naming issues/<id> as their context (comment::thread), rendered through a shared thread_section reused by reviews next. POST /issues opens one (issue::new), POST /issues/{id} edits state/assignees/labels (issue::edit), and POST /issues/{id}/comment adds a context comment (comment::add), each a CSRF-checked signed POST through the injected identity.

Implements model.issue and model.comment-context in the web layer; roots.web-signing, roots.web-session on every mutation route.

Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/src/router.rs @@ -69,6 +69,15 @@ post(pages::comments::resolve::<O>), ) .route("/comments/{id}/reopen", post(pages::comments::reopen::<O>)) + .route( + "/issues", + get(pages::issues::list::<O>).post(pages::issues::create::<O>), + ) + .route( + "/issues/{id}", + get(pages::issues::show::<O>).post(pages::issues::edit::<O>), + ) + .route("/issues/{id}/comment", post(pages::issues::comment::<O>)) .route("/inbox", get(pages::inbox::list::<O>)) .route("/style.css", get(style)) .route("/ents.js", get(script))
crates/cli/ents-web/tests/router.rs @@ -1781,6 +1781,169 @@ assert_eq!(response.status(), StatusCode::NOT_FOUND); } +/// `POST /issues` through the real signed-write path +/// (`ents_forge::issue::new`), returning the new issue's id from the +/// redirect's `Location` (`/issues/<id>`). Asserts the write succeeded. +async fn seed_issue( + router: &axum::Router, + state: &AppState<ObjectStore>, + title: &str, + issue_state: &str, + assignees: &str, + labels: &str, +) -> String { + let (cookie, csrf) = session_cookie_and_csrf(router, state, "/issues").await; + let form = format!( + "title={}&state={issue_state}&assignees={assignees}&labels={labels}&body=the+full+body&csrf={csrf}", + title.replace(' ', "+") + ); + let response = router + .clone() + .oneshot( + Request::post("/issues") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, cookie) + .body(Body::from(form)) + .expect("request"), + ) + .await + .expect("in-process call"); + assert!( + response.status().is_redirection(), + "issue create did not succeed: {:?}", + response.status() + ); + response + .headers() + .get(header::LOCATION) + .expect("a successful issue create redirects to the new issue") + .to_str() + .expect("ascii") + .strip_prefix("/issues/") + .expect("redirect targets /issues/<id>") + .to_owned() +} + +/// `model.issue`, `model.comment-context`: the issues index lists a seeded +/// issue with its state, assignees, and labels; the detail page shows the +/// issue and its discussion thread; a comment naming `issues/<id>` as its +/// context joins that thread; and an edit changes the issue's state -- every +/// mutation a CSRF-checked signed POST calling the same `ents_forge` funcs +/// the CLI and lens do. +#[tokio::test] +// @relation(model.issue, model.comment-context, roots.web-signing, roots.web-session, scope=function, role=Verifies) +async fn issues_index_and_detail_render_a_seeded_issue_and_its_context_comment() { + let state = build_state(FixtureIdentity { + name: "filer", + key: Keypair::from_seed(1), + }); + let router = ents_web::router(state.clone()); + let id = seed_issue( + &router, + &state, + "gate rejects a valid signature", + "triaged", + "jdc", + "bug", + ) + .await; + + // The index lists the issue with its state, assignees, and labels, and + // links into its detail page. + let index = get_body(&router, "/issues").await; + assert!(index.contains("gate rejects a valid signature")); + assert!(index.contains("triaged")); + assert!(index.contains("jdc")); + assert!(index.contains("bug")); + assert!(index.contains(&format!("/issues/{id}"))); + + // The detail page shows the issue and an (initially empty) discussion. + let detail = get_body(&router, &format!("/issues/{id}")).await; + assert!(detail.contains("gate rejects a valid signature")); + assert!(detail.contains("the full body")); + assert!(detail.contains("discussion")); + + // A comment naming the issue as its context joins the thread. + let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/issues").await; + let comment = router + .clone() + .oneshot( + Request::post(format!("/issues/{id}/comment")) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, cookie.clone()) + .body(Body::from(format!("body=cannot+reproduce+yet&csrf={csrf}"))) + .expect("request"), + ) + .await + .expect("in-process call"); + assert!(comment.status().is_redirection(), "{:?}", comment.status()); + let detail = get_body(&router, &format!("/issues/{id}")).await; + assert!( + detail.contains("cannot reproduce yet"), + "the context comment renders in the issue's thread: {detail}" + ); + + // Editing the issue's state lands and reads back. + let edited = router + .clone() + .oneshot( + Request::post(format!("/issues/{id}")) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, cookie) + .body(Body::from(format!("state=closed&csrf={csrf}"))) + .expect("request"), + ) + .await + .expect("in-process call"); + assert!(edited.status().is_redirection()); + let detail = get_body(&router, &format!("/issues/{id}")).await; + assert!(detail.contains("closed"), "the edited state reads back"); +} + +/// `roots.web-session`: opening an issue is a state-changing route, so a +/// `POST /issues` with no CSRF field at all is rejected, and one with the +/// wrong token is a bad request -- the same gate every mutation in this +/// crate runs behind. +#[tokio::test] +// @relation(model.issue, roots.web-session, scope=function, role=Verifies) +async fn issue_create_is_rejected_without_a_valid_csrf_token() { + let state = build_state(FixtureIdentity { + name: "filer", + key: Keypair::from_seed(1), + }); + let router = ents_web::router(Arc::clone(&state)); + + // No CSRF field at all. + let no_csrf = router + .clone() + .oneshot( + Request::post("/issues") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from("title=sneaky&state=open")) + .expect("request"), + ) + .await + .expect("in-process call"); + assert!( + !no_csrf.status().is_success() && !no_csrf.status().is_redirection(), + "a POST with no csrf field must not open an issue" + ); + + // A session's cookie, but the wrong token. + let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/issues").await; + let wrong = router + .oneshot( + Request::post("/issues") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, cookie) + .body(Body::from("title=sneaky&state=open&csrf=not-the-token")) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(wrong.status(), StatusCode::BAD_REQUEST); +} + /// `GET /search?q=` finds a known fixture file path, linking into its /// `/files/...` blob view. #[tokio::test]
crates/cli/ents-web/src/pages/comments.rs @@ -643,6 +643,66 @@ } } +/// One comment in an entity's discussion thread -- an issue's +/// (`crate::pages::issues::show`) or a review's +/// (`crate::pages::commits::show`) -- rendered from an aggregation query +/// (`comment::thread`, `model.comment-context`), never a list any entity +/// stores. Author and time come from the comment ref's own tip commit +/// (`super::commit_authorship`, `model.comment`: no stored author field), +/// its state (`model.comment-state`) shows as a badge, its body renders as +/// AsciiDoc, and it carries the same [`action_forms`] every comment does, +/// with `return_to` pointing back at the entity page rendering it so a +/// reply or resolve returns there. Best effort: a comment whose tip commit +/// cannot be read still renders, only without an author line. +pub(crate) fn thread_comment_card<O: Find + Write>( + state: &AppState<O>, + session: &Session, + id: &str, + comment: &ents_forge::comment::Comment, + return_to: &str, +) -> Markup { + let authorship = ents_model::namespace::comment_ref(id) + .ok() + .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten()) + .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok()); + let body = + crate::asciidoc::to_html(&comment.body).unwrap_or_else(|_| html! { p { (comment.body) } }); + html! { + div.card id={ "thread-" (id) } { + div.comment-meta { + @if let Some((author, seconds)) = &authorship { + span.author { (author) } + span { (super::ago(*seconds)) } + } + @if comment.parent.is_some() { + span { "reply" } + } + span.comment-state { (comment.state) } + } + div.doc-body { (body) } + (action_forms(session, id, comment.state == "resolved", return_to)) + } + } +} + +/// An entity's whole discussion thread as a stack of [`thread_comment_card`]s +/// (`model.comment-context`, `model.comment-thread`) -- what +/// `crate::pages::issues::show` and `crate::pages::commits::show` render a +/// `comment::thread` result through. Renders nothing when the thread is +/// empty. +pub(crate) fn thread_section<O: Find + Write>( + state: &AppState<O>, + session: &Session, + thread: &[(String, ents_forge::comment::Comment)], + return_to: &str, +) -> Markup { + html! { + @for (id, comment) in thread { + (thread_comment_card(state, session, id, comment, return_to)) + } + } +} + /// The comment cards under a blob view (a rendered document, a binary /// placeholder, or -- for a raw-source view -- the ones with no current /// line range to interleave at; see `crate::pages::files::source_view`),
crates/cli/ents-web/src/pages/mod.rs @@ -6,14 +6,14 @@ //! and [`inbox`] are the generic pages: they read a kernel entity and //! render it through [`crate::render`]'s reflection-driven mechanism, //! never matching on which entity type they were handed. -//! [`toolchains`] and [`comments`] are legitimate custom pages +//! [`toolchains`], [`comments`], and [`issues`] are legitimate custom pages //! (`ents-kiln`'s recipe provenance and `ents-forge`'s anchor projection -//! both need domain-specific rendering no generic reflection walk should -//! grow special cases for). [`members`], [`effects`], [`toolchains`], -//! [`redactions`], and [`inbox`] additionally share one `meta` tab and -//! [`META_SECTIONS`] rail rather than each carrying its own top-level tab -//! (see [`Tab`]'s own doc); [`meta`] is that group's `GET /meta` landing -//! page. [`commits`] is a view of the code, not a tab of its own -- both +//! and issue threads all need domain-specific rendering no generic +//! reflection walk should grow special cases for). [`issues`], [`members`], +//! [`effects`], [`toolchains`], [`redactions`], and [`inbox`] additionally +//! share one `meta` tab and [`META_SECTIONS`] rail rather than each carrying +//! its own top-level tab (see [`Tab`]'s own doc); [`meta`] is that group's +//! `GET /meta` landing page. [`commits`] is a view of the code, not a tab of its own -- both //! its routes render with [`Tab::Files`] active, reached from //! [`files`]'s own "history" link. [`search`] renders with no tab active //! at all, like [`account`]; it is reached from [`layout`]'s own nav @@ -26,6 +26,7 @@ pub mod effects; pub mod files; pub mod inbox; +pub mod issues; pub mod members; pub mod meta; pub mod redactions; @@ -104,9 +105,10 @@ /// [`layout`]'s nav bar, so a handler can name which tab it renders behind /// without `layout` re-deriving it from the request path (mirrors /// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `Tab` enum, -/// trimmed to four primary tabs). `Meta` covers five page families -/// ([`super::members`], [`super::effects`], [`super::toolchains`], -/// [`super::redactions`], [`super::inbox`]) behind one tab and the +/// trimmed to four primary tabs). `Meta` covers six page families +/// ([`super::issues`], [`super::members`], [`super::effects`], +/// [`super::toolchains`], [`super::redactions`], [`super::inbox`]) behind +/// one tab and the /// [`META_SECTIONS`] rail (see [`layout_meta`]) rather than a tab each -- /// nine equal tabs did not scale as page families grew. `Account` matches /// none of [`layout`]'s tab-strip arms, so it highlights nothing there; @@ -145,6 +147,11 @@ /// The `meta` tab's registry (see [`MetaSection`]'s own doc). pub(crate) const META_SECTIONS: &[MetaSection] = &[ + MetaSection { + name: "issues", + href: "/issues", + blurb: "Filed issues and their discussion threads.", + }, MetaSection { name: "members", href: "/members",
crates/cli/ents-web/src/pages/issues.rs @@ -1,0 +1,374 @@ +//! `GET /issues`, `GET /issues/{id}`, `POST /issues`, +//! `POST /issues/{id}`, `POST /issues/{id}/comment`: the issue surface +//! (`model.issue`), an entity family under the `meta` tab's registry +//! (`crate::pages::META_SECTIONS`) exactly as members, effects, and the +//! rest are. +//! +//! Every read is `ents_forge::issue::{list,show}` and every mutation is +//! `ents_forge::issue::{new,edit}` or `ents_forge::comment::add` -- the web +//! is another caller of the same library funcs (`lens.parity`), never a +//! second issue or thread implementation. An issue's discussion is its +//! thread: the comments naming `issues/<id>` as their context +//! (`model.comment-context`), aggregated by `ents_forge::comment::thread` +//! and rendered through `crate::pages::comments::thread_section`, never a +//! list the issue stores. + +use std::sync::Arc; + +use axum::Form; +use axum::extract::{Path, State}; +use axum::response::{IntoResponse, Redirect}; +use ents_forge::issue::{self, EditIssue, NewIssue}; +use ents_model::MemberId; +use gix_object::{Find, Write}; +use maud::{Markup, html}; +use serde::Deserialize; + +use crate::error::Result; +use crate::session::Session; +use crate::state::AppState; + +/// `GET /issues`: every issue recorded in this repository +/// (`ents_forge::issue::list`) -- title, state, assignees, and labels -- +/// plus the new-issue form. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +// @relation(model.issue, scope=function) +pub async fn list<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, +) -> Result<Markup> +where + O: Find + Write + Send + 'static, +{ + let rows = issue::list(state.refs.as_ref(), &*state.objects())?; + Ok(super::layout_meta( + &super::RepoHeader::from_state(&state), + &super::identity_label(&state), + "/issues", + "issues", + html! { + @if rows.is_empty() { + p { "No issues yet." } + } @else { + table.entity-list { + thead { + tr { th { "issue" } th { "state" } th { "assignees" } th { "labels" } } + } + tbody { + @for (id, issue) in &rows { + tr { + td { a href=(format!("/issues/{id}")) { (issue.title) } } + td { span.comment-state { (issue.state) } } + td { (join_members(&issue.assignees)) } + td { (issue.labels.join(", ")) } + } + } + } + } + } + h2 { "open an issue" } + (new_form(&session)) + }, + )) +} + +/// `GET /issues/{id}`: one issue (`ents_forge::issue::show`), an edit form +/// for its state/assignees/labels, and its discussion thread -- the +/// comments naming `issues/<id>` as their context +/// (`ents_forge::comment::thread`, `model.comment-context`), rendered like +/// every other conversation in this crate. +/// +/// # Errors +/// +/// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if +/// `id` has no issue ref; otherwise propagates a ref-store or object read +/// failure. +// @relation(model.issue, model.comment-context, scope=function) +pub async fn show<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, + Path(id): Path<String>, +) -> Result<Markup> +where + O: Find + Write + Send + 'static, +{ + let issue = issue::show(state.refs.as_ref(), &*state.objects(), &id)?; + let context = format!("issues/{id}"); + let thread = ents_forge::comment::thread(state.refs.as_ref(), &*state.objects(), &context)?; + let body = + crate::asciidoc::to_html(&issue.body).unwrap_or_else(|_| html! { p { (issue.body) } }); + let return_to = format!("/issues/{id}"); + Ok(super::layout_meta( + &super::RepoHeader::from_state(&state), + &super::identity_label(&state), + "/issues", + &issue.title, + html! { + div.card { + dl { + dt { "state" } dd { span.comment-state { (issue.state) } } + dt { "assignees" } dd { (join_members(&issue.assignees)) } + dt { "labels" } dd { (issue.labels.join(", ")) } + } + div.doc-body { (body) } + } + details { + summary { "edit" } + (edit_form(&session, &issue)) + } + h2 { "discussion" } + (crate::pages::comments::thread_section(&state, &session, &thread, &return_to)) + h2 { "add a comment" } + (comment_form(&session, &id)) + }, + )) +} + +/// The form fields `POST /issues` accepts. +#[derive(Debug, Deserialize)] +pub struct NewForm { + /// The issue's title. + title: String, + /// The issue's body. + #[serde(default)] + body: String, + /// The issue's initial state; defaults to `open` (`model.issue`: the + /// platform has no default of its own, so the frontend chooses one). + #[serde(default = "default_state")] + state: String, + /// Comma- or whitespace-separated assignee usernames. + #[serde(default)] + assignees: String, + /// Comma- or whitespace-separated labels. + #[serde(default)] + labels: String, + /// The per-session CSRF token (`roots.web-session`). + csrf: String, +} + +fn default_state() -> String { + "open".to_owned() +} + +/// `POST /issues`: open an issue at a freshly generated +/// `refs/meta/issues/<id>` (`ents_forge::issue::new`), signed +/// (`roots.web-signing`) on behalf of the current session +/// (`roots.web-session`). +/// +/// # Errors +/// +/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise +/// propagates [`ents_forge::issue::new`]'s own failures. +// @relation(model.issue, roots.web-signing, roots.web-session, scope=function) +pub async fn create<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, + Form(form): Form<NewForm>, +) -> Result<impl IntoResponse> +where + O: Find + Write + Send + 'static, +{ + super::require_csrf(&session, &form.csrf)?; + let identity = state.identity.as_ref(); + let new = NewIssue { + title: form.title, + body: form.body, + state: form.state, + assignees: parse_members(&form.assignees), + labels: parse_labels(&form.labels), + }; + let (id, outcome) = issue::new( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + new, + &crate::receive_identity!(identity), + state.mode, + )?; + crate::error::outcome_to_result(outcome)?; + Ok(Redirect::to(&format!("/issues/{id}"))) +} + +/// The form fields `POST /issues/{id}` accepts. Each field replaces its +/// counterpart on the issue; an empty `assignees`/`labels` leaves that set +/// unchanged (matching `git ents issue edit`'s own semantics), while `state` +/// is always applied. +#[derive(Debug, Deserialize)] +pub struct EditForm { + /// Replace the issue's state. + state: String, + /// Comma- or whitespace-separated assignees; empty leaves them. + #[serde(default)] + assignees: String, + /// Comma- or whitespace-separated labels; empty leaves them. + #[serde(default)] + labels: String, + /// The per-session CSRF token (`roots.web-session`). + csrf: String, +} + +/// `POST /issues/{id}`: mutate `id`'s state, assignees, and/or labels +/// (`ents_forge::issue::edit`) as a signed mutation on the issue's own ref. +/// +/// # Errors +/// +/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise +/// propagates [`ents_forge::issue::edit`]'s own failures (including +/// [`ents_forge::Error::NotFound`] when `id` names no issue). +// @relation(model.issue, roots.web-signing, roots.web-session, scope=function) +pub async fn edit<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, + Path(id): Path<String>, + Form(form): Form<EditForm>, +) -> Result<impl IntoResponse> +where + O: Find + Write + Send + 'static, +{ + super::require_csrf(&session, &form.csrf)?; + let identity = state.identity.as_ref(); + let assignees = parse_members(&form.assignees); + let labels = parse_labels(&form.labels); + let edit = EditIssue { + state: Some(form.state), + assignees: (!assignees.is_empty()).then_some(assignees), + labels: (!labels.is_empty()).then_some(labels), + }; + let outcome = issue::edit( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + &id, + edit, + &crate::receive_identity!(identity), + state.mode, + )?; + crate::error::outcome_to_result(outcome)?; + Ok(Redirect::to(&format!("/issues/{id}"))) +} + +/// The form fields `POST /issues/{id}/comment` accepts. +#[derive(Debug, Deserialize)] +pub struct CommentForm { + /// The comment's body text. + body: String, + /// The per-session CSRF token (`roots.web-session`). + csrf: String, +} + +/// `POST /issues/{id}/comment`: a comment naming `issues/<id>` as its +/// context (`model.comment-context`) -- an ordinary +/// [`ents_forge::comment::add`], contextual and unanchored, so it joins the +/// issue's thread the moment it lands. +/// +/// # Errors +/// +/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise +/// propagates [`ents_forge::comment::add`]'s own failures. +// @relation(model.comment-context, roots.web-signing, roots.web-session, scope=function) +pub async fn comment<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, + Path(id): Path<String>, + Form(form): Form<CommentForm>, +) -> Result<impl IntoResponse> +where + O: Find + Write + Send + 'static, +{ + super::require_csrf(&session, &form.csrf)?; + let identity = state.identity.as_ref(); + let new = ents_forge::comment::NewComment { + body: form.body, + path: None, + lines: None, + rev: "HEAD".to_owned(), + worktree: false, + context: Some(format!("issues/{id}")), + parent: None, + }; + let (_comment_id, outcome) = ents_forge::comment::add( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + &state.path, + new, + &crate::receive_identity!(identity), + state.mode, + )?; + crate::error::outcome_to_result(outcome)?; + Ok(Redirect::to(&format!("/issues/{id}"))) +} + +/// The open-an-issue form (`POST /issues`). +fn new_form(session: &Session) -> Markup { + html! { + form method="post" action="/issues" { + (super::csrf_input(session)) + label { "title" input type="text" name="title"; } + label { "state" input type="text" name="state" value="open"; } + label { "assignees" input type="text" name="assignees" placeholder="alice, bob"; } + label { "labels" input type="text" name="labels" placeholder="bug, gate"; } + label { "body" textarea name="body" {} } + button type="submit" { "open issue" } + } + } +} + +/// The edit-issue form (`POST /issues/{id}`), its fields pre-filled from +/// the current issue. +fn edit_form(session: &Session, issue: &ents_forge::Issue) -> Markup { + html! { + form method="post" action="" { + (super::csrf_input(session)) + label { "state" input type="text" name="state" value=(issue.state); } + label { + "assignees" + input type="text" name="assignees" value=(join_members(&issue.assignees)); + } + label { "labels" input type="text" name="labels" value=(issue.labels.join(", ")); } + button type="submit" { "save" } + } + } +} + +/// The comment-on-this-issue form (`POST /issues/{id}/comment`). +fn comment_form(session: &Session, id: &str) -> Markup { + html! { + form method="post" action=(format!("/issues/{id}/comment")) { + (super::csrf_input(session)) + label { "body" textarea name="body" {} } + button type="submit" { "comment" } + } + } +} + +/// Render a member set for display, comma-joined (`join_members(&[])` is the +/// empty string, so an unassigned issue shows a blank cell rather than a +/// stray separator). +fn join_members(members: &[MemberId]) -> String { + members + .iter() + .map(MemberId::as_str) + .collect::<Vec<_>>() + .join(", ") +} + +/// Parse a comma- or whitespace-separated list into members, dropping empty +/// segments so a trailing comma or extra spacing does not enroll a blank +/// assignee. +fn parse_members(text: &str) -> Vec<MemberId> { + parse_labels(text).into_iter().map(MemberId::new).collect() +} + +/// Parse a comma- or whitespace-separated list into labels, dropping empty +/// segments. +fn parse_labels(text: &str) -> Vec<String> { + text.split([',', ' ', '\t', '\n']) + .map(str::trim) + .filter(|segment| !segment.is_empty()) + .map(str::to_owned) + .collect() +}