patchloom 0.6.0

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations via tree-sitter, multi-file batching, markdown operations, and MCP server
Documentation
use crate::backup::BackupSession;
use crate::cli::global::GlobalFlags;
use crate::cmd::write_dispatch::{WriteMessages, WritePhase, execute_write};
use crate::diff::{DiffResult, format_diff_result_colored, unified_diff};
use crate::write::{atomic_create_new, atomic_write, policy_from_flags};
use anyhow::bail;
use clap::Args;
use serde::Serialize;
use std::fs;

#[derive(Debug, Args)]
#[command(after_help = "\
EXAMPLES:
  patchloom create src/config.json --content '{\"version\": 1}' --apply
  echo 'hello' | patchloom create greeting.txt --stdin --apply")]
pub struct CreateArgs {
    /// Path of the file to create.
    pub file: String,
    /// Content to write (alternative to --stdin).
    #[arg(long)]
    pub content: Option<String>,
    // ref:create-mode:stdin
    /// Read content from stdin.
    #[arg(long)]
    pub stdin: bool,
    // ref:create-mode:force
    /// Overwrite if file already exists.
    #[arg(long)]
    pub force: bool,
    #[command(flatten)]
    pub write: crate::cli::global::WriteFlags,
}

#[derive(Debug, Serialize)]
struct CreateOutput {
    ok: bool,
    path: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    diff: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    applied: Option<bool>,
}

fn create_with_backup(
    path: &std::path::Path,
    content: &str,
    force: bool,
    cwd: &std::path::Path,
    policy: &crate::write::WritePolicy,
) -> anyhow::Result<()> {
    let mut backup = BackupSession::new(cwd)?;
    backup.save_before_write(path)?;
    if force {
        atomic_write(path, content, policy)?;
    } else {
        atomic_create_new(path, content, policy)?;
    }
    backup.finalize()?;
    Ok(())
}

fn ensure_parent_dirs(path: &std::path::Path) -> anyhow::Result<()> {
    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
    {
        fs::create_dir_all(parent)?;
    }
    Ok(())
}

