use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::Local;
use colored::Colorize;
use auditmysite::cli::{Args, OutputFormat, ReportLevel};
use auditmysite::error::{AuditError, Result};
pub(crate) fn output_text(
content: &str,
path: &Option<PathBuf>,
label: &str,
quiet: bool,
) -> Result<()> {
if let Some(path) = path {
fs::create_dir_all(output_directory(path))?;
atomic_write(path, content.as_bytes()).map_err(|e| AuditError::FileError {
path: path.clone(),
reason: e.to_string(),
})?;
if !quiet {
println!(
"{} {}-report saved to {}",
"Done:".green().bold(),
label,
path.display()
);
}
} else {
println!("{}", content);
}
Ok(())
}
#[cfg(feature = "pdf")]
pub(crate) fn output_bytes(content: &[u8], path: &Path, label: &str, quiet: bool) -> Result<()> {
fs::create_dir_all(output_directory(path))?;
atomic_write(path, content)?;
if !quiet {
println!(
"{} {} report saved to {}",
"Done:".green().bold(),
label,
path.display()
);
}
Ok(())
}
fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> {
let directory = output_directory(path);
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("report");
let temporary_path = directory.join(format!(
".{name}.{}.{}.partial",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
let result = (|| {
let mut temporary = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary_path)?;
temporary.write_all(content)?;
temporary.sync_all()?;
drop(temporary);
fs::rename(&temporary_path, path)
})();
if result.is_err() {
if let Err(e) = fs::remove_file(&temporary_path) {
tracing::warn!(
"Failed to remove partial output file {}: {}",
temporary_path.display(),
e
);
}
}
result
}
pub(crate) fn output_directory(path: &Path) -> &Path {
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
}
}
pub(crate) fn default_single_json_output_path(pdf_path: &Path) -> PathBuf {
pdf_path.with_extension("json")
}
pub(crate) fn default_screen_reader_json_output_path(primary_path: &Path) -> PathBuf {
let mut path = primary_path.to_path_buf();
let stem = primary_path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("audit-report");
path.set_file_name(format!("{stem}-screen-reader-audit.json"));
path
}
pub(crate) fn per_page_output_directory(args: &Args) -> PathBuf {
match args.output.as_ref() {
Some(path) if path.extension().is_none() => path.clone(),
Some(path) => output_directory(path).to_path_buf(),
None => PathBuf::from("."),
}
}
pub(crate) fn with_display_suffix(path: &Path, mode: &str) -> PathBuf {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("audit-report");
let name = match path.extension().and_then(|e| e.to_str()) {
Some(ext) => format!("{stem}-{mode}.{ext}"),
None => format!("{stem}-{mode}"),
};
path.with_file_name(name)
}
pub(crate) fn per_page_output_path(
base_dir: &Path,
url: &str,
format: OutputFormat,
_report_level: ReportLevel,
) -> PathBuf {
let date = Local::now().format("%Y-%m-%d");
let subject = page_subject_from_url(url);
let filename = match format {
OutputFormat::Pdf => PathBuf::from(format!("{subject}-{date}-single-report.pdf")),
OutputFormat::Json => PathBuf::from(format!("{subject}-{date}-single-report.json")),
OutputFormat::Table => PathBuf::from(format!("{subject}-{date}-single-report.txt")),
OutputFormat::Ai => PathBuf::from(format!("{subject}-{date}-single-report-ai.json")),
OutputFormat::Summary => PathBuf::from(format!("{subject}-{date}-summary.json")),
OutputFormat::Sarif => PathBuf::from(format!("{subject}-{date}-single-report.sarif")),
};
match filename.file_name() {
Some(name) => base_dir.join(name),
None => base_dir.join(filename),
}
}
pub(crate) fn default_single_pdf_output_path(url: &str, _report_level: ReportLevel) -> PathBuf {
let date = Local::now().format("%Y-%m-%d");
let subject = report_subject_from_url(url);
PathBuf::from(format!("{subject}-{date}-single-report.pdf"))
}
pub(crate) fn default_batch_pdf_output_path(args: &Args) -> PathBuf {
let date = Local::now().format("%Y-%m-%d");
let kind = if args.sitemap.is_some() {
"sitemap"
} else if args.crawl {
"crawl"
} else {
"batch"
};
let source_url = args
.sitemap
.as_deref()
.or(args.url.as_deref())
.unwrap_or("");
let subject = report_subject_from_url(source_url);
PathBuf::from(format!("{subject}-{date}-{kind}-report.pdf"))
}
pub(crate) fn page_subject_from_url(url: &str) -> String {
let host = report_subject_from_url(url);
let Ok(parsed) = url::Url::parse(url) else {
return host;
};
let path = slugify(parsed.path());
let mut subject = if path.is_empty() {
host
} else {
format!("{host}-{path}")
};
if let Some(query) = parsed.query().filter(|q| !q.is_empty()) {
let hash = query.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| {
(h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3)
});
subject.push_str(&format!("-q{:08x}", hash as u32));
}
subject
}
fn slugify(text: &str) -> String {
let slug: String = text
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
slug.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-")
}
pub(crate) fn report_subject_from_url(url: &str) -> String {
let fallback = "audit-report".to_string();
let Ok(parsed) = url::Url::parse(url) else {
return fallback;
};
let Some(host) = parsed.host_str() else {
return fallback;
};
let host = host.strip_prefix("www.").unwrap_or(host);
let slug: String = host
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
fallback
} else {
slug
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_suffix_goes_before_the_extension() {
assert_eq!(
with_display_suffix(Path::new("reports/geo-audit.pdf"), "calm"),
PathBuf::from("reports/geo-audit-calm.pdf")
);
assert_eq!(
with_display_suffix(Path::new("reports/per-page"), "text"),
PathBuf::from("reports/per-page-text")
);
}
#[test]
fn report_subject_strips_www() {
assert_eq!(
report_subject_from_url("https://www.casoon.de/page"),
"casoon-de"
);
}
#[test]
fn report_subject_replaces_dots_with_dashes() {
assert_eq!(
report_subject_from_url("https://sub.example.com/"),
"sub-example-com"
);
}
#[test]
fn report_subject_fallback_on_invalid_url() {
assert_eq!(report_subject_from_url("not-a-url"), "audit-report");
}
#[test]
fn output_directory_returns_parent() {
let path = Path::new("reports/foo.pdf");
assert_eq!(output_directory(path), Path::new("reports"));
}
#[test]
fn output_directory_falls_back_to_dot() {
let path = Path::new("foo.pdf");
assert_eq!(output_directory(path), Path::new("."));
}
#[test]
fn default_single_json_output_path_replaces_extension() {
let pdf = Path::new("reports/casoon-2026-01-01-single-report.pdf");
assert_eq!(
default_single_json_output_path(pdf),
PathBuf::from("reports/casoon-2026-01-01-single-report.json")
);
}
#[test]
fn default_screen_reader_json_output_path_adds_suffix() {
let pdf = Path::new("reports/casoon-2026-01-01-single-report.pdf");
assert_eq!(
default_screen_reader_json_output_path(pdf),
PathBuf::from("reports/casoon-2026-01-01-single-report-screen-reader-audit.json")
);
}
#[test]
fn atomic_write_replaces_complete_files_without_leaving_partial_output() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("report.json");
atomic_write(&path, b"first").unwrap();
atomic_write(&path, b"second").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"second");
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
}
}