1pub mod dashboard;
2pub mod emitter;
3pub mod graph;
4pub mod inspect;
5pub mod shard;
6
7use serde::Serialize;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
18 let directory = path.parent().unwrap_or_else(|| Path::new("."));
19 let mut temporary = std::ffi::OsString::from(".");
20 temporary.push(path.file_name().unwrap_or_default());
21 temporary.push(format!(".tmp.{}", std::process::id()));
22 let temporary_path = directory.join(temporary);
23
24 let mut file = std::fs::File::create(&temporary_path)?;
25 let written = file.write_all(bytes).and_then(|()| file.sync_all());
26 drop(file);
27 if let Err(error) = written {
28 let _ = std::fs::remove_file(&temporary_path);
29 return Err(error);
30 }
31 if let Err(error) = std::fs::rename(&temporary_path, path) {
32 let _ = std::fs::remove_file(&temporary_path);
33 return Err(error);
34 }
35 Ok(())
36}
37
38#[derive(Debug, Clone, Copy, PartialEq)]
40#[non_exhaustive]
41pub enum OutputFormat {
42 Json,
43 Yaml,
44}
45
46impl OutputFormat {
47 pub fn serialize<T: Serialize>(&self, value: &T) -> anyhow::Result<String> {
49 match self {
50 Self::Json => Ok(serde_json::to_string_pretty(value)?),
51 Self::Yaml => Ok(yaml_serde::to_string(value)?),
52 }
53 }
54
55 pub fn extension(&self) -> &'static str {
57 match self {
58 Self::Json => "json",
59 Self::Yaml => "yaml",
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum OutputKind {
68 Html,
70 Text,
72}
73
74pub fn default_output_path(
79 kind: OutputKind,
80 output: Option<PathBuf>,
81 root: &Path,
82) -> Option<PathBuf> {
83 match (kind, output) {
84 (_, Some(path)) => Some(path),
85 (OutputKind::Html, None) => Some(root.with_extension("html")),
86 (OutputKind::Text, None) => None,
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 fn temporary_files(directory: &Path) -> Vec<String> {
95 std::fs::read_dir(directory)
96 .unwrap()
97 .filter_map(|entry| entry.ok())
98 .map(|entry| entry.file_name().to_string_lossy().to_string())
99 .filter(|name| name.contains(".tmp."))
100 .collect()
101 }
102
103 #[test]
104 fn explicit_output_wins_over_the_default() {
105 let given = PathBuf::from("reports/graph.html");
106 assert_eq!(
107 default_output_path(OutputKind::Html, Some(given.clone()), Path::new("demo")),
108 Some(given)
109 );
110 }
111
112 #[test]
113 fn html_defaults_beside_the_analyzed_path() {
114 assert_eq!(
115 default_output_path(OutputKind::Html, None, Path::new("demo")),
116 Some(PathBuf::from("demo.html"))
117 );
118 assert_eq!(
119 default_output_path(OutputKind::Html, None, Path::new("demo/src/main.py")),
120 Some(PathBuf::from("demo/src/main.html"))
121 );
122 }
123
124 #[test]
125 fn text_without_a_path_goes_to_stdout() {
126 assert_eq!(
127 default_output_path(OutputKind::Text, None, Path::new("demo")),
128 None
129 );
130 }
131
132 #[test]
133 fn write_atomic_replaces_content_without_leftovers() {
134 let directory = tempfile::tempdir().unwrap();
135 let path = directory.path().join("graph.json");
136
137 write_atomic(&path, b"first").unwrap();
138 assert_eq!(std::fs::read_to_string(&path).unwrap(), "first");
139
140 write_atomic(&path, b"second").unwrap();
141 assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
142 assert!(
143 temporary_files(directory.path()).is_empty(),
144 "no temporary file survives: {:?}",
145 temporary_files(directory.path())
146 );
147 }
148
149 #[test]
150 fn write_atomic_failure_creates_no_file() {
151 let directory = tempfile::tempdir().unwrap();
152 let missing = directory.path().join("nested").join("graph.json");
153
154 assert!(write_atomic(&missing, b"payload").is_err());
155 assert!(!missing.exists());
156 assert!(temporary_files(directory.path()).is_empty());
157 }
158}