pub mod dashboard;
pub mod emitter;
pub mod graph;
pub mod inspect;
pub mod shard;
use serde::Serialize;
use std::io::Write;
use std::path::{Path, PathBuf};
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let directory = path.parent().unwrap_or_else(|| Path::new("."));
let mut temporary = std::ffi::OsString::from(".");
temporary.push(path.file_name().unwrap_or_default());
temporary.push(format!(".tmp.{}", std::process::id()));
let temporary_path = directory.join(temporary);
let mut file = std::fs::File::create(&temporary_path)?;
let written = file.write_all(bytes).and_then(|()| file.sync_all());
drop(file);
if let Err(error) = written {
let _ = std::fs::remove_file(&temporary_path);
return Err(error);
}
if let Err(error) = std::fs::rename(&temporary_path, path) {
let _ = std::fs::remove_file(&temporary_path);
return Err(error);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum OutputFormat {
Json,
Yaml,
}
impl OutputFormat {
pub fn serialize<T: Serialize>(&self, value: &T) -> anyhow::Result<String> {
match self {
Self::Json => Ok(serde_json::to_string_pretty(value)?),
Self::Yaml => Ok(yaml_serde::to_string(value)?),
}
}
pub fn extension(&self) -> &'static str {
match self {
Self::Json => "json",
Self::Yaml => "yaml",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum OutputKind {
Html,
Text,
}
pub fn default_output_path(
kind: OutputKind,
output: Option<PathBuf>,
root: &Path,
) -> Option<PathBuf> {
match (kind, output) {
(_, Some(path)) => Some(path),
(OutputKind::Html, None) => Some(root.with_extension("html")),
(OutputKind::Text, None) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temporary_files(directory: &Path) -> Vec<String> {
std::fs::read_dir(directory)
.unwrap()
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|name| name.contains(".tmp."))
.collect()
}
#[test]
fn explicit_output_wins_over_the_default() {
let given = PathBuf::from("reports/graph.html");
assert_eq!(
default_output_path(OutputKind::Html, Some(given.clone()), Path::new("demo")),
Some(given)
);
}
#[test]
fn html_defaults_beside_the_analyzed_path() {
assert_eq!(
default_output_path(OutputKind::Html, None, Path::new("demo")),
Some(PathBuf::from("demo.html"))
);
assert_eq!(
default_output_path(OutputKind::Html, None, Path::new("demo/src/main.py")),
Some(PathBuf::from("demo/src/main.html"))
);
}
#[test]
fn text_without_a_path_goes_to_stdout() {
assert_eq!(
default_output_path(OutputKind::Text, None, Path::new("demo")),
None
);
}
#[test]
fn write_atomic_replaces_content_without_leftovers() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("graph.json");
write_atomic(&path, b"first").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first");
write_atomic(&path, b"second").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
assert!(
temporary_files(directory.path()).is_empty(),
"no temporary file survives: {:?}",
temporary_files(directory.path())
);
}
#[test]
fn write_atomic_failure_creates_no_file() {
let directory = tempfile::tempdir().unwrap();
let missing = directory.path().join("nested").join("graph.json");
assert!(write_atomic(&missing, b"payload").is_err());
assert!(!missing.exists());
assert!(temporary_files(directory.path()).is_empty());
}
}