1use std::path::{Path, PathBuf};
8
9pub const TRUST_EXTERNAL_VERIFY_ENV: &str = "DEVFLOW_TRUST_EXTERNAL_VERIFY";
11
12pub fn external_verification_approval() -> Option<Vec<String>> {
18 let value = std::env::var(TRUST_EXTERNAL_VERIFY_ENV).ok()?;
19 let commands = serde_json::from_str::<Vec<String>>(&value).ok()?;
20 (!commands.is_empty()).then_some(commands)
21}
22
23pub fn external_verify_commands(project_root: &Path, phase: u32) -> Vec<String> {
30 let phases_dir = project_root.join(".planning/phases");
31 let phase_prefix = format!("{phase:02}-");
32 let plan_prefix = format!("{phase:02}-");
33 let mut plans = Vec::<PathBuf>::new();
34
35 let Ok(phase_entries) = std::fs::read_dir(phases_dir) else {
36 return Vec::new();
37 };
38 for phase_entry in phase_entries.flatten() {
39 if !phase_entry
40 .file_name()
41 .to_string_lossy()
42 .starts_with(&phase_prefix)
43 {
44 continue;
45 }
46 let Ok(plan_entries) = std::fs::read_dir(phase_entry.path()) else {
47 continue;
48 };
49 plans.extend(plan_entries.flatten().filter_map(|entry| {
50 let name = entry.file_name();
51 let name = name.to_string_lossy();
52 (name.starts_with(&plan_prefix) && name.ends_with("-PLAN.md")).then(|| entry.path())
53 }));
54 }
55 plans.sort();
56
57 plans
58 .into_iter()
59 .filter_map(|path| std::fs::read_to_string(path).ok())
60 .filter_map(|contents| command_from_frontmatter(&contents))
61 .collect()
62}
63
64fn command_from_frontmatter(contents: &str) -> Option<String> {
65 let mut lines = contents.lines();
66 if lines.next()?.trim() != "---" {
67 return None;
68 }
69
70 for line in lines {
71 let line = line.trim();
72 if line == "---" {
73 break;
74 }
75 let Some(value) = line.strip_prefix("external_verify:") else {
76 continue;
77 };
78 let value = value.trim();
79 if value.is_empty() {
80 return None;
81 }
82 if value.starts_with('"') {
83 return serde_json::from_str::<String>(value).ok();
84 }
85 if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
86 return Some(value[1..value.len() - 1].replace("''", "'"));
87 }
88 return Some(value.to_owned());
89 }
90 None
91}
92
93pub fn run_external_verification(cmd: &str, project_root: &Path) -> bool {
100 std::process::Command::new("sh")
101 .arg("-c")
102 .arg(cmd)
103 .current_dir(project_root)
104 .output()
105 .map(|output| output.status.success())
106 .unwrap_or(false)
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 fn write_plan(root: &std::path::Path, contents: &str) {
114 let phase_dir = root.join(".planning/phases/16-pipeline-reliability-hardening");
115 std::fs::create_dir_all(&phase_dir).unwrap();
116 std::fs::write(phase_dir.join("16-03-PLAN.md"), contents).unwrap();
117 }
118
119 #[test]
120 fn reads_external_verify_only_from_plan_frontmatter() {
121 let dir = tempfile::tempdir().unwrap();
122 write_plan(
123 dir.path(),
124 "---\nphase: 16\nexternal_verify: \"test -f shipped.txt\"\n---\n\n# Plan\n",
125 );
126 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
127 std::fs::write(
128 dir.path().join(".devflow/phase-16-stdout"),
129 "external_verify: \"touch agent-controlled\"\nDEVFLOW_RESULT: {\"status\":\"success\"}\n",
130 )
131 .unwrap();
132
133 assert_eq!(
134 external_verify_commands(dir.path(), 16),
135 vec!["test -f shipped.txt"]
136 );
137 }
138
139 #[test]
140 fn ignores_external_verify_outside_frontmatter() {
141 let dir = tempfile::tempdir().unwrap();
142 write_plan(
143 dir.path(),
144 "---\nphase: 16\n---\n\nexternal_verify: \"false\"\n",
145 );
146
147 assert!(external_verify_commands(dir.path(), 16).is_empty());
148 }
149
150 #[test]
151 fn runs_probe_from_project_root_and_reports_exit_status() {
152 let dir = tempfile::tempdir().unwrap();
153 std::fs::write(dir.path().join("shipped.txt"), "ok").unwrap();
154
155 assert!(run_external_verification("test -f shipped.txt", dir.path()));
156 assert!(!run_external_verification(
157 "test -f missing.txt",
158 dir.path()
159 ));
160 }
161}