blob: 7847701b159a37c6d20a9c0930b19ffe9c1d0311 [file]
//! `agents`: development instructions for whoever writes the application.
//!
//! `types` gives an editor the API; this gives a coding agent the rules the
//! API cannot express — no DOM, state in `init`, `notify` after a change,
//! single-use elements, capabilities denied by default — and the commands that
//! check its work. Like `gpui-kit.d.ts` the text is generated from the runtime
//! that will run the application, so the module list and the component catalog
//! are the ones this host actually has.
use std::path::{Path, PathBuf};
use super::HostBrand;
use crate::engine::quickjs::exports;
/// The file written into an application directory. `AGENTS.md` is the name
/// coding agents look for without being told.
pub(super) const AGENTS_FILE: &str = "AGENTS.md";
const TEMPLATE: &str = include_str!("agents.md");
/// The first line of every generated file. Only a file that starts with it is
/// ever replaced, so instructions an author wrote by hand are never lost.
fn marker(brand: HostBrand) -> String {
format!(
"<!-- Generated by `{} agents`. Edits are replaced on the next run; delete this line to keep them. -->",
brand.name()
)
}
/// The instructions for this host, with its name, version, application
/// directory and modules filled in.
pub(super) fn render(brand: HostBrand, components: &crate::FrozenComponentRegistry) -> String {
let body = TEMPLATE
.replace("{modules}", &modules(components))
.replace("{home}", &display_home(brand))
.replace("{version}", brand.version())
.replace("{name}", brand.name());
format!("{}\n\n{body}", marker(brand))
}
fn display_home(brand: HostBrand) -> String {
format!("~/.{}/apps", brand.name())
}
fn modules(components: &crate::FrozenComponentRegistry) -> String {
let mut sections = vec![
module(
"gpui-kit",
"GPUI's own elements and the runtime (`\"gpui\"` is an alias)",
exports::GPUI,
),
module(
"gpui-base",
"unstyled controls, layout helpers and the theme",
exports::GPUI_BASE,
),
module("gpui-fps", "the native performance HUD", exports::GPUI_FPS),
];
if let Some(specifier) = components.module_specifier() {
let names: Vec<&str> = components
.descriptors()
.map(|descriptor| descriptor.name())
.chain(components.states().map(|state| state.export()))
.collect();
sections.push(module(
specifier,
"this host's styled components; prefer them to hand-styled base controls",
&names,
));
}
sections.join("\n")
}
fn module(specifier: &str, description: &str, names: &[&str]) -> String {
let names = names
.iter()
.map(|name| format!("`{name}`"))
.collect::<Vec<_>>()
.join(", ");
format!("- `\"{specifier}\"` — {description}: {names}")
}
/// Writes the instructions into an application directory.
///
/// Returns `Ok(None)` when the file already says exactly this, so a directory
/// an editor watches is not touched on every run.
pub(super) fn write(
directory: &Path,
brand: HostBrand,
components: &crate::FrozenComponentRegistry,
) -> Result<Option<PathBuf>, String> {
if !directory.is_dir() {
return Err(format!("`{}` is not a directory", directory.display()));
}
let path = directory.join(AGENTS_FILE);
let text = render(brand, components);
match std::fs::read_to_string(&path) {
Ok(existing) if existing == text => return Ok(None),
Ok(existing) if !existing.starts_with(&marker(brand)) => {
return Err(format!(
"{} exists and was not generated by {name}; it is left alone. \
Run `{name} agents` to print the instructions instead.",
path.display(),
name = brand.name()
));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("cannot read {}: {error}", path.display())),
}
std::fs::write(&path, text)
.map(|()| Some(path.clone()))
.map_err(|error| format!("cannot write {}: {error}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn brand() -> HostBrand {
HostBrand::new("gpui-shell", "1.2.3")
}
#[test]
fn the_instructions_name_this_host_and_its_modules() {
let text = render(brand(), &crate::FrozenComponentRegistry::default());
assert!(text.starts_with(&marker(brand())));
assert!(text.contains("gpui-shell types"));
assert!(text.contains("~/.gpui-shell/apps"));
assert!(text.contains("\"shell-version\": \"1.2.3\""));
assert!(text.contains("`Button`"), "gpui-base's exports are listed");
assert!(!text.contains("{name}") && !text.contains("{modules}"));
}
#[test]
fn only_a_generated_file_is_replaced() {
let directory =
std::env::temp_dir().join(format!("gpui-shell-agents-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("temporary directory");
let components = crate::FrozenComponentRegistry::default();
let written = write(&directory, brand(), &components).expect("a new file is written");
assert_eq!(written, Some(directory.join(AGENTS_FILE)));
assert_eq!(
write(&directory, brand(), &components).expect("unchanged"),
None,
"an identical file is not rewritten"
);
std::fs::write(directory.join(AGENTS_FILE), "# My own notes\n").expect("hand-written");
let error = write(&directory, brand(), &components).expect_err("hand-written is kept");
assert!(error.contains("left alone"), "{error}");
assert_eq!(
std::fs::read_to_string(directory.join(AGENTS_FILE)).unwrap(),
"# My own notes\n"
);
let _ = std::fs::remove_dir_all(&directory);
}
}