pub fn run(args: CreateArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
    let cwd = global.resolve_cwd()?;

    if args.content.is_some() && args.stdin {
        bail!("--content and --stdin cannot be combined");
    }

    // Resolve content from --content or --stdin.
    let content = if let Some(ref c) = args.content {
        c.clone()
    } else if args.stdin {
        std::io::read_to_string(std::io::stdin())?
    } else {
        bail!("either --content or --stdin must be provided");
    };

    let path = cwd.join(&args.file);

    if path.exists() && !path.is_file() {
        bail!("target is not a file: {}", args.file);
    }

    // For non-write modes, an early exists check is fine (no TOCTOU concern).
    // The --apply path below uses File::create_new for race-free creation.
    if !global.apply && !global.confirm && !args.force && path.exists() {
        bail!("file already exists: {}", args.file);
    }

    let diff_fn = |color: bool| {
        let diff = unified_diff(&args.file, "", &content);
        let diff_result = DiffResult { diffs: vec![diff] };
        format_diff_result_colored(&diff_result, color)
    };

    let check_msg = format!("would create {}", args.file);
    let apply_msg = format!("created {}", args.file);

    execute_write(
        global,
        &cwd,
        |phase, diff| CreateOutput {
            ok: true,
            path: args.file.clone(),
            diff,
            applied: match phase {
                WritePhase::Confirmed(a) => Some(a),
                _ => None,
            },
        },
        Some(&diff_fn),
        || {
            let policy = policy_from_flags(global, Some(&path));
            ensure_parent_dirs(&path)?;
            create_with_backup(&path, &content, args.force, &cwd, &policy)?;
            Ok(())
        },
        WriteMessages {
            check: &check_msg,
            apply: &apply_msg,
            post_confirm: None,
        },
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::exit;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn create_writes_file_with_correct_content() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("new.txt");

        let args = CreateArgs {
            file: file.to_string_lossy().into_owned(),
            content: Some("hello world\n".to_string()),
            stdin: false,
            force: false,
            write: Default::default(),
        };
        let mut global = GlobalFlags::test_default();
        global.apply = true;

        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::SUCCESS);

        let content = fs::read_to_string(&file).unwrap();
        assert_eq!(content, "hello world\n");
    }

    #[test]
    fn create_refuses_to_overwrite_existing_file_without_force() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("existing.txt");
        fs::write(&file, "original\n").unwrap();

        let args = CreateArgs {
            file: file.to_string_lossy().into_owned(),
            content: Some("new content\n".to_string()),
            stdin: false,
            force: false,
            write: Default::default(),
        };
        let mut global = GlobalFlags::test_default();
        global.apply = true;

        let err = run(args, &global).unwrap_err();
        assert!(err.to_string().contains("file already exists:"));

        // Original content should be unchanged.
        let content = fs::read_to_string(&file).unwrap();
        assert_eq!(content, "original\n");
    }

    #[test]
    fn create_with_force_overwrites_existing_file() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("existing.txt");
        fs::write(&file, "original\n").unwrap();

        let args = CreateArgs {
            file: file.to_string_lossy().into_owned(),
            content: Some("overwritten\n".to_string()),
            stdin: false,
            force: true,
            write: Default::default(),
        };
        let mut global = GlobalFlags::test_default();
        global.apply = true;

        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::SUCCESS);

        let content = fs::read_to_string(&file).unwrap();
        assert_eq!(content, "overwritten\n");
    }

    #[test]
    fn create_rejects_directory_target_even_with_force() {
        let dir = TempDir::new().unwrap();
        let target = dir.path().join("folder");
        fs::create_dir(&target).unwrap();

        let args = CreateArgs {
            file: target.to_string_lossy().into_owned(),
            content: Some("hello\n".to_string()),
            stdin: false,
            force: true,
            write: Default::default(),
        };

        let err = run(args, &GlobalFlags::test_default()).unwrap_err();
        assert!(err.to_string().contains("target is not a file"));
    }

    #[test]
    fn create_with_check_returns_exit_2() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("check.txt");

        let args = CreateArgs {
            file: file.to_string_lossy().into_owned(),
            content: Some("content\n".to_string()),
            stdin: false,
            force: false,
            write: Default::default(),
        };
        let mut global = GlobalFlags::test_default();
        global.check = true;

        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::CHANGES_DETECTED);

        // File should NOT have been created.
        assert!(!file.exists());
    }

    #[test]
    fn create_default_mode_shows_diff() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("diff_preview.txt");

        let args = CreateArgs {
            file: file.to_string_lossy().into_owned(),
            content: Some("hello world\n".to_string()),
            stdin: false,
            force: false,
            write: Default::default(),
        };
        // Default mode: no --apply, no --check.
        let global = GlobalFlags::test_default();

        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::SUCCESS);

        // File should NOT be created in default diff-preview mode.
        assert!(!file.exists());
    }

    #[test]
    fn create_with_no_content_source_returns_error() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("no_content.txt");

        let args = CreateArgs {
            file: file.to_string_lossy().into_owned(),
            content: None,
            stdin: false,
            force: false,
            write: Default::default(),
        };
        let global = GlobalFlags::test_default();

        let err = run(args, &global).unwrap_err();
        assert!(err.to_string().contains("--content or --stdin"));
    }

    #[test]
    fn create_apply_creates_backup_session() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("new.txt");

        let args = CreateArgs {
            file: file.to_string_lossy().into_owned(),
            content: Some("backup test\n".to_string()),
            stdin: false,
            force: false,
            write: Default::default(),
        };
        let mut global = GlobalFlags::test_with_cwd(dir.path());
        global.apply = true;

        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::SUCCESS);

        // A backup session should exist.
        let backup_dir = dir.path().join(".patchloom/backups");
        assert!(backup_dir.exists(), "backup directory should be created");

        let sessions: Vec<_> = fs::read_dir(&backup_dir)
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.path().is_dir())
            .collect();
        assert_eq!(sessions.len(), 1, "exactly one backup session expected");
    }

    #[test]
    fn create_force_apply_creates_backup_session() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("existing.txt");
        fs::write(&file, "original\n").unwrap();

        let args = CreateArgs {
            file: file.to_string_lossy().into_owned(),
            content: Some("overwritten\n".to_string()),
            stdin: false,
            force: true,
            write: Default::default(),
        };
        let mut global = GlobalFlags::test_with_cwd(dir.path());
        global.apply = true;

        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::SUCCESS);

        // Backup session should exist with the original content.
        let backup_dir = dir.path().join(".patchloom/backups");
        assert!(backup_dir.exists(), "backup directory should be created");

        let sessions: Vec<_> = fs::read_dir(&backup_dir)
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.path().is_dir())
            .collect();
        assert_eq!(sessions.len(), 1, "exactly one backup session expected");
    }

    #[test]
    fn create_rejects_content_and_stdin_together() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("dual_source.txt");

        let args = CreateArgs {
            file: file.to_string_lossy().into_owned(),
            content: Some("inline\n".to_string()),
            stdin: true,
            force: false,
            write: Default::default(),
        };
        let global = GlobalFlags::test_default();

        let err = run(args, &global).unwrap_err();
        assert!(
            err.to_string()
                .contains("--content and --stdin cannot be combined")
        );
    }
}