Skip to main content

meta_ast/output/
mod.rs

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
11/// Write `bytes` to `path` by renaming a temporary file beside it.
12///
13/// A crash between truncate and flush leaves a partial document, and every
14/// writer here produces output that is either complete or useless. The
15/// temporary file lives in the target directory, so the rename never crosses
16/// a mount point.
17pub(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/// Supported output serialization formats.
39#[derive(Debug, Clone, Copy, PartialEq)]
40#[non_exhaustive]
41pub enum OutputFormat {
42    Json,
43    Yaml,
44}
45
46impl OutputFormat {
47    /// Serialize a value to the chosen format.
48    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    /// File extension for this format, without the dot.
56    pub fn extension(&self) -> &'static str {
57        match self {
58            Self::Json => "json",
59            Self::Yaml => "yaml",
60        }
61    }
62}
63
64/// The document a command writes.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum OutputKind {
68    /// The interactive dashboard.
69    Html,
70    /// The serialized graph or symbol list.
71    Text,
72}
73
74/// Resolve where a command writes its document.
75///
76/// An explicit path always wins. The dashboard then lands beside the analyzed
77/// path as `<stem>.html`, and serialized text goes to stdout.
78pub 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}