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