slave 0.2.0

Laravel-inspired CLI for Rust web apps: scaffold Axum projects with built-in auth, users, roles and Turso migrations
//! Safe file-system helpers shared by the generators.

use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::Path;

use anyhow::{Context, Result, bail};

/// Writes `contents` to `path`, creating parent directories as needed.
///
/// Refuses to touch an existing file unless `force` is set.
pub fn write_file(path: &Path, contents: &str, force: bool) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create directory {}", parent.display()))?;
    }

    let mut options = OpenOptions::new();
    options.write(true);
    if force {
        options.create(true).truncate(true);
    } else {
        // `create_new` fails atomically if the file exists, so nothing is ever clobbered.
        options.create_new(true);
    }

    let mut file = match options.open(path) {
        Ok(file) => file,
        Err(err) if err.kind() == ErrorKind::AlreadyExists => {
            bail!(
                "{} already exists (use --force to overwrite)",
                path.display()
            )
        }
        Err(err) => {
            return Err(err).with_context(|| format!("failed to create {}", path.display()));
        }
    };
    file.write_all(contents.as_bytes())
        .with_context(|| format!("failed to write {}", path.display()))
}

/// Ensures `decl` (e.g. `mod model;`) is declared in `file`, creating the file if needed.
///
/// Declarations are kept in rustfmt's alphabetical order within the last block of
/// `mod` lines; with no block yet, they go after the leading `//!` doc comment.
pub fn ensure_mod_decl(file: &Path, decl: &str) -> Result<()> {
    let name = mod_name(decl).unwrap_or(decl).to_owned();
    edit_lines(file, |lines| {
        if contains_line(lines, decl) {
            return;
        }
        let index = match lines.iter().rposition(|l| mod_name(l).is_some()) {
            Some(last) => {
                let first = lines[..=last]
                    .iter()
                    .rposition(|l| mod_name(l).is_none())
                    .map_or(0, |i| i + 1);
                (first..=last)
                    .find(|&i| mod_name(&lines[i]).is_some_and(|other| other > name.as_str()))
                    .unwrap_or(last + 1)
            }
            None => {
                let docs = lines.iter().take_while(|l| l.starts_with("//!")).count();
                if docs > 0 {
                    lines.insert(docs, String::new());
                }
                docs + usize::from(docs > 0)
            }
        };
        lines.insert(index, decl.to_owned());
    })
}

/// Returns `name` for a `mod name;` or `pub mod name;` line.
fn mod_name(line: &str) -> Option<&str> {
    let line = line.trim();
    let rest = line.strip_prefix("pub ").unwrap_or(line);
    rest.strip_prefix("mod ")?.strip_suffix(';').map(str::trim)
}

/// Appends `line` to `file` unless it is already present, separated by a blank line.
pub fn ensure_trailing_line(file: &Path, line: &str) -> Result<()> {
    edit_lines(file, |lines| {
        if !contains_line(lines, line) {
            lines.push(String::new());
            lines.push(line.to_owned());
        }
    })
}

/// Inserts `line` directly above the line containing `marker`.
///
/// Returns `false` (leaving the file untouched) when the marker is missing.
pub fn insert_before_marker(file: &Path, marker: &str, line: &str) -> Result<bool> {
    let mut found = true;
    edit_lines(file, |lines| {
        if contains_line(lines, line) {
            return;
        }
        match lines.iter().position(|l| l.contains(marker)) {
            Some(index) => lines.insert(index, line.to_owned()),
            None => found = false,
        }
    })?;
    Ok(found)
}

fn contains_line(lines: &[String], wanted: &str) -> bool {
    lines.iter().any(|line| line.trim() == wanted.trim())
}

/// Reads `file` (empty if missing), applies `edit` to its lines, and writes it back if changed.
fn edit_lines(file: &Path, edit: impl FnOnce(&mut Vec<String>)) -> Result<()> {
    let original = match fs::read_to_string(file) {
        Ok(text) => text,
        Err(err) if err.kind() == ErrorKind::NotFound => String::new(),
        Err(err) => return Err(err).with_context(|| format!("failed to read {}", file.display())),
    };

    let mut lines: Vec<String> = original.lines().map(str::to_owned).collect();
    edit(&mut lines);
    let mut updated = lines.join("\n");
    updated.push('\n');

    if updated != original {
        if let Some(parent) = file.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create directory {}", parent.display()))?;
        }
        fs::write(file, updated).with_context(|| format!("failed to update {}", file.display()))?;
    }
    Ok(())
}

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

    fn temp_file(name: &str, contents: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!("slave-gen-{}-{name}", std::process::id()));
        fs::create_dir_all(&dir).unwrap();
        let file = dir.join("mod.rs");
        fs::write(&file, contents).unwrap();
        file
    }

    #[test]
    fn mod_decls_go_after_existing_decls() {
        let file = temp_file("after", "//! Docs.\n\nmod b;\nmod d;\n\npub use b::x;\n");
        ensure_mod_decl(&file, "mod c;").unwrap();
        ensure_mod_decl(&file, "mod c;").unwrap();
        ensure_mod_decl(&file, "mod a;").unwrap();
        ensure_mod_decl(&file, "mod e;").unwrap();
        let text = fs::read_to_string(&file).unwrap();
        assert_eq!(
            text,
            "//! Docs.\n\nmod a;\nmod b;\nmod c;\nmod d;\nmod e;\n\npub use b::x;\n"
        );
    }

    #[test]
    fn mod_decls_go_after_doc_comment() {
        let file = temp_file("docs", "//! Docs.\n");
        ensure_mod_decl(&file, "mod a;").unwrap();
        assert_eq!(fs::read_to_string(&file).unwrap(), "//! Docs.\n\nmod a;\n");
    }

    #[test]
    fn inserts_before_marker_once() {
        let file = temp_file("marker", "a\n    // marker\n}\n");
        assert!(insert_before_marker(&file, "// marker", "    x").unwrap());
        assert!(insert_before_marker(&file, "// marker", "    x").unwrap());
        assert_eq!(
            fs::read_to_string(&file).unwrap(),
            "a\n    x\n    // marker\n}\n"
        );

        let file = temp_file("nomarker", "a\n");
        assert!(!insert_before_marker(&file, "// marker", "x").unwrap());
    }
}