cargo-rahti 0.0.8

Create and maintain Rahti projects: cargo rahti new, cargo rahti upgrade.
//! Amending the two files the scaffold does not own.
//!
//! `Cargo.toml` and `.env` are the author's. Neither is in the ledger, and
//! neither is ever regenerated from a template: rewriting a manifest would
//! undo every dependency the author added and would replace a `--local`
//! project's path dependency with a version that may not be published, and
//! rewriting a `.env` would overwrite credentials.
//!
//! That is a rule about *replacing* a file, and it was read for a while as a
//! rule about touching one at all — so `cargo rahti upgrade --db` wrote
//! `src/db.rs`, which opens with `use sea_orm::…`, and then printed the two
//! `cargo add` commands that would make it compile. A tool that breaks the
//! build and leaves instructions has not finished, and the error the author
//! actually sees is `unresolved import sea_orm`, several minutes after the
//! note scrolled past.
//!
//! So the feature an upgrade adds is wired up the rest of the way here, by
//! the smallest edit that does it: a dependency appended to the end of
//! `[dependencies]`, a feature folded into the `rahti` line, a
//! `DATABASE_URL` put above the settings already in `.env`. Every function
//! below returns `None` when there is nothing to do, so a second run changes
//! nothing, and refuses rather than guesses when the file is not in a shape
//! it recognises — the caller prints what to do by hand instead.

use rahti_build::Backend;

use crate::templates as t;

/// The manifest with `line` added to `[dependencies]`, or `None` if a
/// dependency called `name` is already declared.
///
/// The name is checked separately from the line because they are not the
/// same question: an author who pinned their own `sea-orm` has one, in a
/// form that is theirs, and a second key with the same name is a manifest
/// cargo refuses to parse.
pub fn with_dependency(manifest: &str, name: &str, line: &str) -> Option<String> {
    if declares(manifest, name) {
        return None;
    }
    Some(append_to_dependencies(manifest, line))
}

/// Whether `[dependencies]` names `name`, in either form cargo accepts.
///
/// Matched on the key rather than on the text, so `sea-orm-migration` is not
/// mistaken for `sea-orm`: the name has to be the whole key, with only an
/// `=` after it.
pub fn declares(manifest: &str, name: &str) -> bool {
    let table = format!("[dependencies.{name}]");
    manifest.lines().any(|line| {
        let line = line.trim();
        line == table
            || line
                .strip_prefix(name)
                .is_some_and(|rest| rest.trim_start().starts_with('='))
    })
}

/// `line` written at the end of the `[dependencies]` table.
///
/// The end of the table is the next table header, whatever it is — including
/// a `[dependencies.something]` sub-table, because a bare key written after
/// one of those would land in the sub-table and mean something else entirely.
/// Blank lines before that header are left where they are, so the addition
/// joins the list rather than the gap after it.
///
/// A manifest with no `[dependencies]` at all gets one. Cargo allows it —
/// a package with no dependencies needs no table — and appending is the only
/// answer that does not depend on where anything else sits.
fn append_to_dependencies(manifest: &str, line: &str) -> String {
    let ends_with_newline = manifest.ends_with('\n');
    let mut lines: Vec<String> = manifest.lines().map(str::to_string).collect();

    match lines.iter().position(|l| l.trim() == "[dependencies]") {
        Some(header) => {
            let end = lines[header + 1..]
                .iter()
                .position(|l| l.trim_start().starts_with('['))
                .map(|offset| header + 1 + offset)
                .unwrap_or(lines.len());

            let mut at = end;
            while at > header + 1 && lines[at - 1].trim().is_empty() {
                at -= 1;
            }
            lines.insert(at, line.to_string());
        }
        None => {
            if lines.last().is_some_and(|l| !l.trim().is_empty()) {
                lines.push(String::new());
            }
            lines.push("[dependencies]".to_string());
            lines.push(line.to_string());
        }
    }

    let mut out = lines.join("\n");
    if ends_with_newline {
        out.push('\n');
    }
    out
}

/// The manifest with `features = ["ws"]` on its `rahti` dependency.
///
/// `Ok(None)` when the feature is already there. `Err` names what stopped it,
/// for the caller to print: this is the one edit that has to understand the
/// value rather than append a line, and a dependency spelled across several
/// lines as a `[dependencies.rahti]` table is a shape it will not guess at.
pub fn with_ws_feature(manifest: &str) -> Result<Option<String>, String> {
    if manifest.lines().any(|l| l.trim() == "[dependencies.rahti]") {
        return Err("`rahti` is declared as a [dependencies.rahti] table".to_string());
    }

    let at = manifest
        .lines()
        .position(|l| {
            l.trim_start()
                .strip_prefix("rahti")
                .is_some_and(|rest| rest.trim_start().starts_with('='))
        })
        .ok_or_else(|| "Cargo.toml has no `rahti` dependency".to_string())?;

    let mut lines: Vec<String> = manifest.lines().map(str::to_string).collect();
    let line = &lines[at];
    let (key, value) = line.split_once('=').expect("the line was found by its `=`");

    // Only the value is read, so an indented line stays indented.
    let value = value.trim();
    if value.starts_with('{') && !value.ends_with('}') {
        return Err("the `rahti` dependency spans more than one line".to_string());
    }
    if declares_ws(value) {
        return Ok(None);
    }

    let wired = format!("{key}= {}", t::with_ws_feature(value));
    lines[at] = wired;

    let mut out = lines.join("\n");
    if manifest.ends_with('\n') {
        out.push('\n');
    }
    Ok(Some(out))
}

/// Whether a dependency value already lists the `ws` feature. Quoted, so a
/// path with `ws` in it — or the `tokio/time` a feature enables — is not read
/// as the feature itself.
fn declares_ws(value: &str) -> bool {
    value.contains("\"ws\"")
}

/// A `.env` with `DATABASE_URL` in it, or `None` if it already has one.
///
/// Prepended rather than appended, which is where `cargo rahti new` puts it:
/// the section is a heading and a value, and a connection string under the
/// "AUTHENTICATION AND SESSIONS" heading reads as one of its settings.
pub fn with_database_url(env: &str, backend: Backend) -> Option<String> {
    let present = env
        .lines()
        .any(|l| l.trim_start().starts_with("DATABASE_URL"));
    if present {
        return None;
    }

    let mut out = t::database_env(backend);
    out.push_str(env);
    Some(out)
}

#[cfg(test)]
#[path = "tests/wiring.rs"]
mod tests;