1use crate::profiles::{rule_inventory, RuleInventoryItem};
2use crate::report::AgentPack;
3use std::path::Path;
4
5const MANAGED_START: &str = "<!-- verifyos-cli:agents:start -->";
6const MANAGED_END: &str = "<!-- verifyos-cli:agents:end -->";
7
8#[derive(Debug, Clone, Default)]
9pub struct CommandHints {
10 pub output_dir: Option<String>,
11 pub app_path: Option<String>,
12 pub baseline_path: Option<String>,
13 pub agent_pack_dir: Option<String>,
14 pub profile: Option<String>,
15 pub shell_script: bool,
16 pub fix_prompt_path: Option<String>,
17 pub pr_brief_path: Option<String>,
18 pub pr_comment_path: Option<String>,
19}
20
21pub fn write_agents_file(
22 path: &Path,
23 agent_pack: Option<&AgentPack>,
24 agent_pack_dir: Option<&Path>,
25 command_hints: Option<&CommandHints>,
26) -> Result<(), miette::Report> {
27 let existing = if path.exists() {
28 Some(std::fs::read_to_string(path).map_err(|err| {
29 miette::miette!(
30 "Failed to read existing AGENTS.md at {}: {}",
31 path.display(),
32 err
33 )
34 })?)
35 } else {
36 None
37 };
38
39 let managed_block = build_managed_block(agent_pack, agent_pack_dir, command_hints);
40 let next = merge_agents_content(existing.as_deref(), &managed_block);
41 std::fs::write(path, next)
42 .map_err(|err| miette::miette!("Failed to write AGENTS.md at {}: {}", path.display(), err))
43}
44
45pub fn merge_agents_content(existing: Option<&str>, managed_block: &str) -> String {
46 match existing {
47 None => format!("# AGENTS.md\n\n{}", managed_block),
48 Some(content) => {
49 if let Some((start, end)) = managed_block_range(content) {
50 let mut next = String::new();
51 next.push_str(&content[..start]);
52 if !next.ends_with('\n') {
53 next.push('\n');
54 }
55 next.push_str(managed_block);
56 let tail = &content[end..];
57 if !tail.is_empty() && !tail.starts_with('\n') {
58 next.push('\n');
59 }
60 next.push_str(tail);
61 next
62 } else if content.trim().is_empty() {
63 format!("# AGENTS.md\n\n{}", managed_block)
64 } else {
65 let mut next = content.trim_end().to_string();
66 next.push_str("\n\n");
67 next.push_str(managed_block);
68 next.push('\n');
69 next
70 }
71 }
72 }
73}
74
75pub fn build_managed_block(
76 agent_pack: Option<&AgentPack>,
77 agent_pack_dir: Option<&Path>,
78 command_hints: Option<&CommandHints>,
79) -> String {
80 let inventory = rule_inventory();
81 let agent_pack_dir_display = agent_pack_dir
82 .map(|path| path.display().to_string())
83 .unwrap_or_else(|| ".verifyos-agent".to_string());
84 let mut out = String::new();
85 out.push_str(MANAGED_START);
86 out.push('\n');
87 out.push_str("## verifyOS-cli\n\n");
88 out.push_str("Use `voc` before large iOS submission changes or release builds.\n\n");
89 out.push_str("### Recommended Workflow\n\n");
90 out.push_str("1. Run `voc --app <path-to-.ipa-or-.app> --profile basic` for a quick gate.\n");
91 out.push_str(&format!(
92 "2. Run `voc --app <path-to-.ipa-or-.app> --profile full --agent-pack {} --agent-pack-format bundle` before release or when an AI agent will patch findings.\n",
93 agent_pack_dir_display
94 ));
95 out.push_str(&format!(
96 "3. Read `{}/agent-pack.md` first, then patch the highest-priority scopes.\n",
97 agent_pack_dir_display
98 ));
99 out.push_str("4. Re-run `voc` after each fix batch until the pack is clean.\n\n");
100 out.push_str("### AI Agent Rules\n\n");
101 out.push_str("- Prefer `voc --profile basic` during fast inner loops and `voc --profile full` before shipping.\n");
102 out.push_str(&format!(
103 "- When findings exist, generate an agent bundle with `voc --agent-pack {} --agent-pack-format bundle`.\n",
104 agent_pack_dir_display
105 ));
106 out.push_str("- Fix `high` priority findings before `medium` and `low`.\n");
107 out.push_str("- Treat `Info.plist`, `entitlements`, `ats-config`, and `bundle-resources` as the main fix scopes.\n");
108 out.push_str("- Re-run `voc` after edits and compare against the previous agent pack to confirm findings were actually removed.\n\n");
109 if let Some(hints) = command_hints {
110 append_next_commands(&mut out, hints);
111 }
112 if let Some(pack) = agent_pack {
113 append_current_project_risks(&mut out, pack, &agent_pack_dir_display);
114 }
115 out.push_str("### Rule Inventory\n\n");
116 out.push_str("| Rule ID | Name | Category | Severity | Default Profiles |\n");
117 out.push_str("| --- | --- | --- | --- | --- |\n");
118 for item in inventory {
119 out.push_str(&inventory_row(&item));
120 }
121 out.push('\n');
122 out.push_str(MANAGED_END);
123 out.push('\n');
124 out
125}
126
127fn append_next_commands(out: &mut String, hints: &CommandHints) {
128 let Some(app_path) = hints.app_path.as_deref() else {
129 return;
130 };
131
132 let profile = hints.profile.as_deref().unwrap_or("full");
133 let agent_pack_dir = hints.agent_pack_dir.as_deref().unwrap_or(".verifyos-agent");
134
135 out.push_str("### Next Commands\n\n");
136 out.push_str("Use these exact commands after each patch batch:\n\n");
137 if hints.shell_script {
138 out.push_str(&format!(
139 "- Shortcut script: `{}/next-steps.sh`\n\n",
140 agent_pack_dir
141 ));
142 }
143 if let Some(prompt_path) = hints.fix_prompt_path.as_deref() {
144 out.push_str(&format!("- Agent fix prompt: `{}`\n\n", prompt_path));
145 }
146 if let Some(pr_brief_path) = hints.pr_brief_path.as_deref() {
147 out.push_str(&format!("- PR brief: `{}`\n\n", pr_brief_path));
148 }
149 if let Some(pr_comment_path) = hints.pr_comment_path.as_deref() {
150 out.push_str(&format!("- PR comment draft: `{}`\n\n", pr_comment_path));
151 }
152 out.push_str("```bash\n");
153 out.push_str(&format!(
154 "voc --app {} --profile {}\n",
155 shell_quote(app_path),
156 profile
157 ));
158 out.push_str(&format!(
159 "voc --app {} --profile {} --format json > report.json\n",
160 shell_quote(app_path),
161 profile
162 ));
163 out.push_str(&format!(
164 "voc --app {} --profile {} --agent-pack {} --agent-pack-format bundle\n",
165 shell_quote(app_path),
166 profile,
167 shell_quote(agent_pack_dir)
168 ));
169 if let Some(output_dir) = hints.output_dir.as_deref() {
170 let mut cmd = format!(
171 "voc doctor --output-dir {} --fix --from-scan {} --profile {}",
172 shell_quote(output_dir),
173 shell_quote(app_path),
174 profile
175 );
176 if let Some(baseline) = hints.baseline_path.as_deref() {
177 cmd.push_str(&format!(" --baseline {}", shell_quote(baseline)));
178 }
179 if hints.pr_brief_path.is_some() {
180 cmd.push_str(" --open-pr-brief");
181 }
182 if hints.pr_comment_path.is_some() {
183 cmd.push_str(" --open-pr-comment");
184 }
185 out.push_str(&format!("{cmd}\n"));
186 } else if let Some(baseline) = hints.baseline_path.as_deref() {
187 let mut cmd = format!(
188 "voc init --from-scan {} --profile {} --baseline {} --agent-pack-dir {} --write-commands",
189 shell_quote(app_path),
190 profile,
191 shell_quote(baseline),
192 shell_quote(agent_pack_dir)
193 );
194 if hints.shell_script {
195 cmd.push_str(" --shell-script");
196 }
197 out.push_str(&format!("{cmd}\n"));
198 } else {
199 let mut cmd = format!(
200 "voc init --from-scan {} --profile {} --agent-pack-dir {} --write-commands",
201 shell_quote(app_path),
202 profile,
203 shell_quote(agent_pack_dir)
204 );
205 if hints.shell_script {
206 cmd.push_str(" --shell-script");
207 }
208 out.push_str(&format!("{cmd}\n"));
209 }
210 out.push_str("```\n\n");
211}
212
213pub fn render_fix_prompt(pack: &AgentPack, hints: &CommandHints) -> String {
214 let mut out = String::new();
215 out.push_str("# verifyOS Fix Prompt\n\n");
216 out.push_str(
217 "Patch the current iOS bundle risks conservatively. Prefer minimal, review-safe edits.\n\n",
218 );
219 if let Some(app_path) = hints.app_path.as_deref() {
220 out.push_str(&format!("- App artifact: `{}`\n", app_path));
221 }
222 if let Some(profile) = hints.profile.as_deref() {
223 out.push_str(&format!("- Scan profile: `{}`\n", profile));
224 }
225 if let Some(agent_pack_dir) = hints.agent_pack_dir.as_deref() {
226 out.push_str(&format!("- Agent bundle: `{}`\n", agent_pack_dir));
227 }
228 if let Some(prompt_path) = hints.fix_prompt_path.as_deref() {
229 out.push_str(&format!("- Prompt file: `{}`\n", prompt_path));
230 }
231 out.push('\n');
232
233 if pack.findings.is_empty() {
234 out.push_str("## Findings\n\n- No current findings. Re-run the validation commands to confirm the app is still clean.\n\n");
235 } else {
236 out.push_str("## Findings\n\n");
237 for finding in &pack.findings {
238 out.push_str(&format!(
239 "- **{}** (`{}`)\n",
240 finding.rule_name, finding.rule_id
241 ));
242 out.push_str(&format!(" - Priority: `{}`\n", finding.priority));
243 out.push_str(&format!(" - Scope: `{}`\n", finding.suggested_fix_scope));
244 if !finding.target_files.is_empty() {
245 out.push_str(&format!(
246 " - Target files: {}\n",
247 finding.target_files.join(", ")
248 ));
249 }
250 out.push_str(&format!(
251 " - Why it fails review: {}\n",
252 finding.why_it_fails_review
253 ));
254 out.push_str(&format!(" - Patch hint: {}\n", finding.patch_hint));
255 out.push_str(&format!(" - Recommendation: {}\n", finding.recommendation));
256 }
257 out.push('\n');
258 }
259
260 out.push_str("## Done When\n\n");
261 out.push_str("- The relevant files are patched without widening permissions or exceptions.\n");
262 out.push_str("- `voc` no longer reports the patched findings.\n");
263 out.push_str("- Updated outputs are regenerated for the next loop.\n\n");
264
265 out.push_str("## Validation Commands\n\n");
266 if let Some(app_path) = hints.app_path.as_deref() {
267 let profile = hints.profile.as_deref().unwrap_or("full");
268 let agent_pack_dir = hints.agent_pack_dir.as_deref().unwrap_or(".verifyos-agent");
269 out.push_str("```bash\n");
270 out.push_str(&format!(
271 "voc --app {} --profile {}\n",
272 shell_quote(app_path),
273 profile
274 ));
275 out.push_str(&format!(
276 "voc --app {} --profile {} --agent-pack {} --agent-pack-format bundle\n",
277 shell_quote(app_path),
278 profile,
279 shell_quote(agent_pack_dir)
280 ));
281 out.push_str("```\n");
282 }
283
284 out
285}
286
287pub fn render_pr_brief(pack: &AgentPack, hints: &CommandHints) -> String {
288 let mut out = String::new();
289 out.push_str("# verifyOS PR Brief\n\n");
290 out.push_str("## Summary\n\n");
291 out.push_str(&format!("- Findings in scope: `{}`\n", pack.total_findings));
292 if let Some(app_path) = hints.app_path.as_deref() {
293 out.push_str(&format!("- App artifact: `{}`\n", app_path));
294 }
295 if let Some(profile) = hints.profile.as_deref() {
296 out.push_str(&format!("- Scan profile: `{}`\n", profile));
297 }
298 if let Some(baseline) = hints.baseline_path.as_deref() {
299 out.push_str(&format!("- Baseline: `{}`\n", baseline));
300 }
301 out.push('\n');
302
303 out.push_str("## What Changed\n\n");
304 if pack.findings.is_empty() {
305 out.push_str(
306 "- No new or regressed risks are currently in scope after the latest scan.\n\n",
307 );
308 } else {
309 out.push_str(
310 "- This branch still contains findings that can affect App Store review outcomes.\n",
311 );
312 out.push_str(
313 "- The recommended patch order below is sorted for review safety and repair efficiency.\n\n",
314 );
315 }
316
317 out.push_str("## Current Risks\n\n");
318 if pack.findings.is_empty() {
319 out.push_str("- No open findings.\n\n");
320 } else {
321 let mut findings = pack.findings.clone();
322 findings.sort_by(|a, b| {
323 priority_rank(&a.priority)
324 .cmp(&priority_rank(&b.priority))
325 .then_with(|| a.suggested_fix_scope.cmp(&b.suggested_fix_scope))
326 .then_with(|| a.rule_id.cmp(&b.rule_id))
327 });
328
329 for finding in &findings {
330 out.push_str(&format!(
331 "- **{}** (`{}`)\n",
332 finding.rule_name, finding.rule_id
333 ));
334 out.push_str(&format!(" - Priority: `{}`\n", finding.priority));
335 out.push_str(&format!(" - Scope: `{}`\n", finding.suggested_fix_scope));
336 if !finding.target_files.is_empty() {
337 out.push_str(&format!(
338 " - Target files: {}\n",
339 finding.target_files.join(", ")
340 ));
341 }
342 out.push_str(&format!(
343 " - Why review cares: {}\n",
344 finding.why_it_fails_review
345 ));
346 out.push_str(&format!(" - Patch hint: {}\n", finding.patch_hint));
347 }
348 out.push('\n');
349 }
350
351 out.push_str("## Validation Commands\n\n");
352 if let Some(app_path) = hints.app_path.as_deref() {
353 let profile = hints.profile.as_deref().unwrap_or("full");
354 let agent_pack_dir = hints.agent_pack_dir.as_deref().unwrap_or(".verifyos-agent");
355 out.push_str("```bash\n");
356 out.push_str(&format!(
357 "voc --app {} --profile {}\n",
358 shell_quote(app_path),
359 profile
360 ));
361 out.push_str(&format!(
362 "voc --app {} --profile {} --agent-pack {} --agent-pack-format bundle\n",
363 shell_quote(app_path),
364 profile,
365 shell_quote(agent_pack_dir)
366 ));
367 if let Some(output_dir) = hints.output_dir.as_deref() {
368 let mut cmd = format!(
369 "voc doctor --output-dir {} --fix --from-scan {} --profile {}",
370 shell_quote(output_dir),
371 shell_quote(app_path),
372 profile
373 );
374 if let Some(baseline) = hints.baseline_path.as_deref() {
375 cmd.push_str(&format!(" --baseline {}", shell_quote(baseline)));
376 }
377 if hints.pr_brief_path.is_some() {
378 cmd.push_str(" --open-pr-brief");
379 }
380 out.push_str(&format!("{cmd}\n"));
381 } else if let Some(baseline) = hints.baseline_path.as_deref() {
382 out.push_str(&format!(
383 "voc doctor --fix --from-scan {} --profile {} --baseline {} --open-pr-brief\n",
384 shell_quote(app_path),
385 profile,
386 shell_quote(baseline)
387 ));
388 }
389 out.push_str("```\n");
390 }
391
392 out
393}
394
395pub fn render_pr_comment(pack: &AgentPack, hints: &CommandHints) -> String {
396 let mut out = String::new();
397 out.push_str("## verifyOS review summary\n\n");
398 out.push_str(&format!("- Findings in scope: `{}`\n", pack.total_findings));
399 if let Some(app_path) = hints.app_path.as_deref() {
400 out.push_str(&format!("- App artifact: `{}`\n", app_path));
401 }
402 if let Some(profile) = hints.profile.as_deref() {
403 out.push_str(&format!("- Scan profile: `{}`\n", profile));
404 }
405 out.push('\n');
406
407 if pack.findings.is_empty() {
408 out.push_str("- No open findings after the latest scan.\n\n");
409 } else {
410 out.push_str("### Top risks\n\n");
411 for finding in pack.findings.iter().take(5) {
412 out.push_str(&format!(
413 "- **{}** (`{}`) [{}/{}]\n",
414 finding.rule_name, finding.rule_id, finding.priority, finding.suggested_fix_scope
415 ));
416 out.push_str(&format!(
417 " - Why it matters: {}\n",
418 finding.why_it_fails_review
419 ));
420 out.push_str(&format!(" - Patch hint: {}\n", finding.patch_hint));
421 }
422 out.push('\n');
423 }
424
425 out.push_str("### Validation\n\n");
426 if let Some(app_path) = hints.app_path.as_deref() {
427 let profile = hints.profile.as_deref().unwrap_or("full");
428 out.push_str("```bash\n");
429 out.push_str(&format!(
430 "voc --app {} --profile {}\n",
431 shell_quote(app_path),
432 profile
433 ));
434 out.push_str("```\n");
435 }
436
437 out
438}
439
440fn append_current_project_risks(out: &mut String, pack: &AgentPack, agent_pack_dir: &str) {
441 out.push_str("### Current Project Risks\n\n");
442 out.push_str(&format!(
443 "- Agent bundle: `{}/agent-pack.json` and `{}/agent-pack.md`\n\n",
444 agent_pack_dir, agent_pack_dir
445 ));
446 if pack.findings.is_empty() {
447 out.push_str(
448 "- No new or regressed risks after applying the latest scan context. Re-run `voc` before release to keep this section fresh.\n\n",
449 );
450 return;
451 }
452
453 let mut findings = pack.findings.clone();
454 findings.sort_by(|a, b| {
455 priority_rank(&a.priority)
456 .cmp(&priority_rank(&b.priority))
457 .then_with(|| a.suggested_fix_scope.cmp(&b.suggested_fix_scope))
458 .then_with(|| a.rule_id.cmp(&b.rule_id))
459 });
460
461 out.push_str("| Priority | Rule ID | Scope | Why it matters |\n");
462 out.push_str("| --- | --- | --- | --- |\n");
463 for finding in &findings {
464 out.push_str(&format!(
465 "| `{}` | `{}` | `{}` | {} |\n",
466 finding.priority,
467 finding.rule_id,
468 finding.suggested_fix_scope,
469 finding.why_it_fails_review
470 ));
471 }
472 out.push('\n');
473
474 out.push_str("#### Suggested Patch Order\n\n");
475 for finding in &findings {
476 out.push_str(&format!(
477 "- **{}** (`{}`)\n",
478 finding.rule_name, finding.rule_id
479 ));
480 out.push_str(&format!(" - Priority: `{}`\n", finding.priority));
481 out.push_str(&format!(
482 " - Fix scope: `{}`\n",
483 finding.suggested_fix_scope
484 ));
485 if !finding.target_files.is_empty() {
486 out.push_str(&format!(
487 " - Target files: {}\n",
488 finding.target_files.join(", ")
489 ));
490 }
491 out.push_str(&format!(
492 " - Why it fails review: {}\n",
493 finding.why_it_fails_review
494 ));
495 out.push_str(&format!(" - Patch hint: {}\n", finding.patch_hint));
496 }
497 out.push('\n');
498}
499
500fn priority_rank(priority: &str) -> u8 {
501 match priority {
502 "high" => 0,
503 "medium" => 1,
504 "low" => 2,
505 _ => 3,
506 }
507}
508
509fn inventory_row(item: &RuleInventoryItem) -> String {
510 format!(
511 "| `{}` | {} | `{:?}` | `{:?}` | `{}` |\n",
512 item.rule_id,
513 item.name,
514 item.category,
515 item.severity,
516 item.default_profiles.join(", ")
517 )
518}
519
520fn managed_block_range(content: &str) -> Option<(usize, usize)> {
521 let start = content.find(MANAGED_START)?;
522 let end_marker = content.find(MANAGED_END)?;
523 Some((start, end_marker + MANAGED_END.len()))
524}
525
526fn shell_quote(value: &str) -> String {
527 if value
528 .chars()
529 .all(|ch| ch.is_ascii_alphanumeric() || "/._-".contains(ch))
530 {
531 value.to_string()
532 } else {
533 format!("'{}'", value.replace('\'', "'\"'\"'"))
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::{build_managed_block, merge_agents_content, CommandHints};
540 use crate::report::{AgentFinding, AgentPack};
541 use crate::rules::core::{RuleCategory, Severity};
542 use std::path::Path;
543
544 #[test]
545 fn merge_agents_content_creates_new_file_when_missing() {
546 let block = build_managed_block(None, None, None);
547 let merged = merge_agents_content(None, &block);
548
549 assert!(merged.starts_with("# AGENTS.md"));
550 assert!(merged.contains("## verifyOS-cli"));
551 assert!(merged.contains("RULE_PRIVACY_MANIFEST"));
552 }
553
554 #[test]
555 fn merge_agents_content_replaces_existing_managed_block() {
556 let block = build_managed_block(None, None, None);
557 let existing = r#"# AGENTS.md
558
559Custom note
560
561<!-- verifyos-cli:agents:start -->
562old block
563<!-- verifyos-cli:agents:end -->
564
565Keep this
566"#;
567
568 let merged = merge_agents_content(Some(existing), &block);
569
570 assert!(merged.contains("Custom note"));
571 assert!(merged.contains("Keep this"));
572 assert!(!merged.contains("old block"));
573 assert_eq!(
574 merged.matches("<!-- verifyos-cli:agents:start -->").count(),
575 1
576 );
577 }
578
579 #[test]
580 fn build_managed_block_includes_current_project_risks_when_scan_exists() {
581 let pack = AgentPack {
582 generated_at_unix: 0,
583 total_findings: 1,
584 findings: vec![AgentFinding {
585 rule_id: "RULE_USAGE_DESCRIPTIONS".to_string(),
586 rule_name: "Missing required usage description keys".to_string(),
587 severity: Severity::Warning,
588 category: RuleCategory::Privacy,
589 priority: "medium".to_string(),
590 message: "Missing NSCameraUsageDescription".to_string(),
591 evidence: None,
592 recommendation: "Add usage descriptions".to_string(),
593 suggested_fix_scope: "Info.plist".to_string(),
594 target_files: vec!["Info.plist".to_string()],
595 patch_hint: "Update Info.plist".to_string(),
596 why_it_fails_review: "Protected APIs require usage strings.".to_string(),
597 }],
598 };
599
600 let block = build_managed_block(Some(&pack), Some(Path::new(".verifyos-agent")), None);
601
602 assert!(block.contains("### Current Project Risks"));
603 assert!(block.contains("#### Suggested Patch Order"));
604 assert!(block.contains("`RULE_USAGE_DESCRIPTIONS`"));
605 assert!(block.contains("Info.plist"));
606 assert!(block.contains(".verifyos-agent/agent-pack.md"));
607 }
608
609 #[test]
610 fn build_managed_block_includes_next_commands_when_requested() {
611 let hints = CommandHints {
612 output_dir: Some(".verifyos".to_string()),
613 app_path: Some("examples/bad_app.ipa".to_string()),
614 baseline_path: Some("baseline.json".to_string()),
615 agent_pack_dir: Some(".verifyos-agent".to_string()),
616 profile: Some("basic".to_string()),
617 shell_script: true,
618 fix_prompt_path: Some(".verifyos-agent/fix-prompt.md".to_string()),
619 pr_brief_path: Some(".verifyos-agent/pr-brief.md".to_string()),
620 pr_comment_path: Some(".verifyos-agent/pr-comment.md".to_string()),
621 };
622
623 let block = build_managed_block(None, Some(Path::new(".verifyos-agent")), Some(&hints));
624
625 assert!(block.contains("### Next Commands"));
626 assert!(block.contains("voc --app examples/bad_app.ipa --profile basic"));
627 assert!(block.contains("--baseline baseline.json"));
628 assert!(block.contains("voc doctor --output-dir .verifyos --fix --from-scan examples/bad_app.ipa --profile basic --baseline baseline.json --open-pr-brief"));
629 assert!(block.contains(".verifyos-agent/next-steps.sh"));
630 assert!(block.contains(".verifyos-agent/fix-prompt.md"));
631 assert!(block.contains(".verifyos-agent/pr-brief.md"));
632 assert!(block.contains(".verifyos-agent/pr-comment.md"));
633 }
634}