auditkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/report.rs ADDED
@@ -0,0 +1,250 @@
1
+ use std::collections::BTreeMap;
2
+ use std::fs;
3
+ use std::path::PathBuf;
4
+
5
+ use anyhow::Result;
6
+
7
+ use crate::workspace::Workspace;
8
+
9
+ #[derive(Debug, Clone, PartialEq, Eq)]
10
+ pub struct Brief {
11
+ pub client_name: String,
12
+ pub website: String,
13
+ pub business_type: String,
14
+ pub goal: String,
15
+ pub target_customer: String,
16
+ pub conversion_action: String,
17
+ }
18
+
19
+ #[derive(Debug, Clone)]
20
+ pub struct AgencyConfig {
21
+ pub agency_name: String,
22
+ pub auditor_name: String,
23
+ pub audit_price: String,
24
+ pub refresh_price: String,
25
+ pub growth_price: String,
26
+ }
27
+
28
+ impl Default for AgencyConfig {
29
+ fn default() -> Self {
30
+ Self {
31
+ agency_name: "Origin".to_string(),
32
+ auditor_name: "Daniel White".to_string(),
33
+ audit_price: "Starting at £299".to_string(),
34
+ refresh_price: "Starting at £1,999".to_string(),
35
+ growth_price: "Starting at £499/mo".to_string(),
36
+ }
37
+ }
38
+ }
39
+
40
+ pub fn parse_brief(markdown: &str) -> Brief {
41
+ Brief {
42
+ client_name: markdown
43
+ .lines()
44
+ .find_map(|line| {
45
+ line.strip_prefix("# ")
46
+ .and_then(|line| line.strip_suffix(" Audit Brief"))
47
+ })
48
+ .unwrap_or("Client")
49
+ .to_string(),
50
+ website: field(markdown, "Website"),
51
+ business_type: field(markdown, "Business type"),
52
+ goal: field(markdown, "Goal"),
53
+ target_customer: field(markdown, "Target customer"),
54
+ conversion_action: field(markdown, "Main conversion action"),
55
+ }
56
+ }
57
+
58
+ fn field(markdown: &str, label: &str) -> String {
59
+ let prefix = format!("{label}:");
60
+ markdown
61
+ .lines()
62
+ .find_map(|line| line.strip_prefix(&prefix))
63
+ .map(str::trim)
64
+ .unwrap_or("")
65
+ .to_string()
66
+ }
67
+
68
+ fn strip_title(markdown: &str) -> String {
69
+ markdown
70
+ .lines()
71
+ .skip_while(|line| line.starts_with("# "))
72
+ .collect::<Vec<_>>()
73
+ .join("\n")
74
+ .trim()
75
+ .to_string()
76
+ }
77
+
78
+ fn heading_from_page_path(path: &str) -> String {
79
+ let name = path
80
+ .trim_start_matches("pages/")
81
+ .trim_end_matches(".md")
82
+ .replace("home", "homepage")
83
+ .replace(['-', '_'], " ");
84
+
85
+ name.split_whitespace()
86
+ .map(|word| {
87
+ let mut chars = word.chars();
88
+ match chars.next() {
89
+ Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
90
+ None => String::new(),
91
+ }
92
+ })
93
+ .collect::<Vec<_>>()
94
+ .join(" ")
95
+ }
96
+
97
+ pub fn build_final_report(
98
+ config: &AgencyConfig,
99
+ folder_name: &str,
100
+ files: &BTreeMap<String, String>,
101
+ ) -> String {
102
+ let brief = parse_brief(files.get("brief.md").map(String::as_str).unwrap_or(""));
103
+ let page_sections = files
104
+ .iter()
105
+ .filter(|(path, _)| path.starts_with("pages/") && path.ends_with(".md"))
106
+ .map(|(path, content)| {
107
+ format!(
108
+ "### {} Review\n\n{}",
109
+ heading_from_page_path(path),
110
+ strip_title(content)
111
+ )
112
+ })
113
+ .collect::<Vec<_>>()
114
+ .join("\n\n");
115
+
116
+ format!(
117
+ "# {} Website Audit\n\nPrepared by {}, {}\nAudit folder: {}\n\n## Context\n\nWebsite: {}\nGoal: {}\nBusiness type: {}\nTarget customer: {}\nMain conversion action: {}\n\n## Scorecard\n\n{}\n\n## Automated Check\n\n{}\n\n## Security Check\n\n{}\n\n## Lighthouse Check\n\n{}\n\n## Findings\n\n{}\n\n## Page Reviews\n\n{}\n\n## Recommended Next Step\n\nAudit: {}\nRefresh: {}\nGrowth: {}\n\nUse the audit to prioritise immediate fixes. If most findings require design, copy, and frontend changes, the Refresh package is likely the best next step.\n",
118
+ brief.client_name,
119
+ config.auditor_name,
120
+ config.agency_name,
121
+ folder_name,
122
+ brief.website,
123
+ brief.goal,
124
+ empty_or(&brief.business_type, "Not specified"),
125
+ empty_or(&brief.target_customer, "Not specified"),
126
+ empty_or(&brief.conversion_action, "Not specified"),
127
+ section_file(files, "scorecard.md", "Not completed yet."),
128
+ section_file(files, "automated-check.md", "Not run yet."),
129
+ section_file(files, "security.md", "Not run yet."),
130
+ section_file(files, "lighthouse.md", "Not run yet."),
131
+ section_file(files, "findings.md", "No findings captured yet."),
132
+ if page_sections.is_empty() { "No page reviews captured yet.".to_string() } else { page_sections },
133
+ config.audit_price,
134
+ config.refresh_price,
135
+ config.growth_price,
136
+ )
137
+ }
138
+
139
+ pub fn build_client_email(config: &AgencyConfig, brief: &Brief) -> String {
140
+ format!(
141
+ "Subject: Website audit for {}\n\nHi,\n\nI've completed the website audit for {}.\n\nThe report is in final-report.md. I also included a video-script.md outline so the walkthrough can focus on the highest-impact issues.\n\nMain goal reviewed: {}\n\nRecommended next step:\nReview the priority fixes in the report. If you want me to handle the design, copy, and implementation work, the Refresh package is the most likely fit.\n\nThanks,\n{}\n{}\n",
142
+ brief.client_name,
143
+ empty_or(&brief.website, "your website"),
144
+ empty_or(&brief.goal, "Not specified"),
145
+ config.auditor_name,
146
+ config.agency_name
147
+ )
148
+ }
149
+
150
+ pub fn generate_report(workspace: &Workspace, folder_name: &str) -> Result<(PathBuf, PathBuf)> {
151
+ let config = load_config(workspace);
152
+ let files = workspace.read_audit_files(folder_name)?;
153
+ let brief = parse_brief(files.get("brief.md").map(String::as_str).unwrap_or(""));
154
+ let report = build_final_report(&config, folder_name, &files);
155
+ let email = build_client_email(&config, &brief);
156
+ let report_path = workspace.write_audit_file(folder_name, "final-report.md", &report)?;
157
+ let email_path = workspace.write_audit_file(folder_name, "client-email.md", &email)?;
158
+ Ok((report_path, email_path))
159
+ }
160
+
161
+ fn section_file(files: &BTreeMap<String, String>, path: &str, fallback: &str) -> String {
162
+ files
163
+ .get(path)
164
+ .map(|content| strip_title(content))
165
+ .filter(|content| !content.is_empty())
166
+ .unwrap_or_else(|| fallback.to_string())
167
+ }
168
+
169
+ fn empty_or<'a>(value: &'a str, fallback: &'a str) -> &'a str {
170
+ if value.trim().is_empty() {
171
+ fallback
172
+ } else {
173
+ value
174
+ }
175
+ }
176
+
177
+ fn load_config(workspace: &Workspace) -> AgencyConfig {
178
+ let path = workspace.root.join("auditkit.config.json");
179
+ let Ok(content) = fs::read_to_string(path) else {
180
+ return AgencyConfig::default();
181
+ };
182
+ let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
183
+ return AgencyConfig::default();
184
+ };
185
+
186
+ let mut config = AgencyConfig::default();
187
+ config.agency_name = json["agencyName"]
188
+ .as_str()
189
+ .unwrap_or(&config.agency_name)
190
+ .to_string();
191
+ config.auditor_name = json["auditorName"]
192
+ .as_str()
193
+ .unwrap_or(&config.auditor_name)
194
+ .to_string();
195
+ config.audit_price = json["services"]["audit"]
196
+ .as_str()
197
+ .unwrap_or(&config.audit_price)
198
+ .to_string();
199
+ config.refresh_price = json["services"]["refresh"]
200
+ .as_str()
201
+ .unwrap_or(&config.refresh_price)
202
+ .to_string();
203
+ config.growth_price = json["services"]["growth"]
204
+ .as_str()
205
+ .unwrap_or(&config.growth_price)
206
+ .to_string();
207
+ config
208
+ }
209
+
210
+ #[cfg(test)]
211
+ mod tests {
212
+ use super::*;
213
+
214
+ #[test]
215
+ fn parse_brief_extracts_context() {
216
+ let brief = parse_brief(
217
+ "# Acme Dental Audit Brief\n\nWebsite: https://example.com\nGoal: More bookings",
218
+ );
219
+ assert_eq!(brief.client_name, "Acme Dental");
220
+ assert_eq!(brief.website, "https://example.com");
221
+ assert_eq!(brief.goal, "More bookings");
222
+ }
223
+
224
+ #[test]
225
+ fn final_report_includes_security_and_lighthouse() {
226
+ let mut files = BTreeMap::new();
227
+ files.insert(
228
+ "brief.md".to_string(),
229
+ "# Acme Dental Audit Brief\n\nWebsite: https://example.com\nGoal: Leads".to_string(),
230
+ );
231
+ files.insert(
232
+ "security.md".to_string(),
233
+ "# Security Check\n\nScore: 50/100".to_string(),
234
+ );
235
+ files.insert(
236
+ "lighthouse.md".to_string(),
237
+ "# Lighthouse Check\n\nPerformance: 100/100".to_string(),
238
+ );
239
+ files.insert(
240
+ "pages/home.md".to_string(),
241
+ "# Homepage Review\n\n## CTA".to_string(),
242
+ );
243
+
244
+ let report = build_final_report(&AgencyConfig::default(), "2026-acme", &files);
245
+ assert!(report.contains("## Security Check"));
246
+ assert!(report.contains("Score: 50/100"));
247
+ assert!(report.contains("## Lighthouse Check"));
248
+ assert!(report.contains("### Homepage Review"));
249
+ }
250
+ }
@@ -0,0 +1,193 @@
1
+ use anyhow::Result;
2
+ use reqwest::header::HeaderMap;
3
+
4
+ use crate::ui::score_status;
5
+
6
+ #[derive(Debug, Clone, PartialEq, Eq)]
7
+ pub struct SecurityCheck {
8
+ pub url: String,
9
+ pub score: i32,
10
+ pub https: bool,
11
+ pub hsts: bool,
12
+ pub csp: bool,
13
+ pub frame_options: bool,
14
+ pub referrer_policy: bool,
15
+ pub secure_cookies: bool,
16
+ pub feedback: Vec<String>,
17
+ }
18
+
19
+ fn header_present(headers: &HeaderMap, name: &str) -> bool {
20
+ headers.get(name).is_some()
21
+ }
22
+
23
+ fn cookies_have_security_flags(headers: &HeaderMap) -> bool {
24
+ let cookies = headers.get_all("set-cookie");
25
+ let mut saw_cookie = false;
26
+
27
+ for cookie in cookies.iter() {
28
+ saw_cookie = true;
29
+ let value = cookie.to_str().unwrap_or("").to_lowercase();
30
+ if !(value.contains("secure") && value.contains("httponly") && value.contains("samesite")) {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ !saw_cookie || true
36
+ }
37
+
38
+ pub fn analyze_security_headers(url: &str, headers: &HeaderMap) -> SecurityCheck {
39
+ let https = url.starts_with("https://");
40
+ let hsts = header_present(headers, "strict-transport-security");
41
+ let csp = header_present(headers, "content-security-policy");
42
+ let frame_options = header_present(headers, "x-frame-options");
43
+ let referrer_policy = header_present(headers, "referrer-policy");
44
+ let secure_cookies = cookies_have_security_flags(headers);
45
+
46
+ let mut score = 100;
47
+ let mut feedback = Vec::new();
48
+
49
+ if !https {
50
+ score -= 25;
51
+ feedback.push(
52
+ "Site is not using HTTPS. Serve the site over HTTPS before client launch.".to_string(),
53
+ );
54
+ } else {
55
+ feedback.push("HTTPS is enabled.".to_string());
56
+ }
57
+
58
+ if !hsts {
59
+ score -= 15;
60
+ feedback.push(
61
+ "Missing HSTS header. Add Strict-Transport-Security once HTTPS is stable.".to_string(),
62
+ );
63
+ } else {
64
+ feedback.push("HSTS header found.".to_string());
65
+ }
66
+
67
+ if !csp {
68
+ score -= 20;
69
+ feedback.push(
70
+ "Missing Content-Security-Policy. Add a CSP to reduce script injection risk."
71
+ .to_string(),
72
+ );
73
+ } else {
74
+ feedback.push("Content-Security-Policy found.".to_string());
75
+ }
76
+
77
+ if !frame_options {
78
+ score -= 10;
79
+ feedback.push("Missing X-Frame-Options. Add clickjacking protection.".to_string());
80
+ }
81
+
82
+ if !referrer_policy {
83
+ score -= 5;
84
+ feedback.push("Missing Referrer-Policy. Add one to avoid leaking full URLs.".to_string());
85
+ }
86
+
87
+ if !secure_cookies {
88
+ score -= 10;
89
+ feedback.push("Cookies are missing Secure, HttpOnly, or SameSite flags.".to_string());
90
+ }
91
+
92
+ SecurityCheck {
93
+ url: url.to_string(),
94
+ score: score.max(0),
95
+ https,
96
+ hsts,
97
+ csp,
98
+ frame_options,
99
+ referrer_policy,
100
+ secure_cookies,
101
+ feedback,
102
+ }
103
+ }
104
+
105
+ pub fn check_url(input_url: &str) -> Result<SecurityCheck> {
106
+ let url = if input_url.starts_with("http://") || input_url.starts_with("https://") {
107
+ input_url.to_string()
108
+ } else {
109
+ format!("https://{input_url}")
110
+ };
111
+ let response = reqwest::blocking::Client::new()
112
+ .get(&url)
113
+ .header("user-agent", "AuditKit/0.1")
114
+ .header("accept", "text/html,application/xhtml+xml")
115
+ .send()?;
116
+ Ok(analyze_security_headers(
117
+ &response.url().to_string(),
118
+ response.headers(),
119
+ ))
120
+ }
121
+
122
+ pub fn format_cli(result: &SecurityCheck) -> String {
123
+ let mut output = format!(
124
+ "\n== Security Check ==\nURL: {}\nScore: {}/100 ({})\n\n== Signals ==\nHTTPS: {}\nHSTS: {}\nCSP: {}\nX-Frame-Options: {}\nReferrer-Policy: {}\nSecure cookies: {}\n\n== Feedback ==\n",
125
+ result.url,
126
+ result.score,
127
+ score_status(result.score),
128
+ yes_no(result.https),
129
+ yes_no(result.hsts),
130
+ yes_no(result.csp),
131
+ yes_no(result.frame_options),
132
+ yes_no(result.referrer_policy),
133
+ yes_no(result.secure_cookies)
134
+ );
135
+
136
+ for item in &result.feedback {
137
+ output.push_str(&format!("- {item}\n"));
138
+ }
139
+
140
+ output
141
+ }
142
+
143
+ pub fn format_markdown(result: &SecurityCheck) -> String {
144
+ format!("# Security Check\n\n{}\n", format_cli(result))
145
+ }
146
+
147
+ fn yes_no(value: bool) -> &'static str {
148
+ if value {
149
+ "yes"
150
+ } else {
151
+ "no"
152
+ }
153
+ }
154
+
155
+ #[cfg(test)]
156
+ mod tests {
157
+ use reqwest::header::{HeaderMap, HeaderValue};
158
+
159
+ use super::*;
160
+
161
+ #[test]
162
+ fn missing_headers_are_flagged() {
163
+ let headers = HeaderMap::new();
164
+ let result = analyze_security_headers("http://example.com", &headers);
165
+ assert_eq!(result.score, 25);
166
+ assert!(result
167
+ .feedback
168
+ .join("\n")
169
+ .contains("Site is not using HTTPS"));
170
+ assert!(result
171
+ .feedback
172
+ .join("\n")
173
+ .contains("Missing Content-Security-Policy"));
174
+ }
175
+
176
+ #[test]
177
+ fn strong_headers_score_high() {
178
+ let mut headers = HeaderMap::new();
179
+ headers.insert(
180
+ "strict-transport-security",
181
+ HeaderValue::from_static("max-age=31536000"),
182
+ );
183
+ headers.insert(
184
+ "content-security-policy",
185
+ HeaderValue::from_static("default-src 'self'"),
186
+ );
187
+ headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
188
+ headers.insert("referrer-policy", HeaderValue::from_static("strict-origin"));
189
+
190
+ let result = analyze_security_headers("https://example.com", &headers);
191
+ assert_eq!(result.score, 100);
192
+ }
193
+ }
@@ -0,0 +1,197 @@
1
+ use std::collections::BTreeMap;
2
+
3
+ use url::Url;
4
+
5
+ use crate::audit::AuditInput;
6
+
7
+ fn markdown_list(items: &[String]) -> String {
8
+ if items.is_empty() {
9
+ "- None provided".to_string()
10
+ } else {
11
+ items
12
+ .iter()
13
+ .map(|item| format!("- {item}"))
14
+ .collect::<Vec<_>>()
15
+ .join("\n")
16
+ }
17
+ }
18
+
19
+ fn title_case(value: &str) -> String {
20
+ if value == "/" {
21
+ return "Homepage".to_string();
22
+ }
23
+
24
+ value
25
+ .trim_matches('/')
26
+ .replace(['-', '_', '/'], " ")
27
+ .split_whitespace()
28
+ .map(|word| {
29
+ let mut chars = word.chars();
30
+ match chars.next() {
31
+ Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
32
+ None => String::new(),
33
+ }
34
+ })
35
+ .collect::<Vec<_>>()
36
+ .join(" ")
37
+ }
38
+
39
+ fn page_file_name(page: &str) -> String {
40
+ let raw = if page == "/" {
41
+ "home".to_string()
42
+ } else {
43
+ page.trim_matches('/').to_string()
44
+ };
45
+
46
+ let mut slug = String::new();
47
+ let mut previous_dash = false;
48
+
49
+ for character in raw.chars() {
50
+ if character.is_ascii_alphanumeric() {
51
+ slug.push(character.to_ascii_lowercase());
52
+ previous_dash = false;
53
+ } else if !previous_dash {
54
+ slug.push('-');
55
+ previous_dash = true;
56
+ }
57
+ }
58
+
59
+ format!("pages/{}.md", slug.trim_matches('-'))
60
+ }
61
+
62
+ pub fn create_audit_files(audit: &AuditInput) -> BTreeMap<String, String> {
63
+ let mut files = BTreeMap::new();
64
+
65
+ files.insert("brief.md".to_string(), brief_template(audit));
66
+ files.insert("status.md".to_string(), status_template());
67
+ files.insert("scorecard.md".to_string(), scorecard_template());
68
+ files.insert("checklist.md".to_string(), checklist_template());
69
+ files.insert("findings.md".to_string(), findings_template());
70
+ files.insert("report.md".to_string(), report_template(audit));
71
+ files.insert("video-script.md".to_string(), video_script_template(audit));
72
+ files.insert("links.md".to_string(), links_template(audit));
73
+ files.insert("raw-notes.md".to_string(), "# Raw Notes\n\n".to_string());
74
+
75
+ for page in &audit.pages {
76
+ files.insert(page_file_name(page), page_review_template(page));
77
+ }
78
+
79
+ files
80
+ }
81
+
82
+ pub fn brief_template(audit: &AuditInput) -> String {
83
+ format!(
84
+ "# {} Audit Brief\n\nWebsite: {}\nBusiness type: {}\nGoal: {}\nTarget customer: {}\nMain conversion action: {}\nCreated: {}\n\n## Known Concerns\n\n{}\n\n## Pages\n\n{}\n\n## Competitors\n\n{}\n",
85
+ audit.client_name,
86
+ audit.url,
87
+ audit.business_type,
88
+ audit.goal,
89
+ audit.target_customer,
90
+ audit.conversion_action,
91
+ audit.created_at,
92
+ markdown_list(&audit.known_concerns),
93
+ markdown_list(&audit.pages),
94
+ markdown_list(&audit.competitors)
95
+ )
96
+ }
97
+
98
+ pub fn checklist_template() -> String {
99
+ "# Audit Checklist\n\n## Performance\n\n- [ ] Run PageSpeed on key pages\n- [ ] Check mobile score\n- [ ] Identify LCP element\n- [ ] Check image weight\n- [ ] Check font loading\n- [ ] Check layout shift\n\n## UX and Conversion\n\n- [ ] Above-fold offer is clear\n- [ ] Primary CTA is obvious\n- [ ] User can contact/book within 1 click\n- [ ] Trust signals are visible\n- [ ] Forms are short enough\n- [ ] Navigation supports key journey\n\n## SEO\n\n- [ ] Title tags\n- [ ] Meta descriptions\n- [ ] H1 structure\n- [ ] Internal links\n- [ ] Image alt text\n- [ ] Schema opportunity\n- [ ] Local SEO basics\n".to_string()
100
+ }
101
+
102
+ pub fn findings_template() -> String {
103
+ "# Findings\n\n## Finding Template\n\nTitle:\nCategory: Performance / UX / SEO / Conversion / Trust\nSeverity: Critical / High / Medium / Low\nPage:\nEvidence:\nWhy it matters:\nRecommendation:\nEstimated effort:\nBusiness impact:\nScreenshot/video note:\n\n## Critical\n\n### 1. Finding title\n\nEvidence:\n\nWhy it matters:\n\nRecommendation:\n\nEstimated effort:\n\nBusiness impact:\n\n## High\n\n## Medium\n\n## Low\n".to_string()
104
+ }
105
+
106
+ pub fn scorecard_template() -> String {
107
+ "# Scorecard\n\nPerformance: /10\nUX clarity: /10\nConversion path: /10\nSEO basics: /10\nTrust signals: /10\nMobile experience: /10\n\nOverall:\n\n## Notes\n\n".to_string()
108
+ }
109
+
110
+ pub fn status_template() -> String {
111
+ "# Audit Status\n\n- [ ] Intake complete\n- [ ] Pages reviewed\n- [ ] Performance checked\n- [ ] SEO checked\n- [ ] Findings prioritised\n- [ ] Report drafted\n- [ ] Video recorded\n- [ ] Sent to client\n".to_string()
112
+ }
113
+
114
+ pub fn page_review_template(page: &str) -> String {
115
+ format!(
116
+ "# {} Review\n\nPath: {}\n\n## First Impression\n\n## CTA\n\n## Copy Clarity\n\n## Visual Hierarchy\n\n## Mobile Issues\n\n## SEO Notes\n\n## Performance Notes\n\n## Recommended Fixes\n",
117
+ title_case(page),
118
+ page
119
+ )
120
+ }
121
+
122
+ pub fn report_template(audit: &AuditInput) -> String {
123
+ format!(
124
+ "# {} Website Audit\n\nWebsite: {}\nGoal: {}\n\n## Executive Summary\n\n## Priority Fixes\n\n1.\n2.\n3.\n\n## Performance\n\n## UX and Conversion\n\n## SEO\n\n## Recommended Next Step\n",
125
+ audit.client_name, audit.url, audit.goal
126
+ )
127
+ }
128
+
129
+ pub fn video_script_template(audit: &AuditInput) -> String {
130
+ format!(
131
+ "# {} Video Script\n\n## Opening\n\nWhat I reviewed and what matters most.\n\n## Biggest Conversion Issue\n\n## Performance Issue\n\n## SEO or Structure Issue\n\n## Close\n\nSummarise top 3 fixes and recommended next step.\n",
132
+ audit.client_name
133
+ )
134
+ }
135
+
136
+ pub fn links_template(audit: &AuditInput) -> String {
137
+ let encoded = urlencoding(&audit.url);
138
+ let pages = audit
139
+ .pages
140
+ .iter()
141
+ .map(|page| {
142
+ Url::parse(&audit.url)
143
+ .and_then(|base| base.join(page))
144
+ .map(|url| format!("- {url}"))
145
+ .unwrap_or_else(|_| format!("- {page}"))
146
+ })
147
+ .collect::<Vec<_>>()
148
+ .join("\n");
149
+
150
+ format!(
151
+ "# Audit Links\n\n## Test Links\n\n- PageSpeed: https://pagespeed.web.dev/analysis?url={}\n- Rich Results: https://search.google.com/test/rich-results?url={}\n- Meta Preview: https://metatags.io/?url={}\n\n## Pages\n\n{}\n\n## Competitors\n\n{}\n",
152
+ encoded,
153
+ encoded,
154
+ encoded,
155
+ if pages.is_empty() { "- None provided".to_string() } else { pages },
156
+ markdown_list(&audit.competitors)
157
+ )
158
+ }
159
+
160
+ fn urlencoding(value: &str) -> String {
161
+ url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
162
+ }
163
+
164
+ #[cfg(test)]
165
+ mod tests {
166
+ use super::*;
167
+
168
+ fn audit() -> AuditInput {
169
+ AuditInput {
170
+ client_name: "Acme Dental".to_string(),
171
+ slug: "acme-dental".to_string(),
172
+ url: "https://acmedental.co.uk".to_string(),
173
+ business_type: "Dental clinic".to_string(),
174
+ goal: "More bookings".to_string(),
175
+ target_customer: "Local families".to_string(),
176
+ conversion_action: "Book consultation".to_string(),
177
+ pages: vec![
178
+ "/".to_string(),
179
+ "/pricing".to_string(),
180
+ "/contact".to_string(),
181
+ ],
182
+ known_concerns: vec!["Slow mobile".to_string()],
183
+ competitors: vec!["https://competitor.example".to_string()],
184
+ created_at: "2026-06-02".to_string(),
185
+ }
186
+ }
187
+
188
+ #[test]
189
+ fn create_audit_files_contains_expected_workspace() {
190
+ let files = create_audit_files(&audit());
191
+ assert!(files.contains_key("brief.md"));
192
+ assert!(files.contains_key("scorecard.md"));
193
+ assert!(files.contains_key("pages/home.md"));
194
+ assert!(files.contains_key("pages/pricing.md"));
195
+ assert!(files["brief.md"].contains("Business type: Dental clinic"));
196
+ }
197
+ }