1use crate::agent_assets::AgentAssetLayout;
2use crate::agents::{render_fix_prompt, render_pr_brief, render_pr_comment, CommandHints};
3use crate::report::{render_agent_pack_markdown, AgentPack, AgentPackFormat};
4use miette::{IntoDiagnostic, Result};
5use std::path::Path;
6
7pub fn write_next_steps_script(path: &Path, hints: &CommandHints) -> Result<()> {
8 let Some(app_path) = hints.app_path.as_deref() else {
9 return Err(miette::miette!(
10 "`--shell-script` requires `--from-scan <path>` so voc can build the follow-up commands"
11 ));
12 };
13
14 let profile = hints.profile.as_deref().unwrap_or("full");
15 let agent_pack_dir = hints.agent_pack_dir.as_deref().unwrap_or(".verifyos-agent");
16
17 if let Some(parent) = path.parent() {
18 std::fs::create_dir_all(parent).into_diagnostic()?;
19 }
20
21 let mut script = String::new();
22 script.push_str("#!/usr/bin/env bash\nset -euo pipefail\n\n");
23 script.push_str(&format!(
24 "voc --app {} --profile {}\n",
25 shell_quote(app_path),
26 profile
27 ));
28 script.push_str(&format!(
29 "voc --app {} --profile {} --format json > report.json\n",
30 shell_quote(app_path),
31 profile
32 ));
33 script.push_str(&format!(
34 "voc --app {} --profile {} --agent-pack {} --agent-pack-format bundle\n",
35 shell_quote(app_path),
36 profile,
37 shell_quote(agent_pack_dir)
38 ));
39 if let Some(output_dir) = hints.output_dir.as_deref() {
40 let mut cmd = format!(
41 "voc doctor --output-dir {} --fix --from-scan {} --profile {}",
42 shell_quote(output_dir),
43 shell_quote(app_path),
44 profile
45 );
46 if let Some(baseline) = hints.baseline_path.as_deref() {
47 cmd.push_str(&format!(" --baseline {}", shell_quote(baseline)));
48 }
49 if hints.pr_brief_path.is_some() {
50 cmd.push_str(" --open-pr-brief");
51 }
52 if hints.pr_comment_path.is_some() {
53 cmd.push_str(" --open-pr-comment");
54 }
55 script.push_str(&format!("{cmd}\n"));
56 } else if let Some(baseline) = hints.baseline_path.as_deref() {
57 script.push_str(&format!(
58 "voc init --from-scan {} --profile {} --baseline {} --agent-pack-dir {} --write-commands --shell-script\n",
59 shell_quote(app_path),
60 profile,
61 shell_quote(baseline),
62 shell_quote(agent_pack_dir)
63 ));
64 } else {
65 script.push_str(&format!(
66 "voc init --from-scan {} --profile {} --agent-pack-dir {} --write-commands --shell-script\n",
67 shell_quote(app_path),
68 profile,
69 shell_quote(agent_pack_dir)
70 ));
71 }
72
73 std::fs::write(path, script).into_diagnostic()?;
74 #[cfg(unix)]
75 {
76 use std::os::unix::fs::PermissionsExt;
77 let mut perms = std::fs::metadata(path).into_diagnostic()?.permissions();
78 perms.set_mode(0o755);
79 std::fs::set_permissions(path, perms).into_diagnostic()?;
80 }
81 Ok(())
82}
83
84pub fn write_fix_prompt_file(path: &Path, pack: &AgentPack, hints: &CommandHints) -> Result<()> {
85 write_text_asset(path, &render_fix_prompt(pack, hints))
86}
87
88pub fn write_pr_brief_file(path: &Path, pack: &AgentPack, hints: &CommandHints) -> Result<()> {
89 write_text_asset(path, &render_pr_brief(pack, hints))
90}
91
92pub fn write_pr_comment_file(path: &Path, pack: &AgentPack, hints: &CommandHints) -> Result<()> {
93 write_text_asset(path, &render_pr_comment(pack, hints))
94}
95
96pub fn write_agent_pack(
97 path: &Path,
98 agent_pack: &AgentPack,
99 format: AgentPackFormat,
100) -> Result<()> {
101 match format {
102 AgentPackFormat::Json => {
103 let json = serde_json::to_string_pretty(agent_pack).into_diagnostic()?;
104 std::fs::write(path, json).into_diagnostic()?;
105 }
106 AgentPackFormat::Markdown => {
107 let markdown = render_agent_pack_markdown(agent_pack);
108 std::fs::write(path, markdown).into_diagnostic()?;
109 }
110 AgentPackFormat::Bundle => {
111 std::fs::create_dir_all(path).into_diagnostic()?;
112 let json_path = path.join("agent-pack.json");
113 let markdown_path = path.join("agent-pack.md");
114 let json = serde_json::to_string_pretty(agent_pack).into_diagnostic()?;
115 let markdown = render_agent_pack_markdown(agent_pack);
116 std::fs::write(json_path, json).into_diagnostic()?;
117 std::fs::write(markdown_path, markdown).into_diagnostic()?;
118 }
119 }
120
121 Ok(())
122}
123
124pub fn load_agent_pack(path: &Path) -> Option<AgentPack> {
125 if !path.exists() {
126 return None;
127 }
128
129 let raw = std::fs::read_to_string(path).ok()?;
130 serde_json::from_str(&raw).ok()
131}
132
133pub fn empty_agent_pack() -> AgentPack {
134 AgentPack {
135 generated_at_unix: 0,
136 total_findings: 0,
137 findings: Vec::new(),
138 }
139}
140
141pub fn infer_existing_command_hints(layout: &AgentAssetLayout) -> CommandHints {
142 let mut hints = CommandHints {
143 output_dir: Some(layout.output_dir.display().to_string()),
144 app_path: None,
145 baseline_path: None,
146 agent_pack_dir: Some(layout.agent_bundle_dir.display().to_string()),
147 profile: None,
148 shell_script: layout.next_steps_script_path.exists(),
149 fix_prompt_path: Some(layout.fix_prompt_path.display().to_string()),
150 repair_plan_path: layout
151 .repair_plan_path
152 .exists()
153 .then(|| layout.repair_plan_path.display().to_string()),
154 pr_brief_path: layout
155 .pr_brief_path
156 .exists()
157 .then(|| layout.pr_brief_path.display().to_string()),
158 pr_comment_path: layout
159 .pr_comment_path
160 .exists()
161 .then(|| layout.pr_comment_path.display().to_string()),
162 };
163
164 for command in collect_existing_voc_commands(layout) {
165 let tokens = split_shell_words(&command);
166 if tokens.first().map(String::as_str) != Some("voc") {
167 continue;
168 }
169
170 let mut index = 1;
171 while index < tokens.len() {
172 match tokens[index].as_str() {
173 "--app" | "--from-scan" => {
174 if hints.app_path.is_none() {
175 hints.app_path = tokens.get(index + 1).cloned();
176 }
177 index += 1;
178 }
179 "--profile" => {
180 if hints.profile.is_none() {
181 hints.profile = tokens.get(index + 1).cloned();
182 }
183 index += 1;
184 }
185 "--baseline" => {
186 if hints.baseline_path.is_none() {
187 hints.baseline_path = tokens.get(index + 1).cloned();
188 }
189 index += 1;
190 }
191 "--shell-script" => {
192 hints.shell_script = true;
193 }
194 "--open-pr-brief" => {
195 hints.pr_brief_path = Some(layout.pr_brief_path.display().to_string());
196 }
197 "--open-pr-comment" => {
198 hints.pr_comment_path = Some(layout.pr_comment_path.display().to_string());
199 }
200 _ => {}
201 }
202 index += 1;
203 }
204 }
205
206 hints
207}
208
209fn write_text_asset(path: &Path, contents: &str) -> Result<()> {
210 if let Some(parent) = path.parent() {
211 std::fs::create_dir_all(parent).into_diagnostic()?;
212 }
213 std::fs::write(path, contents).into_diagnostic()?;
214 Ok(())
215}
216
217fn collect_existing_voc_commands(layout: &AgentAssetLayout) -> Vec<String> {
218 let mut commands = Vec::new();
219
220 if let Ok(contents) = std::fs::read_to_string(&layout.agents_path) {
221 commands.extend(
222 contents
223 .lines()
224 .map(str::trim)
225 .filter(|line| line.starts_with("voc "))
226 .map(str::to_string),
227 );
228 }
229
230 if let Ok(contents) = std::fs::read_to_string(&layout.next_steps_script_path) {
231 commands.extend(
232 contents
233 .lines()
234 .map(str::trim)
235 .filter(|line| line.starts_with("voc "))
236 .map(str::to_string),
237 );
238 }
239
240 commands
241}
242
243fn split_shell_words(input: &str) -> Vec<String> {
244 let mut tokens = Vec::new();
245 let mut current = String::new();
246 let mut in_single_quote = false;
247
248 for ch in input.chars() {
249 match ch {
250 '\'' => in_single_quote = !in_single_quote,
251 ' ' | '\t' if !in_single_quote => {
252 if !current.is_empty() {
253 tokens.push(std::mem::take(&mut current));
254 }
255 }
256 _ => current.push(ch),
257 }
258 }
259
260 if !current.is_empty() {
261 tokens.push(current);
262 }
263
264 tokens
265}
266
267fn shell_quote(value: &str) -> String {
268 if value
269 .chars()
270 .all(|ch| ch.is_ascii_alphanumeric() || "/._-".contains(ch))
271 {
272 value.to_string()
273 } else {
274 format!("'{}'", value.replace('\'', "'\"'\"'"))
275 }
276}