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}