use crate::backup::BackupSession;
use crate::cli::global::GlobalFlags;
use crate::diff::{DiffResult, format_diff_result_colored, unified_diff};
use crate::exit;
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 {
pub file: String,
#[arg(long)]
pub content: Option<String>,
#[arg(long)]
pub stdin: bool,
#[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 make_diff_output(path: &str, content: &str, color: bool) -> String {
let diff = unified_diff(path, "", content);
let total_files_changed = usize::from(diff.has_changes);
let diff_result = DiffResult {
diffs: vec![diff],
total_files_changed,
};
format_diff_result_colored(&diff_result, color)
}
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(())
}
#[cfg(feature = "mcp")]
pub(crate) fn apply_create(
path: &std::path::Path,
content: &str,
force: bool,
) -> anyhow::Result<()> {
if path.exists() && !path.is_file() {
bail!("target is not a file: {}", path.display());
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
let policy = crate::write::WritePolicy::default();
if force {
atomic_write(path, content, &policy)?;
} else {
atomic_create_new(path, content, &policy)?;
}
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");
}
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);
}
if !global.apply && !global.confirm && !args.force && path.exists() {
bail!("file already exists: {}", args.file);
}
if global.check {
let output = CreateOutput {
ok: true,
path: args.file.clone(),
diff: None,
applied: None,
};
if !global.emit_json(&output)? && !global.quiet {
println!("would create {}", args.file);
}
return Ok(exit::CHANGES_DETECTED);
}
if global.apply {
let policy = policy_from_flags(global, Some(&path));
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
create_with_backup(&path, &content, args.force, &cwd, &policy)?;
let diff_text = if global.diff {
Some(make_diff_output(&args.file, &content, false))
} else {
None
};
let output = CreateOutput {
ok: true,
path: args.file.clone(),
diff: diff_text,
applied: None,
};
if !global.emit_json(&output)? {
if global.diff {
print!(
"{}",
make_diff_output(&args.file, &content, global.should_color())
);
} else if !global.quiet {
println!("created {}", args.file);
}
}
return Ok(exit::SUCCESS);
}
let diff_text = make_diff_output(&args.file, &content, false);
if global.confirm && (global.json || global.jsonl) {
let applied = global.should_apply();
if applied {
let policy = policy_from_flags(global, Some(&path));
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
create_with_backup(&path, &content, args.force, &cwd, &policy)?;
}
let output = CreateOutput {
ok: true,
path: args.file.clone(),
diff: Some(diff_text),
applied: Some(applied),
};
global.emit_json(&output)?;
return Ok(exit::SUCCESS);
}
let output = CreateOutput {
ok: true,
path: args.file.clone(),
diff: Some(diff_text),
applied: None,
};
if !global.emit_json(&output)? {
print!(
"{}",
make_diff_output(&args.file, &content, global.should_color())
);
}
if global.should_apply() {
let policy = policy_from_flags(global, Some(&path));
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
create_with_backup(&path, &content, args.force, &cwd, &policy)?;
}
Ok(exit::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn default_global() -> GlobalFlags {
GlobalFlags::default()
}
#[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 = default_global();
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 = default_global();
global.apply = true;
let err = run(args, &global).unwrap_err();
assert!(err.to_string().contains("file already exists:"));
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 = default_global();
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, &default_global()).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 = default_global();
global.check = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::CHANGES_DETECTED);
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(),
};
let global = default_global();
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
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 = default_global();
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 {
cwd: Some(dir.path().to_string_lossy().into_owned()),
apply: true,
..GlobalFlags::default()
};
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
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 global = GlobalFlags {
cwd: Some(dir.path().to_string_lossy().into_owned()),
apply: true,
..GlobalFlags::default()
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
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 = default_global();
let err = run(args, &global).unwrap_err();
assert!(
err.to_string()
.contains("--content and --stdin cannot be combined")
);
}
}