1 #![forbid(unsafe_code)]2 //! f0rge pr: pull requests (M3b). Minimal merge: check-only, no engine.3 use crate::args::{parse_flags, spec_from_lists, FlagSet};4 use crate::output;5 use crate::CliError;6 use f0rge_core::{7 is_uuid4_lower, issue_numbers, now_rfc3339, Forge, MergeRecord, Pr, PrJson, PrReview,8 };9 use std::collections::BTreeMap;10 use std::path::{Path, PathBuf};11 use std::process::Command;12 use std::sync::atomic::{AtomicU64, Ordering};13 14 pub fn help() -> &'static str {15 "f0rge pr: pull requests\nusage: f0rge pr <create|diff|review|comment|merge|close|list> [--json]\nverbs: create diff review comment merge close list\ncreate: --base B --head H --issue UUID\ndocs: spec/schema.md §Pr\ncontract: f0rge --skill\n"16 }17 18 // Per-verb helps next to parser arms (#36). Each <=250B/8ln.19 const CREATE_H: &str = "f0rge pr create: open\nusage: f0rge pr create --base B --head H [--issue UUID] [--title T]\ncontract: f0rge --skill\n";20 const DIFF_H: &str = "f0rge pr diff: patch\nusage: f0rge pr diff <id|number>\ncontract: f0rge --skill\n";21 const REVIEW_H: &str = "f0rge pr review: judge\nusage: f0rge pr review <id|number> --body T [--state S]\ncontract: f0rge --skill\n";22 const COMMENT_H: &str = "f0rge pr comment: note\nusage: f0rge pr comment <id|number> --body T\ncontract: f0rge --skill\n";23 const MERGE_H: &str = "f0rge pr merge: land\nusage: f0rge pr merge <id|number>\ncontract: f0rge --skill\n";24 const CLOSE_H: &str = "f0rge pr close: shut\nusage: f0rge pr close <id|number>\ncontract: f0rge --skill\n";25 const LIST_H: &str = "f0rge pr list: all\nusage: f0rge pr list [--state S]\ncontract: f0rge --skill\n";26 27 /// Verb-depth help (#36). None when unknown.28 pub fn verb_help(verb: &str) -> Option<&'static str> {29 Some(match verb {30 "create" => CREATE_H,31 "diff" => DIFF_H,32 "review" => REVIEW_H,33 "comment" => COMMENT_H,34 "merge" => MERGE_H,35 "close" => CLOSE_H,36 "list" => LIST_H,37 _ => return None,38 })39 }40 41 const KNOWN_VERBS: &[&str] = &["create", "diff", "review", "comment", "merge", "close", "list"];42 43 /// First non-flag token as verb guess for pre-verb parse errors (#37).44 fn guess_verb(raw: &[String]) -> Option<String> {45 for a in raw {46 if a == "--" {47 continue;48 }49 if a.starts_with('-') {50 continue;51 }52 // skip values glued to flags is impossible pre-parse; take first bare token53 return Some(a.clone());54 }55 None56 }57 58 /// Append parse hint (#37), one line, when missing.59 fn with_try_verb(e: CliError, verb: &str) -> CliError {60 match e {61 CliError::Usage(m) if !m.contains("(try:") => {62 CliError::Usage(format!("{m} (try: f0rge pr {verb} --help)"))63 }64 other => other,65 }66 }67 68 fn with_try_domain(e: CliError) -> CliError {69 match e {70 CliError::Usage(m) if !m.contains("(try:") => {71 CliError::Usage(format!("{m} (try: f0rge pr -h)"))72 }73 other => other,74 }75 }76 77 static TMP_CTR: AtomicU64 = AtomicU64::new(0);78 79 const VALS: &[&str] = &["title", "body", "base", "head", "issue", "author", "state", "jq"];80 const BOOLS: &[&str] = &["json", "draft"];81 82 pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {83 let spec = spec_from_lists(VALS, BOOLS);84 let fs = parse_flags(args, &spec).map_err(|e| {85 match guess_verb(args) {86 Some(v) if KNOWN_VERBS.contains(&v.as_str()) => with_try_verb(e, &v),87 _ => with_try_domain(e),88 }89 })?;90 if fs.is("help") {91 match guess_verb(args) {92 Some(v) => {93 if let Some(h) = verb_help(&v) {94 output::out(h);95 return Ok(());96 }97 }98 None => {}99 }100 }101 let (json, jq) = (fs.is("json"), fs.get("jq").map(|s| s.to_string()));102 let p = &fs.positional;103 if p.is_empty() {104 return Err(CliError::Usage(105 "pr <create|diff|review|comment|merge|close|list> (try: f0rge pr -h)".to_string(),106 ));107 }108 let r: Result<(), CliError> = match p[0].as_str() {109 "create" => create(&fs, args, fg, json, jq.as_deref()),110 "diff" => diff(p, fg),111 "review" | "comment" => review(&fs, p, fg, json, jq.as_deref()),112 "merge" => merge(p, fg, json, jq.as_deref()),113 "close" => close(p, fg, json, jq.as_deref()),114 "list" => list(&fs, fg, json, jq.as_deref()),115 other => {116 return Err(CliError::Usage(format!(117 "unknown verb: {other} (try: f0rge pr -h)"118 )))119 }120 };121 r.map_err(|e| {122 let v = p[0].clone();123 if KNOWN_VERBS.contains(&v.as_str()) {124 with_try_verb(e, &v)125 } else {126 with_try_domain(e)127 }128 })129 }130 131 fn one_line(s: &str) -> String {132 s.chars()133 .filter(|c| *c != '\n' && *c != '\r')134 .take(160)135 .collect::<String>()136 .trim()137 .to_string()138 }139 140 fn repo_dir(fg: &Forge) -> Result<PathBuf, CliError> {141 fg.root()142 .parent()143 .map(Path::to_path_buf)144 .ok_or_else(|| CliError::Runtime("repo: not found".to_string()))145 }146 147 fn git(cwd: &Path, args: &[&str]) -> Result<std::process::Output, CliError> {148 Command::new("git")149 .args(args)150 .current_dir(cwd)151 .output()152 .map_err(|_| CliError::Runtime("git: not found".to_string()))153 }154 155 fn git_err(what: &str, o: &std::process::Output) -> CliError {156 let s = String::from_utf8_lossy(&o.stderr);157 let first = s.lines().next().unwrap_or("failed").trim();158 CliError::Runtime(one_line(&format!("{what}: {first}")))159 }160 161 struct Rec {162 pr: Pr,163 }164 165 fn load_all(fg: &Forge) -> Result<Vec<Rec>, CliError> {166 let rd = match std::fs::read_dir(fg.root().join("prs")) {167 Ok(r) => r,168 Err(_) => return Ok(Vec::new()),169 };170 let mut out = Vec::new();171 for e in rd.flatten() {172 let ft = match e.file_type() {173 Ok(t) => t,174 Err(_) => continue,175 };176 if ft.is_symlink() || !ft.is_dir() {177 continue;178 }179 let name = e.file_name().to_string_lossy().into_owned();180 let rel = fg.pr_file(&name);181 if !fg.root().join(&rel).is_file() {182 continue;183 }184 let p = fg.load_entity::<Pr>(&rel).map_err(CliError::from)?;185 output::warn_schema(&p.schema_note);186 out.push(Rec { pr: p.value });187 }188 out.sort_by(|a, b| {189 (a.pr.created.as_str(), a.pr.id.as_str())190 .cmp(&(b.pr.created.as_str(), b.pr.id.as_str()))191 });192 Ok(out)193 }194 195 fn numbers(all: &[Rec]) -> BTreeMap<String, u64> {196 let e: Vec<(String, String)> =197 all.iter().map(|r| (r.pr.id.clone(), r.pr.created.clone())).collect();198 issue_numbers(&e)199 }200 201 fn resolve(fg: &Forge, s: &str, all: &[Rec]) -> Result<String, CliError> {202 let s = one_line(s);203 if s.is_empty() {204 return Err(CliError::Usage("pr <id|number>".to_string()));205 }206 if fg.root().join(fg.pr_file(&s)).is_file() {207 return Ok(s);208 }209 if let Ok(n) = s.parse::<u64>() {210 if n >= 1 && (n as usize) <= all.len() {211 return Ok(all[n as usize - 1].pr.id.clone());212 }213 }214 Err(CliError::Runtime(format!("{s}: not found")))215 }216 217 fn scan_issues(raw: &[String]) -> Result<Vec<String>, CliError> {218 let mut out = Vec::new();219 let mut i = 0;220 while i < raw.len() {221 let a = &raw[i];222 let val: Option<String> = if a == "--issue" {223 let v = raw224 .get(i + 1)225 .ok_or_else(|| CliError::Usage("--issue: needs value".to_string()))?;226 Some(v.clone())227 } else if let Some(v) = a.strip_prefix("--issue=") {228 if v.is_empty() {229 return Err(CliError::Usage("--issue: needs value".to_string()));230 }231 Some(v.to_string())232 } else {233 None234 };235 match val {236 Some(v) => {237 for part in v.split(',') {238 let t = part.trim();239 if t.is_empty() {240 continue;241 }242 if !is_uuid4_lower(t) {243 return Err(CliError::Usage(format!("issue {t}: bad id")));244 }245 if !out.contains(&t.to_string()) {246 out.push(t.to_string());247 }248 }249 i += if a == "--issue" { 2 } else { 1 };250 }251 None => i += 1,252 }253 }254 Ok(out)255 }256 257 fn create(258 fs: &FlagSet,259 raw: &[String],260 fg: &Forge,261 json: bool,262 jq: Option<&str>,263 ) -> Result<(), CliError> {264 let usage = "f0rge pr create --base B --head H";265 let base = fs.get("base").filter(|s| !s.is_empty()).ok_or_else(|| CliError::Usage(usage.to_string()))?;266 let head = fs.get("head").filter(|s| !s.is_empty()).ok_or_else(|| CliError::Usage(usage.to_string()))?;267 let repo = repo_dir(fg)?;268 let hp = if Path::new(head).is_absolute() {269 PathBuf::from(head)270 } else {271 repo.join(head)272 };273 let patch: Vec<u8> = if hp.is_file() {274 let label = hp.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "patch".to_string());275 let bytes = std::fs::read(&hp).map_err(|_| CliError::Runtime(format!("{label}: io error")))?;276 bytes277 } else {278 let spec = format!("{base}..{head}");279 let o = git(&repo, &["format-patch", &spec, "--stdout"])?;280 if !o.status.success() {281 return Err(git_err("git format-patch", &o));282 }283 o.stdout284 };285 if patch.iter().all(|b| b.is_ascii_whitespace()) {286 return Err(CliError::Runtime("empty patch: nothing in base..head".to_string()));287 }288 let issues = scan_issues(raw)?;289 let title = fs.get("title").filter(|s| !s.is_empty()).unwrap_or(head).to_string();290 let body = fs.get("body").unwrap_or("").to_string();291 let id = uuid::Uuid::new_v4().to_string();292 let created = now_rfc3339();293 let pr = Pr {294 id: id.clone(),295 title,296 state: "open".to_string(),297 base: base.to_string(),298 head: head.to_string(),299 draft: fs.is("draft"),300 issues,301 ci: None,302 created: created.clone(),303 updated: None,304 };305 fg.save_entity(&fg.pr_file(&id), &pr, &body).map_err(CliError::from)?;306 fg.atomic_write(&fg.pr_patch(&id), &patch).map_err(CliError::from)?;307 let all = load_all(fg)?;308 let n = numbers(&all).get(&id).copied().unwrap_or(1);309 if json {310 output::emit_json(&PrJson::new(n, &pr), jq);311 } else {312 output::out(&format!("{id}\n"));313 }314 Ok(())315 }316 317 fn diff(p: &[String], fg: &Forge) -> Result<(), CliError> {318 let s = p.get(1).ok_or_else(|| CliError::Usage("pr diff <id|number>".to_string()))?;319 let all = load_all(fg)?;320 let id = resolve(fg, s, &all)?;321 let bytes = fg.read_capped(&fg.pr_patch(&id)).map_err(CliError::from)?;322 let mut text = String::from_utf8_lossy(&bytes).into_owned();323 if !text.ends_with('\n') {324 text.push('\n');325 }326 output::out(&text);327 Ok(())328 }329 330 fn review(331 fs: &FlagSet,332 p: &[String],333 fg: &Forge,334 json: bool,335 jq: Option<&str>,336 ) -> Result<(), CliError> {337 let verb = p[0].clone();338 let s = p.get(1).ok_or_else(|| CliError::Usage(format!("pr {verb} <id|number> --body <text>")))?;339 let all = load_all(fg)?;340 let id = resolve(fg, s, &all)?;341 let body = fs.get("body").filter(|s| !s.is_empty()).ok_or_else(|| CliError::Usage(format!("pr {verb} <id|number> --body <text>")))?;342 let state = match fs.get("state") {343 Some(v) => match v {344 "approved" | "changes-requested" | "commented" => Some(v.to_string()),345 _ => return Err(CliError::Usage("review --state <approved|changes-requested|commented>".to_string())),346 },347 None => None,348 };349 let author = fs.get("author").filter(|s| !s.is_empty()).map(str::to_string);350 let rid = uuid::Uuid::new_v4().to_string();351 let created = now_rfc3339();352 let rv = PrReview { id: rid.clone(), author, state, created: created.clone() };353 let rel = fg.pr_reviews(&id).join(format!("{rid}.md"));354 fg.save_entity(&rel, &rv, body).map_err(CliError::from)?;355 if json {356 output::emit_json(&serde_json::json!({"created": created, "id": rid}), jq);357 } else {358 output::out(&format!("{rid}\n"));359 }360 Ok(())361 }362 363 fn conflict_files(stderr: &str) -> Vec<String> {364 let mut out = Vec::new();365 for line in stderr.lines() {366 let l = line.strip_prefix("error: ").unwrap_or(line).trim();367 let f = if let Some(rest) = l.strip_prefix("patch failed: ") {368 rest.split(':').next().unwrap_or("").trim()369 } else if let Some(i) = l.find(": already exists") {370 l[..i].trim()371 } else if let Some(i) = l.find(": does not match index") {372 l[..i].trim()373 } else {374 continue;375 };376 if !f.is_empty() && !out.contains(&f.to_string()) {377 out.push(f.to_string());378 }379 if out.len() >= 8 {380 break;381 }382 }383 out384 }385 386 fn merge_record_name(fg: &Forge, now: &str) -> PathBuf {387 let mut t = now.replace(':', "-");388 let mut n = 0u64;389 loop {390 let rel = fg.merge_file(&t);391 if !fg.root().join(&rel).exists() {392 return rel;393 }394 n += 1;395 t = format!("{}-{n}", now.replace(':', "-"));396 }397 }398 399 /// ONE canonical byte-form for hashing (SEC-08): CRLF->LF so folder400 /// patch.diff bytes and branch `format-patch --stdout` bytes hash equal.401 fn canon_patch(p: &[u8]) -> Vec<u8> {402 let mut out = Vec::with_capacity(p.len());403 let mut i = 0;404 while i < p.len() {405 if p[i] == b'\r' {406 if i + 1 < p.len() && p[i + 1] == b'\n' {407 out.push(b'\n');408 i += 2;409 } else {410 out.push(b'\n');411 i += 1;412 }413 } else {414 out.push(p[i]);415 i += 1;416 }417 }418 out419 }420 421 /// patch-id via git; fallback hash-object (both deterministic, no new deps).422 fn patch_hash(repo: &Path, patch: &[u8]) -> String {423 let canon = canon_patch(patch);424 let patch = &canon;425 let mut c = Command::new("git");426 c.arg("patch-id").arg("--stable").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());427 if let Ok(mut kid) = c.spawn() {428 use std::io::Write;429 if let Some(mut stdin) = kid.stdin.take() {430 let _ = stdin.write_all(patch);431 }432 if let Ok(o) = kid.wait_with_output() {433 if o.status.success() {434 let t = String::from_utf8_lossy(&o.stdout);435 if let Some(h) = t.split_whitespace().next().filter(|h| h.len() >= 8) {436 return h.to_string();437 }438 }439 }440 }441 let mut c2 = Command::new("git");442 c2.arg("hash-object").arg("--stdin").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());443 if let Ok(mut kid) = c2.spawn() {444 use std::io::Write;445 if let Some(mut stdin) = kid.stdin.take() {446 let _ = stdin.write_all(patch);447 }448 if let Ok(o) = kid.wait_with_output() {449 if o.status.success() {450 return String::from_utf8_lossy(&o.stdout).trim().to_string();451 }452 }453 }454 format!("len{}", patch.len())455 }456 457 /// SEC-08: successful merge record whose body pins this patch hash?458 fn landed(fg: &Forge, hash: &str) -> Option<MergeRecord> {459 let rd = std::fs::read_dir(fg.root().join("merges")).ok()?;460 let want = format!("patch: {hash}");461 for e in rd.flatten() {462 let ft = e.file_type().ok()?;463 if ft.is_symlink() || !ft.is_file() {464 continue;465 }466 let name = e.file_name().to_string_lossy().into_owned();467 if !name.ends_with(".md") || name.starts_with('.') {468 continue;469 }470 let rel = Path::new("merges").join(&name);471 let p: f0rge_core::Parsed<MergeRecord> = fg.load_entity(&rel).ok()?;472 if p.value.verdict == "pass" && p.value.applied && p.body.lines().any(|l| l.trim() == want) {473 return Some(p.value);474 }475 }476 None477 }478 479 fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {480 let s = p.get(1).ok_or_else(|| CliError::Usage("pr merge <id|number>".to_string()))?;481 let all = load_all(fg)?;482 let id = resolve(fg, s, &all)?;483 let parsed = fg.load_entity::<Pr>(&fg.pr_file(&id)).map_err(CliError::from)?;484 output::warn_schema(&parsed.schema_note);485 let mut pr = parsed.value;486 let patch = fg.read_capped(&fg.pr_patch(&id)).map_err(CliError::from)?;487 let repo = repo_dir(fg)?;488 let hash = patch_hash(&repo, &patch);489 if let Some(rec) = landed(fg, &hash) {490 if pr.state == "open" {491 pr.state = "merged".to_string();492 pr.ci = Some("pass".to_string());493 pr.updated = Some(now_rfc3339());494 let _ = fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body);495 }496 if json {497 output::emit_json(&rec, jq);498 } else {499 output::out(&format!("already landed: {}\n", rec.pr));500 }501 return Ok(());502 }503 if pr.state != "open" {504 return Err(CliError::Runtime(format!("pr {id}: already {}", pr.state)));505 }506 let n = TMP_CTR.fetch_add(1, Ordering::Relaxed);507 let pid = std::process::id();508 let tmp = std::env::temp_dir().join(format!("f0rge-pr-{pid}-{n}"));509 let patchf = std::env::temp_dir().join(format!("f0rge-patch-{pid}-{n}.diff"));510 std::fs::write(&patchf, &patch)511 .map_err(|_| CliError::Runtime("tmp: io error".to_string()))?;512 let tmp_s = tmp.to_string_lossy().into_owned();513 let patch_s = patchf.to_string_lossy().into_owned();514 let cleanup = |code: Option<i32>| {515 let _ = code;516 let _ = git(&repo, &["worktree", "remove", "--force", &tmp_s]);517 let _ = std::fs::remove_file(&patchf);518 let _ = std::fs::remove_dir_all(&tmp);519 };520 let o = git(&repo, &["worktree", "add", "--detach", &tmp_s, &pr.base])?;521 if !o.status.success() {522 cleanup(None);523 return Err(git_err("git worktree", &o));524 }525 let o = git(&tmp, &["apply", "--check", &patch_s])?;526 let now = now_rfc3339();527 if o.status.success() {528 cleanup(None);529 pr.state = "merged".to_string();530 pr.ci = Some("pass".to_string());531 pr.updated = Some(now.clone());532 fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body).map_err(CliError::from)?;533 let rec = MergeRecord {534 pr: id.clone(),535 applied: true,536 verdict: "pass".to_string(),537 resolved: Vec::new(),538 created: now,539 };540 let rel = merge_record_name(fg, &rec.created);541 fg.save_entity(&rel, &rec, &format!("clean apply\npatch: {hash}\n")).map_err(CliError::from)?;542 let all2 = load_all(fg)?;543 let num = numbers(&all2).get(&id).copied().unwrap_or(1);544 if json {545 output::emit_json(&PrJson::new(num, &pr), jq);546 } else {547 output::out(&format!("merged {id}\n"));548 }549 Ok(())550 } else {551 let files = conflict_files(&String::from_utf8_lossy(&o.stderr));552 let base_body = if files.is_empty() {553 "patch does not apply\n".to_string()554 } else {555 format!("conflict: {}\n", files.join(", "))556 };557 let body = format!("{base_body}patch: {hash}\n");558 let rec = MergeRecord {559 pr: id.clone(),560 applied: false,561 verdict: "fail".to_string(),562 resolved: Vec::new(),563 created: now,564 };565 let rel = merge_record_name(fg, &rec.created);566 let _ = fg.save_entity(&rel, &rec, &body);567 cleanup(None);568 if files.is_empty() {569 return Err(CliError::Runtime("merge conflict: patch does not apply".to_string()));570 }571 return Err(CliError::Runtime(one_line(&format!("merge conflict: {}", files.join(", ")))));572 }573 }574 575 fn close(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {576 let s = p.get(1).ok_or_else(|| CliError::Usage("pr close <id|number>".to_string()))?;577 let all = load_all(fg)?;578 let id = resolve(fg, s, &all)?;579 let parsed = fg.load_entity::<Pr>(&fg.pr_file(&id)).map_err(CliError::from)?;580 output::warn_schema(&parsed.schema_note);581 let mut pr = parsed.value;582 pr.state = "closed".to_string();583 pr.updated = Some(now_rfc3339());584 fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body).map_err(CliError::from)?;585 let all2 = load_all(fg)?;586 let n = numbers(&all2).get(&id).copied().unwrap_or(1);587 if json {588 output::emit_json(&PrJson::new(n, &pr), jq);589 } else {590 output::out(&format!("closed {id}\n"));591 }592 Ok(())593 }594 595 fn list(fs: &FlagSet, fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {596 let filter = fs.get("state").map(str::to_string);597 if let Some(f) = &filter {598 match f.as_str() {599 "open" | "closed" | "merged" | "draft" | "all" => {}600 _ => return Err(CliError::Usage("pr list --state <open|closed|merged|draft|all>".to_string())),601 }602 }603 let all = load_all(fg)?;604 let nums = numbers(&all);605 let mut rows: Vec<String> = Vec::new();606 let mut human: Vec<Vec<String>> = Vec::new();607 for r in &all {608 if let Some(f) = &filter {609 let keep = match f.as_str() {610 "all" => true,611 "draft" => r.pr.draft,612 _ => &r.pr.state == f,613 };614 if !keep {615 continue;616 }617 }618 let n = nums.get(&r.pr.id).copied().unwrap_or(0);619 if json {620 rows.push(output::json_line(&PrJson::new(n, &r.pr)));621 } else {622 human.push(vec![n.to_string(), r.pr.state.clone(), r.pr.title.clone()]);623 }624 }625 if json {626 if rows.is_empty() {627 output::emit_str("[]", jq);628 } else {629 output::emit_str(&rows.join("\n"), jq);630 }631 } else if !human.is_empty() {632 output::out(&format!("{}\n", output::align(&human)));633 }634 Ok(())635 }636 637 #[cfg(test)]638 mod verb_help_tests {639 use super::*;640 fn budget(s: &str) {641 assert!(s.lines().count() <= 8, "lines:\n{s}");642 assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());643 }644 #[test]645 fn per_verb_budgets_and_needles() {646 for (v, needle) in [647 ("create", "--base"),648 ("create", "--head"),649 ("diff", "diff <id|number>"),650 ("review", "--body"),651 ("comment", "--body"),652 ("merge", "merge <id|number>"),653 ("close", "close <id|number>"),654 ("list", "--state"),655 ] {656 let h = verb_help(v).expect(v);657 budget(h);658 assert!(h.contains(needle), "{v} missing `{needle}`:\n{h}");659 }660 assert!(verb_help("frobnicate").is_none());661 }662 }