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