// size-waiver: single-domain agent-rules generator + honesty contracts; do not split for LOC alone #1408
use clap::{Args, ValueEnum};
use crate::exit;
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum AgentMode {
/// Include both CLI and MCP instructions (default).
All,
/// CLI-only: omit MCP section.
Cli,
/// MCP-only: omit CLI shell examples, lead with MCP tools.
Mcp,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum AgentPlatform {
/// Include both Linux and Windows examples where they differ (default).
All,
/// Linux/macOS only: heredocs, single-quote shell syntax.
Linux,
/// Windows only: file arguments, double-quote escaping.
Windows,
}
#[derive(Debug, Args)]
#[command(after_help = "\
EXAMPLES:
patchloom agent-rules >> AGENTS.md
patchloom agent-rules --mode mcp
patchloom agent-rules --platform linux")]
pub struct AgentRulesArgs {
/// Which integration mode to generate instructions for.
#[arg(long, value_enum, default_value = "all")]
pub mode: AgentMode,
/// Which platform to generate shell examples for.
#[arg(long, value_enum, default_value = "all")]
pub platform: AgentPlatform,
}
pub(crate) const AGENT_RULES_GENERATED_MARKER: &str = "<!-- Generated by patchloom v";
pub(crate) fn generate_agent_rules(args: &AgentRulesArgs) -> String {
let version = env!("CARGO_PKG_VERSION");
let show_cli = matches!(args.mode, AgentMode::All | AgentMode::Cli);
let show_mcp = matches!(args.mode, AgentMode::All | AgentMode::Mcp);
let show_linux = matches!(args.platform, AgentPlatform::All | AgentPlatform::Linux);
let show_windows = matches!(args.platform, AgentPlatform::All | AgentPlatform::Windows);
let mut out = String::new();
// Header
out.push_str(&format!(
"{AGENT_RULES_GENERATED_MARKER}{version} — https://github.com/patchloom/patchloom -->\n\
# Patchloom\n\n"
));
if show_mcp && !show_cli {
out.push_str(
"You have MCP tools for file reads, edits, and searches. \
Use them for ALL file operations. Paths are confined to the \
server workspace (MCP rejects `../` escapes and outside symlinks).\n\n",
);
}
if show_mcp && show_cli {
out.push_str(
"**Decision rule: always use patchloom MCP tools instead of your native agent \
tools for file edits.** Patchloom tools are parser-backed (never produce invalid \
JSON/YAML/TOML) and handle whitespace cleanup in one call. MCP always rejects \
workspace escapes (including absolute path strings). CLI is unrestricted by \
default; use `patchloom --cwd <ws> --contain …` so CLI reads, writes, and \
meta-input files (batch/tx/explain plans, patch files, `--files-from` lists) \
must resolve inside the workspace (absolute paths under `--cwd` are allowed).\n\n\
**Host sandbox contract (#1832):** `--contain` is relative to the **effective** \
working directory (`--cwd` if set, else process cwd). An agent that can pass \
`--cwd ..` widens the sandbox to a parent directory. Hosts must pin \
`--cwd <project>` themselves and **strip or ignore model-supplied `--cwd` / \
`--contain`** before exec. MCP always enforces its own server root.\n\n",
);
}
if show_cli {
out.push_str(
"**Decision rule: if you are about to make 3+ tool calls for file edits, use \
`patchloom batch` instead.** One call replaces N round-trips. For agent sandboxes \
that shell out to the CLI, the **host** must invoke \
`patchloom --cwd <workspace> --contain …` and not let the model supply `--cwd` \
(containment follows effective cwd; #1832).\n\n",
);
}
// Shared Apply write semantics (CLI + MCP + library).
out.push_str(
"**Apply write safety:** all Apply paths share one writer. Symlinks are resolved so \
the link entry is not replaced (#1230). On Unix, files with multiple hard links \
(`nlink > 1`) are updated in place so sibling paths stay in sync (#1733); single-link \
files use temp+rename. CLI `rename` and plan `file.rename` (including force overwrite) \
use `fs::rename` so multi-hardlinked sources keep their shared inode (#1739, #1746).\n\n",
);
// When to use
if show_mcp {
out.push_str(
"## Tool selection guide\n\n\
| Task pattern | Tool to use |\n\
|---|---|\n\
| Read file contents (with optional line range) | `read_file` |\n\
| See uncommitted changes vs git HEAD (omits `.patchloom/` backups) | `git_status` |\n\
| Set/get a value in JSON, YAML, or TOML | `doc_set`, `doc_get`, `doc_query` |\n\
| Delete, merge, or ensure a value exists | `doc_delete`, `doc_merge`, `doc_ensure` |\n\
| Compare two structured files | `doc_diff` |\n\
| Edit markdown section, bullet, or table | `md_replace_section`, `md_upsert_bullet`, `md_table_append` |\n\
| Insert text after/before a heading | `md_insert_after_heading`, `md_insert_before_heading` |\n\
| Insert a sibling section after a full section body | `md_insert_after_section` |\n\
| Move a heading section (same file or cross-file) | `md_move_section` |\n\
| Remove later whole sections with a duplicate heading | `md_dedupe_headings` |\n\
| Lint markdown for structural issues | `md_lint` |\n\
| Fix trailing whitespace or missing newlines | `fix_whitespace` (one file) or `batch_tidy` (multiple files) |\n\
| Create, append, prepend, rename, or delete a file | `create_file`, `append_file`, `prepend_file`, `move_file`, `delete_file` |\n\
| Find/replace text in a file | `replace_text` (one file) or `batch_replace` (same replacement across multiple files) |\n\
| Search across files | `search_files` |\n\
| Apply a unified diff patch | `apply_patch` |\n\
| List/read/rename symbols (AST-aware) | `ast_list`, `ast_read`, `ast_rename`, `ast_replace`, `ast_rewrite_signature` |\n\
| Insert, wrap, or manage imports | `ast_insert`, `ast_wrap`, `ast_imports` |\n\
| Reorder, group, or move symbols | `ast_reorder`, `ast_group`, `ast_move` |\n\
| Extract or split files by symbol | `ast_extract_to_file`, `ast_split` |\n\
| Validate syntax, find refs, or analyze impact | `ast_validate`, `ast_refs`, `ast_impact`, `ast_search` |\n\
| Repo map, imports, or structural diff | `ast_map`, `ast_deps`, `ast_diff` |\n\
| Apply same operation to many files | `execute_plan` with `for_each` glob |\n\
| Get server version and working directory | `server_info` |\n\n\
**`replace_text` / plan replace flags (default false):**\n\
- `require_change`: error when the pattern matches zero times (fail closed).\n\
- `command_position`: rewrite only shell invocable tokens (`sudo`/`timeout`/`flock`/`runuser`/`setsid`/`run0`/`gosu`/`su-exec`/`tini`/`dumb-init`/`unshare`/`nsenter`/`taskset`/`systemd-run`/`firejail`/`busybox`/`chpst`/`softlimit`/`envdir`/`setlock` wrappers yes; `uv pip` no).\n\
- `fuzzy`: similarity fallback when exact match fails (also with before_context/after_context).\n\
- `min_fuzzy_score`: reject fuzzy matches below this floor (0.0..=1.0); exact/anchored unaffected (#1687).\n\
- `allow_absent_old`: only then apply fuzzy when exact `old` is not in the file (#1758). Default false (fail closed).\n\
Example: `{\"path\":\"install.sh\",\"old\":\"pip\",\"new\":\"uv\",\
\"command_position\":true,\"require_change\":true}`\n\n\
**Fuzzy defaults fail closed when exact old is absent (#1758):**\n\
- If `old` is not present, fuzzy will **not** rewrite a nearby live span by default, even when \
score ≥ `min_fuzzy_score`. JSON error explains the best candidate; set `allow_absent_old=true` only for deliberate approximate recovery.\n\
- Prefer `ast_rename` / `ast_rename_project` for code identifiers. Fuzzy is a last \
resort for typos in non-AST text (prose, comments), not a general rename tool.\n\
- When you opt into `allow_absent_old`, still check JSON `matched_text` before treating the edit as semantic success (#1736).\n\n\
**Library embedder undo / post-write (Rust hosts, not CLI-only):**\n\
- After `ApplyMode::Apply`, `EditResult.backup_session` is the session id for that write (#1686).\n\
- `backup::restore_path_from_latest_backup(project_root, path)` — latest session that contains the path\n\
- `backup::restore_path_from_session(project_root, timestamp, path)` — one path from a chosen session (#1660)\n\
- `backup::list_sessions_under(root, &ListSessionsOptions { descendants: true, .. })` — nested monorepo sessions (#1688)\n\
- CLI: `patchloom undo --list` walks nested `.patchloom/backups` under the cwd (#1695). Bare CLI undo is dry-run (exit 2); restore needs the write apply flag (see CLI agent-rules).\n\
- `api::run_post_write_validation` / `ReplaceOptions.post_write` / `WritePolicyOptions.post_write` (#1663, #1690) maps to `format_failed` / `EditErrorKind::FormatFailed`\n\
- Multi-doc / wrong-root doc navigation peels to `EditErrorKind::TypeError` via \
`edit_error_kind` / `classify_error` (CLI JSON `error_kind: type_error`; #1883). Do not \
collapse with `InvalidInput` (empty patterns, bad options, sole binary).\n\
- Multi-doc `api::doc_merge(path, value, mode, guard, selector)`: pass `Some(\"0\")` \
(or `\"[0]\"`) to merge into document 0; `None` is root-only and refuses a non-array \
overlay on multi-doc root with `TypeError` (#1909).\n\
- Path binary preflight: `api::is_binary_file` / `files::is_binary_file` (8 KiB NUL probe; open fail → false; #1884)\n\
- Sole-path text load for hosts: `api::load_text(path)` or `files::load_text_strict` (#1894, #1910)\n\
- `EditErrorKind` is `#[non_exhaustive]`; match with a wildcard arm (#1910)\n\
- Text I/O honesty (#1894):\n\
| Surface | Binary / invalid UTF-8 | Unreadable (IO) |\n\
|---------|------------------------|-----------------|\n\
| Sole explicit path (`load_text_strict` / `sole_explicit_non_text`) | `error_kind: invalid_input` | IO / `not_found` |\n\
| Explicit multi-file list | `refused[]` reason `binary` or `invalid_utf8` | `refused[]` reason `unreadable` |\n\
| Directory walk (`try_read_text_file`) | content SoftSkip (silent) | SoftSkip; empty scan must not report pattern `no_matches` if unreadable may have masked it (AST rename) |\n\
| Tx multi-path probe (`read_and_probe`) | SoftSkip `Ok(false)` | Hard `Err` (plan names paths) |\n\
Byte rule: `classify_text_bytes`. Patch file + patch targets use Strict (#1896).\n\
- Project rename: `api::ast_rename_project(root, old, new, &opts, guard)` (#1689)\n\
- Fuzzy tip: bare-identifier typos use token span matching; prefer `min_fuzzy_score` (e.g. 0.80) for agent hosts; always check `matched_text` (#1687, #1694, #1736)\n\
**Match reporting in JSON:** CLI `replace --json`, MCP `replace_text` / `batch_replace` / `execute_plan`, and library `EditResult` report `match_mode` (`exact`/`fuzzy`/`anchored`), optional `match_score`, optional `matched_text` (actual span for fuzzy/anchored; may differ from `old`), and replace `match_count` (plan/tx also on each change + sum) so agents can verify fuzzy sites. Multi-file / multi-op aggregates use worst-case rollup (`fuzzy` > `anchored` > `exact`) and the **minimum** fuzzy `match_score` across paths/ops (lowest confidence). Soft no-match / fuzzy fail-closed refuse sets `ok: false` and `error_kind: no_matches` on CLI, plan/tx, and MCP (body and `is_error` agree; #1791). When some paths write and others soft-refuse or soft-miss, overall ok/success may still be true: check `refused[]` (path, match_mode, match_score, matched_text, reason=`exact_old_absent`, `below_min_fuzzy_score`, or `no_matches` for exact soft miss on explicit multi-path lists; #1792) so partial apply is not mistaken for full coverage. Soft no-match CLI JSON may include `similar_targets` (did-you-mean) for literal patterns (#1669, #1674, #1736, #1747).\n\
**Missing paths CLI vs MCP (#1793):** CLI multi-path `replace` soft-skips missing explicit paths under `skipped[]` and still applies the rest. Plan/batch path ops hard-fail missing files with `not_found` and roll back (atomic) unless `if_exists` / `--if-exists` is set, which soft-skips the missing path like CLI. Soft zero-match on existing paths still applies matches and lists `refused[]` with overall success. Under `--json`/`--jsonl`, CLI does not also print a human stderr \"No such file\" line for paths already in `skipped[]` (#1797).\n\
**Empty `--files-from`:** An empty list file or empty stdin (`--files-from -`) is `error_kind: invalid_input` (exit 1) for **search**, **replace**, and **tidy** (not pattern `no_matches`, and not tidy `ok:true` with zero issues). Fix the path list; do not widen the pattern or treat the workspace as clean (#1796).\n\
**`--files-from` comments (#1811):** Lines whose first non-whitespace character is `#` are comments and are ignored (gitignore-style). Blank lines are ignored. Do not emit markdown headers as path lines.\n\
**Do not branch on `ok` alone (#1804):** Prefer `applied: true` (CLI write JSON) or MCP/tx `files_changed > 0` with `status: success` for \"edit landed.\" Soft refuse / no match uses `error_kind`/`status: no_matches` (and `applied: false` / `files_changed: 0`). Partial multi-path: read `refused[]` / `skipped[]`. Format failure: `error_kind: format_failed` with `applied: true` means the write already landed on disk (also `write_applied` for one release; prefer `applied`; #1831).\n\
**`applied: false` with no write:** When a write was requested but no bytes change (doc ensure/set identity, empty append/prepend, identity replace), JSON still sets `applied: false` and omits `backup_session`. Do not treat success alone as a write. Pre-write hard failures (`already_exists`, `not_found`, `invalid_input`, `parse_error`, `type_error`, `conflicts`, `ambiguous`, `no_matches`, `changes_detected`) also set `applied: false` so agents can branch on `applied` alone.\n\
**Batch `replace` order:** Batch lines are `replace PATH OLD NEW` (not CLI `replace OLD --new NEW path`). Pasting CLI order fails with a parse hint when the third token is an existing file.\n\
**`identity: true` (#1801):** Replace JSON when the pattern matched but every replacement was identical (`old == new`); `match_count > 0` but `file_count: 0` / empty `files` and no write.\n\
**`require_change` + `if_exists` (#1800):** When both are set, `if_exists` wins: zero matches → success (`ok: true`, `match_count: 0`), not fail-closed `no_matches`.\n\
**`tx`/`execute_plan` `strict: false` (#1803):** Continues past soft replace `no_matches` on existing paths. Does **not** continue past hard errors such as `not_found` (missing path). Optional **files** must be omitted from the plan or ensured to exist; optional **content** can use soft miss.\n\
**`for_each` (plan-level field, not an op; #1842):** Fan-out is a top-level plan field with required `glob`. Example:\n\
```json\n\
{\n\
\"for_each\": { \"glob\": \"**/*.txt\" },\n\
\"operations\": [\n\
{ \"op\": \"replace\", \"path\": \"{path}\", \"old\": \"x\", \"new\": \"y\" }\n\
]\n\
}\n\
```\n\
Placeholders: `{path}`, `{item}` (alias of `{path}`), `{dir}`, `{stem}`, `{ext}`, `{name}`. \
Unknown lone `{name}` in path/from/to fields fails with `invalid_input` (not opaque `not_found`). \
Do not put `for_each` inside `operations` and do not pass a bare path array.\n\
**Binary / non-text / unreadable paths (#1813 / SoftTextSkip):** Sole explicit **binary, invalid UTF-8, or unreadable** path on **replace**, **search**, **tidy**, **read**, **md**, **ast**, or **patch** is `invalid_input` (not pattern `no_matches` / not vacuous tidy clean / not unsupported-language / not missing-symbol; NUL is valid UTF-8 so a text load can succeed without a binary probe). This includes a **sole path supplied only via file-backed `--files-from`** (not stdin `-`). Multi-file **replace**, **search**, and **tidy** lists (positionals or `--files-from`) put non-text co-paths in `refused[]` with `reason: binary`, `invalid_utf8`, or `unreadable` (do not treat a partial success as covering every listed path). Multi-file read reports binary paths under `skipped[]` with `error_kind: invalid_input`. Directory walks soft-skip content SoftSkip (binary/utf8) without failing the whole search. If a walk finds **zero** matches/issues and **any** path was unreadable, the result is `invalid_input` (not pattern `no_matches` / not vacuous tidy clean / not `assert-count` success with actual 0 / not AST “no symbols”), because IO may have masked the scan.\n\
**Search max_results:** CLI/MCP/tx `search` with `max_results` keeps full `match_count` (and full `file_count`) but may cap the detailed `matches` array **and** the `files` list in `--count` / `--files-with-matches` modes; when capped, JSON sets `truncated: true` (omitted when complete; #1798). Under `--jsonl`, capped content searches append a final `{\"type\":\"summary\",\"match_count\":N,\"match_emitted\":M,\"truncated\":true}` line so agents still see the total. Do not treat `matches.len()` or `files.len()` as total coverage when `truncated` is true.\n\n\
**Multi-result `--json`:** Commands that emit many items (`ast list` / `ast search` / `ast validate` / \
`ast deps`, `ast map`, undo list, etc.) print one JSON array under `--json` \
(single `json.loads` on full stdout) and one object per line under `--jsonl`. Do not expect \
concatenated pretty multi-documents for `--json`.\n\
**Diagnostic envelopes:** `md lint-agents --json` and MCP `md_lint` emit a single object \
`{ok, path, issue_count, issues}` (not a bare array). `tidy check --json` is the multi-file \
sibling: `{ok, issue_count, issues}` with `path` on each issue (optional `skipped[]`), not a \
top-level path. Branch on `ok` / `issue_count`. MCP `md_lint` keeps isError=false when issues \
are present (same as clean file success with `ok: true`). CLI JSONL stays one issue object per \
line. (#1854, #1859)\n\n",
);
}
if show_cli {
out.push_str(
"**Preview vs apply (#1808, #1810, #1812):** CLI write JSON always includes `applied` \
(`true` after apply mode, `false` for default preview / check mode). Plan/batch/tx JSON also \
includes `applied` (same meaning; pair with `status`: `success` vs `changes_detected`). \
`changed: true` or `files_changed: N` on preview means **would** change, not that bytes were \
written. Exit 2 = changes detected / dry-run.\n\
**`backup_session` on success (#1802):** Successful CLI replace/doc/tx apply JSON \
includes `backup_session` when a backup was created (same field as `format_failed` and library \
`EditResult`). Use it for surgical undo; do not guess newest session under parallel agents.\n\
**CLI vs plan/MCP names (#1809):** CLI replace is positional `OLD` + `--new NEW` \
(not plan aliases `from`/`to` as flags). CLI `doc set` is `doc set PATH SELECTOR VALUE` \
(not a `--key` flag). Plan/MCP accept legacy aliases `from`/`to` and `key` for selector.\n\
**CLI `md upsert-bullet`:** use `--bullet` (or alias `--content`; #1839) with `--heading`.\n\
**Doc query `--json` (#1838):** `doc get`/`has`/`keys`/`len`/`select`/`flatten` success is \
`{\"ok\":true,\"value\":...,\"path\":...,\"selector\":...}` (selector omitted for flatten). Text mode stays bare. \
`doc has` prints `true`/`false` and exits **0** for both (missing key is not `no_matches`; #1843).\n\
**Plan/batch `tidy.fix` defaults (#1840, #1847):** Omitting write-policy fields matches CLI \
`tidy fix` (trim trailing whitespace + ensure final newline). Precedence: defaults → plan \
`write_policy` → op fields. Op fields stick through commit (plan `write_policy` is not re-applied \
to that path); a later non-tidy write clears that. Bare example: `{\"op\":\"tidy.fix\",\"path\":\"f.txt\"}`.\n\
**Replace jsonl multi-file (#1799):** Streams one object per success path \
(`status: ok`), refused soft-miss (`status: refused`), skipped missing (`status: skipped`), \
then a `type: summary` trailer with counts. Prefer this or MCP `batch_replace` over assuming \
success lines are the full path list.\n\n\
Use patchloom when:\n\
- Editing JSON, YAML, or TOML (parser-backed, preserves comments, output is always valid)\n\
- Editing markdown sections, bullets, or tables by heading\n\
- Batching edits across multiple files in one call\n\
- You need atomic rollback if any edit fails\n\n\
**CLI undo (dry-run by default):** `patchloom undo` only previews the most recent \
backup and exits 2 with `applied: false` and `status: changes_detected`. Restore with \
`patchloom undo --apply` (optional `--session <timestamp>`), which sets `applied: true` \
and `status: restored` (#1830). List sessions with `patchloom undo --list`. There is no \
`--latest` flag.\n\
**Replace mode flags (#1829):** CLI replacement text is `--new` (not positional NEW and not \
`--to`). Missing mode errors name `--new` / `--insert-before` / `--insert-after` first; \
plan/MCP still accept field aliases `new`/`to`.\n\
**Insert line placement (#1885):** `--insert-before` / `--insert-after` (and plan/MCP \
`insert_before` / `insert_after`) are line-oriented by default: payloads that look like a \
new line (indent, `//`/`#` comment, embedded newline) or a whole-line anchor get a separating \
newline so agents do not glue text onto the match. Pure mid-line inserts (e.g. `X` after \
`foo` inside a line) stay byte-exact.\n\n",
);
if !show_mcp {
out.push_str(
"For single-file read, search, create, delete, or rename, your native agent tools are faster.\n\n",
);
}
}
// MCP section
if show_mcp {
out.push_str(
"## MCP mode\n\n\
**ALWAYS use MCP tools for ALL file edits.**\n\n\
- For any multi-op or multi-file change, **use `execute_plan`** with an inline plan (or plan_path). \
It provides atomic execution + rollback, exactly like the CLI `tx` command.\n\
- Use `batch_replace` or `batch_tidy` only when applying the *exact same* operation to multiple files.\n\
- Do **not** issue parallel write calls against the same path(s) — per-call success does not guarantee a coherent combined result.\n\
- Nested trees: set a **relative** `cwd` under the server workspace and keep op paths short. \
Do not use absolute `cwd` on MCP. Do not combine `cwd` with `for_each`.\n\
- `search_files`: canonical multi-root field is `paths` (array). Singular `path` is accepted as an alias for one root.\n\n\
Example (edit under a fixture without prefixing every path):\n\n\
```json\n\
{\n\
\"plan\": {\n\
\"version\": 1,\n\
\"cwd\": \"fixtures/svc\",\n\
\"operations\": [\n\
{\"op\": \"doc.set\", \"path\": \"configs/app.yaml\", \"selector\": \"name\", \"value\": \"updated\"}\n\
]\n\
}\n\
}\n\
```\n\n\
That re-roots to `fixtures/svc/configs/app.yaml` (not a same-named file at the workspace root).\n\n",
);
}
// Canonical parameter names (same on CLI flags, tx/MCP JSON, and batch positionals).
out.push_str(
"## Canonical parameter names\n\n\
Use these names in plans, MCP args, and CLI flags (do not invent alternates):\n\n\
| Concept | Canonical name | Notes |\n\
|---------|----------------|-------|\n\
| Text/identifier before | `old` | CLI **replace**: positional `OLD` (not `--old`). CLI **ast rename/replace**: `--old`. Plans/MCP: `\"old\"`. |\n\
| Text/identifier after | `new` | CLI: `--new`. Plans/MCP: `\"new\"`. |\n\
| Doc path into a document | `selector` | CLI positional. Plans/MCP: `\"selector\"`. |\n\
| AST rename / replace / read | path first | `ast rename PATH --old X --new Y`; `ast replace PATH SYMBOL --old … --new …` (no `--symbol` flag). |\n\
| AST refs / impact | symbol first | `ast refs SYMBOL PATH` / `ast impact SYMBOL PATH` (no `--name` flag; #1841). |\n\
| Schema capability filter | `weak` / `medium` / `strong` | `schema --tier` only accepts these (not `small`/`large`). |\n\n\
Replace example: `patchloom replace OLD --new NEW path` (positional OLD + `--new`; never `replace --old …`).\n\
Some plan/MCP fields still **accept** legacy aliases (`from`/`to` for replace, `key` for doc selector, \
`ops` for the plan `operations` array) so older agent prompts keep working, but examples and new plans \
must use the canonical names above.\n\n",
);
// Batching section
if show_cli {
out.push_str("## Batching (the main speed win)\n\n\
Six file edits via native tools = six round-trips. One `batch` call = one round-trip:\n\n");
if show_linux {
out.push_str(
"```bash\n\
patchloom batch --apply <<'EOF'\n\
doc.set config.json version \"2.0.0\"\n\
doc.set config.yaml app.version \"2.0.0\"\n\
replace README.md \"1.0.0\" \"2.0.0\"\n\
replace src/main.rs \"proccess\" \"process\" --fuzzy --min-fuzzy-score 0.80\n\
file.create hello.txt \"Hello, World!\"\n\
file.rename old.txt new.txt\n\
md.upsert_bullet CHANGELOG.md \"## Changes\" \"- Bumped to 2.0.0\"\n\
EOF\n\
```\n\n\
One line per operation. Double-quote values with spaces. Unquoted JSON objects/arrays \
(`file.create f.json {\"x\":1}`) keep inner quotes. In `file.create`/`append`/`prepend` content, \
`\\n` `\\t` `\\r` `\\\\` `\\\"` expand (multi-line content on one batch line).\n\
Batch `replace` accepts optional flags after path/old/new: `--fuzzy`, `--min-fuzzy-score`, \
`--word-boundary`/`-w`, `--command-position`, `--require-change`, `-i`/`--case-insensitive`, \
`--if-exists`. Advanced options (regex, context, nth) need a `tx` plan.\n\n",
);
}
if show_windows {
if show_linux {
out.push_str(
"On Windows (where heredocs are not available), write operations to a file and pass it:\n\n",
);
}
out.push_str(
"```bash\n\
patchloom batch ops.txt --apply\n\
```\n\n",
);
if !show_linux {
out.push_str(
"One line per operation in the file. Double-quote values with spaces. Unquoted JSON objects \
keep inner quotes. In `file.create`/`append`/`prepend` content, `\\n` `\\t` `\\r` expand.\n\n",
);
}
}
out.push_str(
"**Note:** Values are parsed as JSON first. An unquoted `1.0` is parsed as a number. \
To force a string, wrap in JSON quotes. Unix-like shells (bash/zsh): \
`doc.set config.json version '\"1.0\"'`. Windows (`cmd.exe`/PowerShell): \
`doc.set config.json version \"\\\"1.0\\\"\"`.\n\n",
);
out.push_str(
"For complex plans needing format/validate lifecycle, regex replace, or `--nth`, use `tx` with JSON:\n\n\
```bash\n\
patchloom tx plan.json --apply\n\
```\n\n\
Relative paths for batch ops files, `tx`/`explain` plan files, `patch` files, and \
`--files-from` lists resolve under `--cwd` (same base as operation targets). \
Absolute meta-input paths are unchanged. With `--contain`, those meta-input paths \
(and operation targets) must resolve inside the workspace: absolute paths under \
`--cwd` are allowed; `../` escapes and absolute paths outside the workspace are \
rejected.\n\n\
In plan JSON, doc ops use the field name `selector` (not `key`). \
`key` is accepted as an alias if a model emits it, but prefer `selector`.\n\n",
);
// Structured edits section
out.push_str("## Structured edits\n\n");
if show_linux {
out.push_str(
"```bash\n\
# Edit a value in JSON/YAML/TOML by selector path (parser-backed, preserves comments)\n\
patchloom doc set config.json version '\"2.0.0\"' --apply\n\
patchloom doc merge config.yaml --value '{\"db\":{\"pool\":10}}' --apply\n\
\n\
# Append a row to a markdown table\n\
patchloom md table-append README.md --heading \"## API\" --row \"| new | row |\" --apply\n\
```\n\n",
);
}
if show_windows {
if show_linux {
out.push_str("On Windows, use double-quote escaping:\n\n");
}
out.push_str(
"```bash\n\
patchloom doc set config.json version \"\\\"2.0.0\\\"\" --apply\n\
patchloom doc merge config.yaml --value \"{\\\"db\\\":{\\\"pool\\\":10}}\" --apply\n\
patchloom md table-append README.md --heading \"## API\" --row \"| new | row |\" --apply\n\
```\n\n",
);
}
out.push_str(
"Add `--apply` to all write commands. Without it, patchloom previews changes without writing.\n\n",
);
out.push_str(
"## Project configuration\n\n\
Create `.patchloom.toml` in the project root to set defaults for all commands:\n\n\
```toml\n\
[write_policy]\n\
ensure_final_newline = true\n\
normalize_eol = \"lf\"\n\
trim_trailing_whitespace = true\n\
collapse_blanks = true\n\
\n\
[tx]\n\
strict = false\n\
\n\
[exclude]\n\
globs = [\"target/**\", \"node_modules/**\"]\n\
```\n\n\
CLI flags override config values. The file is searched upward from the working directory.\n\n",
);
}
// Workflow examples (CLI only)
if show_cli {
out.push_str("## Workflow examples\n\n");
out.push_str(
"### Rename a function across a codebase\n\n\
```bash\n\
# Prefer AST-aware rename for code identifiers (skips strings/comments)\n\
patchloom ast rename src/ --old old_function_name --new new_function_name --apply\n\n\
# Fallback: text-based workflow when AST mode is unavailable\n\
patchloom search --count \"old_function_name\" src/\n\
patchloom replace \"old_function_name\" --new \"new_function_name\" src/ --apply\n\
```\n\n\
Default: `replace --fuzzy` with a misspelled `old` that is **not** in the file \
refuses the write (#1758). Opt in only with `--allow-absent-old` for deliberate \
approximate recovery, then check `matched_text` (#1736).\n\n",
);
out.push_str(
"### Delete lines matching a pattern\n\n\
```bash\n\
# Delete entire lines containing a pattern; collapse consecutive blanks\n\
patchloom replace 'dbg!' --whole-line --new '' src/ --collapse-blanks --apply\n\n\
# Restrict to a line range (e.g. implementation only, skip tests)\n\
patchloom replace 'TODO' --whole-line --range 10:200 --new '' notes.md --apply\n\n\
# Rewrite shell invocable tokens only (not uv pip / pipenv):\n\
patchloom replace pip --new uv install.sh --command-position --require-change --apply\n\
patchloom replace 'fn proccess_data' --new 'fn process_data' src/ --fuzzy --allow-absent-old --apply\n\
patchloom replace 'fn proccess_data' --new 'fn process_data' src/ --fuzzy --allow-absent-old --min-fuzzy-score 0.80 --apply\n\
```\n\n",
);
out.push_str(
"### Edit a CI workflow\n\n\
```bash\n\
# Set a value in a YAML workflow by selector path (preserves comments and formatting)\n\
patchloom doc set .github/workflows/ci.yml jobs.test.timeout-minutes 30 --apply\n\
```\n\n",
);
out.push_str(
"### Stale patch recovery via three-way merge\n\n\
```bash\n\
# Apply a patch using three-way merge when context has drifted\n\
patchloom patch apply changes.patch --on-stale merge --apply\n\
\n\
# Or use the merge subcommand directly\n\
patchloom patch merge changes.patch --apply\n\
\n\
# If merge produces conflicts, allow them to be written as markers\n\
# WARNING: never commit files containing conflict markers\n\
patchloom patch merge changes.patch --apply --allow-conflicts\n\
\n\
# Diff from stdin (--stdin or a bare '-' path both work)\n\
cat changes.patch | patchloom patch apply --stdin --apply\n\
cat changes.patch | patchloom patch apply - --apply\n\
```\n\n",
);
if show_linux {
out.push_str(
"```bash\n\
# Same via transaction plan (JSON):\n\
patchloom tx - --apply <<'EOF'\n\
{\"version\": 1, \"operations\": [\n\
{\"op\": \"patch.apply\", \"diff\": \"...\", \"on_stale\": \"merge\", \"allow_conflicts\": true}\n\
]}\n\
EOF\n\
```\n\n",
);
}
if show_linux {
out.push_str(
"### Bump a version across config files\n\n\
```bash\n\
patchloom batch --apply <<'EOF'\n\
doc.set package.json version \"2.0.0\"\n\
doc.set Cargo.toml package.version \"2.0.0\"\n\
replace README.md \"1.0.0\" \"2.0.0\"\n\
md.upsert_bullet CHANGELOG.md \"## [2.0.0]\" \"- Initial 2.0 release\"\n\
EOF\n\
```\n\n",
);
out.push_str("### Multi-file refactoring with a transaction\n\n\
```bash\n\
patchloom tx - --apply <<'EOF'\n\
{\"version\": 1, \"operations\": [\n\
{\"op\": \"replace\", \"path\": \"src/config.rs\", \"old\": \"old_default\", \"new\": \"new_default\"},\n\
{\"op\": \"doc.set\", \"path\": \"config.toml\", \"selector\": \"default_value\", \"value\": \"new_default\"},\n\
{\"op\": \"md.replace_section\", \"path\": \"docs/config.md\", \"heading\": \"## Defaults\",\n\
\"content\": \"The default value is now `new_default`.\\n\"}\n\
]}\n\
EOF\n\
```\n\n\
All operations succeed atomically or roll back together.\n\n\
Plan/MCP `replace` accepts library flags (default false):\n\
- `require_change`: fail when the pattern matches zero times (agent fail-closed).\n\
- `command_position`: rewrite only shell invocable tokens (`sudo`/`timeout`/`flock`/`runuser`/`setsid`/`run0`/`gosu`/`su-exec`/`tini`/`dumb-init`/`unshare`/`nsenter`/`taskset`/`systemd-run`/`firejail`/`busybox`/`chpst`/`softlimit`/`envdir`/`setlock` wrappers yes; `uv pip` no).\n\
- `fuzzy`: similarity fallback when exact match fails (also with before_context/after_context).\n\
Example: `{\"op\":\"replace\",\"path\":\"install.sh\",\"old\":\"pip\",\"new\":\"uv\",\
\"command_position\":true,\"require_change\":true}`\n\
Successful plan/tx and `batch_replace` JSON includes `match_mode` (`exact`/`fuzzy`/`anchored`), optional `matched_text` for fuzzy/anchored spans, and `match_count` on replace-backed changes plus worst-case aggregate mode and sum of counts when any replace matched (#1674, #1736). Partial soft-refuses list `refused[]` with reason (`exact_old_absent` / `below_min_fuzzy_score` / `no_matches`); do not treat ok/success alone as full multi-path coverage. Tx search with `max_results` may set `truncated: true` on search results when the matches array was capped.\n\n");
}
}
// AST-aware operations (always shown when AST feature is enabled)
#[cfg(feature = "ast")]
if show_cli {
out.push_str(
"## AST-aware operations\n\n\
Tree-sitter-backed operations that understand code structure (20 languages).\n\n\
```bash\n\
# Rename an identifier across a file, skipping strings and comments\n\
# (path first, then --old / --new)\n\
patchloom ast rename src/lib.rs --old OldName --new NewName --apply\n\n\
# Replace text only within a specific function body\n\
# (PATH then SYMBOL are positionals — no --symbol flag; #1841)\n\
patchloom ast replace src/config.rs default_timeout --old 30 --new 60 --apply\n\n\
# List all symbol definitions\n\
patchloom ast list src/lib.rs\n\n\
# Find all references to a symbol\n\
# (SYMBOL then PATH — order differs from replace/read; #1841)\n\
patchloom ast refs my_function src/\n\
# impact is also SYMBOL then PATH: ast impact my_function src/\n\
```\n\n\
**AST CLI arg order:** `rename`/`replace`/`read`/`validate` use path-first; \
`refs`/`impact` use symbol-first. There is no `--symbol` or `--name` flag on these commands.\n\n\
AST rename, replace, and rewrite_signature can also be used in batch and tx plans:\n\n\
```bash\n\
# In a batch file (path, then old, then new — same names as CLI flags):\n\
ast.rename src/lib.rs OldStruct NewStruct\n\
ast.replace src/config.rs default_timeout \"30\" \"60\"\n\
# path old parameters [return_type]:\n\
ast.rewrite_signature src/lib.rs process \"(x: u64)\" \"-> u64\"\n\
```\n\n",
);
}
// Selector path syntax (always shown — used by all doc.* operations)
out.push_str(
"## Selector path syntax\n\n\
All `doc` operations use selector paths to address values inside JSON, YAML, and TOML files.\n\n\
| Syntax | Meaning | Example |\n\
|--------|---------|---------|\n\
| `name` | Object key | `database.host` |\n\
| `[N]` | Array index (zero-based) | `servers[0].port` |\n\
| `[*]` | Wildcard (all array elements) | `jobs[*].timeout` |\n\
| `[key=val]` | Predicate (filter by field value) | `deps[name=express].version` |\n\n\
Segments are separated by `.` or adjacent brackets. A single leading `/` is treated as \
\"from root\" and stripped (JSON Pointer habit), so `/feature_flag` sets key `feature_flag`, not a key literally named `/feature_flag` (#1794). Only one leading slash is special; `//a` keeps a key named `/a`. Prefer bare keys in prompts (`feature_flag`, `server.port`). Examples:\n\n\
```text\n\
scripts.test # simple selector path\n\
/feature_flag # same as feature_flag (leading slash stripped)\n\
jobs[0].steps[*].name # index + wildcard\n\
dependencies[name=react].version # predicate filter\n\
```\n\n\
**Write ops and predicates:** `doc set` / `doc ensure` / `doc delete` / `doc move` are \
**single-path only** (keys and indexes such as `items.0.val`). Wildcards and predicates \
(`items[id=b].val`, `items[*].enabled`) belong on `doc update` (multi-match write) or \
`doc delete-where` (array filter). If you pass a predicate to `doc set`, the error points \
you at `doc update` or an index path.\n\n\
**Multi-document YAML:** Files with multiple `---` documents parse as a **top-level array** \
(one element per document). Address a field with a document index first, e.g. `0.metadata.name` \
or `[0].metadata.name`, not a bare key at the stream root. A bare root key on multi-doc \
(or any top-level array) fails with `error_kind: type_error` and an index-form hint on \
`doc get`/`select`/`has` and `doc set` (not soft `no_matches` / not soft `has: false`). \
`doc keys .` on multi-doc root is also `type_error` (list keys on `0` / `[0]` first). \
`doc merge` of any overlay into multi-doc **root** is `type_error` (object *or* array \
overlay would replace the whole stream). Merge into one document with \
`--selector 0` / plan `\"selector\": \"0\"` (or `doc.set` under `0.`). Bare-key `doc append`/`prepend`/`delete`/`update`/`move`/`ensure` on multi-doc \
root are also `type_error` with `0.key` / `[0].key` hints (not soft no-match or \
`invalid_input`). Address fields under `0.` / `[0].` instead. Writes keep the multi-doc \
form (still `---` separators, not a single YAML sequence).\n\n\
**Markdown section bounds:** `md_replace_section` / `md_insert_after_section` / section moves \
end at the next heading of the **same or higher** level. Nested lower-level headings belong to \
the parent (replacing `# Intro` also rewrites following `##` children until the next `#`). Prefer \
peer-level headings when siblings must survive. `md_dedupe_headings` removes later **whole \
sections** with a duplicate level+text heading (body under the second heading is discarded, not \
merged).\n\
**Markdown insert placement:** `md_insert_after_heading` inserts **under the heading line** \
(before existing body). To add a sibling `##` section after the full section body, use \
`md_insert_after_section`.\n\n",
);
// Exit codes (CLI only — MCP tools return results as JSON, not exit codes)
if show_cli {
out.push_str(
"## Exit codes\n\n\
| Code | Meaning |\n\
|------|---------|\n\
| 0 | Success (operation completed, or no changes needed) |\n\
| 1 | Failure (error during execution, CLI usage/invalid args/unknown flags/subcommands), or tx `rollback_failed` when mid-commit rollback could not fully restore files |\n\
| 2 | Changes detected (`--check` / write preview including `undo` without `--apply`, or CLI/MCP/plan/tx `search` `assert_count` mismatch; `error_kind: changes_detected` for assert_count). For undo, re-run with `--apply` to restore. |\n\
| 3 | No matches (search/replace pattern miss, undo with no sessions, missing AST symbol for extract/insert/reorder/wrap/move, or tx/plan AST/md/doc target not found; `error_kind: no_matches`) |\n\
| 4 | Parse error (malformed input file or plan, invalid AST search pattern/query, or AST validate parse failure; `error_kind: parse_error`) |\n\
| 5 | Ambiguous (CLI/tx `unique` multi-match, or stale/missing patch context; `error_kind: ambiguous` in CLI/tx JSON) |\n\
| 6 | Validation failed (tx plan validation step returned non-zero; writes may remain when not strict) |\n\
| 7 | Rollback (tx mid-commit failure or strict lifecycle failure; changes were rolled back) |\n\
| 8 | Patch merge conflicts (CLI `patch merge` or plan/tx `patch.apply` with `on_stale: merge` without `allow_conflicts`; `error_kind: conflicts`) |\n\
| 9 | Tx operation staging failure (`operation_failed`) |\n\n\
**JSON `error_kind` (exit 1):** Prefer branching on kind, not English text. File ops set \
`already_exists` (create/rename without force, create race), `not_found` (delete/append/prepend/rename missing source, \
missing `--files-from`/batch input, missing plan file, missing git blob for AST, \
or `read` when every path fails), `invalid_input` (bad flags, non-file target, malformed `read --lines` \
(0-based or end before start; past-EOF range is `no_matches` exit 3), replace `--nth` past the last \
match with live match count in the message, \
`status` outside a git repo, AST map non-dir, doc merge flag conflicts, invalid `normalize_eol`, \
md table-append row/table failures, bad selector/for_each templates, MCP bind/TLS config, \
CLI usage errors under `--json`/`--jsonl`, `--contain` path rejections / empty paths, \
all-explicit-paths-missing for search/replace/tidy, \
invalid search/replace regex patterns (unclosed groups, etc.), \
and plan op option conflicts such as replace whole_line+multiline, tidy dedent+indent, \
md.move_section before/after, search invert_match+multiline), \
`format_failed` (post-write `--format` command non-zero exit or format-timeout; write may already be on disk; JSON includes `backup_session` when a session was created so agents can `patchloom undo --session <id>`, plus `applied: true` (canonical; #1831), `write_applied: true` (deprecated alias), `files_changed`, and `files[].path` for every path already written so agents need not re-scan disk (#1795); re-run the formatter or undo). Empty `--files-from` is `invalid_input` (not `no_matches`; #1796). Doc type mismatches set \
`type_error` (`doc keys`/`len` on wrong type, library doc mutation type errors). \
Clap usage failures with `--json`/`--jsonl` emit the same envelope on stdout before any subcommand runs.\n\n\
**JSON `error_kind` (exit 4):** Batch line parse failures, `explain` plan parse failures, \
invalid AST search patterns/queries, AST validate parse failures, and \
malformed JSON/YAML/TOML document content set `parse_error` so agents can distinguish syntax \
mistakes from runtime failures.\n\n\
**Doc write JSON tip:** With `--json` (CLI) or MCP write tools / `execute_plan`, \
doc delete success includes `changed` (bool) and `removed` (usize). Multi-op \
plans also list per-op rows under `mutations`. Exit 0 / `ok: true` with \
`removed: 0` means idempotent cleanup (nothing matched); do not assume data \
was deleted from exit status alone.\n\n",
);
}
// Operations catalogue generated from schema::OPERATION_REGISTRY so it
// cannot drift from `patchloom schema` / tier export (#1374).
out.push_str(&crate::schema::agent_operations_catalogue());
out.push('\n');
// Troubleshooting (always shown)
out.push_str(
"## Troubleshooting\n\n\
If a command produces unexpected results, enable verbose logging to see \
what patchloom is doing internally:\n\n\
```bash\n\
patchloom --verbose <command> [args]\n\
# or via environment variable:\n\
PATCHLOOM_LOG=1 patchloom <command> [args]\n\
```\n\n\
Diagnostic messages are printed to stderr prefixed with `[patchloom]`.\n",
);
out
}
pub fn run(args: AgentRulesArgs, global: &crate::cli::global::GlobalFlags) -> anyhow::Result<u8> {
let output = generate_agent_rules(&args);
if !global.emit_json(&serde_json::json!({
"ok": true,
"format": "markdown",
"content": output,
}))? {
print!("{output}");
}
Ok(exit::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(mode: AgentMode, platform: AgentPlatform) -> AgentRulesArgs {
AgentRulesArgs { mode, platform }
}
#[test]
fn default_includes_all_sections() {
let out = generate_agent_rules(&args(AgentMode::All, AgentPlatform::All));
assert!(out.contains("# Patchloom"));
assert!(out.contains("## MCP mode"));
assert!(out.contains("## Tool selection guide"));
assert!(out.contains("## Batching"));
assert!(out.contains("## Structured edits"));
assert!(out.contains("## Exit codes"));
assert!(out.contains("<<'EOF'"));
assert!(out.contains("batch ops.txt"));
assert!(
out.contains("resolve under `--cwd`"),
"must document that batch/tx/explain/patch/--files-from meta paths honor --cwd"
);
assert!(
out.contains("meta-input") || out.contains("meta-input files"),
"must document that --contain applies to meta-input files"
);
assert!(
out.contains("omits `.patchloom/` backups") || out.contains("omits `.patchloom/"),
"git_status tool guide must note backup omission: {out}"
);
assert!(
out.contains("multi-line content") || out.contains(r#"\\\""#),
"batch section must document quote escapes / multi-line guidance"
);
}
#[test]
fn mode_cli_omits_mcp_keeps_cli() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(!out.contains("## MCP mode"));
assert!(!out.contains("## Tool selection guide"));
assert!(out.contains("## Batching"));
assert!(out.contains("## Structured edits"));
// CLI-only mode keeps the "native tools are faster" note
assert!(out.contains("native agent tools are faster"));
}
#[test]
fn mcp_mode_documents_plan_cwd_nested_re_root() {
let out = generate_agent_rules(&args(AgentMode::Mcp, AgentPlatform::All));
assert!(
out.contains("\"cwd\": \"fixtures/svc\""),
"MCP rules must show nested plan.cwd example: {out}"
);
assert!(
out.contains("Do not combine `cwd` with `for_each`")
|| out.contains("Do not combine cwd with for_each"),
"MCP rules must forbid cwd+for_each: {out}"
);
assert!(
out.contains("relative") && out.contains("cwd"),
"MCP rules must say cwd is relative under workspace"
);
assert!(
out.contains("search_files") && out.contains("paths") && out.contains("path"),
"MCP rules must document search_files path alias for paths"
);
}
#[test]
fn mode_all_documents_cli_contain_flag() {
let out = generate_agent_rules(&args(AgentMode::All, AgentPlatform::All));
assert!(
out.contains("--contain"),
"combined MCP+CLI rules must document --contain for CLI sandboxes"
);
assert!(
!out.contains("the CLI does not"),
"must not claim CLI has no containment after --contain shipped"
);
assert!(
out.contains("absolute paths under `--cwd` are allowed"),
"must document AllowIfContained: absolute under workspace OK on CLI"
);
assert!(
out.contains("Host sandbox contract") || out.contains("#1832"),
"must document host must pin cwd and strip model --cwd (#1832)"
);
}
#[test]
fn mode_cli_documents_contain_for_agent_sandboxes() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(
out.contains("--contain"),
"CLI-only rules must mention --contain for agent sandboxes"
);
assert!(
out.contains("#1832") || out.contains("model-supplied") || out.contains("effective"),
"CLI rules must warn hosts not to forward agent --cwd under --contain (#1832)"
);
}
#[test]
fn mode_mcp_omits_cli_keeps_mcp() {
let out = generate_agent_rules(&args(AgentMode::Mcp, AgentPlatform::All));
assert!(out.contains("## MCP mode"));
assert!(out.contains("## Tool selection guide"));
// MCP-only mode must not mention batch/transaction tools or CLI
assert!(!out.contains("batch({\"operations\":"));
assert!(!out.contains("transaction({\"operations\":"));
assert!(!out.contains("search_replace"));
assert!(!out.contains("run_terminal_command"));
assert!(!out.contains("command line"));
assert!(out.contains("Use them for ALL file operations"));
assert!(
out.contains("confined to the server workspace"),
"MCP-only rules must state path containment"
);
// CLI-only sections must be absent (check for h2 headings, not h3)
assert!(!out.contains("\n## Batching"));
assert!(!out.contains("\n## Structured edits"));
// Must not tell agent to prefer native tools
assert!(!out.contains("native agent tools are faster"));
}
#[test]
fn platform_linux_omits_windows() {
let out = generate_agent_rules(&args(AgentMode::All, AgentPlatform::Linux));
assert!(out.contains("<<'EOF'"));
assert!(!out.contains("batch ops.txt"));
// Single-quote syntax present in CLI section
assert!(out.contains("'\"2.0.0\"'"));
// Windows-only double-quote escaping in CLI section absent
// (MCP section has its own escaped quotes but that's platform-independent)
assert!(!out.contains("patchloom doc set config.json version \"\\\"2.0.0\\\"\""));
}
#[test]
fn platform_windows_omits_heredoc() {
let out = generate_agent_rules(&args(AgentMode::All, AgentPlatform::Windows));
assert!(!out.contains("<<'EOF'"));
assert!(out.contains("batch ops.txt"));
// Windows escaping present in CLI section
assert!(out.contains("patchloom doc set config.json version \"\\\"2.0.0\\\"\""));
// Linux single-quote syntax absent
assert!(!out.contains("'\"2.0.0\"'"));
}
#[test]
fn exit_codes_present_for_cli_modes() {
// Exit codes are CLI-only (MCP returns JSON results, not exit codes)
for mode in [AgentMode::All, AgentMode::Cli] {
for platform in [
AgentPlatform::All,
AgentPlatform::Linux,
AgentPlatform::Windows,
] {
let out = generate_agent_rules(&args(mode, platform));
assert!(
out.contains("## Exit codes"),
"exit codes missing for mode={mode:?} platform={platform:?}"
);
}
}
// MCP-only mode must NOT have exit codes
let out = generate_agent_rules(&args(AgentMode::Mcp, AgentPlatform::All));
assert!(!out.contains("## Exit codes"));
}
#[test]
fn workflow_includes_whole_line_delete_example() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(out.contains("--whole-line"));
assert!(out.contains("--collapse-blanks"));
}
#[test]
fn workflow_documents_multi_document_yaml_index() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(
out.contains("Multi-document YAML")
&& out.contains("0.metadata.name")
&& out.contains("top-level array")
&& out.contains("type_error")
&& out.contains("doc get")
&& out.contains("has")
&& out.contains("doc keys")
&& out.contains("doc merge")
&& out.contains("doc append")
&& out.contains("move")
&& out.contains("ensure"),
"agents need multi-doc index guidance + bare-key type_error on get/has/set/keys/merge/append/move/ensure"
);
}
#[test]
fn workflow_documents_markdown_section_bounds_and_dedupe() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(
out.contains("Markdown section bounds")
&& out.contains("same or higher")
&& out.contains("md_dedupe_headings")
&& out.contains("whole sections"),
"agents need hierarchical section + dedupe body-loss guidance"
);
}
#[test]
fn workflow_documents_hardlink_preserving_apply() {
let out = generate_agent_rules(&args(AgentMode::All, AgentPlatform::All));
assert!(
out.contains("hard links") && out.contains("nlink") && out.contains("temp+rename"),
"agents need hardlink-preserving Apply guidance (#1733)"
);
}
#[test]
fn workflow_documents_undo_dry_run_and_apply() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(
out.contains("undo --apply") && out.contains("dry-run by default"),
"CLI agent-rules must state undo is dry-run unless --apply"
);
// Exit table should point agents at --apply for undo preview exit 2.
assert!(
out.contains("For undo, re-run with `--apply`"),
"exit code 2 row must mention undo --apply"
);
assert!(
out.contains("applied: false")
&& out.contains("status: changes_detected")
&& out.contains("applied: true")
&& out.contains("status: restored"),
"undo JSON must document applied (#1830): missing applied/status guidance"
);
assert!(
out.contains("--new") && out.contains("#1829"),
"replace mode error guidance must name --new (#1829)"
);
}
#[test]
fn workflow_includes_plan_require_change_and_command_position() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(
out.contains("require_change")
&& out.contains("command_position")
&& out.contains("fuzzy"),
"plan replace library flags must be documented for agents"
);
assert!(
out.contains("--command-position")
&& out.contains("--require-change")
&& out.contains("--fuzzy"),
"CLI flags must appear in agent-rules examples"
);
assert!(
out.contains("flock")
&& out.contains("runuser")
&& out.contains("setsid")
&& out.contains("run0")
&& out.contains("gosu")
&& out.contains("unshare")
&& out.contains("nsenter")
&& out.contains("taskset")
&& out.contains("systemd-run")
&& out.contains("firejail")
&& out.contains("busybox")
&& out.contains("chpst")
&& out.contains("softlimit")
&& out.contains("envdir")
&& out.contains("setlock"),
"agent-rules should name isolation/container/runit wrappers for command_position"
);
let mcp = generate_agent_rules(&args(AgentMode::Mcp, AgentPlatform::All));
assert!(
mcp.contains("require_change")
&& mcp.contains("command_position")
&& mcp.contains("fuzzy")
&& mcp.contains("restore_path_from_session")
&& mcp.contains("run_post_write_validation")
&& mcp.contains("match_mode")
&& mcp.contains("matched_text")
&& mcp.contains("allow_absent_old")
&& mcp.contains("fail closed")
&& mcp.contains("refused[]")
&& mcp.contains("below_min_fuzzy_score")
&& mcp.contains("no_matches")
&& mcp.contains("truncated"),
"MCP-only agent-rules must document replace_text flags, fuzzy fail-closed, refused[], and search truncated"
);
}
#[test]
fn workflow_documents_fuzzy_near_collision_negative_example() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(
out.contains("allow-absent-old") && out.contains("refuses the write"),
"CLI rename workflow must document fuzzy fail-closed when exact old absent (#1758)"
);
}
#[test]
fn exit_codes_include_doc_write_json_tip() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(
out.contains("Doc write JSON tip"),
"agents need changed/removed guidance for idempotent doc delete"
);
assert!(out.contains("removed: 0"));
assert!(
out.contains("already_exists")
&& out.contains("not_found")
&& out.contains("type_error")
&& out.contains("parse_error")
&& out.contains("invalid search/replace regex")
&& out.contains("format_failed"),
"error_kind catalogue must document file-op, doc, batch, invalid regex, and format_failed"
);
}
#[test]
fn agent_rules_includes_project_config_section() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(out.contains("## Project configuration"));
assert!(out.contains(".patchloom.toml"));
assert!(out.contains("collapse_blanks"));
assert!(out.contains("[tx]"));
}
/// Canonical names table must stay present so agents do not invent alternates.
/// Full AST CLI examples live under `#[cfg(feature = "ast")]`; the names table
/// always documents PATH/SYMBOL order so this still holds for test-mcp-no-ast.
#[test]
fn agent_rules_ast_cli_examples_use_real_clap_shapes() {
// #1841: no --symbol / --name; correct positional order.
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(
out.contains("ast replace PATH SYMBOL")
|| out.contains("ast replace src/config.rs default_timeout"),
"replace must use PATH SYMBOL positionals"
);
assert!(
!out.contains("ast replace src/config.rs --symbol")
&& !out.contains("ast replace PATH --symbol"),
"must not document nonexistent --symbol"
);
assert!(
out.contains("ast refs my_function src/") || out.contains("ast refs SYMBOL PATH"),
"refs must document SYMBOL PATH order"
);
assert!(
!out.contains("ast refs src/ --name") && !out.contains("ast refs PATH --name"),
"must not document nonexistent --name on refs"
);
#[cfg(feature = "ast")]
{
assert!(
out.contains("ast replace src/config.rs default_timeout --old 30 --new 60"),
"AST section example must use PATH SYMBOL positionals"
);
assert!(
out.contains("ast refs my_function src/"),
"AST section example must use SYMBOL PATH for refs"
);
}
}
#[test]
fn agent_rules_documents_for_each_plan_json_shape() {
// #1842: complete plan-level for_each example.
let out = generate_agent_rules(&args(AgentMode::All, AgentPlatform::All));
assert!(
out.contains("\"for_each\"") && out.contains("\"glob\""),
"must show plan-level for_each with glob"
);
assert!(
out.contains("plan-level field") || out.contains("#1842"),
"must say for_each is plan-level not an op"
);
}
#[test]
fn agent_rules_documents_doc_query_envelope_and_has_exit() {
// #1838 / #1843 lock strings for CLI agent hosts.
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(
out.contains("Doc query") && out.contains("\"ok\":true") && out.contains("value"),
"must document doc query JSON success envelope"
);
assert!(
out.contains("doc has") && out.contains("#1843"),
"must document doc has exit 0 for missing key"
);
}
#[test]
fn agent_rules_documents_tidy_fix_defaults() {
// #1840 / #1847
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(
out.contains("tidy.fix") && out.contains("#1840") && out.contains("#1847"),
"must document tidy.fix defaults and commit precedence"
);
assert!(
out.contains("{\"op\":\"tidy.fix\"") || out.contains(r#"{"op":"tidy.fix""#),
"must include bare tidy.fix plan example"
);
}
#[test]
fn agent_rules_includes_canonical_parameter_names() {
let out = generate_agent_rules(&args(AgentMode::All, AgentPlatform::All));
assert!(
out.contains("## Canonical parameter names"),
"missing Canonical parameter names section"
);
assert!(out.contains("| `old` |"), "must document canonical old");
assert!(
out.contains("positional `OLD`") && out.contains("not `--old`"),
"replace CLI must document positional OLD, not --old (#1834)"
);
assert!(
!out.contains("| `old` | CLI: `--old`."),
"must not claim replace uses CLI --old (#1834)"
);
assert!(out.contains("| `new` |"), "must document canonical new");
assert!(
out.contains("| `selector` |"),
"must document canonical selector"
);
assert!(
out.contains("`weak` / `medium` / `strong`"),
"must document schema tier names"
);
assert!(
out.contains("#1832") && (out.contains("model-supplied") || out.contains("strip")),
"must document host sandbox contract for --contain/#1832"
);
// Present in both CLI and MCP-only modes (not CLI-only prose).
let mcp_only = generate_agent_rules(&args(AgentMode::Mcp, AgentPlatform::All));
assert!(
mcp_only.contains("## Canonical parameter names"),
"MCP-only agent-rules must still include canonical names"
);
}
#[test]
fn agent_rules_includes_patch_merge_workflow() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(out.contains("--on-stale merge"));
assert!(out.contains("--allow-conflicts"));
assert!(out.contains("never commit files containing conflict markers"));
}
#[test]
fn agent_rules_exit_codes_include_conflicts_and_rollback_failed() {
let out = generate_agent_rules(&args(AgentMode::Cli, AgentPlatform::All));
assert!(out.contains("| 8 |"));
assert!(out.contains("| 9 |"));
assert!(out.contains("rollback_failed"));
assert!(out.contains("operation_failed"));
}
#[test]
fn agent_rules_documents_library_type_error_and_binary_preflight() {
// Library embedder bullets live in the MCP/All agent-rules surface.
let out = generate_agent_rules(&args(AgentMode::All, AgentPlatform::All));
assert!(
out.contains("EditErrorKind::TypeError"),
"library hosts need TypeError peel docs (#1883)"
);
assert!(
out.contains("is_binary_file"),
"library hosts need is_binary_file preflight (#1884)"
);
assert!(
out.contains("load_text_strict"),
"library hosts need text I/O honesty load_text_strict (#1894)"
);
assert!(
out.contains("api::load_text"),
"library hosts need api::load_text alias (#1910)"
);
assert!(
out.contains("api::doc_merge") && out.contains("Some(\"0\")"),
"library hosts need multi-doc doc_merge selector (#1909)"
);
assert!(
out.contains("non_exhaustive"),
"library hosts need EditErrorKind non_exhaustive note (#1910)"
);
// Line-oriented insert is CLI-facing as well (mode All includes CLI).
assert!(
out.contains("Insert line placement"),
"agents need line-oriented insert default (#1885)"
);
}
#[test]
fn json_mode_emits_wrapped_json() {
let args = AgentRulesArgs {
mode: AgentMode::All,
platform: AgentPlatform::All,
};
let _global = crate::cli::global::GlobalFlags {
json: true,
..crate::cli::global::GlobalFlags::default()
};
// Verify that the JSON output would contain the right structure
let output = generate_agent_rules(&args);
let json = serde_json::json!({
"ok": true,
"format": "markdown",
"content": output,
});
let parsed: serde_json::Value = json;
assert_eq!(parsed["ok"], true);
assert_eq!(parsed["format"], "markdown");
assert!(parsed["content"].as_str().unwrap().contains("# Patchloom"));
}
#[test]
fn version_is_embedded() {
let out = generate_agent_rules(&args(AgentMode::All, AgentPlatform::All));
let version = env!("CARGO_PKG_VERSION");
assert!(out.contains(&format!("patchloom v{version}")));
}
#[test]
fn mcp_and_windows_compose_to_minimal() {
let out = generate_agent_rules(&args(AgentMode::Mcp, AgentPlatform::Windows));
assert!(out.contains("## MCP mode"));
// MCP mode must not contain batch/transaction or CLI content
assert!(!out.contains("batch({\"operations\":"));
assert!(!out.contains("transaction({\"operations\":"));
assert!(!out.contains("\n## Batching"));
assert!(!out.contains("batch ops.txt"));
assert!(!out.contains("<<'EOF'"));
}
}