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("---\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}