adnv 1.5.0

CLI that tracks, checks and auto-updates infrastructure dependency versions across Terraform, Helm, Docker, Git and npm
Documentation
#![allow(dead_code)]
use std::borrow::Cow;
use std::ops;
use std::path::Path;

use ropey::Rope;

use crate::{Error, Result};

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action<'a> {
    Delete(Option<usize>, Option<usize>),
    Insert(usize, Cow<'a, str>),
    Replace(Option<usize>, Option<usize>, Cow<'a, str>),
}

impl<'a> Action<'a> {
    pub fn delete<R: ops::RangeBounds<usize>>(range: R) -> Self {
        let start = index_bound_to_option(range.start_bound().cloned());
        let end = index_bound_to_option(range.end_bound().cloned());

        Self::Delete(start, end)
    }

    pub fn insert(idx: usize, text: impl Into<Cow<'a, str>>) -> Self {
        Self::Insert(idx, text.into())
    }

    pub fn replace<R: ops::RangeBounds<usize>>(range: R, text: impl Into<Cow<'a, str>>) -> Self {
        let start = index_bound_to_option(range.start_bound().cloned());
        let end = index_bound_to_option(range.end_bound().cloned());

        Self::Replace(start, end, text.into())
    }
}

fn index_bound_to_option(bound: ops::Bound<usize>) -> Option<usize> {
    match bound {
        ops::Bound::Included(idx) => Some(idx),
        ops::Bound::Excluded(idx) => Some(idx - 1),
        ops::Bound::Unbounded => None,
    }
}

pub fn edit_file(path: &Path, actions: &[Action]) -> Result<()> {
    use std::fs::File;
    use std::io::{BufReader, BufWriter};

    let open = |err| Error::CouldNotOpenFile {
        path: path.to_path_buf(),
        err,
    };
    let write = |err| Error::CouldNotWriteFile {
        path: path.to_path_buf(),
        err,
    };

    let mut text =
        Rope::from_reader(BufReader::new(File::open(path).map_err(open)?)).map_err(open)?;
    edit_rope(&mut text, actions).map_err(|err| Error::FileChangedSinceParsed {
        path: path.to_path_buf(),
        err,
    })?;
    text.write_to(BufWriter::new(File::create(path).map_err(write)?))
        .map_err(write)?;

    Ok(())
}

pub fn edit(input: impl Into<Rope>, actions: &[Action]) -> Result<String, ropey::Error> {
    let mut input = input.into();
    edit_rope(&mut input, actions)?;
    Ok(input.into())
}

fn edit_rope(input: &mut Rope, actions: &[Action]) -> Result<(), ropey::Error> {
    use itertools::Itertools;

    let actions = actions
        .iter()
        .sorted_by_key(|action| match action {
            Action::Delete(start, _) | Action::Replace(start, _, _) => *start,
            Action::Insert(idx, _) => Some(*idx),
        })
        .rev();
    for action in actions {
        apply_action(input, action)?;
    }

    Ok(())
}

/// `Action` carries **byte** offsets, as produced by `util::parse::loc_to_index`
/// and by pest spans, while `Rope` indexes by character. Convert at the
/// boundary, or every edit past a non-ASCII character lands in the wrong place.
#[inline]
fn byte_to_char(input: &Rope, idx: Option<&usize>) -> Result<Option<usize>, ropey::Error> {
    idx.map(|idx| input.try_byte_to_char(*idx)).transpose()
}

/// Every index is checked rather than asserted. The spans come from a parse of
/// the file as it was read earlier, and `edit_file` reads it again: between the
/// two, something else may have shortened it, which would otherwise be a panic
/// in the middle of rewriting the user's files.
#[inline]
fn apply_action(input: &mut Rope, action: &Action) -> Result<(), ropey::Error> {
    match action {
        Action::Delete(start, end) => {
            match (
                byte_to_char(input, start.as_ref())?,
                byte_to_char(input, end.as_ref())?,
            ) {
                (Some(start), Some(end)) => input.try_remove(start..end),
                (Some(start), None) => input.try_remove(start..),
                (None, Some(end)) => input.try_remove(..end),
                (None, None) => input.try_remove(..),
            }
        }
        Action::Insert(idx, text) => {
            let idx = input.try_byte_to_char(*idx)?;
            input.try_insert(idx, text)
        }
        Action::Replace(start, end, text) => {
            apply_action(input, &Action::Delete(*start, *end))?;
            // The deletion shifted the tail, but `start` still points at the
            // same character, so convert against the updated rope.
            let idx = byte_to_char(input, start.as_ref())?.unwrap_or(0);
            input.try_insert(idx, text)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{Action, edit, edit_file};

    /// The spans handed to `edit` are byte offsets: `util::parse::loc_to_index`
    /// derives them from `char_indices`, and pest spans are byte offsets too.
    /// `Rope` indexes by character, so anything non-ASCII before the span used
    /// to shift the edit — silently on a long enough file, with an out-of-bounds
    /// panic on a short one.
    #[test]
    fn edit_handles_non_ascii_before_the_span() {
        for input in [
            "# Déploiement\nversion: \"1.0.0\"\nname: mon-chart\n",
            "# Déploiement à faire\nversion: \"1.0.0\"\n",
            // ASCII witness: it already worked, and must keep working.
            "# Deploiement\nversion: \"1.0.0\"\nname: mon-chart\n",
        ] {
            let start = input.find('"').expect("the fixture quotes its version");
            let end = start + "\"1.0.0\"".len();

            assert_eq!(
                edit(input, &[Action::replace(start..=end, "\"2.0.0\"")]).unwrap(),
                input.replace("\"1.0.0\"", "\"2.0.0\""),
                "input: {input:?}"
            );
        }
    }

    /// Pinned on purpose: both range forms drop one character less than Rust
    /// semantics announce. `index_bound_to_option` keeps `Included(b)` as is and
    /// turns `Excluded(b)` into `b - 1`, then `apply_action` applies the result
    /// exclusively, so `a..=b` removes `a..b` and `a..b` removes `a..b - 1`.
    ///
    /// Callers rely on it: they hold half-open spans — `span().end()` from pest,
    /// `version_start_idx + len` in `sources::parse::helm` — and wrap them in
    /// `a..=b` at `ops::update::apply`, which cancels out exactly. Do not "fix"
    /// `index_bound_to_option` without migrating every caller in the same change,
    /// or every replacement will eat one character too many.
    #[test]
    fn replace_drops_one_character_less_than_the_range_announces() {
        assert_eq!(
            edit("abcdef", &[Action::replace(1..=3, "X")]).unwrap(),
            "aXdef"
        );
        assert_eq!(
            edit("abcdef", &[Action::replace(1..3, "X")]).unwrap(),
            "aXcdef"
        );
    }

    /// `update` parses a file, resolves versions over the network, then reopens
    /// the file to write the new ones. A span that no longer fits used to panic
    /// halfway through rewriting the user's working tree.
    #[test]
    fn a_span_past_the_end_is_an_error_not_a_panic() {
        assert!(edit("short", &[Action::replace(400..=420, "X")]).is_err());
        assert!(edit("short", &[Action::insert(400, "X")]).is_err());
        assert!(edit("short", &[Action::delete(400..=420)]).is_err());
        // Reversed bounds, which `Rope::remove` also refuses. Built from the
        // variant rather than a range, since a reversed literal range is a
        // lint error of its own.
        assert!(edit("short", &[Action::Delete(Some(4), Some(1))]).is_err());
    }

    #[test]
    fn edit_file_reports_the_path_and_leaves_the_file_alone() {
        let dir = tempfile::TempDir::with_prefix("adnv-edit").unwrap();
        let path = dir.path().join("main.tf");
        std::fs::write(&path, "version = \"1.0.0\"\n").unwrap();

        let err = edit_file(&path, &[Action::replace(400..=420, "\"2.0.0\"")])
            .expect_err("the span is past the end of the file");

        assert!(
            matches!(&err, crate::Error::FileChangedSinceParsed { path: reported, .. } if reported == &path),
            "{err:?}"
        );
        assert_eq!(
            std::fs::read_to_string(&path).unwrap(),
            "version = \"1.0.0\"\n",
            "the file was rewritten even though the edit failed"
        );
    }
}