use std::ffi::OsStr;
use std::fmt::Write;
use include_dir::{Dir, File};
use crate::ci::FailedChecks;
use crate::github::{Check, CheckState};
use crate::harness::{Choice, Harness};
use crate::issue::IssueUrl;
use crate::prompt;
use crate::session::{claude_args, codex_args};
use crate::skills::SKILLS;
use Sender::{ArchitectRun, Units};
struct Unit {
anchor: &'static str,
name: &'static str,
}
const IMPLEMENT: Unit = Unit {
anchor: "unit-2",
name: "2 · M · Implement",
};
const REVIEW: Unit = Unit {
anchor: "unit-3",
name: "3 · Y · Review",
};
const FINISH: Unit = Unit {
anchor: "unit-4",
name: "4 · K · Finish",
};
const ISSUE_URL: &str = "<Issue URL>";
const SPEC_URL: &str = "<Spec URL>";
const ISSUE_NUMBER: u64 = u64::MAX;
const NUMBER: &str = "<n>";
const BASE: &str = "<base>";
const BRANCH: &str = "<branch>";
const SPEC_BRANCH: &str = "<Spec branch>";
const PR_URL: &str = "<pull request URL>";
const OWN_HEAD: &str = "<own head>";
const CHECK: &str = "<failing check>";
const CHECK_URL: &str = "<check URL>";
const INHERITED_CHECK: &str = "<Inherited failure>";
const BACKGROUND_WORK: &str = "<background work>";
const FOCUS: &str = "<focus>";
const SESSION_ID: &str = "<session id>";
const PROMPT: &str = "<prompt>";
const MODEL: &str = "<model>";
const EFFORT: &str = "<effort>";
const PLACEHOLDERS: [&str; 17] = [
ISSUE_URL,
SPEC_URL,
NUMBER,
BASE,
BRANCH,
SPEC_BRANCH,
PR_URL,
OWN_HEAD,
CHECK,
CHECK_URL,
INHERITED_CHECK,
BACKGROUND_WORK,
FOCUS,
SESSION_ID,
PROMPT,
MODEL,
EFFORT,
];
enum Sender {
Units(&'static [Unit]),
ArchitectRun,
}
struct Prompt {
id: &'static str,
title: &'static str,
when: &'static str,
sender: Sender,
text: String,
}
fn prompts() -> Vec<Prompt> {
let issue = IssueUrl {
url: ISSUE_URL.to_string(),
owner: "<owner>".to_string(),
repo: "<repo>".to_string(),
number: ISSUE_NUMBER,
};
let spec = IssueUrl {
url: SPEC_URL.to_string(),
..issue.clone()
};
let check = |name: &str| Check {
name: name.to_string(),
state: CheckState::Failed,
url: Some(CHECK_URL.to_string()),
job: None,
};
let failed = FailedChecks {
own: vec![check(CHECK)],
inherited: Vec::new(),
on_base: Vec::new(),
};
let failed_with_inherited = FailedChecks {
own: vec![check(CHECK)],
inherited: vec![check(INHERITED_CHECK)],
on_base: vec![check(INHERITED_CHECK)],
};
let with_sentinel = vec![
Prompt {
id: "prompt-fresh",
title: "Fresh",
when: "Starts the implement session, which goes on to review and open the pull request, when the Run starts a new Issue branch.",
sender: Units(&[IMPLEMENT]),
text: prompt::fresh(&issue, BASE, BRANCH),
},
Prompt {
id: "prompt-continuation",
title: "Continuation, with no pull request",
when: "Starts the implement session when the Run is a Continuation of an Issue branch that has no pull request.",
sender: Units(&[IMPLEMENT]),
text: prompt::continuation(&issue, BASE, BRANCH, None),
},
Prompt {
id: "prompt-continuation-pr",
title: "Continuation, with an open pull request",
when: "Starts the implement session when the Run is a Continuation of an Issue branch whose pull request is open.",
sender: Units(&[IMPLEMENT]),
text: prompt::continuation(&issue, BASE, BRANCH, Some(PR_URL)),
},
Prompt {
id: "prompt-spec-review",
title: "Spec review",
when: "In a Spec run, starts the Spec review once every Ticket has landed on the Spec branch, before the Spec PR, a draft until then, is marked ready.",
sender: Units(&[REVIEW]),
text: prompt::spec_review(&spec, BASE, SPEC_BRANCH, PR_URL),
},
Prompt {
id: "prompt-architecture-review",
title: "Architecture review",
when: "Starts the Architecture review, the session an Architect run opens with, in a worktree at the head of the Base branch. The line naming the focus is left out when the command gives none.",
sender: ArchitectRun,
text: prompt::architecture_review(BASE, Some(FOCUS)),
},
Prompt {
id: "prompt-conflict-repair",
title: "Conflict Repair",
when: "Starts a Repair session when merging the Base branch into the Issue branch leaves conflicts.",
sender: Units(&[FINISH]),
text: prompt::conflict_repair(&issue, BASE, BRANCH, PR_URL),
},
Prompt {
id: "prompt-foreign-conflict-repair",
title: "Conflict Repair, on Foreign commits",
when: "In a Merge run, starts a Repair session when merging Foreign commits from the Issue branch on origin into the local one leaves conflicts.",
sender: Units(&[FINISH]),
text: prompt::conflict_repair(&issue, BRANCH, BRANCH, PR_URL),
},
Prompt {
id: "prompt-review-repair",
title: "Review Repair",
when: "In a Merge run, starts a Repair session once Foreign commits are merged into the Issue branch, to review them from the head the Run last knew as its own before they can be merged.",
sender: Units(&[FINISH]),
text: prompt::review_repair(&issue, BRANCH, PR_URL, OWN_HEAD),
},
Prompt {
id: "prompt-ci-fix-repair",
title: "CI-fix Repair",
when: "Starts a Repair session when CI fails on the pull request's head commit, listing each failed check.",
sender: Units(&[FINISH]),
text: prompt::ci_fix_repair(&issue, BASE, BRANCH, PR_URL, &failed),
},
Prompt {
id: "prompt-ci-fix-repair-inherited",
title: "CI-fix Repair, with Inherited failures",
when: "Starts a Repair session when CI fails on the pull request's head commit and some of the failed checks, but not all, are Inherited failures: they also failed on the Base branch commit the head last merged in, so they are listed apart as not to fix.",
sender: Units(&[FINISH]),
text: prompt::ci_fix_repair(&issue, BASE, BRANCH, PR_URL, &failed_with_inherited),
},
Prompt {
id: "prompt-resume",
title: "Resume",
when: "Continues any session, once, that ended its turn with background work still running, which was killed with it.",
sender: Units(&[IMPLEMENT, REVIEW, FINISH]),
text: prompt::resume(&[BACKGROUND_WORK]),
},
];
with_sentinel
.into_iter()
.map(|prompt| Prompt {
text: prompt
.text
.replace(&format!("#{ISSUE_NUMBER}"), &format!("#{NUMBER}")),
..prompt
})
.collect()
}
const UPDATE: &str = "UPDATE_PROMPTS=1 cargo test prompts_page";
const README: &str = "README.md";
struct PromptFile {
name: String,
contents: String,
}
fn prompt_files() -> Vec<PromptFile> {
let prompts = prompts();
let mut files: Vec<_> = prompts
.iter()
.map(|prompt| PromptFile {
name: file_name(prompt),
contents: markdown(prompt),
})
.collect();
files.push(PromptFile {
name: README.to_string(),
contents: readme(&prompts),
});
files
}
fn file_name(prompt: &Prompt) -> String {
let name = prompt
.id
.strip_prefix("prompt-")
.unwrap_or_else(|| panic!("the prompt id {} has no prompt- prefix", prompt.id));
format!("{name}.md")
}
fn readme(prompts: &[Prompt]) -> String {
let resume = prompts
.iter()
.find(|prompt| prompt.id == "prompt-resume")
.expect("there is a Resume prompt");
let mut readme = format!(
"<!-- Generated by src/prompts_page.rs from the prompts' titles and when sentences in src/prompts_page.rs and the claude and codex arguments in src/session.rs; don't edit. Regenerate with {UPDATE} -->
# Session prompts
Every Session prompt thirdshift can start or resume an agent session with, one file each, with placeholders such as `{BRANCH}` where each Run fills in its own values. The [Prompts and skills page](https://thirdshift.app/prompts/) shows the same prompts.
Every session runs `claude` headless in the Run's worktree, in auto permission mode. The Factory skills are linked into the worktree's `.claude/skills/`, where Claude Code finds them, and kept out of git:
```sh
{new}
```
A [Resume]({resume_file}) continues the session that ended:
```sh
{resume}
```
{codex}
| Prompt | When it is sent | File |
| --- | --- | --- |
",
new = command(None),
resume = command(Some(SESSION_ID)),
resume_file = file_name(resume),
codex = codex_note(|code| format!("`{code}`")),
);
for prompt in prompts {
let _ = writeln!(
readme,
"| {title} | {when} | [{name}]({name}) |",
title = prompt.title,
when = prompt.when,
name = file_name(prompt),
);
}
readme.push_str(
"\nThe Factory skills the prompts name are in [`skills/`](../skills/), as thirdshift writes them out.\n",
);
readme
}
fn markdown(prompt: &Prompt) -> String {
let fence = fence(&prompt.text);
format!(
"<!-- Generated by src/prompts_page.rs from the prompts in src/prompt.rs and their titles and when sentences in src/prompts_page.rs; don't edit. Regenerate with {UPDATE} -->\n\n# {title}\n\n{when}\n\n{fence}\n{text}\n{fence}\n",
title = prompt.title,
when = prompt.when,
text = prompt.text.trim_end_matches('\n'),
)
}
fn fence(text: &str) -> String {
let longest = text.split(|c| c != '`').map(str::len).max().unwrap_or(0);
"`".repeat(longest.max(2) + 1)
}
fn skill_used_by(skill: &str) -> String {
match skill {
"thirdshift-implement" | "thirdshift-tdd" => unit_links(&[IMPLEMENT]),
"thirdshift-code-review" | "thirdshift-pr" => unit_links(&[REVIEW]),
"thirdshift-resolving-merge-conflicts" => {
format!("{}, in a Repair", unit_links(&[FINISH]))
}
"thirdshift-improve-codebase-architecture"
| "thirdshift-to-spec"
| "thirdshift-to-tickets"
| "thirdshift-codebase-design" => {
"the Architecture review, in an Architect run".to_string()
}
_ => panic!("the Factory skill {skill} has no user: add it to skill_used_by"),
}
}
fn render() -> String {
let mut html = String::new();
command_line(&mut html);
prompt_section(&mut html);
skills_section(&mut html);
licence_section(&mut html);
html
}
fn command(resume: Option<&str>) -> String {
let harness = Choice::default();
format!("claude {}", claude_args(&harness, resume, PROMPT).join(" "))
}
fn codex_command() -> String {
let harness = Choice {
harness: Harness::Codex,
model: Some(MODEL.to_string()),
effort: Some(EFFORT.to_string()),
..Choice::default()
};
format!("codex {}", codex_args(&harness, None, PROMPT).join(" "))
}
fn codex_note(code: impl Fn(&str) -> String) -> String {
format!(
"With {harness}, each session runs {command} instead, without {model} or the Effort's {c} where they are not set, and with {resume} before the prompt for a Resume. The Factory skills are linked into {dir}, and a prompt's first line loads its skill as {sigil}.",
harness = code("harness codex"),
command = code(&codex_command()),
model = code(&format!("-m {MODEL}")),
c = code("-c"),
resume = code(&format!("resume {SESSION_ID}")),
dir = code(".agents/skills/"),
sigil = code("$thirdshift-<skill>"),
)
}
fn command_line(html: &mut String) {
let _ = write!(
html,
r##"<section class="sec" aria-labelledby="command-title">
<div class="wrap">
<div class="sec-head"><p class="eyebrow">The command line</p><h2 id="command-title">How each session starts</h2></div>
<p class="lede">Every session runs <code>claude</code> headless in the Run's worktree, in auto permission mode. The Factory skills are linked into the worktree's <code>.claude/skills/</code>, where Claude Code finds them, and kept out of git. The prompt is one of the templates below.</p>
<pre class="job-text" aria-label="A new session"><code>{new}</code></pre>
<p class="lede">A <a href="#prompt-resume">Resume</a> continues the session that ended:</p>
<pre class="job-text" aria-label="A Resume"><code>{resume}</code></pre>
<p class="lede">{codex}</p>
</div>
</section>
"##,
new = highlighted(&command(None)),
resume = highlighted(&command(Some(SESSION_ID))),
codex = codex_note(|code| format!("<code>{}</code>", highlighted(code))),
);
}
fn prompt_section(html: &mut String) {
html.push_str(
r##"<section class="sec" aria-labelledby="prompts-title">
<div class="wrap">
<div class="sec-head"><p class="eyebrow">Prompts</p><h2 id="prompts-title">What a Run tells the agent</h2></div>
<p class="lede">Every prompt a Run can send, with its placeholders highlighted: they change for each Run.</p>
<div class="job-sheets">
"##,
);
for prompt in prompts() {
let _ = write!(
html,
r##" <article class="job-sheet" id="{id}" aria-labelledby="{id}-title">
<h3 id="{id}-title">{title}</h3>
<p>{when}</p>
<p class="sent-by">Sent by {sender}</p>
<pre class="job-text"><code>{text}</code></pre>
</article>
"##,
id = prompt.id,
title = prompt.title,
when = prompt.when,
sender = sent_by(&prompt.sender),
text = highlighted(&prompt.text),
);
}
html.push_str(" </div>\n </div>\n</section>\n");
}
fn skills_section(html: &mut String) {
html.push_str(
r##"<section class="sec" aria-labelledby="skills-title">
<div class="wrap">
<div class="sec-head"><p class="eyebrow">Factory skills</p><h2 id="skills-title">The skills, as the agent reads them</h2></div>
<p class="lede">Every Factory skill and supporting file, raw, exactly as thirdshift writes it out.</p>
<div class="job-sheets">
"##,
);
let mut skills: Vec<&Dir> = SKILLS.dirs().collect();
skills.sort_by_key(|dir| dir.path());
for skill in skills {
let name = skill.path().to_string_lossy();
let _ = write!(
html,
r##" <article class="job-sheet" id="skill-{name}" aria-labelledby="skill-{name}-title">
<h3 id="skill-{name}-title">{name}</h3>
<p class="sent-by">Used by {used_by}</p>
"##,
used_by = skill_used_by(&name),
);
let mut files = Vec::new();
collect_files(skill, &mut files);
files.sort_by_key(|file| {
(
file.path().file_name() != Some(OsStr::new("SKILL.md")),
file.path(),
)
});
for file in files {
file_block(html, file, " ");
}
html.push_str(" </article>\n");
}
html.push_str(" </div>\n </div>\n</section>\n");
}
fn licence_section(html: &mut String) {
html.push_str(
r##"<section class="sec" aria-labelledby="licence-title">
<div class="wrap">
<div class="sec-head"><p class="eyebrow">Licence and credits</p><h2 id="licence-title">Whose skills these are</h2></div>
<p class="lede">The Factory skills are adapted from Matt Pocock's skills, under this licence. The <a href="#skill-thirdshift-pr">thirdshift-pr</a> skill credits Dex Horthy's <code>show-me</code> skill in its <a href="#file-thirdshift-pr-credits-md">CREDITS.md</a>.</p>
"##,
);
let mut files: Vec<&File> = SKILLS.files().collect();
files.sort_by_key(|file| file.path());
for file in files {
assert_eq!(
file.path(),
OsStr::new("LICENSE"),
"a file outside any skill needs a place on the page"
);
file_block(html, file, " ");
}
html.push_str(" </div>\n</section>\n");
}
fn collect_files<'a>(dir: &'a Dir<'a>, files: &mut Vec<&'a File<'a>>) {
files.extend(dir.files());
for dir in dir.dirs() {
collect_files(dir, files);
}
}
fn file_block(html: &mut String, file: &File, indent: &str) {
let path = file.path().to_string_lossy();
let text = file
.contents_utf8()
.unwrap_or_else(|| panic!("{path} is not UTF-8"));
let id: String = path
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let _ = write!(
html,
"{indent}<h4 id=\"file-{id}\"><code>{path}</code></h4>\n{indent}<pre class=\"job-text\"><code>{text}</code></pre>\n",
path = escape(&path),
text = escape(text),
);
}
fn sent_by(sender: &Sender) -> String {
match sender {
Units(units) => unit_links(units),
ArchitectRun => "an Architect run, before any unit".to_string(),
}
}
fn unit_links(units: &[Unit]) -> String {
let links: Vec<_> = units
.iter()
.map(|unit| format!(r##"<a href="/#{}">{}</a>"##, unit.anchor, unit.name))
.collect();
match links.split_last() {
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} or {last}", rest.join(", ")),
None => String::new(),
}
}
fn highlighted(text: &str) -> String {
let mut html = escape(text);
for placeholder in PLACEHOLDERS {
let escaped = escape(placeholder);
html = html.replace(&escaped, &format!(r##"<mark class="ph">{escaped}</mark>"##));
}
html
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use super::*;
const END: &str = "<!-- End of the generated fragment -->\n";
fn begin() -> String {
format!(
"<!-- Generated from the source by src/prompts_page.rs; don't edit. Regenerate with {UPDATE} -->\n"
)
}
fn updating() -> bool {
std::env::var_os("UPDATE_PROMPTS").is_some()
}
#[test]
fn prompts_page_shows_what_the_binary_sends() {
let site = Path::new(env!("CARGO_MANIFEST_DIR")).join("site");
if !site.is_dir() {
eprintln!(
"skipping: no {} (the crates.io package leaves it out)",
site.display()
);
return;
}
let page_path = site.join("prompts/index.html");
let page = fs::read_to_string(&page_path)
.unwrap_or_else(|error| panic!("could not read {}: {error}", page_path.display()));
let begin = begin();
let (before, rest) = page
.split_once(&begin)
.unwrap_or_else(|| panic!("{} has no line {begin}", page_path.display()));
let (checked_in, after) = rest
.split_once(END)
.unwrap_or_else(|| panic!("{} has no line {END}", page_path.display()));
let rendered = render();
if updating() {
fs::write(&page_path, format!("{before}{begin}{rendered}{END}{after}"))
.unwrap_or_else(|error| panic!("could not write {}: {error}", page_path.display()));
return;
}
assert!(
checked_in == rendered,
"{} is out of date with the prompts, the claude and codex arguments or the skills:\n{}\nRegenerate it with: {UPDATE}",
page_path.display(),
diff(checked_in, &rendered, before.lines().count() + 2),
);
}
#[test]
fn prompt_files_show_what_the_binary_sends() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("prompts");
if !dir.is_dir() {
eprintln!(
"skipping: no {} (the crates.io package leaves it out)",
dir.display()
);
return;
}
let files = prompt_files();
if updating() {
write_prompt_files(&dir, &files);
return;
}
let problems = check_prompt_files(&dir, &files);
assert!(
problems.is_empty(),
"{}\nRegenerate them with: {UPDATE}",
problems.join("\n"),
);
}
fn check_prompt_files(dir: &Path, files: &[PromptFile]) -> Vec<String> {
let mut problems = Vec::new();
for file in files {
let path = dir.join(&file.name);
match fs::read_to_string(&path) {
Ok(checked_in) if checked_in == file.contents => {}
Ok(checked_in) => problems.push(format!(
"{} is out of date with the prompts:\n{}",
path.display(),
diff(&checked_in, &file.contents, 1),
)),
Err(error) if error.kind() == ErrorKind::NotFound => problems.push(format!(
"{} is missing:\n{}",
path.display(),
diff("", &file.contents, 1),
)),
Err(error) => panic!("could not read {}: {error}", path.display()),
}
}
for path in stale_files(dir, files) {
problems.push(format!("{} is stale: nothing generates it", path.display()));
}
problems
}
fn write_prompt_files(dir: &Path, files: &[PromptFile]) {
for file in files {
let path = dir.join(&file.name);
fs::write(&path, &file.contents)
.unwrap_or_else(|error| panic!("could not write {}: {error}", path.display()));
}
for path in stale_files(dir, files) {
let removed = if path.is_dir() {
fs::remove_dir_all(&path)
} else {
fs::remove_file(&path)
};
removed.unwrap_or_else(|error| panic!("could not delete {}: {error}", path.display()));
}
}
fn stale_files(dir: &Path, files: &[PromptFile]) -> Vec<PathBuf> {
let entries = fs::read_dir(dir)
.unwrap_or_else(|error| panic!("could not read {}: {error}", dir.display()));
let mut stale: Vec<_> = entries
.map(|entry| {
entry
.unwrap_or_else(|error| panic!("could not read {}: {error}", dir.display()))
.path()
})
.filter(|path| {
!files
.iter()
.any(|file| path.file_name() == Some(OsStr::new(&file.name)))
})
.collect();
stale.sort();
stale
}
fn diff(old: &str, new: &str, first: usize) -> String {
let (old, new): (Vec<_>, Vec<_>) = (old.lines().collect(), new.lines().collect());
let mut common = vec![vec![0usize; new.len() + 1]; old.len() + 1];
for i in (0..old.len()).rev() {
for j in (0..new.len()).rev() {
common[i][j] = if old[i] == new[j] {
common[i + 1][j + 1] + 1
} else {
common[i + 1][j].max(common[i][j + 1])
};
}
}
let (mut i, mut j, mut out) = (0, 0, String::new());
while i < old.len() || j < new.len() {
if i < old.len() && j < new.len() && old[i] == new[j] {
(i, j) = (i + 1, j + 1);
} else if j == new.len() || (i < old.len() && common[i + 1][j] >= common[i][j + 1]) {
let _ = writeln!(out, "{:>5} - {}", first + j, old[i]);
i += 1;
} else {
let _ = writeln!(out, "{:>5} + {}", first + j, new[j]);
j += 1;
}
}
out
}
#[test]
fn diff_shows_only_the_changed_lines() {
assert_eq!(
diff("a\nb\nc\n", "a\nB\nc\nd\n", 1),
" 2 - b\n 2 + B\n 4 + d\n"
);
}
#[test]
fn a_fence_is_longer_than_any_run_of_backticks_in_the_text() {
assert_eq!(fence("no backticks"), "```");
assert_eq!(fence("see `gh run view`"), "```");
assert_eq!(fence("a block:\n```sh\nls\n```\n"), "````");
assert_eq!(fence("``` then `````"), "``````");
}
#[test]
fn a_prompt_file_carries_the_title_the_when_and_the_fenced_text() {
let prompt = Prompt {
id: "prompt-example",
title: "Example",
when: "When an example runs.",
sender: Units(&[IMPLEMENT]),
text: "Say ```hi```.\n".to_string(),
};
let markdown = markdown(&prompt);
let (note, rest) = markdown.split_once("\n\n").unwrap();
assert!(
note.starts_with("<!-- Generated ") && note.contains(UPDATE),
"{note}"
);
assert_eq!(
rest,
"# Example\n\nWhen an example runs.\n\n````\nSay ```hi```.\n````\n"
);
}
#[test]
fn the_prompts_readme_links_every_prompt_file_and_shows_both_command_lines() {
let files = prompt_files();
let readme = &files
.iter()
.find(|file| file.name == README)
.expect("prompts/ has a README.md")
.contents;
assert!(
readme.starts_with("<!-- Generated ") && readme.contains(UPDATE),
"{readme}"
);
assert!(readme.contains("Session prompt"), "{readme}");
assert!(readme.contains(&command(None)), "{readme}");
assert!(readme.contains(&command(Some(SESSION_ID))), "{readme}");
assert!(readme.contains("](../skills/)"), "{readme}");
for prompt in prompts() {
assert!(
readme.contains(&format!("| {} | {} |", prompt.title, prompt.when)),
"{readme}"
);
}
let links: Vec<_> = readme
.lines()
.filter(|line| line.starts_with("| ") && line.contains("]("))
.map(|line| {
let (_, link) = line.rsplit_once("](").unwrap();
link.trim_end_matches(" |").trim_end_matches(')')
})
.collect();
let prompt_names: Vec<_> = files
.iter()
.map(|file| file.name.as_str())
.filter(|name| *name != README)
.collect();
assert_eq!(links, prompt_names);
}
#[test]
fn checking_prompts_names_each_missing_differing_and_stale_file() {
let temp = tempfile::TempDir::new().unwrap();
let dir = temp.path();
fs::write(dir.join("same.md"), "same\n").unwrap();
fs::write(dir.join("changed.md"), "old\n").unwrap();
fs::write(dir.join("stray.md"), "stray\n").unwrap();
fs::create_dir(dir.join("stray-dir")).unwrap();
let file = |name: &str, contents: &str| PromptFile {
name: name.to_string(),
contents: contents.to_string(),
};
let files = [
file("same.md", "same\n"),
file("changed.md", "new\n"),
file("missing.md", "missing\n"),
];
let problems = check_prompt_files(dir, &files);
assert_eq!(problems.len(), 4, "{problems:?}");
assert!(
problems[0].contains("changed.md")
&& problems[0].contains(" 1 - old\n 1 + new\n")
);
assert!(problems[1].contains("missing.md") && problems[1].contains(" 1 + missing\n"));
assert!(problems[2].contains("stray-dir") && problems[2].contains("nothing generates"));
assert!(problems[3].contains("stray.md") && problems[3].contains("nothing generates"));
write_prompt_files(dir, &files);
assert!(check_prompt_files(dir, &files).is_empty());
assert!(!dir.join("stray.md").exists() && !dir.join("stray-dir").exists());
}
#[test]
fn placeholders_are_highlighted_and_fixed_text_is_escaped() {
assert_eq!(
highlighted("Push <branch>; see `gh run view <run-id>` & <n>"),
r##"Push <mark class="ph"><branch></mark>; see `gh run view <run-id>` & <mark class="ph"><n></mark>"##
);
}
}