ferrflow 7.20.0

Universal semantic versioning for monorepos and classic repos
Documentation
use super::VersionFile;
use crate::error_code::{self, ErrorCodeExt};
use anyhow::{Context, Result};
use std::path::Path;

pub struct JsonVersionFile;

fn find_top_level_string_value_span(content: &str, target_key: &str) -> Option<(usize, usize)> {
    let bytes = content.as_bytes();
    let n = bytes.len();
    let mut i = skip_ws(bytes, 0);
    if i >= n || bytes[i] != b'{' {
        return None;
    }
    i += 1;

    loop {
        i = skip_ws(bytes, i);
        if i >= n {
            return None;
        }
        if bytes[i] == b'}' {
            return None;
        }
        if bytes[i] == b',' {
            i += 1;
            continue;
        }
        if bytes[i] != b'"' {
            return None;
        }
        let (key_start, key_end, after_key) = read_string(bytes, i)?;
        let key = std::str::from_utf8(&bytes[key_start..key_end]).ok()?;
        i = skip_ws(bytes, after_key);
        if i >= n || bytes[i] != b':' {
            return None;
        }
        i = skip_ws(bytes, i + 1);
        if i >= n {
            return None;
        }

        if key == target_key {
            if bytes[i] != b'"' {
                return None;
            }
            let (val_start, val_end, _) = read_string(bytes, i)?;
            return Some((val_start, val_end));
        }

        i = skip_value(bytes, i)?;
    }
}

pub(super) fn find_nested_string_value_span(
    content: &str,
    section: &str,
    key: &str,
) -> Option<(usize, usize)> {
    let bytes = content.as_bytes();
    let section_start = find_object_value_start(bytes, 0, section)?;
    let (start, end) = find_object_value_start(bytes, section_start, key)
        .and_then(|value_start| read_string(bytes, value_start).map(|(s, e, _)| (s, e)))?;
    Some((start, end))
}

fn find_object_value_start(bytes: &[u8], from: usize, target_key: &str) -> Option<usize> {
    let n = bytes.len();
    let mut i = skip_ws(bytes, from);
    if i >= n || bytes[i] != b'{' {
        return None;
    }
    i += 1;

    loop {
        i = skip_ws(bytes, i);
        if i >= n || bytes[i] == b'}' {
            return None;
        }
        if bytes[i] == b',' {
            i += 1;
            continue;
        }
        if bytes[i] != b'"' {
            return None;
        }
        let (key_start, key_end, after_key) = read_string(bytes, i)?;
        let key = std::str::from_utf8(&bytes[key_start..key_end]).ok()?;
        i = skip_ws(bytes, after_key);
        if i >= n || bytes[i] != b':' {
            return None;
        }
        i = skip_ws(bytes, i + 1);
        if i >= n {
            return None;
        }
        if key == target_key {
            return Some(i);
        }
        i = skip_value(bytes, i)?;
    }
}

fn skip_ws(bytes: &[u8], mut i: usize) -> usize {
    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
        i += 1;
    }
    i
}

fn read_string(bytes: &[u8], i: usize) -> Option<(usize, usize, usize)> {
    let n = bytes.len();
    if i >= n || bytes[i] != b'"' {
        return None;
    }
    let start = i + 1;
    let mut j = start;
    while j < n {
        match bytes[j] {
            b'\\' => {
                if j + 1 >= n {
                    return None;
                }
                j += 2;
            }
            b'"' => return Some((start, j, j + 1)),
            _ => j += 1,
        }
    }
    None
}

