atomwrite 0.1.38

Atomic file operations CLI for LLM agents — read, write, edit, search, replace with NDJSON output
// SPDX-License-Identifier: MIT OR Apache-2.0
// Included by scope/mod.rs (A-MONO-001).

fn expand_to_full_line(content: &str, start: usize, end: usize) -> (usize, usize) {
    let bytes = content.as_bytes();
    let line_start = bytes[..start]
        .iter()
        .rposition(|&b| b == b'\n')
        .map_or(0, |pos| pos + 1);
    let line_end = bytes[end..]
        .iter()
        .position(|&b| b == b'\n')
        .map_or(content.len(), |pos| end + pos + 1);

    let before_match = &content[line_start..start];

    if before_match.trim().is_empty() {
        (line_start, line_end)
    } else {
        let content_end = if line_end > 0 && bytes[line_end - 1] == b'\n' {
            line_end - 1
        } else {
            line_end
        };
        let trim_start = content[line_start..start]
            .rfind(|c: char| !c.is_whitespace())
            .map_or(start, |pos| line_start + pos + 1);
        (trim_start, content_end)
    }
}

fn apply_scope_action<'a>(
    text: &'a str,
    delete: bool,
    action: Option<ScopeAction>,
    replace_with: Option<&str>,
) -> std::borrow::Cow<'a, str> {
    if delete {
        return std::borrow::Cow::Owned(String::new());
    }
    if let Some(replacement) = replace_with {
        return std::borrow::Cow::Owned(replacement.to_owned());
    }
    match action {
        Some(ScopeAction::Upper) => std::borrow::Cow::Owned(text.to_uppercase()),
        Some(ScopeAction::Lower) => std::borrow::Cow::Owned(text.to_lowercase()),
        Some(ScopeAction::Titlecase) => std::borrow::Cow::Owned(titlecase(text)),
        Some(ScopeAction::Squeeze) => std::borrow::Cow::Owned(squeeze(text)),
        Some(ScopeAction::Symbols) => std::borrow::Cow::Owned(symbolize(text)),
        Some(ScopeAction::Normalize) => std::borrow::Cow::Owned(text.nfc().collect::<String>()),
        None => std::borrow::Cow::Borrowed(text),
    }
}

fn titlecase(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut capitalize_next = true;
    for c in s.chars() {
        if capitalize_next && c.is_alphabetic() {
            result.extend(c.to_uppercase());
            capitalize_next = false;
        } else {
            result.push(c);
            if c.is_whitespace() || c == '_' || c == '-' {
                capitalize_next = true;
            }
        }
    }
    result
}

fn squeeze(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut prev: Option<char> = None;
    for c in s.chars() {
        if Some(c) != prev || !c.is_whitespace() {
            result.push(c);
        }
        prev = Some(c);
    }
    result
}

fn symbolize(s: &str) -> String {
    s.replace("=>", "⇒")
        .replace("->", "→")
        .replace("<-", "←")
        .replace("!=", "≠")
        .replace(">=", "≥")
        .replace("<=", "≤")
        .replace("...", "…")
        .replace("--", "—")
}

fn resolve_patterns(
    query_name: &Option<String>,
    custom_pattern: &Option<String>,
    lang_str: &str,
) -> Result<Vec<String>> {
    if let Some(p) = custom_pattern {
        return Ok(vec![p.clone()]);
    }

    let name = query_name
        .as_deref()
        .ok_or_else(|| AtomwriteError::InvalidInput {
            reason: "either --query or --pattern is required".into(),
        })?;

    lookup_prepared_queries(name, lang_str)
}

fn parse_language(lang_str: &str) -> Result<SupportLang> {
    lang_str.parse().map_err(|_| {
        AtomwriteError::InvalidInput {
            reason: format!("unsupported language: {lang_str}"),
        }
        .into()
    })
}

enum ScopeEvent {
    /// Boxed because this variant carries the whole per-file result and every
    /// event crosses an `mpsc` channel, so the box keeps each send small and
    /// the enum narrow (`clippy::large_enum_variant`).
    Result(Box<ScopeResult>),
    /// A file `scope` matched but could not write. The error travels whole so
    /// the final exit reuses its own variant (code, exit number, suggestion).
    Failed(std::path::PathBuf, anyhow::Error),
    /// A file `scope` could not read. Same treatment as a write failure: the
    /// summary reports it, because a run that never looked inside the file
    /// cannot claim it found nothing there.
    ReadFailed(std::path::PathBuf, anyhow::Error),
}

/// The terminal summary of `scope`.
///
/// This is the shared failure summary, not a copy: a field added to the failure
/// report reaches this verb without a second definition here, which is exactly
/// how `partial` went missing when it was introduced.
pub(crate) type ScopeSummary = crate::ndjson_types::FailureSummary;

/// Failures collected while draining the walker channel.
///
/// A write failure and a read failure do not weigh the same: a write failure
/// means bytes `scope` meant to change did not change, so it always fails the
/// run; a read failure only fails the run when nothing else was read, because a
/// run that read other files produced a real answer (the same rule `search`
/// follows). Both are reported in `errors[]` either way.
#[derive(Default)]
struct ScopeFailures {
    items: Vec<crate::ndjson_types::FileFailure>,
    first_write: Option<anyhow::Error>,
    first_read: Option<anyhow::Error>,
}

impl ScopeFailures {
    fn push_write(&mut self, path: &std::path::Path, err: anyhow::Error) {
        self.record(path, &err);
        self.first_write.get_or_insert(err);
    }

    fn push_read(&mut self, path: &std::path::Path, err: anyhow::Error) {
        self.record(path, &err);
        self.first_read.get_or_insert(err);
    }

    fn record(&mut self, path: &std::path::Path, err: &anyhow::Error) {
        if self.items.len() < crate::config::summary_errors_max_entries() {
            self.items.push(crate::ndjson_types::FileFailure {
                path: path.display().to_string(),
                code: crate::output::failure_identity(err).0,
                message: crate::output::error_message(err),
            });
        }
    }
}


/// Stream walker results to stdout and gather write failures.
///
/// Borrows the receiver so the caller can still hand it to
/// `join_walker` whatever this returns.
fn drain_scope_events(
    rx: &crossbeam_channel::Receiver<ScopeEvent>,
    writer: &mut NdjsonWriter<impl Write>,
    shutdown: &ShutdownSignal,
) -> Result<(ScopeFailures, u64)> {
    let mut failures = ScopeFailures::default();
    let mut bytes_after = 0u64;
    for event in rx {
        if shutdown.is_shutdown() {
            break;
        }
        match event {
            ScopeEvent::Result(r) => {
                bytes_after = bytes_after.saturating_add(r.bytes_after);
                writer.write_event(&r)?;
            }
            ScopeEvent::Failed(path, err) => failures.push_write(&path, err),
            ScopeEvent::ReadFailed(path, err) => failures.push_read(&path, err),
        }
    }
    Ok((failures, bytes_after))
}