use clap::{Args, ValueEnum};
use crate::exit;
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum AgentMode {
All,
Cli,
Mcp,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum AgentPlatform {
All,
Linux,
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 {
#[arg(long, value_enum, default_value = "all")]
pub mode: AgentMode,
#[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();
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",
);
}
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, pass `--contain` (with `--cwd`) so operation targets \
and meta-input files cannot escape the workspace.\n\n",
);
}
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\
| Move a heading section (same file or cross-file) | `md_move_section` |\n\
| Remove duplicate headings | `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",
);
}
if show_cli {
out.push_str(
"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",
);
if !show_mcp {
out.push_str(
"For single-file read, search, create, delete, or rename, your native agent tools are faster.\n\n",
);
}
}
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",
);
}
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: `--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 | path first | `ast rename PATH --old X --new Y`; plan `ast.rename` uses `path`/`old`/`new`. |\n\
| Schema capability filter | `weak` / `medium` / `strong` | `schema --tier` only accepts these (not `small`/`large`). |\n\n\
Some plan/MCP fields still **accept** legacy aliases (`from`/`to` for replace, `key` for doc selector) \
so older agent prompts keep working, but examples and new plans must use the canonical names above.\n\n",
);
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\
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. Escapes in quotes: only `\\\"` and `\\\\` \
(literal `\\n` is not a newline; use `tx`/MCP JSON for multi-line content).\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. Escapes: only `\\\"` and `\\\\` \
(use `tx`/MCP for multi-line content).\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",
);
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",
);
}
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",
);
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\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");
}
}
#[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\
patchloom ast rename src/lib.rs --old OldName --new NewName --apply\n\n\
# Replace text only within a specific function body\n\
patchloom ast replace src/config.rs --symbol 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\
patchloom ast refs src/ --name my_function\n\
```\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",
);
}
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. Examples:\n\n\
```text\n\
scripts.test # simple selector path\n\
jobs[0].steps[*].name # index + wildcard\n\
dependencies[name=react].version # predicate filter\n\
```\n\n",
);
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), or tx `rollback_failed` when mid-commit rollback could not fully restore files |\n\
| 2 | Changes detected (`--check` mode found pending changes) |\n\
| 3 | No matches (search/replace pattern miss, or tx/plan AST/md/doc target not found; `error_kind: no_matches`) |\n\
| 4 | Parse error (malformed input file or plan) |\n\
| 5 | Ambiguous (replacement matched multiple locations without `--nth`, or stale/missing patch context) |\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 (`patch merge` or `--on-stale merge` without `--allow-conflicts`) |\n\
| 9 | Tx operation staging failure (`operation_failed`) |\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",
);
}
out.push_str(&crate::schema::agent_operations_catalogue());
out.push('\n');
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"));
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"
);
}
#[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"
);
}
#[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"));
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"
);
assert!(!out.contains("\n## Batching"));
assert!(!out.contains("\n## Structured edits"));
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"));
assert!(out.contains("'\"2.0.0\"'"));
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"));
assert!(out.contains("patchloom doc set config.json version \"\\\"2.0.0\\\"\""));
assert!(!out.contains("'\"2.0.0\"'"));
}
#[test]
fn exit_codes_present_for_cli_modes() {
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:?}"
);
}
}
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 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"));
}
#[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]"));
}
#[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("| `new` |"), "must document canonical new");
assert!(
out.contains("| `selector` |"),
"must document canonical selector"
);
assert!(
out.contains("`weak` / `medium` / `strong`"),
"must document schema tier names"
);
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 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()
};
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"));
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'"));
}
}