use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use argonauts_core::NoteLevel;
use argonauts_core::app::{AppEvent, ChoiceKind};
use argonauts_core::events::approval::{AnswerAction, AnswerOption};
use argonauts_core::input::PromptsAction;
use argonauts_core::names::ChoiceId;
use argonauts_core::prompt_files::{self, PROJECT_DIR, PromptDirs, Template};
use argonauts_core::settings::crew::CrewRole;
use argonauts_core::template_list::{ChangedIn, TemplateEntry, TemplateList};
use argonauts_core::{layout, workflow_files};
use argonauts_runner::Origin;
use super::{Ctx, Effect, prompt_templates, workflows};
use crate::choices::{self, choose, option};
use crate::prompts::catalog;
use crate::setup::reference;
const NOTHING_COPIED: &str = "Nothing copied.";
const THIS_FOLDER: &str = "this-folder";
const EVERY_FOLDER: &str = "every-folder";
const KEYS: &str = "123456789abcdfghijklmnopqrsuvwxyz";
#[derive(Default)]
pub(crate) struct TemplatesState {
pending: HashMap<ChoiceId, Pending>,
}
enum Pending {
Which,
Where { name: String },
}
pub(crate) fn owns_choice(state: &TemplatesState, id: &ChoiceId) -> bool {
state.pending.contains_key(id)
}
pub(crate) fn command(
ctx: &mut Ctx,
state: &mut TemplatesState,
name: Option<String>,
global: bool,
) -> anyhow::Result<Vec<Effect>> {
let Some(name) = name else {
let list = list(ctx);
ctx.send(AppEvent::Templates { list: list.clone() });
ask_which(ctx, state, &list);
return Ok(Vec::new());
};
if let Some(copied) = copy(ctx, &name, global)? {
ctx.notice(NoteLevel::Info, copied.said);
return Ok(copied.effects);
}
if catalog::find(&name).is_some() {
return prompt_templates::command(ctx, PromptsAction::Copy { name, global });
}
anyhow::bail!("There's no workflow, role or prompt named {name}. /templates lists them.")
}
struct Copied {
said: String,
effects: Vec<Effect>,
}
fn copy(ctx: &Ctx, name: &str, global: bool) -> anyhow::Result<Option<Copied>> {
let lower = name.to_ascii_lowercase();
if let Some(text) = workflow_text(ctx, &lower)? {
return Ok(Some(Copied {
said: copy_workflow(ctx, &lower, &text, global)?,
effects: vec![Effect::CommandsChanged],
}));
}
let Some(role) = role_named(&lower) else {
return Ok(None);
};
Ok(Some(Copied {
said: copy_prompts_of(ctx, role, global)?,
effects: Vec::new(),
}))
}
fn ask_which(ctx: &mut Ctx, state: &mut TemplatesState, list: &TemplateList) {
let entries = list
.workflows
.iter()
.map(|entry| (entry, "workflow"))
.chain(list.prompts.iter().map(|entry| (entry, "prompts")));
let options: Vec<AnswerOption> = entries
.zip(KEYS.chars())
.map(|((entry, kind), key)| {
let mut option = option(key, &entry.name, choose(&entry.name), false);
let marks = [
entry.in_use.then_some("in use"),
entry
.changed
.map(|changed| changed.mark().trim_matches(['(', ')'])),
];
let detail: Vec<&str> = std::iter::once(kind)
.chain(marks.into_iter().flatten())
.collect();
option.detail = Some(detail.join(" ยท "));
option
})
.collect();
ask(
ctx,
state,
Pending::Which,
"Copy which one?".to_owned(),
options,
);
}
fn ask_where(ctx: &mut Ctx, state: &mut TemplatesState, name: &str) {
let mut here = option('t', "This folder", choose(THIS_FOLDER), true);
here.detail = Some(format!("{}/", ctx.env.shown(&write_dir(ctx, false))));
let mut everywhere = option('e', "Every folder", choose(EVERY_FOLDER), false);
everywhere.detail = Some(format!("{}/", ctx.env.shown(&write_dir(ctx, true))));
ask(
ctx,
state,
Pending::Where {
name: name.to_owned(),
},
format!("Copy {name} into"),
vec![here, everywhere],
);
}
fn ask(
ctx: &mut Ctx,
state: &mut TemplatesState,
pending: Pending,
title: String,
options: Vec<AnswerOption>,
) {
let id = ctx.next_choice_id();
ctx.send(AppEvent::Choice {
choice: choices::app_choice(
id.clone(),
ChoiceKind::Templates,
title,
Vec::new(),
options,
),
});
state.pending.insert(id, pending);
}
pub(crate) fn answer_choice(
ctx: &mut Ctx,
state: &mut TemplatesState,
id: ChoiceId,
action: AnswerAction,
) -> anyhow::Result<Vec<Effect>> {
let Some(pending) = state.pending.remove(&id) else {
ctx.notice(NoteLevel::Info, "That question is already closed.");
return Ok(Vec::new());
};
match (pending, action) {
(Pending::Which, AnswerAction::Choose { value }) => {
close(ctx, id, format!("Copying {value}."));
ask_where(ctx, state, &value);
Ok(Vec::new())
}
(Pending::Where { name }, AnswerAction::Choose { value }) => {
let copied = copy(ctx, &name, value == EVERY_FOLDER);
match copied {
Ok(Some(copied)) => {
close(ctx, id, copied.said);
Ok(copied.effects)
}
Ok(None) => {
close(ctx, id, NOTHING_COPIED);
anyhow::bail!("There's no workflow or role named {name} any more.")
}
Err(err) => {
close(ctx, id, NOTHING_COPIED);
Err(err)
}
}
}
(_, AnswerAction::Cancel) => {
close(ctx, id, NOTHING_COPIED);
Ok(Vec::new())
}
(pending, other) => {
state.pending.insert(id, pending);
anyhow::bail!("{other:?} doesn't answer the /templates question")
}
}
}
pub(crate) fn set_aside(
ctx: &mut Ctx,
state: &mut TemplatesState,
id: ChoiceId,
) -> anyhow::Result<Vec<Effect>> {
if state.pending.remove(&id).is_some() {
close(ctx, id, NOTHING_COPIED);
}
Ok(Vec::new())
}
fn close(ctx: &Ctx, id: ChoiceId, outcome: impl Into<String>) {
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: outcome.into(),
});
}
fn list(ctx: &Ctx) -> TemplateList {
let in_use = ctx.settings().workflow.clone();
let library = workflows::library(ctx);
let changed = |origin: Origin| match origin {
Origin::Project => Some(ChangedIn::Here),
Origin::Yours => Some(ChangedIn::Yours),
Origin::BuiltIn => None,
};
let built_in = |name: &str| workflow_files::built_in(name).is_some();
let mut workflows: Vec<TemplateEntry> = workflow_files::BUILT_IN
.iter()
.map(|(name, _)| TemplateEntry {
name: shown_name(name),
in_use: *name == in_use,
changed: library.find(name).and_then(|(origin, _)| changed(origin)),
})
.collect();
let own = library
.list()
.into_iter()
.filter(|workflow| !built_in(&workflow.name));
workflows.extend(own.map(|workflow| TemplateEntry {
in_use: workflow.name == in_use,
changed: changed(workflow.origin),
name: shown_name(&workflow.name),
}));
let dirs = catalog::dirs(&ctx.env);
let all = catalog::all();
let prompts = CrewRole::ALL
.into_iter()
.map(|role| TemplateEntry {
name: role.plural().to_owned(),
in_use: false,
changed: changed_for(ctx, &dirs, &all, role),
})
.collect();
let mut folder = ctx.env.shown(&reference::dir(&ctx.env));
folder.push('/');
TemplateList {
folder,
workflows,
prompts,
}
}
fn changed_for(
ctx: &Ctx,
dirs: &PromptDirs,
all: &[Template],
role: CrewRole,
) -> Option<ChangedIn> {
let project = ctx.env.repo_root.join(PROJECT_DIR);
let mut yours = None;
for template in all.iter().filter(|template| template.role == role) {
match dirs.template(template.id) {
Some(file) if file.path.starts_with(&project) => return Some(ChangedIn::Here),
Some(_) => yours = Some(ChangedIn::Yours),
None => {}
}
}
yours
}
fn shown_name(name: &str) -> String {
let mut letters = name.chars();
letters.next().map_or_else(String::new, |first| {
first.to_uppercase().chain(letters).collect()
})
}
fn write_dir(ctx: &Ctx, global: bool) -> PathBuf {
if global {
ctx.env.paths.home().to_owned()
} else {
ctx.env.repo_root.join(PROJECT_DIR)
}
}
fn workflow_text(ctx: &Ctx, name: &str) -> anyhow::Result<Option<String>> {
match workflows::library(ctx).find(name) {
Some((_, Some(path))) => {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", ctx.env.shown(&path)))?;
Ok(Some(text))
}
_ => Ok(workflow_files::built_in(name).map(str::to_owned)),
}
}
fn copy_workflow(ctx: &Ctx, name: &str, text: &str, global: bool) -> anyhow::Result<String> {
let folder = write_dir(ctx, global).join(layout::user_files::WORKFLOWS);
let path = folder.join(format!("{name}.toml"));
let shown = ctx.env.shown(&path);
if path.exists() {
return Ok(format!(
"{shown} is there already, so it was kept. /workflow edit {name} opens it."
));
}
write(&folder, &path, text).with_context(|| format!("writing {shown}"))?;
Ok(format!(
"Wrote {shown}. It takes the built-in {name}'s place: edit it, and the next run reads \
your file. Delete it to go back to the built-in."
))
}
fn write(folder: &Path, path: &Path, text: &str) -> std::io::Result<()> {
std::fs::create_dir_all(folder)?;
std::fs::write(path, text)
}
fn role_named(name: &str) -> Option<CrewRole> {
CrewRole::ALL
.into_iter()
.find(|role| role.as_str() == name || role.plural() == name)
}
fn copy_prompts_of(ctx: &Ctx, role: CrewRole, global: bool) -> anyhow::Result<String> {
let dir = write_dir(ctx, global);
let of_role: Vec<Template> = catalog::all()
.into_iter()
.filter(|template| template.role == role)
.collect();
let folder = ctx.env.shown(&dir.join(prompt_files::PROMPTS_DIR));
let written = prompt_files::write_templates(&dir, &of_role)
.with_context(|| format!("writing into {folder}"))?;
let who = role.plural();
let kept = match written.kept.len() {
0 => String::new(),
1 => " 1 file holds your own text and was kept.".to_owned(),
kept => format!(" {kept} files hold your own text and were kept."),
};
Ok(format!(
"The prompts the {who} read are in {folder}/: {} written now.{kept} Edit one, and the \
next agent that starts reads your text. A file nobody edited follows the built-in.",
written.written.len()
))
}
#[cfg(test)]
mod tests {
use argonauts_core::app::{AnswerTarget, AppChoice, UiCommand};
use argonauts_core::input::SlashCommand;
use super::*;
use crate::controller::test_support::TestDriver;
fn templates(name: Option<&str>, global: bool) -> UiCommand {
UiCommand::Slash {
command: SlashCommand::Templates {
name: name.map(str::to_owned),
global,
},
}
}
async fn listed(driver: &mut TestDriver) -> TemplateList {
driver.send(templates(None, false)).await;
match driver
.until(|event| matches!(event, AppEvent::Templates { .. }))
.await
{
AppEvent::Templates { list } => list,
_ => unreachable!(),
}
}
async fn said(driver: &mut TestDriver, part: &str) -> String {
match driver
.until(|event| matches!(event, AppEvent::Notice { text, .. } if text.contains(part)))
.await
{
AppEvent::Notice { text, .. } => text,
_ => unreachable!(),
}
}
#[tokio::test]
async fn the_list_marks_the_workflow_in_use_and_what_you_changed() {
let mut driver = TestDriver::start().await;
let list = listed(&mut driver).await;
assert!(list.folder.ends_with("/templates/"), "{}", list.folder);
let names: Vec<&str> = list.workflows.iter().map(|w| w.name.as_str()).collect();
assert_eq!(names[..2], ["Council", "Orchestrator"]);
assert!(names.contains(&"Maker-checker") && names.contains(&"Best-of-n"));
let in_use: Vec<&str> = list
.workflows
.iter()
.filter(|workflow| workflow.in_use)
.map(|workflow| workflow.name.as_str())
.collect();
assert_eq!(in_use, ["Council"]);
assert!(list.workflows.iter().all(|w| w.changed.is_none()));
let prompts: Vec<&str> = list.prompts.iter().map(|p| p.name.as_str()).collect();
assert_eq!(
prompts,
["main", "orchestrator", "workers", "reviewers", "judge"]
);
assert!(list.prompts.iter().all(|p| p.changed.is_none()));
driver.send(templates(Some("Maker-checker"), false)).await;
let wrote = said(&mut driver, "maker-checker.toml").await;
assert!(wrote.starts_with("Wrote "), "{wrote}");
let file = driver
.env
.repo_root
.join(".argonauts/workflows/maker-checker.toml");
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
workflow_files::built_in("maker-checker").unwrap()
);
driver.send(templates(Some("maker-checker"), false)).await;
let kept = said(&mut driver, "is there already").await;
assert!(kept.contains("so it was kept"), "{kept}");
driver.send(templates(Some("reviewers"), true)).await;
let wrote = said(&mut driver, "The prompts the reviewers read").await;
assert!(!wrote.contains("0 written now"), "{wrote}");
let frame = driver.env.paths.home().join("prompts/frames/reviewer.md");
assert!(frame.is_file());
std::fs::write(&frame, "Review as I would.").unwrap();
let list = listed(&mut driver).await;
let changed = |entries: &[TemplateEntry], name: &str| {
entries
.iter()
.find(|entry| entry.name == name)
.and_then(|entry| entry.changed)
};
assert_eq!(
changed(&list.workflows, "Maker-checker"),
Some(ChangedIn::Here)
);
assert_eq!(changed(&list.prompts, "reviewers"), Some(ChangedIn::Yours));
assert_eq!(changed(&list.prompts, "workers"), None);
driver.exit().await;
}
async fn question(driver: &mut TestDriver) -> AppChoice {
let asked = driver
.until(|event| {
matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::Templates)
})
.await;
match asked {
AppEvent::Choice { choice } => choice,
_ => unreachable!(),
}
}
async fn answer(driver: &mut TestDriver, choice: &AppChoice, action: AnswerAction) -> String {
driver
.send(UiCommand::Answer {
to: AnswerTarget::Choice {
id: choice.id.clone(),
},
action,
text: None,
evidence_sha256: None,
tasks: Vec::new(),
remember: false,
})
.await;
let closed = driver
.until(|event| matches!(event, AppEvent::ChoiceClosed { id, .. } if *id == choice.id))
.await;
match closed {
AppEvent::ChoiceClosed { outcome, .. } => outcome,
_ => unreachable!(),
}
}
async fn pick(driver: &mut TestDriver, choice: &AppChoice, label: &str) -> String {
let option = choice
.options
.iter()
.find(|option| option.label == label)
.unwrap_or_else(|| panic!("no option {label} in {:?}", choice.options));
answer(driver, choice, option.action.clone()).await
}
#[tokio::test]
async fn typed_alone_it_asks_which_one_and_where_then_copies_it() {
let mut driver = TestDriver::start().await;
let list = listed(&mut driver).await;
let which = question(&mut driver).await;
assert_eq!(which.title, "Copy which one?");
let offered: Vec<&str> = which.options.iter().map(|o| o.label.as_str()).collect();
let in_the_list: Vec<&str> = list
.workflows
.iter()
.chain(&list.prompts)
.map(|entry| entry.name.as_str())
.collect();
assert_eq!(offered, in_the_list);
assert_eq!(
which.options[0].detail.as_deref(),
Some("workflow ยท in use")
);
assert_eq!(
which.options.last().unwrap().detail.as_deref(),
Some("prompts")
);
let mut keys: Vec<char> = which.options.iter().map(|o| o.key).collect();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), which.options.len(), "one key each");
assert_eq!(
pick(&mut driver, &which, "Maker-checker").await,
"Copying Maker-checker."
);
let where_to = question(&mut driver).await;
assert_eq!(where_to.title, "Copy Maker-checker into");
let places: Vec<(&str, bool)> = where_to
.options
.iter()
.map(|o| (o.label.as_str(), o.recommended))
.collect();
assert_eq!(places, [("This folder", true), ("Every folder", false)]);
let wrote = pick(&mut driver, &where_to, "This folder").await;
assert!(wrote.starts_with("Wrote "), "{wrote}");
let file = driver
.env
.repo_root
.join(".argonauts/workflows/maker-checker.toml");
assert!(file.is_file());
driver.send(templates(None, false)).await;
let which = question(&mut driver).await;
assert_eq!(
which
.options
.iter()
.find(|o| o.label == "Maker-checker")
.and_then(|o| o.detail.as_deref()),
Some("workflow ยท changed here")
);
pick(&mut driver, &which, "reviewers").await;
let where_to = question(&mut driver).await;
let wrote = pick(&mut driver, &where_to, "Every folder").await;
assert!(
wrote.starts_with("The prompts the reviewers read"),
"{wrote}"
);
assert!(
driver
.env
.paths
.home()
.join("prompts/frames/reviewer.md")
.is_file()
);
driver.send(templates(None, false)).await;
let which = question(&mut driver).await;
assert_eq!(
answer(&mut driver, &which, AnswerAction::Cancel).await,
NOTHING_COPIED
);
pick_nothing_new(&driver);
driver.exit().await;
}
fn pick_nothing_new(driver: &TestDriver) {
let copied: Vec<String> =
std::fs::read_dir(driver.env.repo_root.join(".argonauts/workflows"))
.unwrap()
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().into_string().ok())
.collect();
assert_eq!(copied, ["maker-checker.toml"]);
}
#[tokio::test]
async fn a_prompt_goes_by_its_id_and_an_unknown_name_says_so() {
let mut driver = TestDriver::start().await;
driver.send(templates(Some("frames/judge"), false)).await;
let wrote = said(&mut driver, "frames/judge.md").await;
assert!(wrote.starts_with("Wrote "), "{wrote}");
assert!(
driver
.env
.repo_root
.join(".argonauts/prompts/frames/judge.md")
.is_file()
);
driver.send(templates(Some("nope"), false)).await;
let missing = said(&mut driver, "nope").await;
assert!(
missing.contains("There's no workflow, role or prompt named nope"),
"{missing}"
);
driver.exit().await;
}
}