Skip to main content

yarli_cli/
prompt.rs

1//! Prompt loading and run-spec parsing for `yarli run`.
2//!
3//! Opinionated rules:
4//! - Default fallback entrypoint is `PROMPT.md` (resolved by walking up from CWD).
5//! - `PROMPT.md` may contain `@include <path>` directives (repo-confined).
6//! - A single fenced code block with info string `yarli-run` defines what to execute.
7
8use std::collections::{BTreeMap, BTreeSet, HashMap};
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11
12use anyhow::{bail, Context, Result};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16pub const PROMPT_FILENAME: &str = "PROMPT.md";
17const MAX_INCLUDE_DEPTH: usize = 8;
18const MAX_EXPANDED_BYTES: usize = 1024 * 1024;
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct RunSpec {
22    pub version: u32,
23    pub objective: Option<String>,
24    #[serde(default)]
25    pub tasks: RunSpecTasks,
26    #[serde(default)]
27    pub tranches: Option<RunSpecTranches>,
28    #[serde(default)]
29    pub plan_guard: Option<RunSpecPlanGuard>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
33pub struct RunSpecTasks {
34    #[serde(default)]
35    pub items: Vec<RunSpecTask>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct RunSpecTask {
40    pub key: String,
41    pub cmd: String,
42    #[serde(default)]
43    pub class: Option<String>,
44    #[serde(default)]
45    pub depends_on: Vec<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct RunSpecTranches {
50    pub items: Vec<RunSpecTranche>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct RunSpecTranche {
55    pub key: String,
56    #[serde(default)]
57    pub objective: Option<String>,
58    pub task_keys: Vec<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct RunSpecPlanGuard {
63    pub target: String,
64    #[serde(default)]
65    pub mode: RunSpecPlanGuardMode,
66}
67
68#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "kebab-case")]
70pub enum RunSpecPlanGuardMode {
71    Implement,
72    VerifyOnly,
73}
74
75impl Default for RunSpecPlanGuardMode {
76    fn default() -> Self {
77        Self::Implement
78    }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct PromptSnapshot {
83    pub entry_path: String,
84    pub expanded_sha256: String,
85    pub included_files: Vec<PromptFileHash>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89pub struct PromptFileHash {
90    pub path: String,
91    pub sha256: String,
92}
93
94#[derive(Debug, Clone)]
95pub struct LoadedPrompt {
96    pub entry_path: PathBuf,
97    pub expanded_text: String,
98    pub snapshot: PromptSnapshot,
99    pub run_spec: RunSpec,
100}
101
102#[derive(Debug, Clone)]
103pub struct LoadedPromptOptionalRunSpec {
104    pub entry_path: PathBuf,
105    pub expanded_text: String,
106    pub snapshot: PromptSnapshot,
107    pub run_spec: Option<RunSpec>,
108}
109
110pub fn load_prompt_and_run_spec_from_cwd() -> Result<LoadedPrompt> {
111    let entry_path =
112        find_prompt_upwards(std::env::current_dir()?).context("failed to resolve PROMPT.md")?;
113    load_prompt_and_run_spec(&entry_path)
114}
115
116pub fn find_prompt_upwards(mut dir: PathBuf) -> Result<PathBuf> {
117    loop {
118        let candidate = dir.join(PROMPT_FILENAME);
119        if candidate.exists() {
120            return Ok(candidate);
121        }
122        if !dir.pop() {
123            bail!(
124                "{} not found (run from repo root or create PROMPT.md)",
125                PROMPT_FILENAME
126            );
127        }
128    }
129}
130
131pub fn load_prompt_and_run_spec(entry_prompt_path: &Path) -> Result<LoadedPrompt> {
132    let loaded = load_prompt_with_optional_run_spec(entry_prompt_path)?;
133    let Some(run_spec) = loaded.run_spec else {
134        bail!("PROMPT.md must contain exactly one ```yarli-run fenced block (found 0)");
135    };
136
137    Ok(LoadedPrompt {
138        entry_path: loaded.entry_path,
139        expanded_text: loaded.expanded_text,
140        snapshot: loaded.snapshot,
141        run_spec,
142    })
143}
144
145pub fn load_prompt_with_optional_run_spec(
146    entry_prompt_path: &Path,
147) -> Result<LoadedPromptOptionalRunSpec> {
148    let (entry_prompt_path, expanded, snapshot) = load_expanded_prompt(entry_prompt_path)?;
149    let run_spec = parse_run_spec_block(&expanded, false)?;
150    Ok(LoadedPromptOptionalRunSpec {
151        entry_path: entry_prompt_path,
152        expanded_text: expanded,
153        snapshot,
154        run_spec,
155    })
156}
157
158fn load_expanded_prompt(entry_prompt_path: &Path) -> Result<(PathBuf, String, PromptSnapshot)> {
159    let entry_prompt_path = entry_prompt_path
160        .canonicalize()
161        .with_context(|| format!("failed to canonicalize {}", entry_prompt_path.display()))?;
162    let base_dir = entry_prompt_path
163        .parent()
164        .context("PROMPT.md has no parent directory")?
165        .to_path_buf();
166
167    let mut included_hashes: BTreeMap<PathBuf, String> = BTreeMap::new();
168    let mut visiting: BTreeSet<PathBuf> = BTreeSet::new();
169    let mut expanded = String::new();
170    expand_file(
171        &base_dir,
172        &entry_prompt_path,
173        0,
174        &mut visiting,
175        &mut included_hashes,
176        &mut expanded,
177    )?;
178
179    if expanded.len() > MAX_EXPANDED_BYTES {
180        bail!(
181            "expanded prompt exceeds max size ({} > {} bytes)",
182            expanded.len(),
183            MAX_EXPANDED_BYTES
184        );
185    }
186
187    let expanded_sha256 = sha256_hex(expanded.as_bytes());
188    let entry_path_display = entry_prompt_path.display().to_string();
189    let included_files = included_hashes
190        .into_iter()
191        .map(|(path, sha)| PromptFileHash {
192            path: path.display().to_string(),
193            sha256: sha,
194        })
195        .collect::<Vec<_>>();
196
197    Ok((
198        entry_prompt_path,
199        expanded,
200        PromptSnapshot {
201            entry_path: entry_path_display,
202            expanded_sha256,
203            included_files,
204        },
205    ))
206}
207
208fn parse_run_spec_block(expanded: &str, require_block: bool) -> Result<Option<RunSpec>> {
209    let run_spec_blocks = extract_fenced_blocks(expanded, "yarli-run");
210    if run_spec_blocks.is_empty() {
211        if require_block {
212            bail!("PROMPT.md must contain exactly one ```yarli-run fenced block (found 0)");
213        }
214        return Ok(None);
215    }
216    if run_spec_blocks.len() != 1 {
217        bail!(
218            "PROMPT.md must contain exactly one ```yarli-run fenced block (found {})",
219            run_spec_blocks.len()
220        );
221    }
222
223    let run_spec: RunSpec = toml::from_str(&run_spec_blocks[0])
224        .context("failed to parse TOML in ```yarli-run block")?;
225    validate_run_spec(&run_spec)?;
226    Ok(Some(run_spec))
227}
228
229pub fn validate_run_spec(run_spec: &RunSpec) -> Result<()> {
230    // Validate uniqueness and required fields.
231    if run_spec.version != 1 {
232        bail!(
233            "unsupported run spec version {} (expected 1)",
234            run_spec.version
235        );
236    }
237    let mut keys = BTreeSet::new();
238    for task in &run_spec.tasks.items {
239        if task.key.trim().is_empty() {
240            bail!("run spec task.key must be non-empty");
241        }
242        if task.cmd.trim().is_empty() {
243            bail!(
244                "run spec task.cmd must be non-empty (task key {})",
245                task.key
246            );
247        }
248        if !keys.insert(task.key.clone()) {
249            bail!("duplicate task key in run spec: {}", task.key);
250        }
251    }
252
253    validate_run_spec_task_dependencies(&run_spec.tasks.items)?;
254
255    if let Some(tranches) = run_spec.tranches.as_ref() {
256        if tranches.items.is_empty() {
257            bail!("run spec tranches.items must be non-empty when [tranches] is present");
258        }
259        let mut tranche_keys = BTreeSet::new();
260        let mut referenced = BTreeSet::new();
261        for tranche in &tranches.items {
262            if tranche.key.trim().is_empty() {
263                bail!("run spec tranche.key must be non-empty");
264            }
265            if !tranche_keys.insert(tranche.key.clone()) {
266                bail!("duplicate tranche key in run spec: {}", tranche.key);
267            }
268            if tranche.task_keys.is_empty() {
269                bail!(
270                    "run spec tranche.task_keys must be non-empty (tranche key {})",
271                    tranche.key
272                );
273            }
274            for task_key in &tranche.task_keys {
275                if !keys.contains(task_key) {
276                    bail!(
277                        "run spec tranche {} references unknown task key: {}",
278                        tranche.key,
279                        task_key
280                    );
281                }
282                if !referenced.insert(task_key.clone()) {
283                    bail!(
284                        "run spec task key appears in multiple tranches: {}",
285                        task_key
286                    );
287                }
288            }
289        }
290    }
291
292    if let Some(plan_guard) = run_spec.plan_guard.as_ref() {
293        if plan_guard.target.trim().is_empty() {
294            bail!("run spec plan_guard.target must be non-empty");
295        }
296    }
297    Ok(())
298}
299
300fn validate_run_spec_task_dependencies(tasks: &[RunSpecTask]) -> Result<()> {
301    let available_keys: BTreeSet<_> = tasks.iter().map(|task| task.key.as_str()).collect();
302    let mut dependency_graph: HashMap<&str, Vec<&str>> = HashMap::new();
303
304    for task in tasks {
305        let task_key = task.key.trim();
306        if task_key.is_empty() {
307            continue;
308        }
309
310        let mut normalized_deps = Vec::new();
311        for dep in &task.depends_on {
312            let dep = dep.trim();
313            if dep.is_empty() {
314                bail!("run spec task {} has empty depends_on entry", task.key);
315            }
316            if dep == task_key {
317                bail!("run spec task {} cannot depend on itself", task.key);
318            }
319            if !available_keys.contains(dep) {
320                bail!(
321                    "run spec task {} depends on unknown task key: {}",
322                    task.key,
323                    dep
324                );
325            }
326            normalized_deps.push(dep);
327        }
328        dependency_graph.insert(task_key, normalized_deps);
329    }
330
331    let mut visited = BTreeSet::new();
332    let mut visiting_stack = Vec::new();
333    let mut visiting_lookup = BTreeSet::new();
334
335    for task_key in available_keys {
336        if !visited.contains(task_key) {
337            detect_task_dependency_cycle(
338                task_key,
339                &dependency_graph,
340                &mut visited,
341                &mut visiting_stack,
342                &mut visiting_lookup,
343            )?;
344        }
345    }
346
347    Ok(())
348}
349
350#[allow(clippy::too_many_arguments)]
351fn detect_task_dependency_cycle<'a>(
352    task_key: &'a str,
353    dependency_graph: &HashMap<&str, Vec<&'a str>>,
354    visited: &mut BTreeSet<&'a str>,
355    visiting_stack: &mut Vec<&'a str>,
356    visiting_lookup: &mut BTreeSet<&'a str>,
357) -> Result<()> {
358    if visited.contains(task_key) {
359        return Ok(());
360    }
361    if visiting_lookup.contains(task_key) {
362        let cycle_start = visiting_stack
363            .iter()
364            .position(|value| *value == task_key)
365            .unwrap_or(0);
366        let cycle: Vec<&str> = visiting_stack[cycle_start..]
367            .iter()
368            .chain(std::iter::once(&task_key))
369            .copied()
370            .collect();
371        bail!(
372            "run spec has cyclic task dependency: {}",
373            cycle.join(" -> ")
374        );
375    }
376
377    visiting_lookup.insert(task_key);
378    visiting_stack.push(task_key);
379
380    for dep in dependency_graph.get(task_key).into_iter().flatten() {
381        detect_task_dependency_cycle(
382            dep,
383            dependency_graph,
384            visited,
385            visiting_stack,
386            visiting_lookup,
387        )?;
388    }
389
390    visiting_lookup.remove(task_key);
391    visiting_stack.pop();
392    visited.insert(task_key);
393    Ok(())
394}
395
396fn expand_file(
397    base_dir: &Path,
398    file_path: &Path,
399    depth: usize,
400    visiting: &mut BTreeSet<PathBuf>,
401    included_hashes: &mut BTreeMap<PathBuf, String>,
402    out: &mut String,
403) -> Result<()> {
404    if depth > MAX_INCLUDE_DEPTH {
405        bail!(
406            "include depth exceeded (>{}) at {}",
407            MAX_INCLUDE_DEPTH,
408            file_path.display()
409        );
410    }
411
412    let file_path = file_path
413        .canonicalize()
414        .with_context(|| format!("failed to canonicalize {}", file_path.display()))?;
415    ensure_repo_confined(base_dir, &file_path)?;
416
417    if !visiting.insert(file_path.clone()) {
418        bail!("include cycle detected at {}", file_path.display());
419    }
420
421    let raw = fs::read_to_string(&file_path)
422        .with_context(|| format!("failed to read {}", file_path.display()))?;
423    included_hashes.insert(file_path.clone(), sha256_hex(raw.as_bytes()));
424
425    // Write a deterministic boundary marker so the expanded prompt is auditable.
426    out.push_str(&format!(
427        "\n<!-- BEGIN_INCLUDE path={} sha256={} -->\n",
428        file_path.display(),
429        sha256_hex(raw.as_bytes())
430    ));
431
432    for line in raw.lines() {
433        if let Some(path) = parse_include_line(line) {
434            let include_path = resolve_include_path(base_dir, &file_path, &path)?;
435            expand_file(
436                base_dir,
437                &include_path,
438                depth + 1,
439                visiting,
440                included_hashes,
441                out,
442            )?;
443        } else {
444            out.push_str(line);
445            out.push('\n');
446        }
447    }
448
449    out.push_str(&format!(
450        "<!-- END_INCLUDE path={} -->\n",
451        file_path.display()
452    ));
453
454    visiting.remove(&file_path);
455    Ok(())
456}
457
458fn parse_include_line(line: &str) -> Option<String> {
459    let trimmed = line.trim();
460    let rest = trimmed.strip_prefix("@include")?.trim();
461    if rest.is_empty() {
462        return None;
463    }
464    Some(rest.to_string())
465}
466
467fn resolve_include_path(base_dir: &Path, current_file: &Path, include: &str) -> Result<PathBuf> {
468    let include_path = Path::new(include);
469    if include_path.is_absolute() {
470        bail!("absolute include paths are not allowed: {include}");
471    }
472
473    // Resolve relative to the directory of the including file.
474    let parent_dir = current_file
475        .parent()
476        .context("including file has no parent directory")?;
477    let candidate = parent_dir.join(include_path);
478
479    // Reject obvious traversal segments even before canonicalize (friendlier errors).
480    for comp in include_path.components() {
481        if matches!(
482            comp,
483            Component::ParentDir | Component::RootDir | Component::Prefix(_)
484        ) {
485            bail!("include path escape is not allowed: {include}");
486        }
487    }
488
489    let canonical = candidate.canonicalize().with_context(|| {
490        format!(
491            "failed to resolve include {include} from {}",
492            current_file.display()
493        )
494    })?;
495    ensure_repo_confined(base_dir, &canonical)?;
496    Ok(canonical)
497}
498
499fn ensure_repo_confined(repo_root: &Path, candidate: &Path) -> Result<()> {
500    // Confine includes to the directory containing the entry PROMPT.md and its descendants.
501    let repo_root = repo_root
502        .canonicalize()
503        .with_context(|| format!("failed to canonicalize repo root {}", repo_root.display()))?;
504    if !candidate.starts_with(&repo_root) {
505        bail!(
506            "include path escapes repo root ({}): {}",
507            repo_root.display(),
508            candidate.display()
509        );
510    }
511    Ok(())
512}
513
514fn extract_fenced_blocks(markdown: &str, info: &str) -> Vec<String> {
515    // Minimal, deterministic fence parser. Accepts only triple-backtick fences.
516    let mut blocks = Vec::new();
517    let mut in_block = false;
518    let mut current = String::new();
519    let mut matches_info = false;
520
521    for line in markdown.lines() {
522        if !in_block {
523            if let Some(rest) = line.trim_start().strip_prefix("```") {
524                let tag = rest.trim();
525                in_block = true;
526                matches_info = tag == info;
527                current.clear();
528            }
529            continue;
530        }
531
532        if line.trim_start().starts_with("```") {
533            if matches_info {
534                blocks.push(current.clone());
535            }
536            in_block = false;
537            matches_info = false;
538            current.clear();
539            continue;
540        }
541
542        if matches_info {
543            current.push_str(line);
544            current.push('\n');
545        }
546    }
547
548    blocks
549}
550
551fn sha256_hex(bytes: &[u8]) -> String {
552    let mut hasher = Sha256::new();
553    hasher.update(bytes);
554    let digest = hasher.finalize();
555    hex::encode(digest)
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use tempfile::TempDir;
562
563    #[test]
564    fn extracts_single_run_spec_block() {
565        let md = r#"
566hello
567```yarli-run
568version = 1
569objective = "x"
570[tasks]
571items = [{ key = "a", cmd = "echo ok" }]
572```
573"#;
574        let blocks = extract_fenced_blocks(md, "yarli-run");
575        assert_eq!(blocks.len(), 1);
576        let spec: RunSpec = toml::from_str(&blocks[0]).unwrap();
577        assert_eq!(spec.version, 1);
578        assert_eq!(spec.tasks.items.len(), 1);
579    }
580
581    #[test]
582    fn include_expands_and_is_confined() {
583        let temp = TempDir::new().unwrap();
584        let root = temp.path();
585        fs::write(root.join("PROMPT.md"), "@include sub/plan.md\n").unwrap();
586        fs::create_dir_all(root.join("sub")).unwrap();
587        fs::write(
588            root.join("sub/plan.md"),
589            "```yarli-run\nversion = 1\n[tasks]\nitems=[{key=\"a\",cmd=\"echo ok\"}]\n```\n",
590        )
591        .unwrap();
592
593        let loaded = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap();
594        assert!(loaded.expanded_text.contains("BEGIN_INCLUDE"));
595        assert_eq!(loaded.run_spec.tasks.items[0].key, "a");
596        assert_eq!(loaded.snapshot.included_files.len(), 2);
597    }
598
599    #[test]
600    fn optional_loader_accepts_plain_prompt_without_run_spec_block() {
601        let temp = TempDir::new().unwrap();
602        let root = temp.path();
603        fs::write(root.join("PROMPT.md"), "# plain prompt\nDo the work.\n").unwrap();
604
605        let loaded = load_prompt_with_optional_run_spec(&root.join("PROMPT.md")).unwrap();
606        assert!(loaded.run_spec.is_none());
607        assert!(loaded.expanded_text.contains("plain prompt"));
608    }
609
610    #[test]
611    fn strict_loader_still_rejects_missing_run_spec_block() {
612        let temp = TempDir::new().unwrap();
613        let root = temp.path();
614        fs::write(root.join("PROMPT.md"), "# plain prompt\nNo fenced block.\n").unwrap();
615
616        let err = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap_err();
617        assert!(err
618            .to_string()
619            .contains("must contain exactly one ```yarli-run fenced block (found 0)"));
620    }
621
622    #[test]
623    fn rejects_multiple_run_spec_blocks() {
624        let temp = TempDir::new().unwrap();
625        let root = temp.path();
626        fs::write(
627            root.join("PROMPT.md"),
628            "```yarli-run\nversion=1\n[tasks]\nitems=[{key=\"a\",cmd=\"echo ok\"}]\n```\n```yarli-run\nversion=1\n[tasks]\nitems=[{key=\"b\",cmd=\"echo ok\"}]\n```\n",
629        )
630        .unwrap();
631        let err = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap_err();
632        assert!(err.to_string().contains("exactly one"));
633    }
634
635    #[test]
636    fn accepts_explicit_tranche_sequence() {
637        let md = r#"
638```yarli-run
639version = 1
640objective = "x"
641[tasks]
642items = [
643  { key = "a", cmd = "echo a" },
644  { key = "b", cmd = "echo b" },
645]
646[tranches]
647items = [
648  { key = "first", task_keys = ["a"] },
649  { key = "second", objective = "second pass", task_keys = ["b"] },
650]
651```
652"#;
653        let blocks = extract_fenced_blocks(md, "yarli-run");
654        let spec: RunSpec = toml::from_str(&blocks[0]).unwrap();
655        assert_eq!(spec.tranches.as_ref().unwrap().items.len(), 2);
656    }
657
658    #[test]
659    fn rejects_duplicate_tranche_task_keys() {
660        let temp = TempDir::new().unwrap();
661        let root = temp.path();
662        fs::write(
663            root.join("PROMPT.md"),
664            "```yarli-run\nversion=1\n[tasks]\nitems=[{key=\"a\",cmd=\"echo ok\"},{key=\"b\",cmd=\"echo ok\"}]\n[tranches]\nitems=[{key=\"t1\",task_keys=[\"a\"]},{key=\"t2\",task_keys=[\"a\",\"b\"]}]\n```\n",
665        )
666        .unwrap();
667        let err = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap_err();
668        assert!(err.to_string().contains("appears in multiple tranches"));
669    }
670
671    #[test]
672    fn parses_plan_guard_verify_only_mode() {
673        let md = r#"
674```yarli-run
675version = 1
676objective = "verification-only: CARD-R8-01"
677[tasks]
678items = [{ key = "test", cmd = "cargo test --workspace" }]
679[plan_guard]
680target = "CARD-R8-01"
681mode = "verify-only"
682```
683"#;
684        let blocks = extract_fenced_blocks(md, "yarli-run");
685        let spec: RunSpec = toml::from_str(&blocks[0]).unwrap();
686        let guard = spec.plan_guard.expect("plan_guard should parse");
687        assert_eq!(guard.target, "CARD-R8-01");
688        assert_eq!(guard.mode, RunSpecPlanGuardMode::VerifyOnly);
689    }
690
691    #[test]
692    fn rejects_empty_plan_guard_target() {
693        let temp = TempDir::new().unwrap();
694        let root = temp.path();
695        fs::write(
696            root.join("PROMPT.md"),
697            "```yarli-run\nversion=1\n[tasks]\nitems=[{key=\"a\",cmd=\"echo ok\"}]\n[plan_guard]\ntarget=\"\"\n```\n",
698        )
699        .unwrap();
700        let err = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap_err();
701        assert!(err.to_string().contains("plan_guard.target"));
702    }
703
704    #[test]
705    fn accepts_minimal_run_spec_without_tasks_for_config_first_mode() {
706        let temp = TempDir::new().unwrap();
707        let root = temp.path();
708        fs::write(
709            root.join("PROMPT.md"),
710            "```yarli-run\nversion=1\nobjective=\"implement plan\"\n```\n",
711        )
712        .unwrap();
713
714        let loaded = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap();
715        assert_eq!(loaded.run_spec.version, 1);
716        assert!(loaded.run_spec.tasks.items.is_empty());
717    }
718}