Skip to main content

xtask/
web_source.rs

1use anyhow::{bail, Context, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4use walkdir::WalkDir;
5
6use crate::{command_exists, run_cmd};
7
8const WEB_APP: &str = "apps/web";
9const WEB_BUNDLE: &str = "crates/soma/web/assets/source";
10const AURORA_COMPONENTS: &[&str] = &[
11    "https://aurora.tootie.tv/r/aurora-tokens.json",
12    "@aurora/aurora-badge",
13    "@aurora/aurora-button",
14    "@aurora/aurora-card",
15    "@aurora/aurora-input",
16    "@aurora/aurora-progress",
17    "@aurora/aurora-separator",
18    "@aurora/aurora-skeleton",
19    "@aurora/aurora-tabs",
20];
21
22pub(crate) fn sync() -> Result<()> {
23    let source = Path::new(WEB_APP);
24    let destination = Path::new(WEB_BUNDLE);
25    ensure_app_dir_exists(source)?;
26
27    if destination.exists() {
28        fs::remove_dir_all(destination)
29            .with_context(|| format!("failed to remove {}", destination.display()))?;
30    }
31    fs::create_dir_all(destination)
32        .with_context(|| format!("failed to create {}", destination.display()))?;
33
34    for entry in app_entries(source)? {
35        copy_entry(source, destination, &entry)?;
36    }
37
38    println!(
39        "==> sync-web-source: copied {} to {}",
40        source.display(),
41        destination.display()
42    );
43    Ok(())
44}
45
46pub(crate) fn check() -> Result<()> {
47    let source = Path::new(WEB_APP);
48    let destination = Path::new(WEB_BUNDLE);
49    ensure_app_dir_exists(source)?;
50    ensure_app_dir_exists(destination)?;
51
52    let mut web_app_entries = app_entries(source)?;
53    let mut bundle_entries = app_entries(destination)?;
54    web_app_entries.sort();
55    bundle_entries.sort();
56
57    if web_app_entries != bundle_entries {
58        report_entry_drift(&web_app_entries, &bundle_entries);
59        bail!("web source bundle is out of sync; run `cargo xtask sync-web-source`");
60    }
61
62    let mut mismatches = Vec::new();
63    for relative in &web_app_entries {
64        let source_path = source.join(relative);
65        let destination_path = destination.join(relative);
66        if !entries_match(&source_path, &destination_path)
67            .with_context(|| format!("failed to compare {}", relative.display()))?
68        {
69            mismatches.push(relative.clone());
70        }
71    }
72
73    if !mismatches.is_empty() {
74        println!("==> check-web-source-sync: content drift:");
75        for path in mismatches.iter().take(25) {
76            println!("  DIFF  {}", path.display());
77        }
78        if mismatches.len() > 25 {
79            println!("  ... {} more", mismatches.len() - 25);
80        }
81        bail!("web source bundle is out of sync; run `cargo xtask sync-web-source`");
82    }
83
84    println!("==> check-web-source-sync: bundled web source matches apps/web");
85    Ok(())
86}
87
88pub(crate) fn update_aurora() -> Result<()> {
89    if !command_exists("pnpm") {
90        bail!("pnpm is not installed; install it before updating Aurora components");
91    }
92
93    for component in AURORA_COMPONENTS {
94        println!("==> update-aurora-web: refreshing {component}");
95        run_cmd(
96            "pnpm",
97            &[
98                "--dir",
99                WEB_APP,
100                "dlx",
101                "shadcn@latest",
102                "add",
103                "--yes",
104                "--overwrite",
105                component,
106            ],
107        )
108        .with_context(|| format!("failed to refresh Aurora component {component}"))?;
109    }
110
111    println!("==> update-aurora-web: validating apps/web");
112    run_cmd("pnpm", &["--dir", WEB_APP, "validate"])
113        .context("apps/web validation failed after Aurora refresh")?;
114
115    sync().context("failed to sync refreshed Aurora web source")?;
116    Ok(())
117}
118
119fn ensure_app_dir_exists(path: &Path) -> Result<()> {
120    if !path.is_dir() {
121        bail!("{} does not exist or is not a directory", path.display());
122    }
123    Ok(())
124}
125
126fn app_entries(root: &Path) -> Result<Vec<PathBuf>> {
127    let mut entries = Vec::new();
128    for entry in WalkDir::new(root)
129        .follow_links(false)
130        .into_iter()
131        .filter_entry(|entry| !is_ignored(entry.path().strip_prefix(root).unwrap_or(entry.path())))
132    {
133        let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?;
134        let relative = entry
135            .path()
136            .strip_prefix(root)
137            .with_context(|| format!("walk entry was outside {}", root.display()))?;
138        if relative.as_os_str().is_empty() || entry.file_type().is_dir() {
139            continue;
140        }
141        entries.push(relative.to_path_buf());
142    }
143    Ok(entries)
144}
145
146fn is_ignored(relative: &Path) -> bool {
147    relative.components().any(|component| {
148        let name = component.as_os_str().to_string_lossy();
149        matches!(name.as_ref(), ".next" | "node_modules" | "out")
150    }) || relative
151        .file_name()
152        .and_then(|name| name.to_str())
153        .is_some_and(|name| matches!(name, "tsconfig.tsbuildinfo" | ".DS_Store"))
154}
155
156fn copy_entry(source: &Path, destination: &Path, relative: &Path) -> Result<()> {
157    let source_path = source.join(relative);
158    let destination_path = destination.join(relative);
159    if let Some(parent) = destination_path.parent() {
160        fs::create_dir_all(parent)
161            .with_context(|| format!("failed to create {}", parent.display()))?;
162    }
163
164    let metadata = fs::symlink_metadata(&source_path)
165        .with_context(|| format!("failed to stat {}", source_path.display()))?;
166    if metadata.file_type().is_symlink() {
167        let target = fs::read_link(&source_path)
168            .with_context(|| format!("failed to read symlink {}", source_path.display()))?;
169        symlink_file(&target, &destination_path)
170            .with_context(|| format!("failed to create symlink {}", destination_path.display()))?;
171    } else {
172        fs::copy(&source_path, &destination_path).with_context(|| {
173            format!(
174                "failed to copy {} to {}",
175                source_path.display(),
176                destination_path.display()
177            )
178        })?;
179    }
180    Ok(())
181}
182
183fn entries_match(left: &Path, right: &Path) -> Result<bool> {
184    let left_meta =
185        fs::symlink_metadata(left).with_context(|| format!("failed to stat {}", left.display()))?;
186    let right_meta = fs::symlink_metadata(right)
187        .with_context(|| format!("failed to stat {}", right.display()))?;
188
189    if left_meta.file_type().is_symlink() || right_meta.file_type().is_symlink() {
190        return Ok(left_meta.file_type().is_symlink()
191            && right_meta.file_type().is_symlink()
192            && fs::read_link(left)? == fs::read_link(right)?);
193    }
194
195    if left_meta.len() != right_meta.len() {
196        return Ok(false);
197    }
198    Ok(fs::read(left)? == fs::read(right)?)
199}
200
201fn report_entry_drift(app_entries: &[PathBuf], bundle_entries: &[PathBuf]) {
202    println!("==> check-web-source-sync: file list drift:");
203    for path in app_entries
204        .iter()
205        .filter(|path| !bundle_entries.contains(path))
206        .take(25)
207    {
208        println!("  MISSING  {}", path.display());
209    }
210    for path in bundle_entries
211        .iter()
212        .filter(|path| !app_entries.contains(path))
213        .take(25)
214    {
215        println!("  EXTRA    {}", path.display());
216    }
217}
218
219#[cfg(unix)]
220fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
221    std::os::unix::fs::symlink(target, link)
222}
223
224#[cfg(windows)]
225fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
226    std::os::windows::fs::symlink_file(target, link)
227}