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 =
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    // Extract body (skip frontmatter if present)
176    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    // Project variables
191    result = result.replace("{{project.name}}", &config.project.name);
192
193    // Spec variables
194    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    // Spec path (constructed from id)
202    let spec_path = format!("{}/{}.md", SPECS_DIR, spec.id);
203    result = result.replace("{{spec.path}}", &spec_path);
204
205    // The full spec content
206    result = result.replace("{{spec}}", &format_spec_for_prompt(spec));
207
208    // Target files
209    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    // Context files - read and include content
216    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    // Worktree context variables
229    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    // Inject execution environment section if running in a worktree
252    // This gives agents awareness of their isolated context
253    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    // Inject output schema section if present
272    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                    // Log warning but don't fail prompt assembly
281                    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    // Inject commit instruction if not already present (and if enabled)
293    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    // ID
314    output.push_str(&format!("Spec ID: {}\n\n", spec.id));
315
316    // Title and body
317    output.push_str(&spec.body);
318
319    // Target files if any
320    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        // Should contain commit instruction
412        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        // Count occurrences of the section header
427        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        // Should NOT contain commit instruction
441        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    // =========================================================================
512    // PROMPT INHERITANCE TESTS
513    // =========================================================================
514
515    #[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        // Change to temp directory for test
637        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        // Restore original directory
644        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        // Change to temp directory for test
668        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        // Restore original directory
677        std::env::set_current_dir(original_dir).unwrap();
678    }
679}