rok-sqlc 0.3.0

Generate SQLx Rust database modules from SQL schema files
Documentation
use std::collections::HashSet;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use crate::report::display_path;

const GENERATED_MARKER: &str = "// @generated by rok-sqlc";

#[derive(Debug, Default)]
pub struct WriteReport {
    pub written: Vec<PathBuf>,
    pub unchanged: Vec<PathBuf>,
    pub refused: Vec<(PathBuf, String)>,
}

/// Whether `path` is an existing file carrying the rok-sqlc generated header.
pub fn is_generated_file(path: &Path) -> bool {
    let mut prefix = [0; GENERATED_MARKER.len()];
    fs::File::open(path)
        .and_then(|mut file| file.read_exact(&mut prefix))
        .is_ok_and(|()| prefix == GENERATED_MARKER.as_bytes())
}

/// Whether a file's `existing` text is the planned `contents`. Generated
/// files use `\n`; a checkout that turned them into CRLF (Git's
/// `core.autocrlf`) holds the same code, so line endings are ignored.
fn same_text(existing: &[u8], contents: &str) -> bool {
    existing == contents.as_bytes()
        || std::str::from_utf8(existing)
            .is_ok_and(|existing| existing.replace("\r\n", "\n") == contents)
}

/// Planned files that do not exist yet, and planned files whose contents
/// differ from the plan, ignoring line endings. Touches nothing.
pub fn compare_files(files: &[(PathBuf, String)]) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
    let (mut missing, mut changed) = (Vec::new(), Vec::new());
    for (path, contents) in files {
        if !path.exists() {
            missing.push(path.clone());
            continue;
        }
        let existing = fs::read(path)
            .with_context(|| format!("failed to read existing file `{}`", display_path(path)))?;
        if !same_text(&existing, contents) {
            changed.push(path.clone());
        }
    }
    Ok((missing, changed))
}

/// Stale files: files under `output` that carry the generated header but are
/// not in `keep`. Files without the header are never stale. Sorted.
pub fn find_stale(output: &Path, keep: &HashSet<PathBuf>) -> Result<Vec<PathBuf>> {
    fn walk(dir: &Path, keep: &HashSet<PathBuf>, stale: &mut Vec<PathBuf>) -> Result<()> {
        let entries = match fs::read_dir(dir) {
            Ok(entries) => entries,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
            Err(err) => {
                return Err(err)
                    .with_context(|| format!("failed to read directory `{}`", display_path(dir)));
            }
        };
        for entry in entries {
            let entry = entry
                .with_context(|| format!("failed to read directory `{}`", display_path(dir)))?;
            let path = entry.path();
            // Symlinks are neither followed nor removed.
            let file_type = entry
                .file_type()
                .with_context(|| format!("failed to inspect `{}`", display_path(&path)))?;
            if file_type.is_dir() {
                walk(&path, keep, stale)?;
            } else if file_type.is_file() && !keep.contains(&path) && is_generated_file(&path) {
                stale.push(path);
            }
        }
        Ok(())
    }

    let mut stale = Vec::new();
    walk(output, keep, &mut stale)?;
    stale.sort();
    Ok(stale)
}

/// Removes `stale` files, then the directories under `output` they leave
/// empty. `output` itself is kept.
pub fn remove_stale(output: &Path, stale: &[PathBuf]) -> Result<()> {
    for path in stale {
        fs::remove_file(path)
            .with_context(|| format!("failed to remove `{}`", display_path(path)))?;
    }
    for path in stale {
        let mut dir = path.parent();
        while let Some(current) = dir {
            if current == output || !current.starts_with(output) {
                break;
            }
            // Fails, and stops the climb, while the directory has entries.
            if fs::remove_dir(current).is_err() {
                break;
            }
            dir = current.parent();
        }
    }
    Ok(())
}

pub fn apply_files(files: &[(PathBuf, String)], dry_run: bool, force: bool) -> Result<WriteReport> {
    let mut report = WriteReport::default();
    let mut writes: Vec<(&PathBuf, &String)> = Vec::new();

    for (path, contents) in files {
        match classify(path, contents, force)? {
            Action::Write => {
                report.written.push(path.clone());
                writes.push((path, contents));
            }
            Action::Unchanged => report.unchanged.push(path.clone()),
            Action::Refuse(reason) => report.refused.push((path.clone(), reason)),
        }
    }

    if dry_run || !report.refused.is_empty() {
        return Ok(report);
    }

    for (path, contents) in writes {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| {
                format!("failed to create directory `{}`", display_path(parent))
            })?;
        }
        fs::write(path, contents)
            .with_context(|| format!("failed to write `{}`", display_path(path)))?;
    }

    Ok(report)
}

enum Action {
    Write,
    Unchanged,
    Refuse(String),
}

