Skip to main content

chant/
prompt.rs

1//! Prompt template management and variable substitution.
2//!
3//! # Doc Audit
4//! - audited: 2026-01-25
5//! - docs: concepts/prompts.md
6//! - ignore: false
7
8use 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/// Frontmatter for prompt templates
21#[derive(Debug, Deserialize, Default)]
22pub struct PromptFrontmatter {
23    /// Name of the prompt
24    pub name: Option<String>,
25    /// Purpose/description of the prompt
26    pub purpose: Option<String>,
27    /// Parent prompt name to extend from
28    pub extends: Option<String>,
29}
30
31/// Context about the execution environment (worktree, branch, isolation).
32///
33/// This information is passed to prompt assembly so agents can be aware
34/// of their execution context - whether they're running in an isolated
35/// worktree, what branch they're on, etc.
36#[derive(Debug, Clone, Default)]
37pub struct WorktreeContext {
38    /// Path to the worktree directory (e.g., `/tmp/chant-{spec-id}`)
39    pub worktree_path: Option<PathBuf>,
40    /// Branch name the agent is working on
41    pub branch_name: Option<String>,
42    /// Whether execution is isolated (in a worktree vs main repo)
43    pub is_isolated: bool,
44}
45
46/// Ask user for confirmation with a yes/no prompt.
47/// Returns true if user confirms (y/yes), false if user declines (n/no).
48/// Repeats until user provides valid input.
49///
50/// In non-interactive (non-TTY) contexts, automatically proceeds without prompting
51/// and logs a message indicating confirmation was skipped.
52pub fn confirm(message: &str) -> Result<bool> {
53    // Detect non-TTY contexts (e.g., when running in worktrees or piped input)
54    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
77/// Assemble a prompt by substituting template variables.
78///
79/// This version uses default (empty) worktree context. For parallel execution
80/// in isolated worktrees, use `assemble_with_context` instead.
81pub fn assemble(spec: &Spec, prompt_path: &Path, config: &Config) -> Result<String> {
82    assemble_with_context(spec, prompt_path, config, &WorktreeContext::default())
83}
84
85/// Assemble a prompt with explicit worktree context.
86///
87/// Use this when the agent will run in an isolated worktree and should be
88/// aware of its execution environment (worktree path, branch, isolation status).
89pub fn assemble_with_context(
90    spec: &Spec,
91    prompt_path: &Path,
92    config: &Config,
93    worktree_ctx: &WorktreeContext,
94) -> Result<String> {
95    // Resolve prompt with inheritance
96    let mut visited = HashSet::new();
97    let resolved_body = resolve_prompt_inheritance(prompt_path, &mut visited)?;
98
99    // Check if this is a split prompt (don't inject commit instruction for analysis prompts)
100    let is_split_prompt = prompt_path
101        .file_stem()
102        .map(|s| s.to_string_lossy() == "split")
103        .unwrap_or(false);
104
105    // Substitute template variables and inject commit instruction (except for split)
106    let mut message = substitute(&resolved_body, spec, config, !is_split_prompt, worktree_ctx);
107
108    // Append prompt extensions from config
109    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
118/// Resolve prompt inheritance by loading parent prompts recursively.
119/// Returns the fully resolved prompt body with {{> parent}} markers replaced.
120fn resolve_prompt_inheritance(
121    prompt_path: &Path,
122    visited: &mut HashSet<PathBuf>,
123) -> Result<String> {
124    // Check for circular dependencies
125    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    // Parse frontmatter
137    let (frontmatter_str, body) = split_frontmatter(&prompt_content);
138
139    // Check if this prompt extends another
140    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            // Construct parent prompt path
151            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            // Recursively resolve parent
155            let parent_body = resolve_prompt_inheritance(&parent_path, visited)?;
156
157            // Replace {{> parent}} marker with parent content
158            let resolved = body.replace("{{> parent}}", &parent_body);
159            return Ok(resolved);
160        }
161    }
162
163    // No parent, return body as-is
164    Ok(body.to_string())
165}
166
167/// Load a prompt extension from .chant/prompts/extensions/
168fn 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    // Extract body (skip frontmatter if present)
178    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    // Project variables
193    result = result.replace("{{project.name}}", &config.project.name);
194
195    // Spec variables
196    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    // Spec path (constructed from id)
204    let spec_path = format!("{}/{}.md", SPECS_DIR, spec.id);
205    result = result.replace("{{spec.path}}", &spec_path);
206
207    // The full spec content
208    result = result.replace("{{spec}}", &format_spec_for_prompt(spec));
209
210    // Target files
211    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    // Context files - read and include content
218    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    // Worktree context variables
231    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    // Inject execution environment section if running in a worktree
254    // This gives agents awareness of their isolated context
255    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    // Inject output schema section if present
274    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                    // Log warning but don't fail prompt assembly
283                    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    // Inject commit instruction if not already present (and if enabled)
295    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    // ID
316    output.push_str(&format!("Spec ID: {}\n\n", spec.id));
317
318    // Title and body
319    output.push_str(&spec.body);
320
321    // Target files if any
322    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        // Should contain commit instruction
414        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        // Count occurrences of the section header
429        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        // Should NOT contain commit instruction
443        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    // =========================================================================
514    // PROMPT INHERITANCE TESTS
515    // =========================================================================
516
517    #[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        // Change to temp directory for test
639        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        // Restore original directory
646        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        // Change to temp directory for test
670        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        // Restore original directory
679        std::env::set_current_dir(original_dir).unwrap();
680    }
681}