Skip to main content

verifyos_cli/
agents.rs

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}