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)>,
}
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())
}
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)
}
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))
}
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();
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)
}
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;
}
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(())
}
}