1use anyhow::{Context, Result};
9use serde::Deserialize;
10use std::collections::HashSet;
11use std::fs;
12use std::io::{self, Write};
13use std::path::{Path, PathBuf};
14
15use crate::config::Config;
16use crate::paths::SPECS_DIR;
17use crate::spec::{split_frontmatter, Spec};
18use crate::validation;
19
20#[derive(Debug, Deserialize, Default)]
22pub struct PromptFrontmatter {
23 pub name: Option<String>,
25 pub purpose: Option<String>,
27 pub extends: Option<String>,
29}
30
31#[derive(Debug, Clone, Default)]
37pub struct WorktreeContext {
38 pub worktree_path: Option<PathBuf>,
40 pub branch_name: Option<String>,
42 pub is_isolated: bool,
44}
45
46pub fn confirm(message: &str) -> Result<bool> {
53 if !atty::is(atty::Stream::Stdin) {
55 eprintln!("ℹ Non-interactive mode detected, proceeding without confirmation");
56 return Ok(true);
57 }
58
59 loop {
60 print!("{} (y/n): ", message);
61 io::stdout().flush()?;
62
63 let mut input = String::new();
64 io::stdin().read_line(&mut input)?;
65 let input = input.trim().to_lowercase();
66
67 match input.as_str() {
68 "y" | "yes" => return Ok(true),
69 "n" | "no" => return Ok(false),
70 _ => {
71 println!("Please enter 'y' or 'n'.");
72 }
73 }
74 }
75}
76
77pub fn assemble(spec: &Spec, prompt_path: &Path, config: &Config) -> Result<String> {
82 assemble_with_context(spec, prompt_path, config, &WorktreeContext::default())
83}
84
85pub fn assemble_with_context(
90 spec: &Spec,
91 prompt_path: &Path,
92 config: &Config,
93 worktree_ctx: &WorktreeContext,
94) -> Result<String> {
95 let mut visited = HashSet::new();
97 let resolved_body = resolve_prompt_inheritance(prompt_path, &mut visited)?;
98
99 let is_split_prompt = prompt_path
101 .file_stem()
102 .map(|s| s.to_string_lossy() == "split")
103 .unwrap_or(false);
104
105 let mut message = substitute(&resolved_body, spec, config, !is_split_prompt, worktree_ctx);
107
108 for extension_name in &config.defaults.prompt_extensions {
110 let extension_content = load_extension(extension_name)?;
111 message.push_str("\n\n");
112 message.push_str(&extension_content);
113 }
114
115 Ok(message)
116}
117
118fn resolve_prompt_inheritance(
121 prompt_path: &Path,
122 visited: &mut HashSet<PathBuf>,
123) -> Result<String> {
124 if visited.contains(prompt_path) {
126 anyhow::bail!(
127 "Circular prompt inheritance detected: {}",
128 prompt_path.display()
129 );
130 }
131 visited.insert(prompt_path.to_path_buf());
132
133 let prompt_content = fs::read_to_string(prompt_path)
134 .with_context(|| format!("Failed to read prompt from {}", prompt_path.display()))?;
135
136 let (frontmatter_str, body) = split_frontmatter(&prompt_content);
138
139 if let Some(frontmatter_str) = frontmatter_str {
141 let frontmatter: PromptFrontmatter =
142 serde_yaml::from_str(&frontmatter_str).with_context(|| {
143 format!(
144 "Failed to parse prompt frontmatter from {}",
145 prompt_path.display()
146 )
147 })?;
148
149 if let Some(parent_name) = frontmatter.extends {
150 let prompt_dir = prompt_path.parent().unwrap_or(Path::new(".chant/prompts"));
152 let parent_path = prompt_dir.join(format!("{}.md", parent_name));
153
154 let parent_body = resolve_prompt_inheritance(&parent_path, visited)?;
156
157 let resolved = body.replace("{{> parent}}", &parent_body);
159 return Ok(resolved);
160 }
161 }
162
163 Ok(body.to_string())
165}
166
167fn load_extension(extension_name: &str) -> Result<String> {
169 let extension_path =
170 Path::new(".chant/prompts/extensions").join(format!("{}.md", extension_name));
171
172 let content = fs::read_to_string(&extension_path)
173 .with_context(|| format!("Failed to read extension from {}", extension_path.display()))?;
174
175 let (_frontmatter, body) = split_frontmatter(&content);
177
178 Ok(body.to_string())
179}
180
181fn substitute(
182 template: &str,
183 spec: &Spec,
184 config: &Config,
185 inject_commit: bool,
186 worktree_ctx: &WorktreeContext,
187) -> String {
188 let mut result = template.to_string();
189
190 result = result.replace("{{project.name}}", &config.project.name);
192
193 result = result.replace("{{spec.id}}", &spec.id);
195 result = result.replace(
196 "{{spec.title}}",
197 spec.title.as_deref().unwrap_or("(untitled)"),
198 );
199 result = result.replace("{{spec.description}}", &spec.body);
200
201 let spec_path = format!("{}/{}.md", SPECS_DIR, spec.id);
203 result = result.replace("{{spec.path}}", &spec_path);
204
205 result = result.replace("{{spec}}", &format_spec_for_prompt(spec));
207
208 if let Some(files) = &spec.frontmatter.target_files {
210 result = result.replace("{{spec.target_files}}", &files.join("\n"));
211 } else {
212 result = result.replace("{{spec.target_files}}", "");
213 }
214
215 if let Some(context_paths) = &spec.frontmatter.context {
217 let mut context_content = String::new();
218 for path in context_paths {
219 if let Ok(content) = fs::read_to_string(path) {
220 context_content.push_str(&format!("\n--- {} ---\n{}\n", path, content));
221 }
222 }
223 result = result.replace("{{spec.context}}", &context_content);
224 } else {
225 result = result.replace("{{spec.context}}", "");
226 }
227
228 result = result.replace(
230 "{{worktree.path}}",
231 worktree_ctx
232 .worktree_path
233 .as_ref()
234 .map(|p| p.display().to_string())
235 .as_deref()
236 .unwrap_or(""),
237 );
238 result = result.replace(
239 "{{worktree.branch}}",
240 worktree_ctx.branch_name.as_deref().unwrap_or(""),
241 );
242 result = result.replace(
243 "{{worktree.isolated}}",
244 if worktree_ctx.is_isolated {
245 "true"
246 } else {
247 "false"
248 },
249 );
250
251 if worktree_ctx.is_isolated {
254 let env_section = format!(
255 "\n\n## Execution Environment\n\n\
256 You are running in an **isolated worktree**:\n\
257 - **Working directory:** `{}`\n\
258 - **Branch:** `{}`\n\
259 - **Isolation:** Changes are isolated from the main repository until merged\n\n\
260 This means your changes will not affect the main branch until explicitly merged.\n",
261 worktree_ctx
262 .worktree_path
263 .as_ref()
264 .map(|p| p.display().to_string())
265 .unwrap_or_default(),
266 worktree_ctx.branch_name.as_deref().unwrap_or("unknown"),
267 );
268 result.push_str(&env_section);
269 }
270
271 if let Some(ref schema_path) = spec.frontmatter.output_schema {
273 let schema_path = Path::new(schema_path);
274 if schema_path.exists() {
275 match validation::generate_schema_prompt_section(schema_path) {
276 Ok(schema_section) => {
277 result.push_str(&schema_section);
278 }
279 Err(e) => {
280 eprintln!("Warning: Failed to generate schema prompt section: {}", e);
282 }
283 }
284 } else {
285 eprintln!(
286 "Warning: Output schema file not found: {}",
287 schema_path.display()
288 );
289 }
290 }
291
292 if inject_commit && !result.to_lowercase().contains("commit your work") {
294 let commit_instruction = "\n\n## Required: Commit Your Work\n\n\
295 When you have completed the work, commit your changes with:\n\n\
296 ```\n\
297 git commit -m \"chant(";
298 result.push_str(commit_instruction);
299 result.push_str(&spec.id);
300 result.push_str(
301 "): <brief description of changes>\"\n\
302 ```\n\n\
303 This commit message pattern is required for chant to track your work.",
304 );
305 }
306
307 result
308}
309
310fn format_spec_for_prompt(spec: &Spec) -> String {
311 let mut output = String::new();
312
313 output.push_str(&format!("Spec ID: {}\n\n", spec.id));
315
316 output.push_str(&spec.body);
318
319 if let Some(files) = &spec.frontmatter.target_files {
321 output.push_str("\n\n## Target Files\n\n");
322 for file in files {
323 output.push_str(&format!("- {}\n", file));
324 }
325 }
326
327 output
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::spec::SpecFrontmatter;
334
335 fn make_test_config() -> Config {
336 Config {
337 project: crate::config::ProjectConfig {
338 name: "test-project".to_string(),
339 prefix: None,
340 silent: false,
341 },
342 defaults: crate::config::DefaultsConfig::default(),
343 providers: crate::provider::ProviderConfig::default(),
344 parallel: crate::config::ParallelConfig::default(),
345 repos: vec![],
346 enterprise: crate::config::EnterpriseConfig::default(),
347 approval: crate::config::ApprovalConfig::default(),
348 validation: crate::config::OutputValidationConfig::default(),
349 site: crate::config::SiteConfig::default(),
350 lint: crate::config::LintConfig::default(),
351 watch: crate::config::WatchConfig::default(),
352 }
353 }
354
355 fn make_test_spec() -> Spec {
356 Spec {
357 id: "2026-01-22-001-x7m".to_string(),
358 frontmatter: SpecFrontmatter::default(),
359 title: Some("Fix the bug".to_string()),
360 body: "# Fix the bug\n\nDescription here.".to_string(),
361 }
362 }
363
364 #[test]
365 fn test_substitute() {
366 let template = "Project: {{project.name}}\nSpec: {{spec.id}}\nTitle: {{spec.title}}";
367 let spec = make_test_spec();
368 let config = make_test_config();
369 let worktree_ctx = WorktreeContext::default();
370
371 let result = substitute(template, &spec, &config, true, &worktree_ctx);
372
373 assert!(result.contains("Project: test-project"));
374 assert!(result.contains("Spec: 2026-01-22-001-x7m"));
375 assert!(result.contains("Title: Fix the bug"));
376 }
377
378 #[test]
379 fn test_spec_path_substitution() {
380 let template = "Edit {{spec.path}} to check off criteria";
381 let spec = make_test_spec();
382 let config = make_test_config();
383 let worktree_ctx = WorktreeContext::default();
384
385 let result = substitute(template, &spec, &config, true, &worktree_ctx);
386
387 assert!(result.contains(".chant/specs/2026-01-22-001-x7m.md"));
388 }
389
390 #[test]
391 fn test_split_frontmatter_extracts_body() {
392 let content = r#"---
393name: test
394---
395
396Body content here."#;
397
398 let (_frontmatter, body) = split_frontmatter(content);
399 assert_eq!(body, "Body content here.");
400 }
401
402 #[test]
403 fn test_commit_instruction_is_injected() {
404 let template = "# Do some work\n\nThis is a test prompt.";
405 let spec = make_test_spec();
406 let config = make_test_config();
407 let worktree_ctx = WorktreeContext::default();
408
409 let result = substitute(template, &spec, &config, true, &worktree_ctx);
410
411 assert!(result.contains("## Required: Commit Your Work"));
413 assert!(result.contains("git commit -m \"chant(2026-01-22-001-x7m):"));
414 }
415
416 #[test]
417 fn test_commit_instruction_not_duplicated() {
418 let template =
419 "# Do some work\n\n## Required: Commit Your Work\n\nAlready has instruction.";
420 let spec = make_test_spec();
421 let config = make_test_config();
422 let worktree_ctx = WorktreeContext::default();
423
424 let result = substitute(template, &spec, &config, true, &worktree_ctx);
425
426 let count = result.matches("## Required: Commit Your Work").count();
428 assert_eq!(count, 1, "Commit instruction should not be duplicated");
429 }
430
431 #[test]
432 fn test_commit_instruction_skipped_when_disabled() {
433 let template = "# Analyze something\n\nJust output text.";
434 let spec = make_test_spec();
435 let config = make_test_config();
436 let worktree_ctx = WorktreeContext::default();
437
438 let result = substitute(template, &spec, &config, false, &worktree_ctx);
439
440 assert!(
442 !result.contains("## Required: Commit Your Work"),
443 "Commit instruction should not be injected when disabled"
444 );
445 }
446
447 #[test]
448 fn test_worktree_context_substitution() {
449 let template =
450 "Path: {{worktree.path}}\nBranch: {{worktree.branch}}\nIsolated: {{worktree.isolated}}";
451 let spec = make_test_spec();
452 let config = make_test_config();
453 let worktree_ctx = WorktreeContext {
454 worktree_path: Some(PathBuf::from("/tmp/chant-test-spec")),
455 branch_name: Some("chant/test-spec".to_string()),
456 is_isolated: true,
457 };
458
459 let result = substitute(template, &spec, &config, false, &worktree_ctx);
460
461 assert!(result.contains("Path: /tmp/chant-test-spec"));
462 assert!(result.contains("Branch: chant/test-spec"));
463 assert!(result.contains("Isolated: true"));
464 }
465
466 #[test]
467 fn test_worktree_context_empty_when_not_isolated() {
468 let template = "Path: '{{worktree.path}}'\nBranch: '{{worktree.branch}}'\nIsolated: {{worktree.isolated}}";
469 let spec = make_test_spec();
470 let config = make_test_config();
471 let worktree_ctx = WorktreeContext::default();
472
473 let result = substitute(template, &spec, &config, false, &worktree_ctx);
474
475 assert!(result.contains("Path: ''"));
476 assert!(result.contains("Branch: ''"));
477 assert!(result.contains("Isolated: false"));
478 }
479
480 #[test]
481 fn test_execution_environment_section_injected_when_isolated() {
482 let template = "# Do some work";
483 let spec = make_test_spec();
484 let config = make_test_config();
485 let worktree_ctx = WorktreeContext {
486 worktree_path: Some(PathBuf::from("/tmp/chant-test-spec")),
487 branch_name: Some("chant/test-spec".to_string()),
488 is_isolated: true,
489 };
490
491 let result = substitute(template, &spec, &config, false, &worktree_ctx);
492
493 assert!(result.contains("## Execution Environment"));
494 assert!(result.contains("isolated worktree"));
495 assert!(result.contains("/tmp/chant-test-spec"));
496 assert!(result.contains("chant/test-spec"));
497 }
498
499 #[test]
500 fn test_execution_environment_section_not_injected_when_not_isolated() {
501 let template = "# Do some work";
502 let spec = make_test_spec();
503 let config = make_test_config();
504 let worktree_ctx = WorktreeContext::default();
505
506 let result = substitute(template, &spec, &config, false, &worktree_ctx);
507
508 assert!(!result.contains("## Execution Environment"));
509 }
510
511 #[test]
516 fn test_resolve_prompt_no_inheritance() {
517 use tempfile::TempDir;
518
519 let tmp = TempDir::new().unwrap();
520 let prompt_path = tmp.path().join("simple.md");
521
522 fs::write(
523 &prompt_path,
524 r#"---
525name: simple
526---
527
528Simple prompt body."#,
529 )
530 .unwrap();
531
532 let mut visited = HashSet::new();
533 let result = resolve_prompt_inheritance(&prompt_path, &mut visited).unwrap();
534
535 assert_eq!(result, "Simple prompt body.");
536 }
537
538 #[test]
539 fn test_resolve_prompt_with_parent() {
540 use tempfile::TempDir;
541
542 let tmp = TempDir::new().unwrap();
543 let parent_path = tmp.path().join("parent.md");
544 let child_path = tmp.path().join("child.md");
545
546 fs::write(
547 &parent_path,
548 r#"---
549name: parent
550---
551
552Parent content here."#,
553 )
554 .unwrap();
555
556 fs::write(
557 &child_path,
558 r#"---
559name: child
560extends: parent
561---
562
563{{> parent}}
564
565Additional child content."#,
566 )
567 .unwrap();
568
569 let mut visited = HashSet::new();
570 let result = resolve_prompt_inheritance(&child_path, &mut visited).unwrap();
571
572 assert!(result.contains("Parent content here."));
573 assert!(result.contains("Additional child content."));
574 assert!(!result.contains("{{> parent}}"));
575 }
576
577 #[test]
578 fn test_circular_inheritance_detection() {
579 use tempfile::TempDir;
580
581 let tmp = TempDir::new().unwrap();
582 let prompt_a = tmp.path().join("a.md");
583 let prompt_b = tmp.path().join("b.md");
584
585 fs::write(
586 &prompt_a,
587 r#"---
588name: a
589extends: b
590---
591
592{{> parent}}"#,
593 )
594 .unwrap();
595
596 fs::write(
597 &prompt_b,
598 r#"---
599name: b
600extends: a
601---
602
603{{> parent}}"#,
604 )
605 .unwrap();
606
607 let mut visited = HashSet::new();
608 let result = resolve_prompt_inheritance(&prompt_a, &mut visited);
609
610 assert!(result.is_err());
611 assert!(result
612 .unwrap_err()
613 .to_string()
614 .contains("Circular prompt inheritance"));
615 }
616
617 #[test]
618 fn test_load_extension() {
619 use tempfile::TempDir;
620
621 let tmp = TempDir::new().unwrap();
622 let extensions_dir = tmp.path().join(".chant/prompts/extensions");
623 fs::create_dir_all(&extensions_dir).unwrap();
624
625 let extension_path = extensions_dir.join("test-ext.md");
626 fs::write(
627 &extension_path,
628 r#"---
629name: test-ext
630---
631
632Extension content here."#,
633 )
634 .unwrap();
635
636 let original_dir = std::env::current_dir().unwrap();
638 std::env::set_current_dir(&tmp).unwrap();
639
640 let result = load_extension("test-ext").unwrap();
641 assert_eq!(result, "Extension content here.");
642
643 std::env::set_current_dir(original_dir).unwrap();
645 }
646
647 #[test]
648 fn test_prompt_extensions_in_config() {
649 use tempfile::TempDir;
650
651 let tmp = TempDir::new().unwrap();
652 let extensions_dir = tmp.path().join(".chant/prompts/extensions");
653 fs::create_dir_all(&extensions_dir).unwrap();
654
655 let extension_path = extensions_dir.join("concise.md");
656 fs::write(&extension_path, "Keep output concise.").unwrap();
657
658 let prompt_path = tmp.path().join("main.md");
659 fs::write(&prompt_path, "Main prompt.").unwrap();
660
661 let mut config = make_test_config();
662 config.defaults.prompt_extensions = vec!["concise".to_string()];
663
664 let spec = make_test_spec();
665 let worktree_ctx = WorktreeContext::default();
666
667 let original_dir = std::env::current_dir().unwrap();
669 std::env::set_current_dir(&tmp).unwrap();
670
671 let result = assemble_with_context(&spec, &prompt_path, &config, &worktree_ctx).unwrap();
672
673 assert!(result.contains("Main prompt."));
674 assert!(result.contains("Keep output concise."));
675
676 std::env::set_current_dir(original_dir).unwrap();
678 }
679}