auditkit 0.1.0 → 0.1.3

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/ui.rs CHANGED
@@ -1,23 +1,56 @@
1
+ use std::io::{self, IsTerminal, Write};
1
2
  use std::time::Duration;
2
3
 
3
- use anyhow::Result;
4
+ use anyhow::{bail, Result};
4
5
  use colored::Colorize;
6
+ use crossterm::{
7
+ event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
8
+ execute,
9
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
10
+ };
5
11
  use indicatif::{ProgressBar, ProgressStyle};
12
+ use ratatui::{
13
+ backend::CrosstermBackend,
14
+ layout::{Alignment, Constraint, Direction, Layout, Rect},
15
+ style::{Color, Modifier, Style},
16
+ text::{Line, Span},
17
+ widgets::{Block, Borders, Gauge, Paragraph},
18
+ Frame, Terminal,
19
+ };
20
+
21
+ pub struct NewAuditAnswers {
22
+ pub client_name: String,
23
+ pub url: String,
24
+ pub business_type: String,
25
+ pub goal: String,
26
+ pub target_customer: String,
27
+ pub conversion_action: String,
28
+ pub pages: String,
29
+ pub known_concerns: String,
30
+ pub competitors: String,
31
+ }
32
+
33
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
34
+ pub enum FeedbackTone {
35
+ Positive,
36
+ Warning,
37
+ Critical,
38
+ }
6
39
 
7
40
  pub fn section(title: &str) {
8
- println!("\n{}", format!("== {title} ==").bold());
41
+ println!("\n{}", format!("╭─ {title} ").bold().cyan());
9
42
  }
10
43
 
11
44
  pub fn bullet(value: &str) {
12
- println!("{} {value}", "-".cyan());
45
+ println!(" {} {value}", "".cyan());
13
46
  }
14
47
 
15
48
  pub fn saved(path: impl std::fmt::Display) {
16
- println!("{} {}", "[saved]".green(), path);
49
+ println!("{} {}", "saved".green().bold(), path);
17
50
  }
18
51
 
19
52
  pub fn error(message: impl std::fmt::Display) {
20
- eprintln!("{} {}", "[error]".red(), message);
53
+ eprintln!("{} {}", "error".red().bold(), message);
21
54
  }
22
55
 
23
56
  pub fn score_status(score: i32) -> &'static str {
@@ -30,47 +63,502 @@ pub fn score_status(score: i32) -> &'static str {
30
63
  }
31
64
  }
32
65
 
66
+ pub fn score_badge(score: i32) -> String {
67
+ let value = format!("{score}/100 ({})", score_status(score));
68
+ match score {
69
+ 85..=100 => value.green().bold().to_string(),
70
+ 65..=84 => value.yellow().bold().to_string(),
71
+ _ => value.red().bold().to_string(),
72
+ }
73
+ }
74
+
75
+ pub fn signal_line(label: &str, value: impl std::fmt::Display, tone: FeedbackTone) -> String {
76
+ let label = format!("{label:<21}").dimmed();
77
+ let value = value.to_string();
78
+ let value = match tone {
79
+ FeedbackTone::Positive => value.green().bold().to_string(),
80
+ FeedbackTone::Warning => value.yellow().bold().to_string(),
81
+ FeedbackTone::Critical => value.red().bold().to_string(),
82
+ };
83
+ format!(" {} {} {}", tone_icon(tone), label, value)
84
+ }
85
+
86
+ pub fn feedback_line(tone: FeedbackTone, value: &str) -> String {
87
+ let label = match tone {
88
+ FeedbackTone::Positive => "OK".green().bold().to_string(),
89
+ FeedbackTone::Warning => "WATCH".yellow().bold().to_string(),
90
+ FeedbackTone::Critical => "FIX".red().bold().to_string(),
91
+ };
92
+ let value = match tone {
93
+ FeedbackTone::Positive => value.dimmed().to_string(),
94
+ FeedbackTone::Warning => value.yellow().to_string(),
95
+ FeedbackTone::Critical => value.red().bold().to_string(),
96
+ };
97
+ format!(" {} {:<9} {}", tone_icon(tone), label, value)
98
+ }
99
+
100
+ pub fn frame_line(value: &str) -> String {
101
+ value.cyan().bold().to_string()
102
+ }
103
+
104
+ fn tone_icon(tone: FeedbackTone) -> String {
105
+ match tone {
106
+ FeedbackTone::Positive => "✓".green().bold().to_string(),
107
+ FeedbackTone::Warning => "◆".yellow().bold().to_string(),
108
+ FeedbackTone::Critical => "●".red().bold().to_string(),
109
+ }
110
+ }
111
+
33
112
  pub fn with_task<T>(label: &str, task: impl FnOnce() -> Result<T>) -> Result<T> {
34
113
  let spinner = ProgressBar::new_spinner();
35
114
  spinner.set_style(
36
115
  ProgressStyle::with_template("{spinner:.cyan} {msg}")
37
- .unwrap_or_else(|_| ProgressStyle::default_spinner()),
116
+ .unwrap_or_else(|_| ProgressStyle::default_spinner())
117
+ .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
38
118
  );
39
119
  spinner.set_message(label.to_string());
40
- spinner.enable_steady_tick(Duration::from_millis(80));
120
+ spinner.enable_steady_tick(Duration::from_millis(70));
41
121
 
42
122
  match task() {
43
123
  Ok(value) => {
44
- spinner.finish_with_message(format!("Done: {label}"));
124
+ spinner.finish_and_clear();
125
+ println!("{} {label}", "✓".green().bold());
45
126
  Ok(value)
46
127
  }
47
128
  Err(error) => {
48
- spinner.finish_with_message(format!("Failed: {label}"));
129
+ spinner.finish_and_clear();
130
+ eprintln!("{} {label}", "✕".red().bold());
49
131
  Err(error)
50
132
  }
51
133
  }
52
134
  }
