use std::io::{BufReader, Read};
use crate::error::AtomwriteError;
pub mod backup_names;
pub(crate) use backup_names::{
backup_exclude_globs, is_backup_path, is_backup_timestamp_name, push_backup_excludes,
};
pub mod agent_surface;
pub mod apply;
pub mod backup;
pub mod batch;
pub mod calc;
pub mod case;
pub(crate) mod child_reemit;
pub mod codemod;
pub mod command_tree;
pub mod config_cmd;
pub mod copy;
pub mod count;
pub mod del;
pub mod delete;
pub mod diff;
pub mod doctor;
pub mod doctor_docs;
pub mod edit;
pub mod edit_loop;
pub mod extract;
pub mod extract_artifact;
pub mod get;
pub mod hash;
pub mod list;
pub mod locale_cmd;
pub mod r#move;
#[cfg(feature = "ast")]
#[cfg_attr(docsrs, doc(cfg(feature = "ast")))]
pub mod outline;
#[cfg(not(feature = "ast"))]
#[path = "outline_stub.rs"]
pub mod outline;
pub mod path_resolution;
pub mod prune_backups;
#[cfg(feature = "ast")]
#[cfg_attr(docsrs, doc(cfg(feature = "ast")))]
pub mod query;
#[cfg(not(feature = "ast"))]
#[path = "query_stub.rs"]
pub mod query;
pub mod read;
pub mod recipe;
pub mod regex_gen;
pub mod replace;
pub mod rollback;
#[cfg(feature = "ast")]
#[cfg_attr(docsrs, doc(cfg(feature = "ast")))]
pub mod scope;
#[cfg(not(feature = "ast"))]
#[path = "scope_stub.rs"]
pub mod scope;
pub mod scratch;
pub mod search;
pub mod semantic_merge;
pub mod semantic_search;
pub mod set;
pub(crate) mod set_json_span;
pub mod sparse;
pub mod summary_metrics;
pub(crate) mod toml_path;
#[cfg(feature = "ast")]
#[cfg_attr(docsrs, doc(cfg(feature = "ast")))]
pub mod transform;
#[cfg(not(feature = "ast"))]
#[path = "transform_stub.rs"]
pub mod transform;
pub mod wal_stats;
pub mod watch;
pub mod write;
pub mod write_guards;
#[derive(Debug, Clone, Copy)]
pub(crate) struct ResolvedBackup {
pub backup: bool,
pub keep: bool,
pub retention: u8,
}
pub(crate) fn resolve_backup(
opts: &crate::cli_args::BackupOpts,
defaults: &crate::config::DefaultsSection,
) -> ResolvedBackup {
resolve_backup_for(opts, defaults, defaults.backup.unwrap_or(true))
}
pub(crate) fn resolve_backup_for(
opts: &crate::cli_args::BackupOpts,
defaults: &crate::config::DefaultsSection,
silent_default: bool,
) -> ResolvedBackup {
let backup = if opts.no_backup {
false
} else if opts.backup == Some(true) {
true
} else {
silent_default
};
ResolvedBackup {
backup,
keep: opts.keep_backup,
retention: opts.retention.unwrap_or(defaults.retention),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StdinCapOrigin {
LocalMaxSize,
GlobalMaxFilesize,
}
pub(crate) fn read_stdin_content_cancellable(
stdin: impl Read,
max_size: Option<u64>,
cap_origin: StdinCapOrigin,
allow_empty: bool,
shutdown: Option<&crate::signal::ShutdownSignal>,
) -> anyhow::Result<(Vec<u8>, u64)> {
let mut reader = BufReader::with_capacity(crate::constants::BUF_CAPACITY, stdin);
let mut buf = Vec::with_capacity(crate::constants::STDIN_INITIAL_CAPACITY);
if let Some(max) = max_size {
let reserve = usize::try_from(max.min(u64::from(u32::MAX))).unwrap_or(usize::MAX);
if let Err(e) = buf.try_reserve(reserve.min(
crate::constants::STDIN_INITIAL_CAPACITY * crate::constants::STDIN_RESERVE_CAP_FACTOR,
)) {
tracing::debug!(error = %e, "stdin pre-reserve failed; buffer will grow on demand");
}
}
let mut chunk = [0u8; crate::constants::BUF_CAPACITY];
loop {
if shutdown.is_some_and(|s| s.is_shutdown()) {
return Err(
crate::signal::cancelled_error(crate::signal::current_cancel_reason()).into(),
);
}
let n = reader.read(&mut chunk).map_err(|e| {
crate::stdin_cancel::surface_cancel(
anyhow::Error::new(e).context("failed to read stdin"),
)
})?;
if n == 0 {
break;
}
buf.try_reserve(n)
.map_err(|e| AtomwriteError::InternalError {
reason: format!("allocation failed while reading stdin (+{n} bytes): {e}"),
})?;
buf.extend_from_slice(&chunk[..n]);
if let Some(max) = max_size {
if buf.len() as u64 > max {
return Err(match cap_origin {
StdinCapOrigin::LocalMaxSize => AtomwriteError::InvalidInput {
reason: format!(
"stdin exceeds max size {} bytes (got {} bytes)",
max,
buf.len()
),
},
StdinCapOrigin::GlobalMaxFilesize => AtomwriteError::FileTooLarge {
path: std::path::PathBuf::from(crate::constants::STDIN_SUBJECT),
size: buf.len() as u64,
max_size: max,
},
}
.into());
}
}
}
let n = buf.len();
if !allow_empty && n == 0 {
return Err(AtomwriteError::InvalidInput {
reason: "stdin produced 0 bytes; pass --allow-empty-stdin to confirm an empty write is intentional".into(),
}
.into());
}
Ok((buf, n as u64))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WalkerFlavor {
RespectGlobalFlags,
RespectGlobalFlagsByType,
AlwaysVisitHidden,
CopyTree,
DeleteTree,
}
impl WalkerFlavor {
const fn skips_hidden(self, global_hidden: bool) -> bool {
match self {
Self::RespectGlobalFlags | Self::RespectGlobalFlagsByType | Self::CopyTree => {
!global_hidden
}
Self::AlwaysVisitHidden | Self::DeleteTree => false,
}
}
const fn honors_gitignore(self, no_gitignore: bool) -> bool {
match self {
Self::CopyTree | Self::DeleteTree => false,
_ => !no_gitignore,
}
}
const fn honors_ignore_files(self) -> bool {
!matches!(self, Self::CopyTree | Self::DeleteTree)
}
const fn follows_links(self, global_follow: bool) -> bool {
match self {
Self::DeleteTree => false,
_ => global_follow,
}
}
const fn stays_on_one_file_system(self) -> bool {
matches!(self, Self::DeleteTree)
}
}
pub(crate) fn build_scoped_walker_flavored(
canonical_paths: &[std::path::PathBuf],
global: &crate::cli::GlobalArgs,
flavor: WalkerFlavor,
include: &[String],
exclude: &[String],
) -> anyhow::Result<ignore::WalkBuilder> {
let mut builder = ignore::WalkBuilder::new(&canonical_paths[0]);
for path in canonical_paths.iter().skip(1) {
builder.add(path);
}
builder
.hidden(flavor.skips_hidden(global.hidden))
.ignore(flavor.honors_ignore_files())
.parents(flavor.honors_ignore_files())
.git_ignore(flavor.honors_gitignore(global.no_gitignore))
.git_global(flavor.honors_ignore_files())
.git_exclude(flavor.honors_ignore_files())
.follow_links(flavor.follows_links(global.follow_symlinks))
.same_file_system(flavor.stays_on_one_file_system());
crate::concurrency::apply_walk_threads(&mut builder, global.threads);
if flavor == WalkerFlavor::RespectGlobalFlagsByType {
crate::walk_globs::add_include_types(&mut builder, include)?;
crate::walk_globs::add_overrides(&mut builder, &canonical_paths[0], &[], exclude)?;
} else {
crate::walk_globs::add_overrides(&mut builder, &canonical_paths[0], include, exclude)?;
}
Ok(builder)
}
pub(crate) fn build_scoped_walker(
canonical_paths: &[std::path::PathBuf],
global: &crate::cli::GlobalArgs,
include: &[String],
exclude: &[String],
) -> anyhow::Result<ignore::WalkBuilder> {
build_scoped_walker_flavored(
canonical_paths,
global,
WalkerFlavor::RespectGlobalFlags,
include,
exclude,
)
}
pub(crate) fn read_stdin_text_guarded(
stdin: impl std::io::Read,
max_size: u64,
stdin_is_tty: bool,
mode: &str,
) -> Result<String, crate::error::AtomwriteError> {
if stdin_is_tty {
return Err(AtomwriteError::InvalidInput {
reason: format!(
"--{mode} reads content from stdin but stdin is a terminal; \
pipe content (echo 'new text' | atomwrite edit ...) or use --new-file"
),
});
}
let (bytes, _) = read_stdin_content_cancellable(
stdin,
Some(max_size),
StdinCapOrigin::GlobalMaxFilesize,
true,
None,
)
.map_err(|e| match e.downcast::<AtomwriteError>() {
Ok(err) => err,
Err(other) => AtomwriteError::InternalError {
reason: format!("cannot read stdin: {other:#}"),
},
})?;
String::from_utf8(bytes).map_err(|e| AtomwriteError::InvalidInput {
reason: format!("stdin is not valid UTF-8: {e}"),
})
}
#[cfg(test)]
mod backup_name_tests {
use super::is_backup_timestamp_name;
#[test]
fn accepts_both_timestamp_shapes() {
assert!(is_backup_timestamp_name("foo.txt.bak.20260615_035515"));
assert!(is_backup_timestamp_name("foo.txt.bak.20260615_035515_042"));
assert!(is_backup_timestamp_name("a.bak.b.bak.20260615_035515"));
}
#[test]
fn rejects_other_names() {
assert!(!is_backup_timestamp_name("foo.txt"));
assert!(!is_backup_timestamp_name("foo.txt.bak."));
assert!(!is_backup_timestamp_name("foo.txt.bak.20260615_035515_42"));
assert!(!is_backup_timestamp_name("foo.txt.bak.2026061_5035515"));
assert!(!is_backup_timestamp_name(
"foo.txt.bak.20260615_035515.orig"
));
assert!(!is_backup_timestamp_name("foo.txt.bak.2026061a_035515"));
}
}
#[cfg(test)]
mod tty_guard_tests {
use super::read_stdin_text_guarded;
use crate::error::AtomwriteError;
#[test]
fn tty_stdin_rejected_with_invalid_input() {
let result = read_stdin_text_guarded(std::io::empty(), 1024, true, "after-match");
match result {
Err(AtomwriteError::InvalidInput { reason }) => {
assert!(reason.contains("after-match"));
assert!(reason.contains("terminal"));
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn non_tty_stdin_reads_content() {
let content = read_stdin_text_guarded("hello\n".as_bytes(), 1024, false, "range")
.expect("should read stdin when not a tty");
assert_eq!(content, "hello\n");
}
#[test]
fn non_tty_stdin_refuses_a_payload_above_max_size() {
let err = read_stdin_text_guarded("abcdef".as_bytes(), 3, false, "between")
.expect_err("a payload above the cap is refused, not truncated");
match err {
AtomwriteError::FileTooLarge { size, max_size, .. } => {
assert_eq!((size, max_size), (6, 3));
}
other => panic!("expected FileTooLarge, got {other:?}"),
}
}
}