use std::path::Path;
use crate::ai::AiClient;
use crate::ai::prompts::{Prompt, PromptLibrary, iso_from_long};
use crate::config::Config;
use crate::error::Result;
use crate::project::ProjectLayout;
use super::PromptsCommand;
pub fn run(project: &Path, cmd: PromptsCommand) -> Result<()> {
match cmd {
PromptsCommand::Bootstrap {
language,
genre,
provider,
update,
} => bootstrap(
project,
&language,
genre.as_deref(),
provider.as_deref(),
update,
),
}
}
const EMBEDDED_PROMPTS: &[(&str, &str)] = &[
(
"grammar-check",
"F7 — copy-edit pass over the open paragraph.",
),
(
"explain-diagnostic",
"Ctrl+F12 — explain the Typst compiler diagnostic at the cursor.",
),
(
"critique-edit",
"F12 (editor mode) — identify the two or three weakest elements in the open paragraph.",
),
(
"critique-changes",
"F12 (split-edit mode) — evaluate what the latest revision changed.",
),
(
"critique-compare",
"F12 (split-view mode, 1.2.12+) — compare two distinct paragraphs (translation source vs translation, draft vs draft); identify convergence, divergence, which one lands the beat better.",
),
(
"show-dont-tell",
"Ctrl+B Shift+T — find every place the writer is telling instead of showing.",
),
(
"sentence-rhythm-rewrite",
"Ctrl+B Shift+M — break monotonous sentence rhythm while preserving voice.",
),
(
"timeline-health",
"Timeline modal — review the story timeline for internal consistency.",
),
];
fn bootstrap(
project: &Path,
language: &str,
genre: Option<&str>,
provider: Option<&str>,
update: bool,
) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let ai = AiClient::from_config(&cfg.llm)?;
let (model, _env_var) = ai.resolve_provider(&cfg.llm, provider)?;
let lang_iso = iso_from_long(language).to_string();
let prompt = build_prompt(language, &lang_iso, genre);
eprintln!(
"inkhaven prompts bootstrap · language: {language} ({lang_iso}){genre_tail} · model: {model}",
genre_tail = genre
.map(|g| format!(" · genre: {g}"))
.unwrap_or_default(),
);
let raw = match crate::ai::stream::collect_blocking(
ai.client.clone(),
model.to_string(),
Some(SYSTEM_PROMPT.to_string()),
prompt,
) {
Ok(raw) => raw,
Err(e) => {
eprintln!();
eprintln!("inference error: {e}");
return Ok(());
}
};
eprintln!();
let parsed = match parse_prompts(&raw) {
Ok(p) => p,
Err(why) => {
eprintln!("could not parse model response: {why}");
eprintln!("---- raw response ----");
eprintln!("{raw}");
eprintln!("---- end ----");
return Ok(());
}
};
for (name, _) in EMBEDDED_PROMPTS {
if parsed.get(*name).map(String::is_empty).unwrap_or(true) {
eprintln!("warning: model returned no `{name}` body");
}
}
if update {
match apply_update(project, &lang_iso, &parsed) {
Ok((written, backup)) => {
eprintln!(
"patched {} (pre-patch backup: {})",
written.display(),
backup.display(),
);
}
Err(e) => {
eprintln!("in-place update failed: {e}");
eprintln!(
"(nothing was written; pasting the snippet below by hand still works)"
);
}
}
}
print_snippet(&lang_iso, &parsed);
Ok(())
}
fn apply_update(
project: &Path,
lang_iso: &str,
parsed: &std::collections::HashMap<String, String>,
) -> std::result::Result<(std::path::PathBuf, std::path::PathBuf), String> {
let layout = ProjectLayout::new(project);
let prompts_path = layout.root.join("prompts.hjson");
let baseline = if prompts_path.exists() {
std::fs::read_to_string(&prompts_path)
.map_err(|e| format!("read {}: {e}", prompts_path.display()))?
} else {
crate::config::DEFAULT_PROMPTS.to_string()
};
let backup_dir = layout.root.join(".config-backups");
std::fs::create_dir_all(&backup_dir)
.map_err(|e| format!("create {}: {e}", backup_dir.display()))?;
let ts = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
let backup_path = backup_dir.join(format!("prompts_{ts}.hjson"));
std::fs::write(&backup_path, &baseline)
.map_err(|e| format!("write {}: {e}", backup_path.display()))?;
let mut library: PromptLibrary = serde_hjson::from_str(&baseline)
.map_err(|e| format!("parse baseline HJSON: {e}"))?;
merge_into_library(&mut library, lang_iso, parsed);
let body = serde_hjson::to_string(&library)
.map_err(|e| format!("serialise PromptLibrary: {e}"))?;
let mut tmp = prompts_path.clone();
let mut name = prompts_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
name.push_str(".tmp");
tmp.set_file_name(&name);
std::fs::write(&tmp, &body)
.map_err(|e| format!("write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &prompts_path)
.map_err(|e| format!("rename {} → {}: {e}", tmp.display(), prompts_path.display()))?;
Ok((prompts_path, backup_path))
}
fn merge_into_library(
library: &mut PromptLibrary,
lang_iso: &str,
parsed: &std::collections::HashMap<String, String>,
) {
for (name, _) in EMBEDDED_PROMPTS {
let Some(body) = parsed.get(*name) else {
continue;
};
if body.trim().is_empty() {
continue;
}
let template = body.to_string();
let lang_tag = lang_iso.to_string();
let existing = library.prompts.iter_mut().find(|p| {
p.name == *name
&& p.language
.as_deref()
.map(|l| l.eq_ignore_ascii_case(&lang_tag))
.unwrap_or(false)
});
match existing {
Some(prompt) => {
prompt.template = template;
}
None => {
library.prompts.push(Prompt {
name: name.to_string(),
description: format!("{name} ({lang_tag})"),
template,
language: Some(lang_tag),
});
}
}
}
}
const SYSTEM_PROMPT: &str = "\
You are a precise literary-craft translator helping an author localise \
the prompt library of their writing tool. Reply with a SINGLE JSON \
object and nothing else — no prose, no preamble, no markdown fences. \
Every prompt body must be in the requested target language (a native \
speaker would write it that way). Preserve technical references to \
Typst markup verbatim — they're API tokens, not prose. Preserve any \
double-curly placeholders like {{selection}} and {{context}} verbatim — \
they're substituted at runtime. Keep the tight, directive tone of the \
English originals; do not soften the imperative voice.";
fn build_prompt(language: &str, lang_iso: &str, genre: Option<&str>) -> String {
let mut out = String::new();
out.push_str(&format!(
"Translate / adapt the seven inkhaven embedded prompts below into \
{language}. Each prompt is a system message inkhaven sends to an \
LLM to perform one specific writing-craft task on the open \
paragraph. Match the directive tone, technical references, and \
output-discipline clauses (e.g. \"Return ONLY the rewritten \
paragraph\") exactly — those clauses matter for the downstream \
pipeline.\n\n"
));
if let Some(g) = genre {
out.push_str(&format!(
"Where vocabulary choice has wiggle room, bias toward the \
{g} register (the author works in that mode).\n\n"
));
}
out.push_str("--- The seven prompts ---\n\n");
for (name, description) in EMBEDDED_PROMPTS {
out.push_str(&format!("Name: {name}\nDescription: {description}\n\n"));
}
out.push_str(&format!(
"--- Output format ---\n\n\
Reply with EXACTLY this JSON shape (target language: {language}, \
ISO 639-1 code: {lang_iso}) and NO other text:\n\n\
{{\n\
\x20 \"grammar-check\": \"…\",\n\
\x20 \"explain-diagnostic\": \"…\",\n\
\x20 \"critique-edit\": \"…\",\n\
\x20 \"critique-changes\": \"…\",\n\
\x20 \"show-dont-tell\": \"…\",\n\
\x20 \"sentence-rhythm-rewrite\": \"…\",\n\
\x20 \"timeline-health\": \"…\"\n\
}}\n\n\
Each value is the full prompt body the model will see. Do NOT \
translate the JSON keys — those are stable identifiers. Do NOT \
add any other top-level keys; do NOT include English originals \
alongside the translations.\n"
));
out
}
fn parse_prompts(raw: &str) -> std::result::Result<std::collections::HashMap<String, String>, String> {
let trimmed = raw.trim();
let stripped = strip_code_fence(trimmed);
let json = extract_json_object(stripped)
.ok_or_else(|| "no JSON object found in response".to_string())?;
serde_json::from_str::<std::collections::HashMap<String, String>>(json)
.map_err(|e| format!("invalid JSON: {e}"))
}
fn strip_code_fence(s: &str) -> &str {
let s = s.trim();
let s = s.strip_prefix("```json").unwrap_or(s);
let s = s.strip_prefix("```").unwrap_or(s);
s.strip_suffix("```").unwrap_or(s).trim()
}
fn extract_json_object(s: &str) -> Option<&str> {
let bytes = s.as_bytes();
let start = bytes.iter().position(|&b| b == b'{')?;
let mut depth: i32 = 0;
let mut in_string = false;
let mut escape = false;
for (i, &b) in bytes.iter().enumerate().skip(start) {
if in_string {
if escape {
escape = false;
} else if b == b'\\' {
escape = true;
} else if b == b'"' {
in_string = false;
}
continue;
}
match b {
b'"' => in_string = true,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some(&s[start..=i]);
}
}
_ => {}
}
}
None
}
fn print_snippet(
lang_iso: &str,
parsed: &std::collections::HashMap<String, String>,
) {
println!("// Paste under the `prompts` array in prompts.hjson.");
println!(
"// Each block carries `language: {lang_iso}` so the multilingual resolver"
);
println!("// picks it over the embedded English fallback when the project");
println!("// language (or paragraph-detected language) matches.");
println!();
for (name, _description) in EMBEDDED_PROMPTS {
let Some(body) = parsed.get(*name) else {
continue;
};
if body.trim().is_empty() {
continue;
}
println!("{{");
println!(" name: {name}");
println!(" language: {lang_iso}");
println!(" description: {name} ({lang_iso})");
println!(" template: '''");
for line in body.lines() {
println!(" {line}");
}
println!(" '''");
println!("}}");
println!();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_json_handles_chatter() {
let raw = "Here you go:\n\n{ \"grammar-check\": \"проверь грамматику\", \"explain-diagnostic\": \"объясни\", \"critique-edit\": \"критика\", \"critique-changes\": \"оцени\", \"show-dont-tell\": \"показывай\", \"sentence-rhythm-rewrite\": \"перепиши\", \"timeline-health\": \"проверь хронологию\" }\n\nLet me know.";
let json = extract_json_object(raw).unwrap();
let parsed: std::collections::HashMap<String, String> =
serde_json::from_str(json).unwrap();
assert_eq!(parsed.get("grammar-check").unwrap(), "проверь грамматику");
assert_eq!(parsed.len(), 7);
}
#[test]
fn parse_prompts_handles_fenced_block() {
let raw = "```json\n{\"grammar-check\":\"x\",\"explain-diagnostic\":\"x\",\"critique-edit\":\"x\",\"critique-changes\":\"x\",\"show-dont-tell\":\"x\",\"sentence-rhythm-rewrite\":\"x\",\"timeline-health\":\"x\"}\n```";
let parsed = parse_prompts(raw).unwrap();
assert_eq!(parsed.len(), 7);
}
#[test]
fn parse_prompts_rejects_garbage() {
let err = parse_prompts("not json").unwrap_err();
assert!(err.contains("no JSON object"));
}
#[test]
fn embedded_prompts_table_covers_every_named_resolver_path() {
let names: Vec<&str> = EMBEDDED_PROMPTS.iter().map(|(n, _)| *n).collect();
for expected in [
"grammar-check",
"explain-diagnostic",
"critique-edit",
"critique-changes",
"critique-compare",
"show-dont-tell",
"sentence-rhythm-rewrite",
"timeline-health",
] {
assert!(
names.contains(&expected),
"EMBEDDED_PROMPTS missing entry for `{expected}`",
);
}
assert_eq!(EMBEDDED_PROMPTS.len(), 8);
}
#[test]
fn merge_replaces_same_name_same_language() {
let mut library = PromptLibrary::default();
library.prompts.push(Prompt {
name: "grammar-check".into(),
description: "old".into(),
template: "OLD RU".into(),
language: Some("ru".into()),
});
let mut parsed = std::collections::HashMap::new();
parsed.insert("grammar-check".into(), "NEW RU".into());
merge_into_library(&mut library, "ru", &parsed);
assert_eq!(library.prompts.len(), 1);
assert_eq!(library.prompts[0].template, "NEW RU");
}
#[test]
fn merge_appends_when_no_same_language_match() {
let mut library = PromptLibrary::default();
library.prompts.push(Prompt {
name: "grammar-check".into(),
description: "english".into(),
template: "EN".into(),
language: Some("en".into()),
});
let mut parsed = std::collections::HashMap::new();
parsed.insert("grammar-check".into(), "RU".into());
merge_into_library(&mut library, "ru", &parsed);
assert_eq!(library.prompts.len(), 2);
assert!(
library
.prompts
.iter()
.any(|p| p.language.as_deref() == Some("en") && p.template == "EN")
);
assert!(
library
.prompts
.iter()
.any(|p| p.language.as_deref() == Some("ru") && p.template == "RU")
);
}
#[test]
fn merge_skips_empty_bodies() {
let mut library = PromptLibrary::default();
let mut parsed = std::collections::HashMap::new();
parsed.insert("grammar-check".into(), " ".into());
merge_into_library(&mut library, "ru", &parsed);
assert_eq!(library.prompts.len(), 0);
}
}