53
135
 
54
136
  pub fn help() {
137
+ let title = format!("{:<72}", "Audit Kit").bold().cyan();
138
+ println!();
139
+ println!(
140
+ "{}",
141
+ "╭──────────────────────────────────────────────────────────────────────────╮".cyan()
142
+ );
143
+ println!("{} {} {}", "│".cyan(), title, "│".cyan());
144
+ println!(
145
+ "{}",
146
+ "├──────────────────────────────────────────────────────────────────────────┤".cyan()
147
+ );
148
+ help_row("ak new", "create an audit workspace");
149
+ help_row("ak list", "show audit folders");
150
+ help_row("ak inspect latest", "run check, security, and Lighthouse");
151
+ help_row("ak report latest", "create final report and client email");
152
+ help_row("ak check <url>", "quick one-off website feedback");
153
+ help_row("ak security <url>", "quick one-off security check");
154
+ help_row("ak lighthouse <url>", "quick one-off Lighthouse check");
55
155
  println!(
56
156
  "{}",
57
- "+--------------------------------------------------------+\n\
58
- | Audit Kit |\n\
59
- +--------------------------------------------------------+\n\
60
- | ak new create audit workspace |\n\
61
- | ak check latest run + save quick website feedback |\n\
62
- | ak security latest run + save security header check |\n\
63
- | ak lighthouse latest run + save Lighthouse audit |\n\
64
- | ak inspect latest run check + security + Lighthouse |\n\
65
- | ak report latest create final report + client email |\n\
66
- | ak list show audit folders |\n\
67
- | ak check <url> quick one-off website check |\n\
68
- | ak security <url> quick one-off security check |\n\
69
- | ak lighthouse <url> quick one-off Lighthouse check |\n\
70
- +--------------------------------------------------------+"
157
+ "╰──────────────────────────────────────────────────────────────────────────╯".cyan()
71
158
  );
72
159
  }
73
160
 
