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