Skip to main content

git_slop/report/
write.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
4
5use anyhow::{Context, Result, anyhow};
6use chrono::{DateTime, Utc};
7use serde_json::Value;
8
9use super::assembly::assemble_report;
10use super::render::{render_compatibility_summary, render_terminal};
11use crate::config;
12use crate::health::render_health_from_report;
13use crate::model::{Analysis, FindResult, HealthRollup};
14
15static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
16
17pub fn load_report(path: &Path) -> Result<Value> {
18    let source =
19        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
20    let report: Value = serde_json::from_str(&source)
21        .with_context(|| format!("invalid git-slop report JSON: {}", path.display()))?;
22    Ok(report)
23}
24
25fn timestamp_slug(generated_at: &str) -> String {
26    if let Ok(timestamp) = DateTime::parse_from_rfc3339(generated_at) {
27        return timestamp
28            .with_timezone(&Utc)
29            .format("%Y%m%dT%H%M%SZ")
30            .to_string();
31    }
32    let normalized = generated_at
33        .chars()
34        .map(|character| {
35            if character.is_ascii_alphanumeric() {
36                character
37            } else {
38                '-'
39            }
40        })
41        .collect::<String>();
42    let normalized = normalized.trim_matches('-');
43    if normalized.is_empty() {
44        Utc::now().format("%Y%m%dT%H%M%SZ").to_string()
45    } else {
46        normalized.to_string()
47    }
48}
49
50fn temporary_directory(parent: &Path, label: &str) -> PathBuf {
51    let sequence = TEMP_SEQUENCE.fetch_add(1, AtomicOrdering::Relaxed);
52    parent.join(format!(".{label}-{}-{sequence}.tmp", std::process::id()))
53}
54
55fn unique_run_root(runs_root: &Path, preferred_slug: &str) -> PathBuf {
56    let preferred = runs_root.join(preferred_slug);
57    if !preferred.exists() {
58        return preferred;
59    }
60    for suffix in 2..10_000 {
61        let candidate = runs_root.join(format!("{preferred_slug}-{suffix}"));
62        if !candidate.exists() {
63            return candidate;
64        }
65    }
66    runs_root.join(format!(
67        "{preferred_slug}-{}",
68        TEMP_SEQUENCE.fetch_add(1, AtomicOrdering::Relaxed)
69    ))
70}
71
72fn write_bundle_files(
73    root: &Path,
74    report_json: &str,
75    report_yaml: &str,
76    summary: &str,
77    health: &str,
78) -> Result<()> {
79    fs::create_dir_all(root)
80        .with_context(|| format!("failed to create report directory {}", root.display()))?;
81    for (name, content) in [
82        ("report.json", report_json),
83        ("report.yaml", report_yaml),
84        ("summary.md", summary),
85        ("health.md", health),
86    ] {
87        let path = root.join(name);
88        fs::write(&path, content).with_context(|| format!("failed to write {}", path.display()))?;
89    }
90    Ok(())
91}
92
93fn replace_latest_atomically(
94    latest: &Path,
95    report_json: &str,
96    report_yaml: &str,
97    summary: &str,
98    health: &str,
99) -> Result<()> {
100    let parent = latest.parent().ok_or_else(|| {
101        anyhow!(
102            "latest report directory has no parent: {}",
103            latest.display()
104        )
105    })?;
106    let temporary = temporary_directory(parent, "latest");
107    let backup = temporary_directory(parent, "latest-backup");
108    if let Err(error) = write_bundle_files(&temporary, report_json, report_yaml, summary, health) {
109        let _ = fs::remove_dir_all(&temporary);
110        return Err(error);
111    }
112    if latest.exists() {
113        if let Err(error) = fs::rename(latest, &backup) {
114            let _ = fs::remove_dir_all(&temporary);
115            return Err(error).with_context(|| {
116                format!(
117                    "failed to stage existing latest report {}",
118                    latest.display()
119                )
120            });
121        }
122    }
123    if let Err(error) = fs::rename(&temporary, latest) {
124        if backup.exists() && !latest.exists() {
125            let _ = fs::rename(&backup, latest);
126        }
127        let _ = fs::remove_dir_all(&temporary);
128        return Err(error).with_context(|| {
129            format!(
130                "failed to publish latest report directory {}",
131                latest.display()
132            )
133        });
134    }
135    if backup.exists() {
136        fs::remove_dir_all(&backup)
137            .with_context(|| format!("failed to remove report backup {}", backup.display()))?;
138    }
139    Ok(())
140}
141
142fn write_run_atomically(
143    run_root: &Path,
144    report_json: &str,
145    report_yaml: &str,
146    summary: &str,
147    health: &str,
148) -> Result<()> {
149    let parent = run_root
150        .parent()
151        .ok_or_else(|| anyhow!("run report directory has no parent: {}", run_root.display()))?;
152    let temporary = temporary_directory(parent, "run");
153    if let Err(error) = write_bundle_files(&temporary, report_json, report_yaml, summary, health) {
154        let _ = fs::remove_dir_all(&temporary);
155        return Err(error);
156    }
157    if let Err(error) = fs::rename(&temporary, run_root) {
158        let _ = fs::remove_dir_all(&temporary);
159        return Err(error).with_context(|| {
160            format!(
161                "failed to publish timestamped report directory {}",
162                run_root.display()
163            )
164        });
165    }
166    Ok(())
167}
168
169pub fn write_report_bundle(analysis: &Analysis, health: &HealthRollup) -> Result<FindResult> {
170    config::ensure_state_dirs(&analysis.repo_root)?;
171    let report = assemble_report(analysis, health);
172    let report_json =
173        serde_json::to_string_pretty(&report).context("failed to render report JSON")? + "\n";
174    let report_yaml = serde_yaml::to_string(&report).context("failed to render report YAML")?;
175    let summary = render_compatibility_summary(&report);
176    let health_markdown = render_health_from_report(&report)?;
177    let terminal = render_terminal(&report);
178
179    let runs_root = config::runs_dir(&analysis.repo_root);
180    fs::create_dir_all(&runs_root)
181        .with_context(|| format!("failed to create {}", runs_root.display()))?;
182    let run_root = unique_run_root(&runs_root, &timestamp_slug(&analysis.generated_at));
183    write_run_atomically(
184        &run_root,
185        &report_json,
186        &report_yaml,
187        &summary,
188        &health_markdown,
189    )?;
190    let latest = config::latest_dir(&analysis.repo_root);
191    replace_latest_atomically(
192        &latest,
193        &report_json,
194        &report_yaml,
195        &summary,
196        &health_markdown,
197    )?;
198
199    Ok(FindResult {
200        report,
201        report_json: latest.join("report.json"),
202        report_yaml: latest.join("report.yaml"),
203        summary_md: latest.join("summary.md"),
204        health_md: latest.join("health.md"),
205        terminal,
206    })
207}