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 = Path::new(".chant")
170 .join("prompts")
171 .join("extensions")
172 .join(format!("{}.md", extension_name));
173
174 let content = fs::read_to_string(&extension_path)
175 .with_context(|| format!("Failed to read extension from {}", extension_path.display()))?;
176
177 let (_frontmatter, body) = split_frontmatter(&content);
179
180 Ok(body.to_string())
181}
182
183fn substitute(
184 template: &str,
185 spec: &Spec,
186 config: &Config,
187 inject_commit: bool,
188 worktree_ctx: &WorktreeContext,
189) -> String {
190 let mut result = template.to_string();
191
192 result = result.replace("{{project.name}}", &config.project.name);
194
195 result = result.replace("{{spec.id}}", &spec.id);
197 result = result.replace(
198 "{{spec.title}}",
199 spec.title.as_deref().unwrap_or("(untitled)"),
200 );
201 result = result.replace("{{spec.description}}", &spec.body);
202
203 let spec_path = format!("{}/{}.md", SPECS_DIR, spec.id);
205 result = result.replace("{{spec.path}}", &spec_path);
206
207 result = result.replace("{{spec}}", &format_spec_for_prompt(spec));
209
210 if let Some(files) = &spec.frontmatter.target_files {
212 result = result.replace("{{spec.target_files}}", &files.join("\n"));
213 } else {
214 result = result.replace("{{spec.target_files}}", "");
215 }
216
217 if let Some(context_paths) = &spec.frontmatter.context {
219 let mut context_content = String::new();
220 for path in context_paths {
221 if let Ok(content) = fs::read_to_string(path) {
222 context_content.push_str(&format!("\n--- {} ---\n{}\n", path, content));
223 }
224 }
225 result = result.replace("{{spec.context}}", &context_content);
226 } else {
227 result = result.replace("{{spec.context}}", "");
228 }
229
230 result = result.replace(
232 "{{worktree.path}}",
233 worktree_ctx
234 .worktree_path
235 .as_ref()
236 .map(|p| p.display().to_string())
237 .as_deref()
238 .unwrap_or(""),
239 );
240 result = result.replace(
241 "{{worktree.branch}}",
242 worktree_ctx.branch_name.as_deref().unwrap_or(""),
243 );
244 result = result.replace(
245 "{{worktree.isolated}}",
246 if worktree_ctx.is_isolated {
247 "true"
248 } else {
249 "false"
250 },
251 );
252
253 if worktree_ctx.is_isolated {
256 let env_section = format!(
257 "\n\n## Execution Environment\n\n\
258 You are running in an **isolated worktree**:\n\
259 - **Working directory:** `{}`\n\
260 - **Branch:** `{}`\n\
261 - **Isolation:** Changes are isolated from the main repository until merged\n\n\
262 This means your changes will not affect the main branch until explicitly merged.\n",
263 worktree_ctx
264 .worktree_path
265 .as_ref()
266 .map(|p| p.display().to_string())
267 .unwrap_or_default(),
268 worktree_ctx.branch_name.as_deref().unwrap_or("unknown"),
269 );
270 result.push_str(&env_section);
271 }
272
273 if let Some(ref schema_path) = spec.frontmatter.output_schema {
275 let schema_path = Path::new(schema_path);
276 if schema_path.exists() {
277 match validation::generate_schema_prompt_section(schema_path) {
278 Ok(schema_section) => {
279 result.push_str(&schema_section);
280 }
281 Err(e) => {
282 eprintln!("Warning: Failed to generate schema prompt section: {}", e);
284 }
285 }
286 } else {
287 eprintln!(
288 "Warning: Output schema file not found: {}",
289 schema_path.display()
290 );
291 }
292 }
293
294 if inject_commit && !result.to_lowercase().contains("commit your work") {
296 let commit_instruction = "\n\n## Required: Commit Your Work\n\n\
297 When you have completed the work, commit your changes with:\n\n\
298 ```\n\
299 git commit -m \"chant(";
300 result.push_str(commit_instruction);
301 result.push_str(&spec.id);
302 result.push_str(
303 "): <brief description of changes>\"\n\
304 ```\n\n\
305 This commit message pattern is required for chant to track your work.",
306 );
307 }
308
309 result
310}
311
312fn format_spec_for_prompt(spec: &Spec) -> String {
313 let mut output = String::new();
314
315 output.push_str(&format!("Spec ID: {}\n\n", spec.id));
317
318 output.push_str(&spec.body);
320
321 if let Some(files) = &spec.frontmatter.target_files {
323 output.push_str("\n\n## Target Files\n\n");
324 for file in files {
325 output.push_str(&format!("- {}\n", file));
326 }
327 }
328
329 output
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::spec::SpecFrontmatter;
336
337 fn make_test_config() -> Config {
338 Config {
339 project: crate::config::ProjectConfig {
340 name: "test-project".to_string(),
341 prefix: None,
342 silent: false,
343 },
344 defaults: crate::config::DefaultsConfig::default(),
345 providers: crate::provider::ProviderConfig::default(),
346 parallel: crate::config::ParallelConfig::default(),
347 repos: vec![],
348 enterprise: crate::config::EnterpriseConfig::default(),
349 approval: crate::config::ApprovalConfig::default(),
350 validation: crate::config::OutputValidationConfig::default(),
351 site: crate::config::SiteConfig::default(),
352 lint: crate::config::LintConfig::default(),
353 watch: crate::config::WatchConfig::default(),
354 }
355 }
356
357 fn make_test_spec() -> Spec {
358 Spec {
359 id: "2026-01-22-001-x7m".to_string(),
360 frontmatter: SpecFrontmatter::default(),
361 title: Some("Fix the bug".to_string()),
362 body: "# Fix the bug\n\nDescription here.".to_string(),
363 }
364 }
365
366 #[test]
367 fn test_substitute() {
368 let template = "Project: {{project.name}}\nSpec: {{spec.id}}\nTitle: {{spec.title}}";
369 let spec = make_test_spec();
370 let config = make_test_config();
371 let worktree_ctx = WorktreeContext::default();
372
373 let result = substitute(template, &spec, &config, true, &worktree_ctx);
374
375 assert!(result.contains("Project: test-project"));
376 assert!(result.contains("Spec: 2026-01-22-001-x7m"));
377 assert!(result.contains("Title: Fix the bug"));
378 }
379
380 #[test]
381 fn test_spec_path_substitution() {
382 let template = "Edit {{spec.path}} to check off criteria";
383 let spec = make_test_spec();
384 let config = make_test_config();
385 let worktree_ctx = WorktreeContext::default();
386
387 let result = substitute(template, &spec, &config, true, &worktree_ctx);
388
389 assert!(result.contains(".chant/specs/2026-01-22-001-x7m.md"));
390 }
391
392 #[test]
393 fn test_split_frontmatter_extracts_body() {
394 let content = r#"---
395name: test
396---
397
398Body content here."#;
399
400 let (_frontmatter, body) = split_frontmatter(content);
401 assert_eq!(body, "Body content here.");
402 }
403
404 #[test]
405 fn test_commit_instruction_is_injected() {
406 let template = "# Do some work\n\nThis is a test prompt.";
407 let spec = make_test_spec();
408 let config = make_test_config();
409 let worktree_ctx = WorktreeContext::default();
410
411 let result = substitute(template, &spec, &config, true, &worktree_ctx);
412
413 assert!(result.contains("## Required: Commit Your Work"));
415 assert!(result.contains("git commit -m \"chant(2026-01-22-001-x7m):"));
416 }
417
418 #[test]
419 fn test_commit_instruction_not_duplicated() {
420 let template =
421 "# Do some work\n\n## Required: Commit Your Work\n\nAlready has instruction.";
422 let spec = make_test_spec();
423 let config = make_test_config();
424 let worktree_ctx = WorktreeContext::default();
425
426 let result = substitute(template, &spec, &config, true, &worktree_ctx);
427
428 let count = result.matches("## Required: Commit Your Work").count();
430 assert_eq!(count, 1, "Commit instruction should not be duplicated");
431 }
432
433 #[test]
434 fn test_commit_instruction_skipped_when_disabled() {
435 let template = "# Analyze something\n\nJust output text.";
436 let spec = make_test_spec();
437 let config = make_test_config();
438 let worktree_ctx = WorktreeContext::default();
439
440 let result = substitute(template, &spec, &config, false, &worktree_ctx);
441
442 assert!(
444 !result.contains("## Required: Commit Your Work"),
445 "Commit instruction should not be injected when disabled"
446 );
447 }
448
449 #[test]
450 fn test_worktree_context_substitution() {
451 let template =
452 "Path: {{worktree.path}}\nBranch: {{worktree.branch}}\nIsolated: {{worktree.isolated}}";
453 let spec = make_test_spec();
454 let config = make_test_config();
455 let worktree_ctx = WorktreeContext {
456 worktree_path: Some(PathBuf::from("/tmp/chant-test-spec")),
457 branch_name: Some("chant/test-spec".to_string()),
458 is_isolated: true,
459 };
460
461 let result = substitute(template, &spec, &config, false, &worktree_ctx);
462
463 assert!(result.contains("Path: /tmp/chant-test-spec"));
464 assert!(result.contains("Branch: chant/test-spec"));
465 assert!(result.contains("Isolated: true"));
466 }
467
468 #[test]
469 fn test_worktree_context_empty_when_not_isolated() {
470 let template = "Path: '{{worktree.path}}'\nBranch: '{{worktree.branch}}'\nIsolated: {{worktree.isolated}}";
471 let spec = make_test_spec();
472 let config = make_test_config();
473 let worktree_ctx = WorktreeContext::default();
474
475 let result = substitute(template, &spec, &config, false, &worktree_ctx);
476
477 assert!(result.contains("Path: ''"));
478 assert!(result.contains("Branch: ''"));
479 assert!(result.contains("Isolated: false"));
480 }
481
482 #[test]
483 fn test_execution_environment_section_injected_when_isolated() {
484 let template = "# Do some work";
485 let spec = make_test_spec();
486 let config = make_test_config();
487 let worktree_ctx = WorktreeContext {
488 worktree_path: Some(PathBuf::from("/tmp/chant-test-spec")),
489 branch_name: Some("chant/test-spec".to_string()),
490 is_isolated: true,
491 };
492
493 let result = substitute(template, &spec, &config, false, &worktree_ctx);
494
495 assert!(result.contains("## Execution Environment"));
496 assert!(result.contains("isolated worktree"));
497 assert!(result.contains("/tmp/chant-test-spec"));
498 assert!(result.contains("chant/test-spec"));
499 }
500
501 #[test]
502 fn test_execution_environment_section_not_injected_when_not_isolated() {
503 let template = "# Do some work";
504 let spec = make_test_spec();
505 let config = make_test_config();
506 let worktree_ctx = WorktreeContext::default();
507
508 let result = substitute(template, &spec, &config, false, &worktree_ctx);
509
510 assert!(!result.contains("## Execution Environment"));
511 }
512
513 #[test]
518 fn test_resolve_prompt_no_inheritance() {
519 use tempfile::TempDir;
520
521 let tmp = TempDir::new().unwrap();
522 let prompt_path = tmp.path().join("simple.md");
523
524 fs::write(
525 &prompt_path,
526 r#"---
527name: simple
528---
529
530Simple prompt body."#,
531 )
532 .unwrap();
533
534 let mut visited = HashSet::new();
535 let result = resolve_prompt_inheritance(&prompt_path, &mut visited).unwrap();
536
537 assert_eq!(result, "Simple prompt body.");
538 }
539
540 #[test]
541 fn test_resolve_prompt_with_parent() {
542 use tempfile::TempDir;
543
544 let tmp = TempDir::new().unwrap();
545 let parent_path = tmp.path().join("parent.md");
546 let child_path = tmp.path().join("child.md");
547
548 fs::write(
549 &parent_path,
550 r#"---
551name: parent
552---
553
554Parent content here."#,
555 )
556 .unwrap();
557
558 fs::write(
559 &child_path,
560 r#"---
561name: child
562extends: parent
563---
564
565{{> parent}}
566
567Additional child content."#,
568 )
569 .unwrap();
570
571 let mut visited = HashSet::new();
572 let result = resolve_prompt_inheritance(&child_path, &mut visited).unwrap();
573
574 assert!(result.contains("Parent content here."));
575 assert!(result.contains("Additional child content."));
576 assert!(!result.contains("{{> parent}}"));
577 }
578
579 #[test]
580 fn test_circular_inheritance_detection() {
581 use tempfile::TempDir;
582
583 let tmp = TempDir::new().unwrap();
584 let prompt_a = tmp.path().join("a.md");
585 let prompt_b = tmp.path().join("b.md");
586
587 fs::write(
588 &prompt_a,
589 r#"---
590name: a
591extends: b
592---
593
594{{> parent}}"#,
595 )
596 .unwrap();
597
598 fs::write(
599 &prompt_b,
600 r#"---
601name: b
602extends: a
603---
604
605{{> parent}}"#,
606 )
607 .unwrap();
608
609 let mut visited = HashSet::new();
610 let result = resolve_prompt_inheritance(&prompt_a, &mut visited);
611
612 assert!(result.is_err());
613 assert!(result
614 .unwrap_err()
615 .to_string()
616 .contains("Circular prompt inheritance"));
617 }
618
619 #[test]
620 fn test_load_extension() {
621 use tempfile::TempDir;
622
623 let tmp = TempDir::new().unwrap();
624 let extensions_dir = tmp.path().join(".chant/prompts/extensions");
625 fs::create_dir_all(&extensions_dir).unwrap();
626
627 let extension_path = extensions_dir.join("test-ext.md");
628 fs::write(
629 &extension_path,
630 r#"---
631name: test-ext
632---
633
634Extension content here."#,
635 )
636 .unwrap();
637
638 let original_dir = std::env::current_dir().unwrap();
640 std::env::set_current_dir(&tmp).unwrap();
641
642 let result = load_extension("test-ext").unwrap();
643 assert_eq!(result, "Extension content here.");
644
645 std::env::set_current_dir(original_dir).unwrap();
647 }
648
649 #[test]
650 fn test_prompt_extensions_in_config() {
651 use tempfile::TempDir;
652
653 let tmp = TempDir::new().unwrap();
654 let extensions_dir = tmp.path().join(".chant/prompts/extensions");
655 fs::create_dir_all(&extensions_dir).unwrap();
656
657 let extension_path = extensions_dir.join("concise.md");
658 fs::write(&extension_path, "Keep output concise.").unwrap();
659
660 let prompt_path = tmp.path().join("main.md");
661 fs::write(&prompt_path, "Main prompt.").unwrap();
662
663 let mut config = make_test_config();
664 config.defaults.prompt_extensions = vec!["concise".to_string()];
665
666 let spec = make_test_spec();
667 let worktree_ctx = WorktreeContext::default();
668
669 let original_dir = std::env::current_dir().unwrap();
671 std::env::set_current_dir(&tmp).unwrap();
672
673 let result = assemble_with_context(&spec, &prompt_path, &config, &worktree_ctx).unwrap();
674
675 assert!(result.contains("Main prompt."));
676 assert!(result.contains("Keep output concise."));
677
678 std::env::set_current_dir(original_dir).unwrap();
680 }
681}