fn classify(path: &Path, contents: &str, force: bool) -> Result<Action> {
    if !path.exists() {
        return Ok(Action::Write);
    }

    let existing = fs::read_to_string(path)
        .with_context(|| format!("failed to read existing file `{}`", display_path(path)))?;

    if !existing.starts_with(GENERATED_MARKER) && !force {
        return Ok(Action::Refuse(
            "file exists and was not generated by rok-sqlc; pass --force to overwrite".to_owned(),
        ));
    }

    if same_text(existing.as_bytes(), contents) {
        return Ok(Action::Unchanged);
    }

    Ok(Action::Write)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn generated_file(contents: &str) -> (PathBuf, String) {
        (
            PathBuf::from("db/mod.rs"),
            format!("// @generated by rok-sqlc. Do not edit by hand.\n{contents}"),
        )
    }

    #[test]
    fn writes_new_files() -> Result<()> {
        let dir = TempDir::new()?;
        let files = vec![(dir.path().join("db/mod.rs"), "pub mod schema;\n".to_owned())];
        let report = apply_files(&files, false, false)?;
        assert_eq!(report.written.len(), 1);
        assert!(report.refused.is_empty());
        assert_eq!(
            fs::read_to_string(dir.path().join("db/mod.rs"))?,
            "pub mod schema;\n"
        );
        Ok(())
    }

    #[test]
    fn regenerates_files_with_generated_header() -> Result<()> {
        let dir = TempDir::new()?;
        let file = dir.path().join("db/mod.rs");
        fs::create_dir_all(file.parent().unwrap())?;
        fs::write(&file, generated_file("pub mod old;\n").1)?;

        let files = vec![(file.clone(), "pub mod new;\n".to_owned())];
        let report = apply_files(&files, false, false)?;
        assert_eq!(report.written.len(), 1);
        assert_eq!(fs::read_to_string(&file)?, "pub mod new;\n");
        Ok(())
    }

    #[test]
    fn refuses_foreign_files_without_force() -> Result<()> {
        let dir = TempDir::new()?;
        let file = dir.path().join("db/mod.rs");
        fs::create_dir_all(file.parent().unwrap())?;
        fs::write(&file, "// hand-written, precious\n")?;

        let files = vec![(file.clone(), "pub mod new;\n".to_owned())];
        let report = apply_files(&files, false, false)?;
        assert_eq!(report.refused.len(), 1);
        assert_eq!(fs::read_to_string(&file)?, "// hand-written, precious\n");

        let report = apply_files(&files, false, true)?;
        assert_eq!(report.written.len(), 1);
        assert_eq!(fs::read_to_string(&file)?, "pub mod new;\n");
        Ok(())
    }

    #[test]
    fn refuses_anything_when_one_file_is_refused() -> Result<()> {
        let dir = TempDir::new()?;
        let guarded = dir.path().join("db/precious.rs");
        fs::create_dir_all(guarded.parent().unwrap())?;
        fs::write(&guarded, "// hand-written\n")?;

        let files = vec![
            (guarded, "x".to_owned()),
            (dir.path().join("db/fresh.rs"), "y".to_owned()),
        ];
        let report = apply_files(&files, false, false)?;
        assert_eq!(report.refused.len(), 1);
        assert!(!dir.path().join("db/fresh.rs").exists());
        Ok(())
    }

    #[test]
    fn dry_run_writes_nothing() -> Result<()> {
        let dir = TempDir::new()?;
        let files = vec![(dir.path().join("db/mod.rs"), "pub mod schema;\n".to_owned())];
        let report = apply_files(&files, true, false)?;
        assert_eq!(report.written.len(), 1);
        assert!(!dir.path().join("db/mod.rs").exists());
        Ok(())
    }

    #[test]
    fn stale_files_carry_the_header_and_are_not_kept() -> Result<()> {
        let dir = TempDir::new()?;
        let out = dir.path();
        fs::create_dir_all(out.join("gone/deeper"))?;
        fs::write(out.join("gone/deeper/models.rs"), generated_file("").1)?;
        fs::write(out.join("kept.rs"), generated_file("").1)?;
        fs::write(out.join("hand.rs"), "// hand-written\n")?;
        fs::write(out.join("short.rs"), "//")?;

        let keep = HashSet::from([out.join("kept.rs")]);
        let stale = find_stale(out, &keep)?;
        assert_eq!(stale, vec![out.join("gone/deeper/models.rs")]);

        remove_stale(out, &stale)?;
        assert!(!out.join("gone").exists());
        assert!(out.join("kept.rs").is_file());
        assert!(out.join("hand.rs").is_file());
        assert!(out.join("short.rs").is_file());
        Ok(())
    }

    #[test]
    fn missing_output_directory_has_no_stale_files() -> Result<()> {
        let dir = TempDir::new()?;
        assert!(find_stale(&dir.path().join("nope"), &HashSet::new())?.is_empty());
        Ok(())
    }

    #[test]
    fn reports_unchanged_files() -> Result<()> {
        let dir = TempDir::new()?;
        let files = vec![(
            dir.path().join("db/mod.rs"),
            generated_file("pub mod schema;\n").1,
        )];
        apply_files(&files, false, false)?;
        let report = apply_files(&files, false, false)?;
        assert_eq!(report.unchanged.len(), 1);
        assert!(report.written.is_empty());
        Ok(())
    }
}