use std::io::{BufReader, Read, Write};
use std::time::Instant;
use anyhow::{Context, Result, bail};
use crate::atomic::{AtomicWriteOptions, atomic_write};
use crate::checksum;
use crate::cli::{GlobalArgs, WriteArgs};
use crate::commands::resolve_backup;
use crate::error::AtomwriteError;
use crate::ndjson_types::WriteOutput;
use crate::output::NdjsonWriter;
use crate::signal::ShutdownSignal;
#[tracing::instrument(skip_all, fields(command = "write"))]
pub fn cmd_write(
args: &WriteArgs,
global: &GlobalArgs,
stdin: impl Read,
writer: &mut NdjsonWriter<impl Write>,
shutdown: &ShutdownSignal,
defaults: &crate::config::DefaultsSection,
write_cfg: &crate::config::WriteSection,
) -> Result<()> {
let start = Instant::now();
let workspace = global.resolve_workspace()?;
let resolved = crate::path_safety::validate_path(&args.target, &workspace)?;
let resolved_backup = resolve_backup(&args.backup_opts, defaults);
let effective_backup = resolved_backup.backup;
let stdin_bytes_read;
let mut content = {
let (buf, n) = read_stdin_content_cancellable(
stdin,
args.max_size,
args.allow_empty_stdin,
Some(shutdown),
)?;
stdin_bytes_read = n;
buf
};
if shutdown.is_shutdown() {
bail!("interrupted before write");
}
if args.append || args.prepend {
content = handle_append_prepend(
&resolved,
&content,
args.append,
global.effective_max_filesize(),
args.allow_empty_stdin,
)?;
}
content = normalize_line_endings(&content, args.line_ending, &resolved);
if let Some(ref expected) = args.expect_checksum {
if stdin_bytes_read == 0 && (args.append || args.prepend) {
if args.no_checksum_when_empty {
tracing::warn!(
path = %resolved.display(),
expected = %expected,
"G120 L3: --append/--prepend + --expect-checksum with empty stdin; \
skipping checksum verification per --no-checksum-when-empty"
);
} else {
tracing::info!(
path = %resolved.display(),
expected = %expected,
"G120 L3: --append/--prepend + --expect-checksum with empty stdin; \
verifying pre-mutation state. Pass --no-checksum-when-empty to skip."
);
verify_checksum(&resolved, expected, global.effective_max_filesize())?;
}
} else {
verify_checksum(&resolved, expected, global.effective_max_filesize())?;
}
}
if resolved.exists() {
let size = std::fs::metadata(&resolved).map(|m| m.len()).unwrap_or(0);
if size > write_cfg.confirm_large_bytes && !args.ack_overwrite {
return Err(AtomwriteError::InvalidInput {
reason: format!(
"overwrite of large file {} ({} bytes) requires --ack-overwrite \
(threshold XDG [write].confirm_large_bytes={}; one-shot; no interactive prompt)",
resolved.display(),
size,
write_cfg.confirm_large_bytes
),
}
.into());
}
}
if !args.allow_shrink && resolved.exists() {
let original_size = std::fs::metadata(&resolved).map(|m| m.len()).unwrap_or(0);
let new_size = content.len() as u64;
if original_size > 0 {
let remain_pct = new_size.saturating_mul(100) / original_size;
let min_remain = 100u64.saturating_sub(u64::from(write_cfg.shrink_block_percent));
if remain_pct < min_remain {
let shrink_pct =
100u64.saturating_sub(new_size.saturating_mul(100) / original_size);
return Err(AtomwriteError::InvalidInput {
reason: format!(
"stdin is {}% smaller than target ({} -> {} bytes); \
pass --allow-shrink to confirm intentional truncation",
shrink_pct, original_size, new_size
),
}
.into());
}
}
}
let target_str = args.target.display().to_string();
if args.dry_run {
let plan = crate::ndjson_types::DryRunPlan {
r#type: "plan",
operation: "write".into(),
path: target_str,
would_modify: true,
details: Some(format!("{} bytes from stdin", content.len())),
};
writer.write_event(&plan)?;
return Ok(());
}
if args.require_backup && (args.backup_opts.no_backup || !effective_backup) && resolved.exists()
{
return Err(AtomwriteError::InvalidInput {
reason: "--require-backup is set but --no-backup disables backup; remove --no-backup or remove --require-backup".into(),
}
.into());
}
let auto_rotate_active = args.auto_rotate
&& effective_backup
&& resolved.exists()
&& std::fs::metadata(&resolved)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| {
age < std::time::Duration::from_secs(write_cfg.auto_rotate_max_age_secs)
});
let new_bytes = content.len() as u64;
let original_bytes = if resolved.exists() {
std::fs::metadata(&resolved).map(|m| m.len()).unwrap_or(0)
} else {
0
};
let mut risk_assessment = None;
if let Some(content_risk) = assess_content_risk(&content, original_bytes, new_bytes, write_cfg)
{
crate::runtime::warn_stderr(
global.color_mode(),
format!(
"write content risk {} (guard={})",
content_risk.risk_level, content_risk.guard_triggered
),
);
risk_assessment = Some(content_risk);
}
if risk_assessment.is_none() && resolved.exists() && !args.append && !args.prepend {
let original = original_bytes;
if original > 0 {
let delta_pct = ((new_bytes.abs_diff(original)) * 100 / original) as u32;
if delta_pct >= u32::from(args.risk_threshold) {
let level = if delta_pct >= 90 {
"high"
} else if delta_pct >= 70 {
"medium"
} else {
"low"
};
crate::runtime::warn_stderr(
global.color_mode(),
&rust_i18n::t!(
"warn.write-risk",
level = level,
delta_pct = delta_pct,
original = original,
new_bytes = new_bytes
),
);
if !args.allow_shrink && new_bytes < original {
return Err(AtomwriteError::InvalidInput {
reason: format!(
"write risk {} ({}% size delta, {} -> {} bytes) blocked; \
pass --allow-shrink to override",
level, delta_pct, original, new_bytes
),
}
.into());
}
risk_assessment = Some(crate::ndjson_types::WriteRiskAssessment {
original_bytes: original,
new_bytes,
size_delta_pct: delta_pct,
risk_level: level,
guard_triggered: "size",
});
}
}
}
let opts = AtomicWriteOptions {
backup: effective_backup || auto_rotate_active,
syntax_check: args.syntax_check,
retention: resolved_backup.retention,
preserve_timestamps: args.preserve_timestamps,
backup_output_dir: None,
strategy: None,
strict_atomic: false,
wal_policy: args.wal_policy,
keep_backup: resolved_backup.keep || args.require_backup || auto_rotate_active,
durability: args.durability.into(),
};
let result = atomic_write(&resolved, &content, &opts, &workspace)?;
let output = WriteOutput {
r#type: "write",
status: "success",
path: target_str,
bytes_written: result.bytes_written,
checksum: result.checksum,
checksum_before: result.checksum_before,
backup_path: result.backup_path,
elapsed_ms: start.elapsed().as_millis() as u64,
stdin_bytes_read,
wal_policy: args.wal_policy.as_str(),
platform: result.platform,
mtime_preserved: if args.preserve_timestamps {
Some(true)
} else {
None
},
risk_assessment,
};
writer.write_event(&output)?;
Ok(())
}
include!("write_helpers.inc.rs");