auditmysite 1.7.3

Resource-efficient WCAG 2.2 AA Accessibility Checker in Rust
Documentation
//! Output path helpers and file writing utilities.
//!
//! Pure functions for determining output file paths and writing report
//! content to disk or stdout. Extracted from main.rs to keep that file
//! focused on bootstrap and dispatch.

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};

/// Write text content to `path` (or stdout when `path` is None).
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(())
}

/// Write binary content to `path`.
#[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
}

/// Return the parent directory of `path`, defaulting to `"."`.
pub(crate) fn output_directory(path: &Path) -> &Path {
    match path.parent() {
        Some(parent) if !parent.as_os_str().is_empty() => parent,
        _ => Path::new("."),
    }
}

/// Derive the companion `.json` path from a PDF output path.
pub(crate) fn default_single_json_output_path(pdf_path: &Path) -> PathBuf {
    pdf_path.with_extension("json")
}

/// Derive the standalone screen-reader audit JSON sidecar path.
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
}

/// Directory used for per-page batch output files.
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("."),
    }
}

/// `reports/site.pdf` → `reports/site-calm.pdf`; a directory path without
/// extension (per-page reports) gets the suffix appended (#653).
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)
}

/// Concrete output path for one page inside a per-page batch run.
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");
    // Host and path: every page of one site shares the host, and with the
    // host alone each page overwrote the previous one's file.
    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),
    }
}

/// Default PDF output path for a single-URL audit.
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"))
}

/// Default PDF output path for a batch audit.
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"))
}

/// File-name subject for one page of a site: host and path as a slug
/// (`casoon-de-blog-post`), plus a short hash of the query string when there
/// is one, so two pages never share a file name.
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()) {
        // FNV-1a: stable across builds, unlike `DefaultHasher`.
        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("-")
}

/// Derive a filename-safe domain slug from a URL (e.g. `"casoon.de"`).
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
    }
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[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);
    }
}