161
+ pub fn collect_audit_input() -> Result<NewAuditAnswers> {
162
+ if io::stdin().is_terminal() && io::stdout().is_terminal() {
163
+ run_form()
164
+ } else {
165
+ fallback_prompts()
166
+ }
167
+ }
168
+
169
+ fn help_row(command: &str, description: &str) {
170
+ let command = format!("{command:<23}").bold();
171
+ let description = format!("{description:<45}").dimmed();
172
+ println!("{} {} {} {}", "│".cyan(), command, description, "│".cyan());
173
+ }
174
+
175
+ fn fallback_prompts() -> Result<NewAuditAnswers> {
176
+ Ok(NewAuditAnswers {
177
+ client_name: prompt("Client name ")?,
178
+ url: prompt("Website URL ")?,
179
+ business_type: prompt("Business type ")?,
180
+ goal: prompt("Primary goal ")?,
181
+ target_customer: prompt("Target customer ")?,
182
+ conversion_action: prompt("Main conversion action ")?,
183
+ pages: prompt("Pages, comma-separated ")?,
184
+ known_concerns: prompt("Known concerns ")?,
185
+ competitors: prompt("Competitors ")?,
186
+ })
187
+ }
188
+
189
+ fn prompt(label: &str) -> Result<String> {
190
+ print!("{}", label.cyan());
191
+ io::stdout().flush()?;
192
+ let mut value = String::new();
193
+ io::stdin().read_line(&mut value)?;
194
+ Ok(value.trim().to_string())
195
+ }
196
+
197
+ fn run_form() -> Result<NewAuditAnswers> {
198
+ enable_raw_mode()?;
199
+ let mut stdout = io::stdout();
200
+ execute!(stdout, EnterAlternateScreen)?;
201
+ let backend = CrosstermBackend::new(stdout);
202
+ let mut terminal = Terminal::new(backend)?;
203
+ let result = run_form_loop(&mut terminal);
204
+ disable_raw_mode()?;
205
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
206
+ terminal.show_cursor()?;
207
+ result
208
+ }
209
+
210
+ fn run_form_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<NewAuditAnswers> {
211
+ let mut form = AuditForm::new();
212
+
213
+ loop {
214
+ terminal.draw(|frame| draw_form(frame, &form))?;
215
+ if event::poll(Duration::from_millis(110))? {
216
+ if let Event::Key(key) = event::read()? {
217
+ if key.kind == KeyEventKind::Press {
218
+ match form.handle_key(key)? {
219
+ FormAction::Continue => {}
220
+ FormAction::Submit => return form.answers(),
221
+ FormAction::Cancel => bail!("Audit creation cancelled."),
222
+ }
223
+ }
224
+ }
225
+ } else {
226
+ form.tick = form.tick.wrapping_add(1);
227
+ }
228
+ }
229
+ }
230
+
231
+ fn draw_form(frame: &mut Frame, form: &AuditForm) {
232
+ let area = centered(frame.area(), 86, 33);
233
+ let shell = Block::default()
234
+ .borders(Borders::ALL)
235
+ .border_style(Style::default().fg(Color::Cyan))
236
+ .title(Span::styled(
237
+ " Audit Kit ",
238
+ Style::default()
239
+ .fg(Color::Cyan)
240
+ .add_modifier(Modifier::BOLD),
241
+ ))
242
+ .title_alignment(Alignment::Center);
243
+ frame.render_widget(shell, area);
244
+
245
+ let chunks = Layout::default()
246
+ .direction(Direction::Vertical)
247
+ .margin(2)
248
+ .constraints([
249
+ Constraint::Length(3),
250
+ Constraint::Length(3),
251
+ Constraint::Min(19),
252
+ Constraint::Length(4),
253
+ ])
254
+ .split(area);
255
+
256
+ let header = Paragraph::new(vec![
257
+ Line::from(vec![
258
+ Span::styled(
259
+ "New audit workspace",
260
+ Style::default()
261
+ .fg(Color::White)
262
+ .add_modifier(Modifier::BOLD),
263
+ ),
264
+ Span::raw(" "),
265
+ Span::styled(
266
+ "capture the brief once, then run inspect",
267
+ Style::default().fg(Color::DarkGray),
268
+ ),
269
+ ]),
270
+ Line::from(Span::styled(
271
+ "Tab or Enter to move, Shift+Tab to go back, Ctrl+S to create, Esc to cancel",
272
+ Style::default().fg(Color::DarkGray),
273
+ )),
274
+ ]);
275
+ frame.render_widget(header, chunks[0]);
276
+
277
+ let completed = form
278
+ .fields
279
+ .iter()
280
+ .filter(|field| !field.value.trim().is_empty())
281
+ .count();
282
+ let ratio = completed as f64 / form.fields.len() as f64;
283
+ let gauge = Gauge::default()
284
+ .block(
285
+ Block::default()
286
+ .borders(Borders::ALL)
287
+ .title(" Progress: completed fields "),
288
+ )
289
+ .gauge_style(Style::default().fg(Color::Cyan))
290
+ .label(format!("{completed}/{} fields", form.fields.len()))
291
+ .ratio(ratio);
292
+ frame.render_widget(gauge, chunks[1]);
293
+
294
+ let field_panel = Paragraph::new(field_lines(form)).block(
295
+ Block::default()
296
+ .borders(Borders::ALL)
297
+ .border_style(Style::default().fg(Color::DarkGray))
298
+ .title(" Fields "),
299
+ );
300
+ frame.render_widget(field_panel, chunks[2]);
301
+
302
+ let active_field = &form.fields[form.active];
303
+ let required = if active_field.required {
304
+ Span::styled("Required", Style::default().fg(Color::Yellow))
305
+ } else {
306
+ Span::styled("Optional", Style::default().fg(Color::DarkGray))
307
+ };
308
+ let footer = Paragraph::new(vec![
309
+ Line::from(vec![
310
+ Span::styled("Field help: ", Style::default().fg(Color::Cyan)),
311
+ Span::styled(active_field.help, Style::default().fg(Color::Reset)),
312
+ ]),
313
+ Line::from(vec![
314
+ required,
315
+ Span::styled(
316
+ " • Progress fills when a field has any text",
317
+ Style::default().fg(Color::DarkGray),
318
+ ),
319
+ ]),
320
+ ])
321
+ .block(
322
+ Block::default()
323
+ .borders(Borders::TOP)
324
+ .border_style(Style::default().fg(Color::DarkGray)),
325
+ );
326
+ frame.render_widget(footer, chunks[3]);
327
+ }
328
+
329
+ fn field_lines(form: &AuditForm) -> Vec<Line<'_>> {
330
+ form.fields
331
+ .iter()
332
+ .enumerate()
333
+ .map(|(index, field)| {
334
+ let active = index == form.active;
335
+ let marker = if active { "▶" } else { " " };
336
+ let cursor = if active && (form.tick / 5).is_multiple_of(2) {
337
+ "▌"
338
+ } else {
339
+ ""
340
+ };
341
+ let label_style = if active {
342
+ Style::default()
343
+ .fg(Color::Cyan)
344
+ .add_modifier(Modifier::BOLD)
345
+ } else {
346
+ Style::default().fg(Color::DarkGray)
347
+ };
348
+ let value_style = if field.value.is_empty() {
349
+ Style::default().fg(Color::DarkGray)
350
+ } else if active {
351
+ Style::default()
352
+ .fg(Color::Yellow)
353
+ .add_modifier(Modifier::BOLD)
354
+ } else {
355
+ Style::default()
356
+ .fg(Color::Green)
357
+ .add_modifier(Modifier::BOLD)
358
+ };
359
+ let value = if field.value.is_empty() {
360
+ field.placeholder
361
+ } else {
362
+ field.value.as_str()
363
+ };
364
+
365
+ Line::from(vec![
366
+ Span::styled(format!(" {marker} "), label_style),
367
+ Span::styled(format!("{:<24}", field.label), label_style),
368
+ Span::styled(value, value_style),
369
+ Span::styled(cursor, Style::default().fg(Color::Yellow)),
370
+ ])
371
+ })
372
+ .collect()
373
+ }
374
+
375
+ fn centered(area: Rect, max_width: u16, max_height: u16) -> Rect {
376
+ let width = area.width.min(max_width);
377
+ let height = area.height.min(max_height);
378
+ let horizontal = Layout::default()
379
+ .direction(Direction::Horizontal)
380
+ .constraints([
381
+ Constraint::Length((area.width.saturating_sub(width)) / 2),
382
+ Constraint::Length(width),
383
+ Constraint::Min(0),
384
+ ])
385
+ .split(area);
386
+ let vertical = Layout::default()
387
+ .direction(Direction::Vertical)
388
+ .constraints([
389
+ Constraint::Length((area.height.saturating_sub(height)) / 2),
390
+ Constraint::Length(height),
391
+ Constraint::Min(0),
392
+ ])
393
+ .split(horizontal[1]);
394
+ vertical[1]
395
+ }
396
+
397
+ struct FormField {
398
+ label: &'static str,
399
+ placeholder: &'static str,
400
+ help: &'static str,
401
+ required: bool,
402
+ value: String,
403
+ }
404
+
405
+ struct AuditForm {
406
+ fields: Vec<FormField>,
407
+ active: usize,
408
+ tick: usize,
409
+ }
410
+
411
+ enum FormAction {
412
+ Continue,
413
+ Submit,
414
+ Cancel,
415
+ }
416
+
417
+ impl AuditForm {
418
+ fn new() -> Self {
419
+ Self {
420
+ fields: vec![
421
+ FormField {
422
+ label: "Client name",
423
+ placeholder: "Acme Dental",
424
+ help: "The client or business name used for the audit folder and report titles.",
425
+ required: true,
426
+ value: String::new(),
427
+ },
428
+ FormField {
429
+ label: "Website URL",
430
+ placeholder: "https://example.com",
431
+ help: "The website Audit Kit will inspect for HTML, security, and Lighthouse checks.",
432
+ required: true,
433
+ value: String::new(),
434
+ },
435
+ FormField {
436
+ label: "Business type",
437
+ placeholder: "Local service business, SaaS, ecommerce...",
438
+ help: "A short category that gives the final report useful business context.",
439
+ required: false,
440
+ value: String::new(),
441
+ },
442
+ FormField {
443
+ label: "Primary goal",
444
+ placeholder: "More demo bookings, quote requests, sales...",
445
+ help: "The main business outcome the website should improve.",
446
+ required: false,
447
+ value: String::new(),
448
+ },
449
+ FormField {
450
+ label: "Target customer",
451
+ placeholder: "Who the site needs to persuade",
452
+ help: "The buyer or audience the website needs to win over.",
453
+ required: false,
454
+ value: String::new(),
455
+ },
456
+ FormField {
457
+ label: "Main conversion action",
458
+ placeholder: "Book a call, buy now, request a quote...",
459
+ help: "The primary action visitors should take after reading the page.",
460
+ required: false,
461
+ value: String::new(),
462
+ },
463
+ FormField {
464
+ label: "Pages",
465
+ placeholder: "/, /pricing, /contact",
466
+ help: "Comma-separated page paths to create review templates for.",
467
+ required: false,
468
+ value: String::new(),
469
+ },
470
+ FormField {
471
+ label: "Known concerns",
472
+ placeholder: "Slow site, weak offer, low conversions...",
473
+ help: "Comma-separated issues the client already suspects or wants checked.",
474
+ required: false,
475
+ value: String::new(),
476
+ },
477
+ FormField {
478
+ label: "Competitors",
479
+ placeholder: "competitor.com, anotherbrand.com",
480
+ help: "Comma-separated competitor sites for context while writing findings.",
481
+ required: false,
482
+ value: String::new(),
483
+ },
484
+ ],
485
+ active: 0,
486
+ tick: 0,
487
+ }
488
+ }
489
+
490
+ fn handle_key(&mut self, key: KeyEvent) -> Result<FormAction> {
491
+ if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
492
+ return Ok(FormAction::Cancel);
493
+ }
494
+ if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
495
+ return Ok(FormAction::Submit);
496
+ }
497
+
498
+ match key.code {
499
+ KeyCode::Esc => Ok(FormAction::Cancel),
500
+ KeyCode::Tab | KeyCode::Down => {
501
+ self.next();
502
+ Ok(FormAction::Continue)
503
+ }
504
+ KeyCode::BackTab | KeyCode::Up => {
505
+ self.previous();
506
+ Ok(FormAction::Continue)
507
+ }
508
+ KeyCode::Enter => {
509
+ if self.active + 1 == self.fields.len() {
510
+ Ok(FormAction::Submit)
511
+ } else {
512
+ self.next();
513
+ Ok(FormAction::Continue)
514
+ }
515
+ }
516
+ KeyCode::Backspace => {
517
+ self.fields[self.active].value.pop();
518
+ Ok(FormAction::Continue)
519
+ }
520
+ KeyCode::Char(value) => {
521
+ self.fields[self.active].value.push(value);
522
+ Ok(FormAction::Continue)
523
+ }
524
+ _ => Ok(FormAction::Continue),
525
+ }
526
+ }
527
+
528
+ fn next(&mut self) {
529
+ self.active = (self.active + 1).min(self.fields.len() - 1);
530
+ }
531
+
532
+ fn previous(&mut self) {
533
+ self.active = self.active.saturating_sub(1);
534
+ }
535
+
536
+ fn answers(&self) -> Result<NewAuditAnswers> {
537
+ if self.fields[0].value.trim().is_empty() {
538
+ bail!("Client name is required.");
539
+ }
540
+ if self.fields[1].value.trim().is_empty() {
541
+ bail!("Website URL is required.");
542
+ }
543
+
544
+ Ok(NewAuditAnswers {
545
+ client_name: self.value(0),
546
+ url: self.value(1),
547
+ business_type: self.value(2),
548
+ goal: self.value(3),
549
+ target_customer: self.value(4),
550
+ conversion_action: self.value(5),
551
+ pages: self.value(6),
552
+ known_concerns: self.value(7),
553
+ competitors: self.value(8),
554
+ })
555
+ }
556
+
557
+ fn value(&self, index: usize) -> String {
558
+ self.fields[index].value.trim().to_string()
559
+ }
560
+ }
561
+
74
562
  #[cfg(test)]
