pub mod batch;
pub mod create;
pub mod delete;
pub mod doc;
pub mod explain;
pub mod init;
#[cfg(feature = "mcp")]
pub mod mcp;
pub mod md;
pub mod patch;
pub mod read;
pub mod rename;
pub mod replace;
pub mod search;
pub mod status;
pub mod tidy;
pub mod tx;
pub mod undo;
use crate::cli::Cli;
use clap::{Args, Subcommand, ValueEnum};
#[derive(Debug, Subcommand)]
pub enum Command {
Create(create::CreateArgs),
Delete(delete::DeleteArgs),
Search(search::SearchArgs),
Replace(replace::ReplaceArgs),
Status(status::StatusArgs),
Patch(patch::PatchArgs),
Read(read::ReadArgs),
Rename(rename::RenameArgs),
Md(md::MdArgs),
Doc(doc::DocArgs),
Tidy(tidy::TidyArgs),
Tx(tx::TxArgs),
Batch(batch::BatchArgs),
Explain(explain::ExplainArgs),
Undo(undo::UndoArgs),
AgentRules(AgentRulesArgs),
Init(init::InitArgs),
#[cfg(feature = "mcp")]
McpServer {
#[arg(long)]
allow_shell: bool,
#[arg(long)]
log: Option<String>,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}
#[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";
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.\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.\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.\n\n",
);
}
if show_mcp {
out.push_str(
"## Tool selection guide\n\n\
| Task pattern | Tool to use |\n\
|---|---|\n\
| Set/get a key in JSON, YAML, or TOML | `doc_set`, `doc_get`, `doc_query` |\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\
| Fix trailing whitespace or missing newlines | `fix_whitespace` (one file) or `batch_tidy` (multiple files) |\n\
| Create, rename, or delete a file | `create_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\n",
);
}
if show_cli && !show_mcp {
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\
For single-file read, search, create, delete, or rename, your native agent tools are faster.\n\n",
);
}
if show_cli && show_mcp {
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(
"## MCP mode\n\n\
**ALWAYS use MCP tools for ALL file edits.** Use `batch_replace` or `batch_tidy` \
when applying the same operation to multiple files; use individual tools otherwise.\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.\n\n\
**Note:** Values are parsed as JSON. A quoted `\"1.0\"` produces the JSON number `1.0`, not the string `\"1.0\"`. To set a string that looks numeric, omit the outer quotes: `doc.set config.json version 1.0`.\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.\n\n\
**Note:** Values are parsed as JSON. A quoted `\"1.0\"` produces the JSON number `1.0`, not the string `\"1.0\"`. To set a string that looks numeric, omit the outer quotes: `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",
);
out.push_str("## Structured edits\n\n");
if show_linux {
out.push_str(
"```bash\n\
# Edit a value in JSON/YAML/TOML by selector (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",
);
}
if show_cli {
out.push_str("## Workflow examples\n\n");
out.push_str(
"### Rename a function across a codebase\n\n\
```bash\n\
# Find all occurrences first\n\
patchloom search --count \"old_function_name\" src/\n\n\
# Replace in all matching files\n\
patchloom replace \"old_function_name\" --to \"new_function_name\" src/ --apply\n\
```\n\n",
);
out.push_str(
"### Edit a CI workflow\n\n\
```bash\n\
# Set a value in a YAML workflow by selector (preserves comments and formatting)\n\
patchloom doc set .github/workflows/ci.yml jobs.test.timeout-minutes 30 --apply\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\", \"from\": \"old_default\", \"to\": \"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");
}
}
out.push_str(
"## Selector syntax\n\n\
All `doc` operations use selectors to address values inside JSON, YAML, and TOML files.\n\n\
| Syntax | Meaning | Example |\n\
|--------|---------|---------|\n\
| `key` | 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 key 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) |\n\
| 2 | Changes detected (`--check` mode found pending changes) |\n\
| 3 | No matches (search/replace found nothing matching the pattern) |\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) |\n\
| 7 | Rollback (tx apply failed partway; changes were rolled back) |\n",
);
}
out
}
fn load_project_config(global: &mut crate::cli::global::GlobalFlags) {
let cwd = global.resolve_cwd().unwrap_or_default();
if let Some((config, _)) = crate::config::find_and_load(&cwd) {
crate::config::apply_config(global, &config);
}
}
pub fn dispatch(cli: Cli) -> anyhow::Result<u8> {
let mut global = cli.global;
match cli.command {
#[cfg(feature = "mcp")]
Command::McpServer { allow_shell, log } => {
load_project_config(&mut global);
mcp::run_mcp_server(&global, allow_shell, log)
}
Command::AgentRules(args) => {
let output = generate_agent_rules(&args);
print!("{output}");
Ok(crate::exit::SUCCESS)
}
Command::Init(args) => init::run(args, &global),
Command::Completions { shell } => {
let mut cmd = <Cli as clap::CommandFactory>::command();
clap_complete::generate(shell, &mut cmd, "patchloom", &mut std::io::stdout());
Ok(crate::exit::SUCCESS)
}
Command::Read(args) => {
load_project_config(&mut global);
read::run(args, &global)
}
Command::Explain(args) => {
load_project_config(&mut global);
explain::run(args, &global)
}
Command::Undo(args) => {
load_project_config(&mut global);
undo::run(args, &global)
}
Command::Search(args) => {
load_project_config(&mut global);
search::run(args, &global)
}
Command::Status(args) => {
load_project_config(&mut global);
status::run(args, &global)
}
Command::Create(args) => {
global.merge_write(&args.write);
load_project_config(&mut global);
create::run(args, &global)
}
Command::Delete(args) => {
global.merge_write(&args.write);
load_project_config(&mut global);
delete::run(args, &global)
}
Command::Rename(args) => {
global.merge_write(&args.write);
load_project_config(&mut global);
rename::run(args, &global)
}
Command::Replace(args) => {
global.merge_write(&args.write);
load_project_config(&mut global);
replace::run(args, &global)
}
Command::Patch(args) => {
global.merge_write(&args.write);
load_project_config(&mut global);
patch::run(args, &global)
}
Command::Md(args) => {
global.merge_write(&args.write);
load_project_config(&mut global);
md::run(args, &global)
}
Command::Doc(args) => {
global.merge_write(&args.write);
load_project_config(&mut global);
doc::run(args, &global)
}
Command::Tidy(args) => {
global.merge_write(&args.write);
load_project_config(&mut global);
tidy::run(args, &global)
}
Command::Tx(args) => {
global.merge_write(&args.write);
load_project_config(&mut global);
tx::run(args, &global)
}
Command::Batch(args) => {
global.merge_write(&args.write);
load_project_config(&mut global);
batch::run(args, &global)
}
}
}
#[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"));
}
#[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 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("\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 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'"));
}
}