ferrflow 7.4.1

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

pub struct TxtVersionFile;

fn compile_selector(selector: &str) -> Result<Regex> {
    let re = Regex::new(selector)
        .with_context(|| format!("invalid regex selector: {selector:?}"))
        .error_code(error_code::TXT_VERSION_NOT_FOUND)?;
    if re.captures_len() != 2 {
        Err(anyhow::anyhow!(
            "regex selector must contain exactly one capture group: {selector:?}"
        ))
        .error_code(error_code::TXT_VERSION_NOT_FOUND)?;
    }
    Ok(re)
}

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

    #[test]
    fn read_version_from_txt() {
        let mut f = NamedTempFile::new().unwrap();
        writeln!(f, "1.2.3").unwrap();
        let v = TxtVersionFile.read_version(f.path()).unwrap();
        assert_eq!(v, "1.2.3");
    }

    #[test]
    fn read_version_trims_whitespace() {
        let mut f = NamedTempFile::new().unwrap();
        write!(f, "  0.4.1\n\n").unwrap();
        let v = TxtVersionFile.read_version(f.path()).unwrap();
        assert_eq!(v, "0.4.1");
    }

    #[test]
    fn read_empty_file_fails() {
        let f = NamedTempFile::new().unwrap();
        let result = TxtVersionFile.read_version(f.path());
        assert!(result.is_err());
    }

    #[test]
    fn write_version_to_txt() {
        let mut f = NamedTempFile::new().unwrap();
        writeln!(f, "1.0.0").unwrap();
        TxtVersionFile.write_version(f.path(), "2.0.0").unwrap();
        let content = std::fs::read_to_string(f.path()).unwrap();
        assert_eq!(content, "2.0.0\n");
    }

    #[test]
    fn read_with_regex_selector_picks_capture_group() {
        let mut f = NamedTempFile::new().unwrap();
        write!(f, "name=foo\nVERSION=4.5.6\nother=ignored\n").unwrap();
        let v = TxtVersionFile
            .read_version_with_selector(f.path(), Some(r"(?m)^VERSION=(.+)$"))
            .unwrap();
        assert_eq!(v, "4.5.6");
    }

    #[test]
    fn write_with_regex_selector_replaces_capture_only() {
        let mut f = NamedTempFile::new().unwrap();
        write!(f, "name=foo\nVERSION=1.0.0\nother=ignored\n").unwrap();
        TxtVersionFile
            .write_version_with_selector(f.path(), "2.0.0", Some(r"(?m)^VERSION=(.+)$"))
            .unwrap();
        let content = std::fs::read_to_string(f.path()).unwrap();
        assert_eq!(content, "name=foo\nVERSION=2.0.0\nother=ignored\n");
    }

    #[test]
    fn selector_with_no_match_errors() {
        let mut f = NamedTempFile::new().unwrap();
        write!(f, "no version here").unwrap();
        let result = TxtVersionFile.read_version_with_selector(f.path(), Some(r"^VERSION=(.+)$"));
        assert!(result.is_err());
    }

    #[test]
    fn selector_with_wrong_capture_count_errors() {
        let mut f = NamedTempFile::new().unwrap();
        write!(f, "VERSION=1.0.0").unwrap();
        let result = TxtVersionFile.read_version_with_selector(f.path(), Some(r"VERSION=.+"));
        assert!(result.is_err());
        let result = TxtVersionFile.read_version_with_selector(f.path(), Some(r"(VERSION)=(.+)"));
        assert!(result.is_err());
    }
}

impl super::VersionFile for TxtVersionFile {
    fn read_version(&self, file_path: &Path) -> Result<String> {
        let content = std::fs::read_to_string(file_path)
            .with_context(|| format!("failed to read {}", file_path.display()))
            .error_code(error_code::TXT_READ)?;
        let version = content.trim();
        if version.is_empty() {
            Err(anyhow::anyhow!(
                "no version found in {}",
                file_path.display()
            ))
            .error_code(error_code::TXT_VERSION_NOT_FOUND)?;
        }
        Ok(version.to_string())
    }

    fn write_version(&self, file_path: &Path, version: &str) -> Result<()> {
        std::fs::write(file_path, format!("{version}\n"))
            .with_context(|| format!("failed to write {}", file_path.display()))
            .error_code(error_code::TXT_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::TXT_INVALID_UTF8)?;
        let version = text.trim();
        if version.is_empty() {
            Err(anyhow::anyhow!("no version found in {filename}"))
                .error_code(error_code::TXT_VERSION_NOT_FOUND)?;
        }
        Ok(version.to_string())
    }

    fn read_version_with_selector(
        &self,
        file_path: &Path,
        selector: Option<&str>,
    ) -> Result<String> {
        let Some(sel) = selector else {
            return self.read_version(file_path);
        };
        let re = compile_selector(sel)?;
        let content = std::fs::read_to_string(file_path)
            .with_context(|| format!("failed to read {}", file_path.display()))
            .error_code(error_code::TXT_READ)?;
        let cap = re
            .captures(&content)
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "selector {sel:?} did not match anything in {}",
                    file_path.display()
                )
            })
            .error_code(error_code::TXT_VERSION_NOT_FOUND)?;
        let m = cap.get(1).ok_or_else(|| {
            anyhow::anyhow!("selector {sel:?} matched but capture group 1 is empty")
        })?;
        Ok(m.as_str().to_string())
    }

    fn write_version_with_selector(
        &self,
        file_path: &Path,
        version: &str,
        selector: Option<&str>,
    ) -> Result<()> {
        let Some(sel) = selector else {
            return self.write_version(file_path, version);
        };
        let re = compile_selector(sel)?;
        let content = std::fs::read_to_string(file_path)
            .with_context(|| format!("failed to read {}", file_path.display()))
            .error_code(error_code::TXT_READ)?;
        let cap = re
            .captures(&content)
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "selector {sel:?} did not match anything in {}",
                    file_path.display()
                )
            })
            .error_code(error_code::TXT_VERSION_NOT_FOUND)?;
        let m = cap.get(1).ok_or_else(|| {
            anyhow::anyhow!("selector {sel:?} matched but capture group 1 is empty")
        })?;
        let mut new_content = String::with_capacity(content.len() + version.len());
        new_content.push_str(&content[..m.start()]);
        new_content.push_str(version);
        new_content.push_str(&content[m.end()..]);
        std::fs::write(file_path, new_content)
            .with_context(|| format!("failed to write {}", file_path.display()))
            .error_code(error_code::TXT_WRITE)?;
        Ok(())
    }
}