75
563
  mod tests {
76
564
  use super::*;
@@ -81,4 +569,26 @@ mod tests {
81
569
  assert_eq!(score_status(74), "okay");
82
570
  assert_eq!(score_status(40), "needs work");
83
571
  }
572
+
573
+ #[test]
574
+ fn form_moves_between_fields_and_collects_values() {
575
+ let mut form = AuditForm::new();
576
+ form.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE))
577
+ .unwrap();
578
+ form.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE))
579
+ .unwrap();
580
+ form.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
581
+ .unwrap();
582
+ form.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
583
+ .unwrap();
584
+ assert_eq!(form.active, 1);
585
+ assert_eq!(form.fields[0].value, "Ac");
586
+ assert_eq!(form.fields[1].value, "h");
587
+ }
588
+
589
+ #[test]
590
+ fn form_requires_client_name_and_url() {
591
+ let form = AuditForm::new();
592
+ assert!(form.answers().is_err());
593
+ }
84
594
  }
package/src/workspace.rs CHANGED
@@ -89,6 +89,57 @@ impl Workspace {
89
89
  fs::write(&path, content)?;
90
90
  Ok(path)
91
91
  }
92
+
93
+ pub fn update_workspace_section(
94
+ &self,
95
+ folder_name: &str,
96
+ heading: &str,
97
+ content: &str,
98
+ ) -> Result<PathBuf> {
99
+ let path = self.audit_folder(folder_name).join("workspace.md");
100
+ let existing =
101
+ fs::read_to_string(&path).unwrap_or_else(|_| "# Audit Workspace\n".to_string());
102
+ let updated = replace_markdown_section(&existing, heading, content);
103
+ fs::write(&path, updated)?;
104
+ Ok(path)
105
+ }
106
+ }
107
+
108
+ fn replace_markdown_section(document: &str, heading: &str, content: &str) -> String {
109
+ let heading = format!("## {heading}");
110
+ let content = normalise_section_content(content);
111
+ let lines = document.lines().collect::<Vec<_>>();
112
+ let Some(start) = lines.iter().position(|line| line.trim() == heading) else {
113
+ return format!("{}\n\n{}\n\n{}\n", document.trim_end(), heading, content);
114
+ };
115
+ let end = lines
116
+ .iter()
117
+ .enumerate()
118
+ .skip(start + 1)
119
+ .find_map(|(index, line)| line.starts_with("## ").then_some(index))
120
+ .unwrap_or(lines.len());
121
+
122
+ let mut output = Vec::new();
123
+ output.extend_from_slice(&lines[..=start]);
124
+ output.push("");
125
+ output.extend(content.lines());
126
+ output.push("");
127
+ output.extend_from_slice(&lines[end..]);
128
+ format!("{}\n", output.join("\n").trim_end())
129
+ }
130
+
131
+ fn normalise_section_content(content: &str) -> String {
132
+ let content = content.trim();
133
+ if content.starts_with("# ") {
134
+ return content
135
+ .lines()
136
+ .skip(1)
137
+ .collect::<Vec<_>>()
138
+ .join("\n")
139
+ .trim()
140
+ .to_string();
141
+ }
142
+ content.to_string()
92
143
  }
93
144
 
94
145
  fn read_markdown_files(
@@ -109,3 +160,27 @@ fn read_markdown_files(
109
160
 
110
161
  Ok(())
111
162
  }
163
+
164
+ #[cfg(test)]
165
+ mod tests {
166
+ use super::*;
167
+
168
+ #[test]
169
+ fn replaces_existing_workspace_section() {
170
+ let document = "# Audit Workspace\n\n## Automated Check\n\nNot run yet.\n\n## Security Check\n\nNot run yet.\n";
171
+ let updated = replace_markdown_section(
172
+ document,
173
+ "Automated Check",
174
+ "# Automated Check\n\nScore: 90/100",
175
+ );
176
+ assert!(updated.contains("## Automated Check\n\nScore: 90/100\n\n## Security Check"));
177
+ assert!(!updated.contains("Not run yet.\n\n## Security Check"));
178
+ }
179
+
180
+ #[test]
181
+ fn appends_missing_workspace_section() {
182
+ let document = "# Audit Workspace\n";
183
+ let updated = replace_markdown_section(document, "Lighthouse Check", "Performance: 80/100");
184
+ assert!(updated.contains("## Lighthouse Check\n\nPerformance: 80/100"));
185
+ }
186
+ }