sqlx-turso-cli 0.1.0-alpha.1

Metadata preparation helper for sqlx-turso checked query macros
use std::{
    env,
    ffi::OsString,
    fs, io,
    path::{Path, PathBuf},
    process::{Command, ExitCode},
};

use clap::{Args, CommandFactory, Parser, Subcommand};

fn main() -> ExitCode {
    match CommandLine::try_parse() {
        Ok(command) => match run(command) {
            Ok(()) => ExitCode::SUCCESS,
            Err(error) => {
                eprintln!("error: {error}");
                ExitCode::FAILURE
            }
        },
        Err(error) => {
            let exit_code = if error.use_stderr() {
                ExitCode::FAILURE
            } else {
                ExitCode::SUCCESS
            };

            let _ = error.print();
            exit_code
        }
    }
}

fn run(command: CommandLine) -> Result<(), String> {
    match command.command {
        Some(CommandLineCommand::Prepare(prepare)) => prepare.run(),
        None => {
            let mut command = CommandLine::command();
            command
                .print_help()
                .map_err(|error| format!("failed to print help: {error}"))?;
            println!();
            Ok(())
        }
    }
}

#[derive(Debug, Eq, Parser, PartialEq)]
#[command(version, about)]
struct CommandLine {
    #[command(subcommand)]
    command: Option<CommandLineCommand>,
}

#[derive(Debug, Eq, PartialEq, Subcommand)]
enum CommandLineCommand {
    Prepare(Prepare),
}

#[derive(Args, Debug, Eq, PartialEq)]
struct Prepare {
    #[arg(short = 'D', long)]
    database_url: Option<String>,

    #[arg(long, default_value = ".sqlx")]
    offline_dir: PathBuf,

    #[arg(last = true, value_name = "CARGO CHECK ARGS")]
    cargo_args: Vec<String>,
}

impl Prepare {
    fn run(self) -> Result<(), String> {
        let cargo = env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned());
        self.run_with_program(cargo)
    }

    fn run_with_program(self, cargo: impl Into<String>) -> Result<(), String> {
        let workspace = PrepareWorkspace::create(&self.offline_dir)?;
        let mut command = self
            .command_spec(cargo, workspace.staging_dir())
            .into_command();

        let status = command
            .status()
            .map_err(|error| format!("failed to run cargo check: {error}"))?;

        if !status.success() {
            workspace.cleanup();
            return Err(format!("cargo check exited with {status}"));
        }

        workspace.commit().map_err(|error| {
            format!(
                "failed to replace {} with prepared metadata: {error}",
                self.offline_dir.display()
            )
        })
    }

    fn command_spec(&self, cargo: impl Into<String>, offline_dir: &Path) -> CommandSpec {
        let mut args = vec!["check".to_owned()];
        if self.cargo_args.is_empty() {
            args.push("--all-targets".to_owned());
        } else {
            args.extend(self.cargo_args.iter().cloned());
        }

        let mut envs = vec![(
            "SQLX_OFFLINE_DIR".to_owned(),
            offline_dir.as_os_str().to_owned(),
        )];
        if let Some(database_url) = &self.database_url {
            envs.push(("DATABASE_URL".to_owned(), OsString::from(database_url)));
        }

        CommandSpec {
            program: cargo.into(),
            args,
            envs,
        }
    }
}

#[derive(Debug, Eq, PartialEq)]
struct CommandSpec {
    program: String,
    args: Vec<String>,
    envs: Vec<(String, OsString)>,
}

impl CommandSpec {
    fn into_command(self) -> Command {
        let mut command = Command::new(self.program);
        command.args(self.args);
        for (key, value) in self.envs {
            command.env(key, value);
        }

        command
    }
}

#[derive(Debug)]
struct PrepareWorkspace {
    final_dir: PathBuf,
    staging_dir: PathBuf,
    backup_dir: PathBuf,
}

impl PrepareWorkspace {
    fn create(final_dir: &Path) -> Result<Self, String> {
        validate_offline_dir(final_dir)?;

        if final_dir.exists() && !final_dir.is_dir() {
            return Err(format!("{} is not a directory", final_dir.display()));
        }

        let parent = final_dir.parent().unwrap_or_else(|| Path::new("."));
        fs::create_dir_all(parent).map_err(|error| {
            format!(
                "failed to create parent directory {}: {error}",
                parent.display()
            )
        })?;

        let staging_dir = unique_sibling(final_dir, "tmp");
        fs::create_dir(&staging_dir).map_err(|error| {
            format!(
                "failed to create staging directory {}: {error}",
                staging_dir.display()
            )
        })?;

        Ok(Self {
            final_dir: final_dir.to_path_buf(),
            staging_dir,
            backup_dir: unique_sibling(final_dir, "old"),
        })
    }

