sofie-cli 0.3.0

The `sofie` command: gen, check and the tools reading the app model.
Documentation
use std::fmt::Write as _;
use std::path::{Path, PathBuf};

use sofie_model::naming::task_name;
use sofie_model::{App, Module};

use crate::gen_command::{GenError, read_existing};
use crate::new_command::NEW_COMMANDS;
use crate::routes_command::GENERATOR_PATHS;
use crate::scaffold_tests::test_places;
use crate::sorted;

/// Marks the file as `sofie gen`'s, so it may rewrite it.
const AGENTS_HEADER: &str = "<!-- @generated by `sofie gen`; do not edit. -->\n\n";

pub(crate) fn render_agents_md(app: &App) -> Result<(PathBuf, Option<String>), GenError> {
    let path = app.root.join("AGENTS.md");
    if read_existing(&path)?.is_some_and(|existing| !existing.starts_with(AGENTS_HEADER)) {
        return Ok((path, None));
    }
    let sections = [
        format!(
            "# Working on `{}`\n\nWritten by `sofie gen` from the code; `sofie check` reports it \
             stale when the code changes. Ask `sofie explain NAME` before reading files.\n",
            app.app.package
        ),
        modules(app),
        areas(app),
        commands(),
        APP_TOOLS.to_owned(),
        PLACES.to_owned(),
        RULES.to_owned(),
        RECIPES.to_owned(),
        DATA.to_owned(),
    ]
    .join("\n");
    Ok((path, Some(format!("{AGENTS_HEADER}{sections}"))))
}

fn modules(app: &App) -> String {
    let modules = sorted::modules(app);
    let mut text = "## Modules\n\n".to_owned();
    if modules.is_empty() {
        text.push_str("None yet; add one with `sofie new module NAME`.\n");
    }
    for module in modules {
        let _ = writeln!(text, "{}", module_line(&app.root, module));
    }
    text
}

fn module_line(root: &Path, module: &Module) -> String {
    let path = module
        .krate
        .path
        .strip_prefix(root)
        .unwrap_or(&module.krate.path);
    let tier = module
        .manifest
        .as_ref()
        .map_or("no tier", |manifest| manifest.tier.name());
    let mut parts = vec![format!(
        "- `{}` ({tier}) in `{}/`",
        module.krate.package,
        path.display()
    )];
    let areas: Vec<&str> = module.areas.iter().map(|area| area.area.as_str()).collect();
    parts.extend(listed("areas", &areas));
    let aggregates: Vec<&str> = module
        .code
        .aggregates
        .iter()
        .map(|aggregate| aggregate.name.as_str())
        .collect();
    parts.extend(listed("aggregates", &aggregates));
    let events: Vec<&str> = module.events.iter().map(String::as_str).collect();
    parts.extend(listed("events", &events));
    let tasks: Vec<String> = module
        .tasks
        .iter()
        .map(|task| task_name(module, &task.function))
        .collect();
    let tasks: Vec<&str> = tasks.iter().map(String::as_str).collect();
    parts.extend(listed("tasks", &tasks));
    if let Some(contract) = &module.contract {
        parts.push(format!("contract `{}`", contract.package));
    }
    parts.join("; ")
}

fn commands() -> String {
    let new: Vec<String> = NEW_COMMANDS
        .iter()
        .map(|command| format!("`{}`", command.invocation()))
        .collect();
    format!(
        "{COMMANDS_BEFORE_NEW}- {}: stubs in their place, then `sofie gen` and `sofie check`.\n\
         {COMMANDS_AFTER_NEW}",
        new.join(", ")
    )
}

fn listed(label: &str, names: &[&str]) -> Option<String> {
    if names.is_empty() {
        return None;
    }
    let quoted: Vec<String> = names.iter().map(|name| format!("`{name}`")).collect();
    Some(format!("{label} {}", quoted.join(", ")))
}

fn areas(app: &App) -> String {
    let mut text = "## Areas\n\n".to_owned();
    for area in app.areas() {
        let owner = area
            .contract
            .map_or("the framework".to_owned(), |contract| {
                format!("`{contract}`")
            });
        let prefix = match area.prefix() {
            Some("") => "at the root".to_owned(),
            Some(prefix) => format!("under `{prefix}`"),
            None => "under a prefix that is not a literal".to_owned(),
        };
        let client = match area.definition.map(|definition| definition.client) {
            Some(sofie_model::AreaClient::Svelte) => {
                "; its screens are compiled into Svelte components (`explain` and `components` \
                 hold for them too; markup, forms and actions behave as in HTML areas)"
            }
            _ => "",
        };
        let _ = writeln!(
            text,
            "- `{}`: {prefix}, defined by {owner}{client}",
            area.name
        );
    }
    let _ = writeln!(text, "\n{GENERATOR_PATHS}");
    text
}

