use std::path::{Path, PathBuf};
use super::GenerateError;
use super::dsl::Field;
use super::naming::{pascal, pluralize, snake};
use super::scaffold::ScaffoldOptions;
pub(super) fn child_marker(child_plural: &str) -> String {
format!("// autumn:nested:{child_plural}")
}
const CHILD_MARKER_PREFIX: &str = "// autumn:nested:";
const EXTRACTORS_MARKER: &str = "// autumn:nested-extractors";
const EXTRACTOR_PARAM_DECLS: &[&str] = &[
"autumn_web::extract::State(__nested_state): autumn_web::extract::State<autumn_web::AppState>",
"__nested_session: autumn_web::session::Session",
"__nested_csrf: Option<CsrfToken>",
"__nested_csrf_field: Option<CsrfFormField>",
"__nested_submit_token: Option<SubmitToken>",
"__nested_submit_field: Option<SubmitFormField>",
];
fn extractors_allow_line() -> String {
format!("{ALLOW_ATTR} {EXTRACTORS_MARKER}")
}
const ALLOW_ATTR: &str = "#[allow(clippy::too_many_arguments)]";
fn extractor_params_inline() -> String {
format!(", {}", EXTRACTOR_PARAM_DECLS.join(", "))
}
const SHOW_RETURN: &str = ") -> AutumnResult<Markup> {";
const SHOW_SIGNATURE_PREFIX: &str = "pub async fn show(id: Path<";
const SHOW_DB_PARAM: &str = "mut db: Db";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Endings {
Lf,
Crlf,
}
impl Endings {
fn split(src: &str) -> (String, Self) {
if src.contains("\r\n") {
(src.replace("\r\n", "\n"), Self::Crlf)
} else {
(src.to_owned(), Self::Lf)
}
}
fn rejoin(self, lines: &[String], original: &str) -> String {
let sep = match self {
Self::Lf => "\n",
Self::Crlf => "\r\n",
};
let mut out = lines.join(sep);
if original.ends_with('\n') {
out.push_str(sep);
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Nesting {
pub parent_pascal: String,
pub parent_snake: String,
pub parent_plural: String,
pub fk: String,
pub inferred: bool,
}
impl Nesting {
pub fn route_path(&self, child_plural: &str) -> String {
format!("/{}/{{{}}}/{child_plural}", self.parent_plural, self.fk)
}
}
#[allow(
clippy::too_many_lines,
reason = "a linear sequence of independent preconditions, each carrying the \
actionable message the user sees — splitting it would scatter those \
messages across helpers without simplifying anything"
)]
pub(super) fn resolve(
project_root: &Path,
child_plural: &str,
fields: &[Field],
options: &ScaffoldOptions,
for_revert: bool,
) -> Result<Option<Nesting>, GenerateError> {
let inferred_parent = if options.belongs_to.is_none() {
infer_parent_from_markers(project_root, child_plural, fields)
} else {
None
};
let inferred = options.belongs_to.is_none() && inferred_parent.is_some();
let effective = options.belongs_to.as_deref().or(inferred_parent.as_deref());
if !for_revert && !options.api {
let effective_plural = effective.map(|parent| pluralize(&snake(parent)));
let stale: Vec<String> = parents_carrying_child(project_root, child_plural)
.iter()
.filter_map(|path| path.file_stem()?.to_str().map(ToOwned::to_owned))
.filter(|parent_plural| Some(parent_plural.as_str()) != effective_plural.as_deref())
.collect();
if let Some(stale_parent) = stale.first() {
let because = effective_plural.as_deref().map_or_else(
|| {
format!(
"this field list has no `references` column pointing at {stale_parent}, so \
the nested routes that section calls would not be regenerated at all and \
that call would no longer compile"
)
},
|new_parent| {
format!(
"this run binds {child_plural} to {new_parent} instead, so the same helper \
would start reading THAT parent's id — the call keeps compiling and \
quietly serves the wrong rows"
)
},
);
return Err(GenerateError::Config(format!(
"{child_plural} is already nested under {stale_parent}: \
src/routes/{stale_parent}.rs renders their children section and calls \
`crate::routes::{child_plural}::children_section(…)` with its own row id. But \
{because}. \
Run `autumn destroy scaffold` for this resource first (it removes the \
parent-side section from src/routes/{stale_parent}.rs), then generate again. \
Nothing has been written."
)));
}
}
let Some(raw_parent) = effective else {
return Ok(None);
};
let parent_pascal = pascal(raw_parent);
let parent_snake = snake(raw_parent);
let parent_plural = pluralize(&parent_snake);
let unsupported = if options.api {
Some((
"--api",
"an API scaffold renders no HTML views, and the nested list + inline create \
form are exactly those views",
))
} else if options.live_validation {
Some((
"--live-validation",
"the live-validation form owns an htmx contract the nested inline form does \
not participate in",
))
} else if options.live {
Some((
"--live",
"the SSE live list owns an out-of-band swap contract that a second, \
parent-scoped list on the same page would break",
))
} else if options.model.sharded {
Some((
"--sharded",
"a sharded repository has no cross-shard parent lookup to scope the child \
list through",
))
} else {
None
};
if let Some((variant, why)) = unsupported {
return Err(GenerateError::Config(format!(
"`--belongs-to` is not supported together with `{variant}`: {why}. Scaffold the \
child resource without `{variant}` to get the nested routes and the parent-side \
list, or drop `--belongs-to` to keep a flat resource."
)));
}
let Some(fk_field) = fields
.iter()
.find(|f| f.reference_table().as_deref() == Some(parent_plural.as_str()))
else {
return Err(GenerateError::Config(format!(
"`--belongs-to {parent_pascal}` needs a foreign key to {parent_plural}: add a \
`{parent_snake}:references` column to the field list (it becomes the \
`{parent_snake}_id` column the nested routes filter and insert on)."
)));
};
if fk_field.nullable {
return Err(GenerateError::Config(format!(
"`--belongs-to {parent_pascal}` requires a non-nullable parent reference, but \
`{}` is optional. A nested resource always has a parent — the nested create \
route sets the foreign key from the URL — so declare it as \
`{parent_snake}:references`.",
fk_field.name
)));
}
if let Some(attachment) = fields.iter().find(|f| f.kind.is_attachment()) {
return Err(GenerateError::Config(format!(
"`--belongs-to {parent_pascal}` is not supported alongside the `{}` attachment \
column: the flat create streams a `multipart/form-data` body, and the \
nested inline create form is url-encoded. Scaffold the child without the \
attachment column (or without `--belongs-to`) for now.",
attachment.name
)));
}
if parent_plural == child_plural {
return Err(GenerateError::Config(format!(
"`--belongs-to {parent_pascal}` would nest a resource under itself. \
Self-referential parents (threaded replies) are out of scope for nested \
scaffolding — scaffold the resource flat and wire the recursion by hand."
)));
}
let nesting = Nesting {
parent_pascal: parent_pascal.clone(),
parent_snake: parent_snake.clone(),
parent_plural: parent_plural.clone(),
fk: fk_field.name.clone(),
inferred,
};
if for_revert {
return Ok(Some(nesting));
}
let parent_routes = project_root
.join("src")
.join("routes")
.join(format!("{parent_plural}.rs"));
let Ok(parent_src) = std::fs::read_to_string(&parent_routes) else {
return Err(GenerateError::Config(format!(
"`--belongs-to {parent_pascal}` needs the parent resource to be scaffolded first: \
src/routes/{parent_plural}.rs was not found. Run \
`autumn generate scaffold {parent_pascal} <fields…>` first, then re-run this \
command."
)));
};
if injection_anchors(&parent_src).is_none() {
return Err(GenerateError::Config(format!(
"`--belongs-to {parent_pascal}` could not find a standard `show` handler in \
src/routes/{parent_plural}.rs to render the children list in. Nested scaffolding \
patches the `show` view the flat scaffold generated — not a `slug`-keyed or \
`:states(…)` parent, and not a `show` whose signature or view body has been \
rewritten by hand. Scaffold {child_plural} WITHOUT `--belongs-to` (the \
`{parent_snake}:references` column and its belongs_to dropdown still work), then \
hand-write the parent-scoped list in that `show` view."
)));
}
if !show_signature(&parent_src).is_some_and(|sig| sig.contains(SHOW_DB_PARAM)) {
return Err(GenerateError::Config(format!(
"`--belongs-to {parent_pascal}` needs a parent on the standard `Db` connection, \
but src/routes/{parent_plural}.rs's `show` does not take one (a `--sharded` \
parent holds a `ShardedDb`, which the children-section query cannot borrow). \
Scaffold {child_plural} without `--belongs-to` and write the parent-scoped list \
against the shard handle by hand."
)));
}
Ok(Some(nesting))
}
pub(super) fn children_nested_under(project_root: &Path, parent_plural: &str) -> Vec<String> {
let path = project_root
.join("src")
.join("routes")
.join(format!("{parent_plural}.rs"));
std::fs::read_to_string(&path)
.map_or_else(|_| Vec::new(), |src| children_nested_under_src(&src))
}
pub(super) fn reapply_children(previous: &str, fresh: &str) -> Result<String, Vec<String>> {
let children = children_nested_under_src(previous);
if children.is_empty() {
return Ok(fresh.to_owned());
}
if !show_accepts_children_section(fresh) {
return Err(children);
}
Ok(children.iter().fold(fresh.to_owned(), |acc, child| {
inject_into_parent_show(&acc, child)
}))
}
fn show_accepts_children_section(src: &str) -> bool {
injection_anchors(src).is_some()
&& show_signature(src).is_some_and(|sig| sig.contains(SHOW_DB_PARAM))
}
fn children_nested_under_src(src: &str) -> Vec<String> {
let mut children: Vec<String> = src
.lines()
.filter_map(|line| {
let trimmed = line.trim_end();
let at = trimmed.rfind(CHILD_MARKER_PREFIX)?;
let child = &trimmed[at + CHILD_MARKER_PREFIX.len()..];
(!child.is_empty()).then(|| child.to_owned())
})
.collect();
children.sort();
children.dedup();
children
}
fn infer_parent_from_markers(
project_root: &Path,
child_plural: &str,
fields: &[Field],
) -> Option<String> {
let mut bases: Vec<String> = parents_carrying_child(project_root, child_plural)
.iter()
.filter_map(|path| path.file_stem()?.to_str().map(ToOwned::to_owned))
.filter_map(|parent_plural| {
let fk = fields
.iter()
.find(|f| f.reference_table().as_deref() == Some(parent_plural.as_str()))?;
Some(fk.name.strip_suffix("_id").unwrap_or(&fk.name).to_owned())
})
.collect();
bases.dedup();
match bases.as_slice() {
[only] => Some(only.clone()),
_ => None,
}
}
fn show_signature(src: &str) -> Option<String> {
let lines: Vec<&str> = src.lines().collect();
let start = find_show_signature_line(src)?;
let end = signature_end_line(&lines, start)?;
Some(
lines[start..=end]
.iter()
.map(|line| line.trim())
.collect::<Vec<_>>()
.join(" "),
)
}
fn carries_child_marker(src: &str, child_plural: &str) -> bool {
let marker = child_marker(child_plural);
src.lines().any(|line| line.trim_end().ends_with(&marker))
}
pub(super) fn parents_carrying_child(project_root: &Path, child_plural: &str) -> Vec<PathBuf> {
let routes_dir = project_root.join("src").join("routes");
let Ok(entries) = std::fs::read_dir(&routes_dir) else {
return Vec::new();
};
let mut found: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "rs"))
.filter(|path| {
std::fs::read_to_string(path).is_ok_and(|src| carries_child_marker(&src, child_plural))
})
.collect();
found.sort();
found
}
fn find_show_signature_line(src: &str) -> Option<usize> {
let lines: Vec<&str> = src.lines().collect();
(0..lines.len()).find(|i| {
let trimmed = lines[*i].trim_start();
if trimmed.starts_with(SHOW_SIGNATURE_PREFIX) {
return signature_end_line(&lines, *i).is_some();
}
trimmed == "pub async fn show("
&& lines
.get(i + 1)
.is_some_and(|next| next.trim_start().starts_with("id: Path<"))
&& signature_end_line(&lines, *i).is_some()
})
}
fn signature_end_line(lines: &[&str], start: usize) -> Option<usize> {
const MAX_SIGNATURE_LINES: usize = 40;
let closes = |line: &str| {
let trimmed = line.trim_start();
trimmed.starts_with(SHOW_RETURN)
};
if lines[start].trim_end().ends_with(SHOW_RETURN)
|| lines[start].trim_end().ends_with(EXTRACTORS_MARKER)
{
return Some(start);
}
let limit = (start + MAX_SIGNATURE_LINES).min(lines.len());
(start + 1..limit).find(|i| closes(lines[*i]))
}
fn show_body_range(lines: &[&str], signature: usize) -> (usize, usize) {
let start = signature_end_line(lines, signature).unwrap_or(signature) + 1;
let end = lines[start..]
.iter()
.position(|line| *line == "}")
.map_or(lines.len(), |offset| start + offset);
(start, end)
}
const RENDER_ANCHOR: &str = "(autumn_web::a11y::Link::new(paths::index(), \"Back to list\"))";
fn injection_anchors(src: &str) -> Option<(usize, usize, usize)> {
let lines: Vec<&str> = src.lines().collect();
let signature = find_show_signature_line(src)?;
let (body_start, body_end) = show_body_range(&lines, signature);
let ok_tail = (body_start..body_end).find(|i| lines[*i].trim_start().starts_with("Ok("))?;
let render_anchor = (body_start..body_end).find(|i| lines[*i].trim_start() == RENDER_ANCHOR)?;
if render_anchor <= ok_tail {
return None;
}
Some((signature, ok_tail, render_anchor))
}
pub(super) fn inject_into_parent_show(parent_src: &str, child_plural: &str) -> String {
let marker = child_marker(child_plural);
if carries_child_marker(parent_src, child_plural) {
return parent_src.to_owned();
}
let (source, endings) = Endings::split(parent_src);
let Some((signature, ok_tail, render_anchor)) = injection_anchors(&source) else {
return parent_src.to_owned();
};
let mut lines: Vec<String> = source.lines().map(ToOwned::to_owned).collect();
let signature_close = signature_end_line(
&lines.iter().map(String::as_str).collect::<Vec<_>>(),
signature,
)
.unwrap_or(signature);
let already_injected = lines[signature..=signature_close].iter().any(|line| {
line.contains(EXTRACTORS_MARKER) || EXTRACTOR_PARAM_DECLS.iter().any(|d| line.contains(d))
});
let mut shift = 0usize;
if !already_injected {
let attrs_start = lines[..signature]
.iter()
.rposition(|line| !line.trim_start().starts_with('#'))
.map_or(signature, |last_non_attr| last_non_attr + 1);
lines.insert(attrs_start, extractors_allow_line());
let (signature, signature_close) = (signature + 1, signature_close + 1);
if signature == signature_close {
lines[signature] = format!(
"{}{}{SHOW_RETURN} {EXTRACTORS_MARKER}",
lines[signature]
.strip_suffix(SHOW_RETURN)
.expect("signature_end_line matched this suffix"),
extractor_params_inline(),
);
} else {
for (offset, decl) in EXTRACTOR_PARAM_DECLS.iter().enumerate() {
lines.insert(
signature_close + offset,
format!(" {decl}, {EXTRACTORS_MARKER}"),
);
}
shift = EXTRACTOR_PARAM_DECLS.len();
}
shift += 1; }
let (ok_tail, render_anchor) = (ok_tail + shift, render_anchor + shift);
lines.insert(
render_anchor,
format!(" (__autumn_children_{child_plural}) {marker}"),
);
let binding = format!(
" let __autumn_children_{child_plural} = crate::routes::{child_plural}::children_section(\
&mut db, row.id, &autumn_web::pagination::PageRequest::default(), \
&__nested_state, &__nested_session, \
__nested_csrf.as_ref(), __nested_csrf_field.as_ref(), __nested_submit_token.as_ref(), \
__nested_submit_field.as_ref()).await?; {marker}"
);
lines.insert(ok_tail, binding);
endings.rejoin(&lines, parent_src)
}
pub(super) fn remove_nested_child_section(content: &str, child_plural: &str) -> String {
let (source, endings) = Endings::split(content);
let all: Vec<&str> = source.lines().collect();
let binding_head = format!("let __autumn_children_{child_plural} =");
let render_call = format!("(__autumn_children_{child_plural})");
let (from, to) = find_show_signature_line(&source)
.map_or((0, all.len()), |signature| show_body_range(&all, signature));
let mut drop = vec![false; all.len()];
let mut i = from;
while i < to {
let trimmed = all[i].trim_start();
if trimmed.starts_with(&binding_head) {
let end = (i..to).find(|j| all[*j].contains(".await?;")).unwrap_or(i);
for item in drop.iter_mut().take(end + 1).skip(i) {
*item = true;
}
i = end + 1;
continue;
}
if trimmed.starts_with(&render_call) {
drop[i] = true;
}
i += 1;
}
let mut lines: Vec<String> = all
.iter()
.enumerate()
.filter(|(i, _)| !drop[*i])
.map(|(_, line)| (*line).to_owned())
.collect();
let any_child_left = lines
.iter()
.any(|line| line.contains(CHILD_MARKER_PREFIX) && !line.contains(EXTRACTORS_MARKER));
let was_injected = lines.iter().any(|line| line.contains(EXTRACTORS_MARKER));
if !any_child_left && was_injected {
let decl_lines: Vec<String> = EXTRACTOR_PARAM_DECLS
.iter()
.map(|decl| format!("{decl},"))
.collect();
lines.retain(|line| {
let trimmed = line.trim();
if trimmed == EXTRACTORS_MARKER {
return false; }
let Some(marked) = trimmed.strip_suffix(EXTRACTORS_MARKER) else {
return !decl_lines.iter().any(|decl| trimmed == decl);
};
let marked = marked.trim_end();
!(decl_lines.iter().any(|decl| marked == decl) || marked == ALLOW_ATTR)
});
let inline = extractor_params_inline();
for line in &mut lines {
if !line.trim_end().ends_with(EXTRACTORS_MARKER) {
continue;
}
let stripped = line.replace(&inline, "");
if stripped == *line {
continue;
}
if let Some(rest) = stripped.trim_end().strip_suffix(EXTRACTORS_MARKER) {
rest.trim_end().clone_into(line);
}
}
}
endings.rejoin(&lines, content)
}
#[cfg(test)]
mod tests {
use super::*;
const PARENT: &str = r#"pub async fn show(id: Path<i64>, mut db: Db, flash: Flash) -> AutumnResult<Markup> {
let row: Post = posts::table
.find(*id)
.select(Post::as_select())
.first(&mut *db)
.await
.map_err(AutumnError::not_found)?;
let props: Vec<(&str, maud::Markup)> = vec![
("Id", maud::html! { (row.id) }),
];
Ok(crate::layout(&format!("Post #{}", row.id), "/posts", flash_messages(&flash.consume().await), html! {
h1 { "Post #" (row.id) }
(autumn_web::widgets::property_list(&props))
(autumn_web::a11y::Link::new(paths::index(), "Back to list"))
" "
(autumn_web::a11y::Link::new(paths::edit(row.id), "Edit"))
}))
}
"#;
#[test]
fn injection_adds_extractors_binding_and_render_call() {
let out = inject_into_parent_show(PARENT, "comments");
assert!(out.contains("__nested_csrf: Option<CsrfToken>"), "{out}");
assert!(
out.contains("crate::routes::comments::children_section(&mut db, row.id,"),
"{out}"
);
assert!(
out.contains(" (__autumn_children_comments) // autumn:nested:comments"),
"{out}"
);
let render = out.find("(__autumn_children_comments)").unwrap();
let back = out.find("\"Back to list\"").unwrap();
assert!(render < back, "{out}");
}
#[test]
fn injection_is_idempotent() {
let once = inject_into_parent_show(PARENT, "comments");
let twice = inject_into_parent_show(&once, "comments");
assert_eq!(once, twice);
}
#[test]
fn round_trips_back_to_the_original_source() {
let injected = inject_into_parent_show(PARENT, "comments");
assert_ne!(injected, PARENT);
assert_eq!(remove_nested_child_section(&injected, "comments"), PARENT);
}
#[test]
fn two_children_share_the_extractors_and_removal_is_per_child() {
let one = inject_into_parent_show(PARENT, "comments");
let both = inject_into_parent_show(&one, "likes");
assert_eq!(
both.matches("__nested_csrf: Option<CsrfToken>").count(),
1,
"the extractor edit is shared, not repeated:\n{both}"
);
let after = remove_nested_child_section(&both, "comments");
assert!(!after.contains("__autumn_children_comments"), "{after}");
assert!(after.contains("__autumn_children_likes"), "{after}");
assert!(
after.contains("__nested_csrf: Option<CsrfToken>"),
"{after}"
);
assert_eq!(remove_nested_child_section(&after, "likes"), PARENT);
}
const FORMATTED_PARENT: &str = r#"pub async fn show(
id: Path<i64>,
mut db: Db,
flash: Flash,
autumn_web::extract::State(__nested_state): autumn_web::extract::State<autumn_web::AppState>,
__nested_session: autumn_web::session::Session,
__nested_csrf: Option<CsrfToken>,
__nested_csrf_field: Option<CsrfFormField>,
__nested_submit_token: Option<SubmitToken>,
__nested_submit_field: Option<SubmitFormField>,
) -> AutumnResult<Markup> {
// autumn:nested-extractors
let row: Post = posts::table.find(*id).first(&mut *db).await?;
let __autumn_children_comments = crate::routes::comments::children_section(
&mut db,
row.id,
&autumn_web::pagination::PageRequest::default(),
&__nested_state,
&__nested_session,
)
.await?; // autumn:nested:comments
Ok(crate::layout(&format!("Post #{}", row.id), "/posts", flash, html! {
h1 { "Post #" (row.id) }
(autumn_web::widgets::property_list(&props))
(__autumn_children_comments) // autumn:nested:comments
(autumn_web::a11y::Link::new(paths::index(), "Back to list"))
}))
}
"#;
#[test]
fn removal_takes_a_rustfmt_split_binding_out_whole() {
let out = remove_nested_child_section(FORMATTED_PARENT, "comments");
assert!(
!out.contains("__autumn_children_comments"),
"the whole reflowed statement must go, not just its marked last line:\n{out}"
);
assert!(!out.contains("children_section("), "{out}");
assert!(
!out.contains("&mut db,"),
"orphaned argument lines left behind:\n{out}"
);
assert!(!out.contains("__nested_csrf"), "{out}");
assert!(!out.contains(EXTRACTORS_MARKER), "{out}");
assert!(out.contains("let row: Post = posts::table"), "{out}");
assert!(out.contains("\"Back to list\""), "{out}");
}
#[test]
fn a_second_child_injects_into_a_rustfmt_split_signature() {
let out = inject_into_parent_show(FORMATTED_PARENT, "likes");
assert!(out.contains("__autumn_children_likes"), "{out}");
assert!(out.contains("__autumn_children_comments"), "{out}");
assert_eq!(out.matches("__nested_csrf:").count(), 1, "{out}");
}
#[test]
fn crlf_parent_files_keep_their_line_endings() {
let crlf = PARENT.replace('\n', "\r\n");
let injected = inject_into_parent_show(&crlf, "comments");
assert!(injected.contains("\r\n"), "CRLF must survive the injection");
assert!(
!injected.contains("\n\n"),
"no bare LF lines should be introduced"
);
assert_eq!(remove_nested_child_section(&injected, "comments"), crlf);
}
#[test]
fn injection_refuses_a_show_whose_view_body_was_rewritten() {
let mangled = PARENT.replace(
" (autumn_web::a11y::Link::new(paths::index(), \"Back to list\"))\n",
"",
);
assert_ne!(mangled, PARENT, "the fixture must actually change");
assert_eq!(inject_into_parent_show(&mangled, "comments"), mangled);
}
#[test]
fn the_injected_call_only_uses_extractors_the_signature_declares() {
let out = inject_into_parent_show(PARENT, "comments");
let signature = out
.lines()
.find(|line| line.contains("pub async fn show("))
.expect("show signature");
for binding in [
"__nested_state",
"__nested_session",
"__nested_csrf",
"__nested_csrf_field",
"__nested_submit_token",
"__nested_submit_field",
] {
assert!(
signature.contains(binding),
"the injected call passes `{binding}` but the signature does not declare it:\n{signature}"
);
}
}
#[test]
fn removal_never_deletes_a_signature_carrying_an_unrecognised_extractor_splice() {
let stale = PARENT.replace(
") -> AutumnResult<Markup> {",
", __nested_csrf: Option<CsrfToken>) -> AutumnResult<Markup> { // autumn:nested-extractors",
);
let out = remove_nested_child_section(&stale, "comments");
assert!(
out.contains("pub async fn show("),
"the signature must survive an unrecognised splice:\n{out}"
);
assert!(out.contains("-> AutumnResult<Markup> {"), "{out}");
}
#[test]
fn a_marker_is_never_matched_as_a_prefix_of_a_longer_plural() {
let with_longer = inject_into_parent_show(PARENT, "postscripts");
assert!(with_longer.contains("// autumn:nested:postscripts"));
assert!(
!carries_child_marker(&with_longer, "posts"),
"a `postscripts` section must not read as a `posts` one:\n{with_longer}"
);
assert!(carries_child_marker(&with_longer, "postscripts"));
let both = inject_into_parent_show(&with_longer, "posts");
assert!(both.contains("__autumn_children_posts ="), "{both}");
assert!(both.contains("__autumn_children_postscripts ="), "{both}");
let after = remove_nested_child_section(&both, "posts");
assert!(!after.contains("__autumn_children_posts ="), "{after}");
assert!(after.contains("__autumn_children_postscripts ="), "{after}");
}
#[test]
fn removal_is_a_no_op_on_a_file_that_was_never_injected() {
assert_eq!(remove_nested_child_section(PARENT, "comments"), PARENT);
}
#[test]
fn a_non_standard_parent_show_is_left_untouched() {
let hand_rolled =
"pub async fn show(Path(slug): Path<String>) -> AutumnResult<Markup> {\n}\n";
assert_eq!(
inject_into_parent_show(hand_rolled, "comments"),
hand_rolled
);
}
}