fn skip_value(bytes: &[u8], i: usize) -> Option<usize> {
    let n = bytes.len();
    if i >= n {
        return None;
    }
    match bytes[i] {
        b'"' => read_string(bytes, i).map(|(_, _, after)| after),
        b'{' | b'[' => {
            let open = bytes[i];
            let close = if open == b'{' { b'}' } else { b']' };
            let mut depth: i32 = 1;
            let mut j = i + 1;
            while j < n && depth > 0 {
                match bytes[j] {
                    b'"' => {
                        let (_, _, after) = read_string(bytes, j)?;
                        j = after;
                        continue;
                    }
                    b'{' | b'[' => depth += 1,
                    c if c == close => depth -= 1,
                    _ => {}
                }
                j += 1;
            }
            if depth == 0 { Some(j) } else { None }
        }
        _ => {
            let mut j = i;
            while j < n && !matches!(bytes[j], b',' | b'}' | b']') {
                j += 1;
            }
            Some(j)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    fn write_temp(content: &str) -> NamedTempFile {
        let mut f = NamedTempFile::new().unwrap();
        f.write_all(content.as_bytes()).unwrap();
        f
    }

    #[test]
    fn read_version_from_package_json() {
        let f = write_temp(r#"{"name":"foo","version":"1.2.3"}"#);
        let handler = JsonVersionFile;
        assert_eq!(handler.read_version(f.path()).unwrap(), "1.2.3");
    }

    #[test]
    fn read_version_missing_field() {
        let f = write_temp(r#"{"name":"foo"}"#);
        let handler = JsonVersionFile;
        assert!(handler.read_version(f.path()).is_err());
    }

    #[test]
    fn write_version_updates_field() {
        let f = write_temp(r#"{"name":"foo","version":"1.0.0"}"#);
        let handler = JsonVersionFile;
        handler.write_version(f.path(), "2.0.0").unwrap();
        assert_eq!(handler.read_version(f.path()).unwrap(), "2.0.0");
    }

    #[test]
    fn write_preserves_other_fields() {
        let f = write_temp(r#"{"name":"foo","version":"1.0.0","private":true}"#);
        let handler = JsonVersionFile;
        handler.write_version(f.path(), "2.0.0").unwrap();
        let content = std::fs::read_to_string(f.path()).unwrap();
        let v: serde_json::Value = serde_json::from_str(&content).unwrap();
        assert_eq!(v["name"], "foo");
        assert_eq!(v["private"], true);
        assert_eq!(v["version"], "2.0.0");
    }

    #[test]
    fn write_preserves_tab_indentation() {
        let original =
            "{\n\t\"name\": \"foo\",\n\t\"version\": \"1.0.0\",\n\t\"private\": true\n}\n";
        let f = write_temp(original);
        JsonVersionFile.write_version(f.path(), "2.0.0").unwrap();
        let after = std::fs::read_to_string(f.path()).unwrap();
        assert_eq!(
            after, "{\n\t\"name\": \"foo\",\n\t\"version\": \"2.0.0\",\n\t\"private\": true\n}\n",
            "tab indentation and trailing newline must be preserved"
        );
    }

    #[test]
    fn write_preserves_four_space_indentation() {
        let original =
            "{\n    \"name\": \"foo\",\n    \"version\": \"1.0.0\",\n    \"private\": true\n}\n";
        let f = write_temp(original);
        JsonVersionFile.write_version(f.path(), "2.0.0").unwrap();
        let after = std::fs::read_to_string(f.path()).unwrap();
        assert_eq!(
            after,
            "{\n    \"name\": \"foo\",\n    \"version\": \"2.0.0\",\n    \"private\": true\n}\n"
        );
    }

    #[test]
    fn write_preserves_missing_trailing_newline() {
        let original = "{\n  \"name\": \"foo\",\n  \"version\": \"1.0.0\"\n}";
        let f = write_temp(original);
        JsonVersionFile.write_version(f.path(), "2.0.0").unwrap();
        let after = std::fs::read_to_string(f.path()).unwrap();
        assert!(
            !after.ends_with('\n'),
            "must not invent a trailing newline that wasn't there"
        );
        assert_eq!(
            after,
            "{\n  \"name\": \"foo\",\n  \"version\": \"2.0.0\"\n}"
        );
    }

    #[test]
    fn write_single_line_diff() {
        let original = "{\n  \"name\": \"foo\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"build\": \"tsc\"\n  }\n}\n";
        let f = write_temp(original);
        JsonVersionFile.write_version(f.path(), "1.0.1").unwrap();
        let after = std::fs::read_to_string(f.path()).unwrap();
        let differing_lines: Vec<(usize, &str, &str)> = original
            .lines()
            .zip(after.lines())
            .enumerate()
            .filter(|(_, (a, b))| a != b)
            .map(|(i, (a, b))| (i, a, b))
            .collect();
        assert_eq!(
            differing_lines.len(),
            1,
            "expected a single-line diff, got {differing_lines:?}"
        );
        assert!(differing_lines[0].1.contains("1.0.0"));
        assert!(differing_lines[0].2.contains("1.0.1"));
    }

    #[test]
    fn write_ignores_nested_version_keys() {
        let original = "{\n  \"name\": \"app\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"left-pad\": {\n      \"version\": \"9.9.9\"\n    }\n  }\n}\n";
        let f = write_temp(original);
        JsonVersionFile.write_version(f.path(), "1.0.1").unwrap();
        let after = std::fs::read_to_string(f.path()).unwrap();
        assert!(after.contains("\"version\": \"1.0.1\""));
        assert!(
            after.contains("\"version\": \"9.9.9\""),
            "nested dependency version must remain untouched"
        );
    }

    #[test]
    fn write_preserves_key_order() {
        let f = write_temp(
            r#"{"name":"foo","version":"1.0.0","private":true,"description":"x","scripts":{"build":"tsc"}}"#,
        );
        let handler = JsonVersionFile;
        handler.write_version(f.path(), "2.0.0").unwrap();
        let content = std::fs::read_to_string(f.path()).unwrap();

        let name_pos = content.find("\"name\"").unwrap();
        let version_pos = content.find("\"version\"").unwrap();
        let private_pos = content.find("\"private\"").unwrap();
        let description_pos = content.find("\"description\"").unwrap();
        let scripts_pos = content.find("\"scripts\"").unwrap();

        assert!(name_pos < version_pos, "name must come before version");
        assert!(
            version_pos < private_pos,
            "version must come before private"
        );
        assert!(
            private_pos < description_pos,
            "private must come before description"
        );
        assert!(
            description_pos < scripts_pos,
            "description must come before scripts"
        );
    }
}

impl VersionFile for JsonVersionFile {
    fn read_version(&self, file_path: &Path) -> Result<String> {
        let content = std::fs::read_to_string(file_path)
            .with_context(|| format!("Cannot read {}", file_path.display()))
            .error_code(error_code::JSON_READ)?;
        let v: serde_json::Value = serde_json::from_str(&content)
            .with_context(|| format!("Invalid JSON in {}", file_path.display()))
            .error_code(error_code::JSON_PARSE)?;
        v["version"]
            .as_str()
            .map(|s| s.to_string())
            .ok_or_else(|| anyhow::anyhow!("No 'version' field in {}", file_path.display()))
            .error_code(error_code::JSON_VERSION_NOT_FOUND)
    }

    fn write_version(&self, file_path: &Path, version: &str) -> Result<()> {
        let content = std::fs::read_to_string(file_path)
            .with_context(|| format!("Cannot read {}", file_path.display()))
            .error_code(error_code::JSON_READ)?;

        serde_json::from_str::<serde_json::Value>(&content)
            .with_context(|| format!("Invalid JSON in {}", file_path.display()))
            .error_code(error_code::JSON_PARSE)?;

        let new_content = match find_top_level_string_value_span(&content, "version") {
            Some((start, end)) => {
                let mut s = String::with_capacity(content.len() + version.len());
                s.push_str(&content[..start]);
                s.push_str(version);
                s.push_str(&content[end..]);
                s
            }
            None => {
                let mut v: serde_json::Value = serde_json::from_str(&content)
                    .with_context(|| format!("Invalid JSON in {}", file_path.display()))
                    .error_code(error_code::JSON_PARSE)?;
                v["version"] = serde_json::Value::String(version.to_string());
                serde_json::to_string_pretty(&v)
                    .with_context(|| format!("Cannot serialize JSON for {}", file_path.display()))
                    .error_code(error_code::JSON_WRITE)?
                    + "\n"
            }
        };

        std::fs::write(file_path, new_content)
            .with_context(|| format!("Cannot write {}", file_path.display()))
            .error_code(error_code::JSON_WRITE)?;
        Ok(())
    }

    fn read_version_from_bytes(&self, content: &[u8], filename: &str) -> Result<String> {
        let text = std::str::from_utf8(content)
            .with_context(|| format!("Invalid UTF-8 in {filename}"))
            .error_code(error_code::JSON_INVALID_UTF8)?;
        let v: serde_json::Value = serde_json::from_str(text)
            .with_context(|| format!("Invalid JSON in {filename}"))
            .error_code(error_code::JSON_PARSE)?;
        v["version"]
            .as_str()
            .map(|s| s.to_string())
            .ok_or_else(|| anyhow::anyhow!("No 'version' field in {filename}"))
            .error_code(error_code::JSON_VERSION_NOT_FOUND)
    }
}