const COMMANDS_BEFORE_NEW: &str = "## Commands

- `sofie gen`: rewrites the generated files (wiring, contracts' `gen/`, `.env.example`, this file); run it after changing routes, modules, aggregates or screens.
- `sofie check`: every rule below; `app/build.rs` runs it, so a finding fails `cargo build`.
- `sofie fix`: applies the fixes that have exactly one form, then reports the rest.
- `sofie explain NAME`: a route, area, module, event, job, task, read model, aggregate, screen, screen component or flag.
- `sofie routes`: the route table.
- `sofie components`: every component a screen template may use, with its props, and the template syntax.
- `sofie tasks`: each `#[task]` with the `--name value` arguments it reads; the binary runs one with `<app> task MODULE.NAME [--name value]...`.
- `sofie dev`: runs the app, rebuilding and reloading on changes.
- `sofie feature add|remove NAME`: turns a `sofie` feature on or off in `app/Cargo.toml`, editing only that dependency's features.
- `sofie new module NAME`, `sofie scaffold tests`, `sofie scaffold screen KEY`: new code in its place, wired.
";

const COMMANDS_AFTER_NEW: &str = "- `sofie build [--image]`: the release binary, and with `--image` its container image.
- `sofie bench`: load tests of the routes, held to a baseline.
- `sofie fuzz [--seed N] [--requests N]`: sends every route generated valid and broken inputs, signed in and out, and reports each 500, dropped answer or crash with its input, span tree and log lines; a local database only.
- `sofie seed [--volume N]`: fills the local database through the app's own POST routes, first with each module's `seeds/<area>.<handler>.json` (a JSON array of inputs), then N generated inputs per route, as the member `sofie bench` signs in with.
- `sofie mcp`: these tools over the Model Context Protocol, each generator with `dry_run`, and the app's own tools below.
- `cargo test --offline`: the tests.
";

const APP_TOOLS: &str = "## Running the app through `sofie mcp`

- Before calling a route, `explain` it: it names the input's fields and their JSON shape.
- `request` runs the app itself on a free local port and rebuilds it when code changed, so there is no need to start `sofie dev`. Its `session` is a named cookie jar: sign up or in through the app's own routes, then act as that user. With `target`, the id a `Form` or `Action` targets, it asks for that fragment alone. The answer carries the request's span tree; jobs and listeners of the same trace come later through `traces` with its trace id.
- `dev` starts, stops or reports the app; `logs` follows its output with a cursor; `traces` lists recent span trees, requests apart from jobs and listeners.
- `task` runs one `#[task]` once against the app's database, as `<app> task` does; its `arguments` are a JSON object.
- `tests` lists each test file of the app and its modules with its test functions; with `name` it answers one test's source. Read the nearest one before writing a test, and follow its style.
- `test` answers each test that ran by name and outcome; with `filter`, also the tests in changed files the filter left out, so run those too.
- `lint` runs `cargo fmt --check` and `cargo clippy --all-targets -- -D warnings` on the app's own packages (with `write` it formats them first); run it with `check` and `test` before calling work done.
- `changes` lists the files added, modified, renamed or deleted under the app root since git's last commit, and with `path` one file's diff: review it before calling work done.
- `db_status`: call it before and after adding or changing a migration; it shows per module which migrations are applied, pending, edited after being applied or missing as files. Never edit an applied migration: add a new one, or rebuild a local database with `sofie db fresh`.
- The app needs `DATABASE_URL` and `SOFIE_SIGNING_KEYS` from the environment or `.env`.
";

const PLACES: &str = "## Where code goes

Under `modules/<module>/`:

- `src/module.rs`: `MODULE`, the manifest (name, tier, settings) spreading the generated `src/wiring.rs`; `src/config.rs`: its configuration.
- `src/<aggregate>/mod.rs`: an aggregate, changed only through its methods; `factory.rs` beside it is its only constructor.
- `src/actions/`: one file per action (writes); `src/queries/`: one per query (reads) and their read models.
- `src/areas/<area>/routes.rs`: the module's endpoints in an area; `menu.rs` its menu entries; `screens/<name>.html` its templates.
- `src/tasks.rs` or `src/tasks/`: `#[task]` functions run once by hand, such as a data backfill.
- `src/gateways/`: outgoing requests; `src/extensions/`: implementations of other contracts' extension points; `src/domain/`: private code.
- `contract/`: the only crate other modules may use: traits, events (`events.rs`), areas (`area.rs`); `contract/src/gen/` is generated.
- `migrations/NNNN_name.sql`, `lang/<language>.toml`.

`app/src/wiring.rs` and each module's `src/wiring.rs` are generated; the app crate holds no module code.
";

const RULES: &str = "## Rules `sofie check` holds the app to

- A module uses another module only through its contract crate; tiers depend downward (Experience, Core, Platform) and nothing depends on the app.
- A Platform module never listens to events of modules above it; events are declared in contracts.
- Only queries are routed under `get`; SQL is written only in queries; no two endpoints share a path in an area.
- Code asks for `Clock` instead of reading the system time, and reads the environment only through its module's config.
- Requests leave the app only through gateways and contracts.
- Every field constraint and enum of an aggregate has a CHECK or unique index in a migration.
- Screens bind only what their loader provides; flags are named `<module>.<flag>` and declared by their module with the day they were added.
- No dead code or unused dependency (unless `dead-code-allow.toml` gives a reason); an app setting `structure-ceilings = true` also keeps comments within 2.86% of code lines and no type a god type.
- Generated files are current: run `sofie gen`.
";

const RECIPES: &str = concat!(
    "## How to add

- A module: `sofie new module NAME`, then set its tier in `src/module.rs`.
- A route: `sofie new route`, or one line in `src/areas/<area>/routes.rs` naming a handler in `actions/` or `queries/`, such as `routes.post(\"/orders\", actions::place_order)`; then `sofie gen`.
- A screen: `sofie new route MODULE AREA get PATH handler --screen NAME` (a path naming an aggregate, such as `/orders/{order}`, gets a read model of its fields and a screen showing them; `--fields a,b` picks them), or `src/areas/<area>/screens/<name>.html` routed with `routes.screen(\"/path\", \"<name>\", queries::loader)`; then `sofie gen`.
- An aggregate: `sofie new aggregate`, or `src/<aggregate>/mod.rs` with `#[derive(Aggregate)]`, re-exported from `lib.rs`, and a migration creating its table; then `sofie gen` and `sofie check`.
- Child rows: a `#[derive(Child)]` struct with an `id: Id<Self>` field, held in a `Children<T>` field of the aggregate (`push`, `retain`, the slice's methods), its table named after the child with a column named after the aggregate; they load and save with the aggregate. To show them, a loader `find`s the parent and maps its children into a computed read model (`OrderView { lines: Vec<LineView> }`); a read model cannot read a child table by `#[read(from = …)]`.
- An event and its listener: `sofie new event`, then `sofie new listener` in each module reacting to it. A listener runs after the request answered, so a rule the request must refuse (one check-in per day, a seat limit) belongs in one aggregate's method over its own fields or children, whose version refuses a racing commit with 409; a listener only projects.
- Sign-up for a member area: `impl sofie_identity::SignUp for <Area> { const PATH: &'static str = \"<prefix>/sign-up\"; }` in the area's contract; without it the area takes invitations only. Signing up signs nobody in: the client posts to the sign-in route next.
- A date: `sofie::prelude::Date` with the `sofie` feature `time` (serializes, so events and read models take it), not `sofie::core::Date`.
- Tests: `sofie scaffold tests` writes them for what the modules declare. ",
    test_places!(),
    "\n"
);

const DATA: &str = "## Loaders, read models and screens

- A loader is an `async fn` in `queries/` that takes `read: Read` (and route parameters) and answers a read model, or a `Vec`, `Option`, `Paginated` or `Listed` of one; `sofie gen` checks every screen binding against that type without compiling, so any other type is a finding. A loader taking `listing: Listing<Row>` answers `listing.page(&read).await`, which a `Table` pages, sorts and filters; a GET takes no `Input`, so it reads the query string as `query: sofie::http::Query<Search>`, `Search` a `#[derive(Input)]` struct checked as a body is (a listing takes `Listing<Row, Search>` and reads it with `listing.parameters()`).
- A list's URL reads `?page=2&size=50&sort=-total&status=PAID&total.gt=100`: a field name filters by equality, `field.<operator>` by one of `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `contains`, `empty`, `sort` names a field (`-` for descending).
- `#[derive(ReadModel)]` with `#[read(from = Order)]` selects those columns of an aggregate: `OrderRow::query().where_(…).latest().limit(50).all(&read)`, also `first`, `count`, `exists`; `all` without `limit` reads every match, and `stream` (the `sofie` feature `streams`, no ordering or limit) is only for more rows than fit in memory. `Status::VARIANTS` lists an `#[derive(Enum)]`'s `(name, value)` pairs, e.g. to show statuses with no records. A field `#[read(via = Order::CUSTOMER, field = Customer::NAME)]` reads a column of the record the base's id column names (the `sofie` feature `joins`). Reads are already scoped to what the principal may see, so a loader never filters by owner itself. Everything a read model selects reaches JSON and tables, so a field reading a sensitive one is a compile error unless it is marked `#[sensitive]`.
- Without `#[read(from = …)]` a read model is computed: the loader fills its fields, so counts, sums and groupings are plain Rust over rows it read, e.g. `OrderSummary { by_status: Vec<StatusCount>, total: Money }` with `StatusCount` a computed read model too; bindings are checked through both. Never change the framework's crates; report what it lacks.
- Templates only access fields and format them; compute everything in the loader.
- `routes.resource::<Order>()` serves built-in screens keyed `order_index`, `order_show`, `order_create` and `order_edit`; `src/areas/<area>/screens/<key>.html` (`sofie scaffold screen MODULE/AREA/KEY` writes the built-in one there) replaces one.
- With `sofie mcp` connected, write scaffolding through its generate tools (`dry_run` first), not by hand; fill in the bodies they leave.
- Through `sofie mcp`, `edit_screen` changes an existing screen template by key: it replaces an exact `find` text that occurs once with `replace`, then runs `sofie gen` and `sofie check` (`dry_run` first).
- A menu entry: `sofie new menu MODULE AREA handler LABEL` (MCP `new_menu_entry`) writes it, declares the file and enables the feature. It goes in `src/areas/<area>/menu.rs`, which holds only `pub fn menu(menu: &mut Menu<Area>) { menu.entry(\"Label\", <module>_contract::routes::<area>::<handler>()); }` statements and needs the `sofie` feature `menu`; an entry names a page of its area without parameters.
- An area has no `/` page unless a module routes one; the app answers \"Not found.\" there.
";

#[cfg(test)]
mod tests {
    use sofie_model::testing::{TemporaryDirectory, aggregate, app, area, define_area, module};

    use super::{AGENTS_HEADER, render_agents_md};
    use crate::routes_command::GENERATOR_PATHS;

    #[test]
    fn lists_each_module_and_area_under_the_generated_header() {
        let directory = TemporaryDirectory::new("sofie-agents");
        let mut billing = module("billing", directory.path("modules/billing"));
        billing.areas.push(area(&billing, "member", Vec::new()));
        define_area(&mut billing, "member");
        billing.code.aggregates = vec![aggregate("Invoice", Vec::new(), "invoice/mod.rs")];
        let shop = app(directory.root(), vec![billing]);

        let (path, text) = render_agents_md(&shop).unwrap();

        let text = text.unwrap();
        assert_eq!(path, directory.path("AGENTS.md"));
        assert!(text.starts_with(AGENTS_HEADER), "{text}");
        assert!(
            text.contains(
                "- `billing` (Core) in `modules/billing/`; areas `member`; aggregates \
                 `Invoice`; contract `billing-contract`\n"
            ),
            "{text}"
        );
        assert!(
            text.contains(
                "- `web`: at the root, defined by the framework\n- `member`: under \
                 `/account`, defined by `billing-contract`\n"
            ),
            "{text}"
        );
        assert!(text.contains(&format!("\n\n{GENERATOR_PATHS}\n")), "{text}");
        assert!(
            text.contains("- Through `sofie mcp`, `edit_screen` changes"),
            "{text}"
        );
        assert!(
            text.contains("`sofie new mail MODULE name [--list LIST]`, "),
            "{text}"
        );
        assert!(
            text.contains("- `sofie feature add|remove NAME`: turns a `sofie` feature on"),
            "{text}"
        );
        assert!(
            text.contains(
                "- Tests: `sofie scaffold tests` writes them for what the modules declare. \
                 Scaffolded tests live in `app/tests/scaffolded/`, one file per run; extend them \
                 there; hand-written tests go in `app/tests/<topic>.rs`. Write each as \
                 `#[test_app] async fn name(app: TestApp)`, as the scaffolded ones are; \
                 `#[tokio::test]` only where `#[test_app]` cannot run it, with a line saying why.\n"
            ),
            "{text}"
        );
        assert!(
            text.contains("a field reading a sensitive one is a compile error unless it is marked `#[sensitive]`"),
            "{text}"
        );
    }

    #[test]
    fn leaves_an_agents_md_written_by_hand_alone() {
        let directory = TemporaryDirectory::new("sofie-agents");
        directory.write("AGENTS.md", "# Our own rules\n");
        let shop = app(directory.root(), Vec::new());

        let (_, text) = render_agents_md(&shop).unwrap();

        assert_eq!(text, None);
    }
}