    fn staging_dir(&self) -> &Path {
        &self.staging_dir
    }

    fn commit(self) -> io::Result<()> {
        if self.backup_dir.exists() {
            fs::remove_dir_all(&self.backup_dir)?;
        }

        let had_existing = self.final_dir.exists();
        if had_existing {
            fs::rename(&self.final_dir, &self.backup_dir)?;
        }

        match fs::rename(&self.staging_dir, &self.final_dir) {
            Ok(()) => {
                if had_existing {
                    fs::remove_dir_all(&self.backup_dir)?;
                }
                Ok(())
            }
            Err(error) => {
                if had_existing {
                    let _ = fs::rename(&self.backup_dir, &self.final_dir);
                }

                Err(error)
            }
        }
    }

    fn cleanup(&self) {
        let _ = fs::remove_dir_all(&self.staging_dir);
    }
}

fn validate_offline_dir(path: &Path) -> Result<(), String> {
    if path.as_os_str().is_empty() {
        return Err("offline directory cannot be empty".to_owned());
    }

    if path == Path::new(".") {
        return Err("offline directory cannot be the current directory".to_owned());
    }

    if path.parent().is_none() && path.has_root() {
        return Err("offline directory cannot be the filesystem root".to_owned());
    }

    Ok(())
}

fn unique_sibling(path: &Path, suffix: &str) -> PathBuf {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    let name = path
        .file_name()
        .unwrap_or_else(|| std::ffi::OsStr::new("sqlx"))
        .to_string_lossy();

    let pid = std::process::id();

    for attempt in 0.. {
        let candidate = parent.join(format!(".{name}.{suffix}-{pid}-{attempt}"));
        if !candidate.exists() {
            return candidate;
        }
    }

    unreachable!("unbounded suffix search must return a candidate")
}

#[cfg(test)]
mod tests {
    use std::{fs, process};

    use super::{CommandSpec, Prepare, PrepareWorkspace};

    #[test]
    fn command_spec_uses_default_check_args_and_staging_dir() {
        let prepare = Prepare {
            database_url: Some("turso::memory:".to_owned()),
            offline_dir: ".sqlx".into(),
            cargo_args: Vec::new(),
        };

        let spec = prepare.command_spec("cargo-test", "target/offline-staging".as_ref());

        assert_eq!(
            spec,
            CommandSpec {
                program: "cargo-test".to_owned(),
                args: vec!["check".to_owned(), "--all-targets".to_owned()],
                envs: vec![
                    (
                        "SQLX_OFFLINE_DIR".to_owned(),
                        "target/offline-staging".into()
                    ),
                    ("DATABASE_URL".to_owned(), "turso::memory:".into()),
                ],
            }
        );
    }

    #[test]
    fn workspace_keeps_existing_dir_until_commit() {
        let root = std::env::temp_dir().join(format!(
            "sqlx-turso-cli-{}-{}",
            process::id(),
            "workspace-keeps-existing"
        ));
        let final_dir = root.join(".sqlx");
        let old_file = final_dir.join("old.json");
        let new_file = "new.json";

        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(&final_dir).unwrap();
        fs::write(&old_file, b"old").unwrap();

        let workspace = PrepareWorkspace::create(&final_dir).unwrap();
        fs::write(workspace.staging_dir().join(new_file), b"new").unwrap();

        assert!(old_file.exists());
        workspace.commit().unwrap();

        assert!(!old_file.exists());
        assert!(final_dir.join(new_file).exists());

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn workspace_cleanup_preserves_existing_dir() {
        let root = std::env::temp_dir().join(format!(
            "sqlx-turso-cli-{}-{}",
            process::id(),
            "workspace-cleanup"
        ));
        let final_dir = root.join(".sqlx");
        let old_file = final_dir.join("old.json");

        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(&final_dir).unwrap();
        fs::write(&old_file, b"old").unwrap();

        let workspace = PrepareWorkspace::create(&final_dir).unwrap();
        let staging_dir = workspace.staging_dir().to_path_buf();
        workspace.cleanup();

        assert!(old_file.exists());
        assert!(!staging_dir.exists());

        let _ = fs::remove_dir_all(&root);
    }
}