Skip to main content

verifyos_cli/
agent_io.rs

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}