1use crate::runtime::{FileSystemHost, FileSystemObservationHost};
2use anyhow::{Result, bail};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6const MANIFEST_FILE: &str = "app-manifest.toml";
7pub const APP_MANIFEST_SCHEMA_VERSION: u32 = 1;
8
9#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
10pub struct AppManifest {
11 #[serde(default = "legacy_manifest_schema_version")]
12 pub schema_version: u32,
13 #[serde(default)]
14 pub entries: Vec<AppEntry>,
15}
16
17fn legacy_manifest_schema_version() -> u32 {
18 0
19}
20
21impl Default for AppManifest {
22 fn default() -> Self {
23 Self {
24 schema_version: APP_MANIFEST_SCHEMA_VERSION,
25 entries: Vec::new(),
26 }
27 }
28}
29
30#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
31#[serde(tag = "mode", rename_all = "kebab-case")]
32pub enum AppInstallStrategy {
33 #[default]
34 Copy,
35 JsonMerge {
36 managed_keys: Vec<String>,
37 },
38}
39
40impl AppInstallStrategy {
41 pub fn is_copy(&self) -> bool {
42 matches!(self, Self::Copy)
43 }
44}
45
46#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
47pub struct AppEntry {
48 pub source: String,
49 pub destination: PathBuf,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub backup: Option<PathBuf>,
52 pub content_hash: u64,
53 #[serde(default, skip_serializing_if = "AppInstallStrategy::is_copy")]
54 pub install_strategy: AppInstallStrategy,
55 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
58 pub uses_env: bool,
59 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
61 pub requires_admin: bool,
62}
63
64pub fn hash_content(bytes: &[u8]) -> u64 {
65 const FNV_OFFSET: u64 = 14695981039346656037;
67 const FNV_PRIME: u64 = 1099511628211;
68 bytes.iter().fold(FNV_OFFSET, |hash, &byte| {
69 (hash ^ (byte as u64)).wrapping_mul(FNV_PRIME)
70 })
71}
72
73impl AppManifest {
74 pub async fn load(host: &impl FileSystemObservationHost, shine_dir: &Path) -> Result<Self> {
75 let path = shine_dir.join(MANIFEST_FILE);
76 let mut manifest: Self = match host.read(&path).await {
77 Ok(bytes) => toml::from_slice(&bytes)?,
78 Err(error) if error.is_not_found() => Self::default(),
79 Err(error) => return Err(error.into_anyhow("failed to read app manifest")),
80 };
81 match manifest.schema_version {
82 0 => manifest.schema_version = APP_MANIFEST_SCHEMA_VERSION,
83 APP_MANIFEST_SCHEMA_VERSION => {}
84 version => bail!(
85 "app manifest schema version {version} is newer than this Shine supports ({APP_MANIFEST_SCHEMA_VERSION})"
86 ),
87 }
88 Ok(manifest)
89 }
90
91 pub async fn save(&self, host: &impl FileSystemHost, shine_dir: &Path) -> Result<()> {
92 if self.schema_version != APP_MANIFEST_SCHEMA_VERSION {
93 bail!(
94 "cannot write app manifest schema version {}; expected {APP_MANIFEST_SCHEMA_VERSION}",
95 self.schema_version
96 );
97 }
98 let bytes = toml::to_string_pretty(self)?;
99 host.write_atomic(&shine_dir.join(MANIFEST_FILE), bytes.as_bytes())
100 .await
101 .map_err(|error| error.into_anyhow("failed to write app manifest"))
102 }
103
104 pub fn upsert(&mut self, entry: AppEntry) {
105 self.entries.retain(|existing| {
106 existing.destination != entry.destination && existing.source != entry.source
107 });
108 self.entries.push(entry);
109 }
110
111 pub fn remove_by_dest(&mut self, dest: &Path) -> Option<AppEntry> {
112 if let Some(pos) = self.entries.iter().position(|e| e.destination == dest) {
113 Some(self.entries.remove(pos))
114 } else {
115 None
116 }
117 }
118
119 pub fn find_by_dest(&self, dest: &Path) -> Option<&AppEntry> {
120 self.entries.iter().find(|e| e.destination == dest)
121 }
122
123 pub fn find_by_source(&self, source: &str) -> Option<&AppEntry> {
124 self.entries
129 .iter()
130 .rev()
131 .find(|entry| entry.source == source)
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use tokio::fs;
139
140 async fn make_temp_dir() -> PathBuf {
141 let path = std::env::temp_dir().join(format!("shine-manifest-{}", uuid::Uuid::new_v4()));
142 fs::create_dir_all(&path).await.unwrap();
143 path
144 }
145
146 fn sample_entry(dest: &str) -> AppEntry {
147 AppEntry {
148 source: format!(
149 "app/test/{}",
150 Path::new(dest).file_name().unwrap().to_string_lossy()
151 ),
152 destination: PathBuf::from(dest),
153 backup: None,
154 content_hash: 42,
155 install_strategy: AppInstallStrategy::Copy,
156 uses_env: false,
157 requires_admin: false,
158 }
159 }
160
161 #[tokio::test]
162 async fn load_returns_empty_when_missing() {
163 let dir = make_temp_dir().await;
164 let manifest = AppManifest::load(&crate::runtime::RealHost, &dir)
165 .await
166 .unwrap();
167 assert!(manifest.entries.is_empty());
168 fs::remove_dir_all(&dir).await.unwrap();
169 }
170
171 #[tokio::test]
172 async fn save_and_load_roundtrip() {
173 let dir = make_temp_dir().await;
174 let mut manifest = AppManifest::default();
175 manifest.upsert(sample_entry("/tmp/foo.toml"));
176 manifest
177 .save(&crate::runtime::RealHost, &dir)
178 .await
179 .unwrap();
180
181 let loaded = AppManifest::load(&crate::runtime::RealHost, &dir)
182 .await
183 .unwrap();
184 assert_eq!(loaded.schema_version, APP_MANIFEST_SCHEMA_VERSION);
185 assert_eq!(loaded.entries.len(), 1);
186 assert_eq!(
187 loaded.entries[0].destination,
188 PathBuf::from("/tmp/foo.toml")
189 );
190 fs::remove_dir_all(&dir).await.unwrap();
191 }
192
193 #[tokio::test]
194 async fn legacy_unversioned_manifest_normalizes_and_writes_version_one() {
195 let dir = make_temp_dir().await;
196 fs::write(
197 dir.join(MANIFEST_FILE),
198 r#"[[entries]]
199source = "app/test/foo.toml"
200destination = "/tmp/foo.toml"
201content_hash = 7
202"#,
203 )
204 .await
205 .unwrap();
206
207 let manifest = AppManifest::load(&crate::runtime::RealHost, &dir)
208 .await
209 .unwrap();
210 assert_eq!(manifest.schema_version, APP_MANIFEST_SCHEMA_VERSION);
211 let after_read = fs::read_to_string(dir.join(MANIFEST_FILE)).await.unwrap();
212 assert!(!after_read.contains("schema_version"));
213 manifest
214 .save(&crate::runtime::RealHost, &dir)
215 .await
216 .unwrap();
217
218 let written = fs::read_to_string(dir.join(MANIFEST_FILE)).await.unwrap();
219 assert!(written.contains("schema_version = 1"));
220 fs::remove_dir_all(&dir).await.unwrap();
221 }
222
223 #[tokio::test]
224 async fn future_manifest_version_fails_before_use() {
225 let dir = make_temp_dir().await;
226 fs::write(dir.join(MANIFEST_FILE), "schema_version = 2\n")
227 .await
228 .unwrap();
229
230 let error = AppManifest::load(&crate::runtime::RealHost, &dir)
231 .await
232 .unwrap_err();
233 assert!(error.to_string().contains("newer than this Shine supports"));
234 fs::remove_dir_all(&dir).await.unwrap();
235 }
236
237 #[tokio::test]
238 async fn upsert_adds_new_entry() {
239 let dir = make_temp_dir().await;
240 let mut manifest = AppManifest::default();
241 manifest.upsert(sample_entry("/tmp/a.toml"));
242 manifest.upsert(sample_entry("/tmp/b.toml"));
243 manifest
244 .save(&crate::runtime::RealHost, &dir)
245 .await
246 .unwrap();
247
248 let loaded = AppManifest::load(&crate::runtime::RealHost, &dir)
249 .await
250 .unwrap();
251 assert_eq!(loaded.entries.len(), 2);
252 fs::remove_dir_all(&dir).await.unwrap();
253 }
254
255 #[test]
256 fn upsert_updates_existing_entry_by_destination() {
257 let mut manifest = AppManifest::default();
258 manifest.upsert(AppEntry {
259 source: "app/x/foo.toml".to_string(),
260 destination: PathBuf::from("/tmp/foo.toml"),
261 backup: None,
262 content_hash: 1,
263 install_strategy: AppInstallStrategy::Copy,
264 uses_env: false,
265 requires_admin: false,
266 });
267 manifest.upsert(AppEntry {
268 source: "app/x/foo.toml".to_string(),
269 destination: PathBuf::from("/tmp/foo.toml"),
270 backup: None,
271 content_hash: 2,
272 install_strategy: AppInstallStrategy::Copy,
273 uses_env: false,
274 requires_admin: false,
275 });
276 assert_eq!(manifest.entries.len(), 1);
277 assert_eq!(manifest.entries[0].content_hash, 2);
278 }
279
280 #[test]
281 fn upsert_relocates_existing_entry_by_source() {
282 let mut manifest = AppManifest::default();
283 let old = sample_entry("/tmp/old.toml");
284 let mut relocated = sample_entry("/tmp/new.toml");
285 relocated.source = old.source.clone();
286 manifest.upsert(old.clone());
287 manifest.upsert(relocated);
288 assert_eq!(manifest.entries.len(), 1);
289 assert_eq!(manifest.entries[0].destination, Path::new("/tmp/new.toml"));
290 assert!(manifest.find_by_source(&old.source).is_some());
291 }
292
293 #[test]
294 fn find_by_source_prefers_the_latest_legacy_relocation_receipt() {
295 let source = "app/clash-verge/merge.yaml".to_string();
296 let old = AppEntry {
297 source: source.clone(),
298 destination: PathBuf::from("C:/Users/example/.config/clash-verge/merge.yaml"),
299 backup: None,
300 content_hash: 1,
301 install_strategy: AppInstallStrategy::Copy,
302 uses_env: false,
303 requires_admin: false,
304 };
305 let current = AppEntry {
306 source: source.clone(),
307 destination: PathBuf::from("C:/Users/example/.shine/clash-verge/merge.yaml"),
308 backup: None,
309 content_hash: 1,
310 install_strategy: AppInstallStrategy::Copy,
311 uses_env: false,
312 requires_admin: false,
313 };
314 let manifest = AppManifest {
315 schema_version: 0,
316 entries: vec![old, current.clone()],
317 };
318
319 assert_eq!(manifest.find_by_source(&source), Some(¤t));
320 }
321
322 #[test]
323 fn remove_by_dest_removes_matching_entry() {
324 let mut manifest = AppManifest::default();
325 manifest.upsert(sample_entry("/tmp/a.toml"));
326 manifest.upsert(sample_entry("/tmp/b.toml"));
327 let removed = manifest.remove_by_dest(Path::new("/tmp/a.toml"));
328 assert!(removed.is_some());
329 assert_eq!(manifest.entries.len(), 1);
330 }
331
332 #[test]
333 fn remove_by_dest_is_no_op_for_missing_entry() {
334 let mut manifest = AppManifest::default();
335 manifest.upsert(sample_entry("/tmp/a.toml"));
336 let removed = manifest.remove_by_dest(Path::new("/tmp/nonexistent.toml"));
337 assert!(removed.is_none());
338 assert_eq!(manifest.entries.len(), 1);
339 }
340
341 #[test]
342 fn find_by_dest_returns_entry() {
343 let mut manifest = AppManifest::default();
344 manifest.upsert(sample_entry("/tmp/a.toml"));
345 assert!(manifest.find_by_dest(Path::new("/tmp/a.toml")).is_some());
346 assert!(
347 manifest
348 .find_by_dest(Path::new("/tmp/other.toml"))
349 .is_none()
350 );
351 }
352
353 #[test]
354 fn hash_content_is_deterministic() {
355 let h1 = hash_content(b"hello");
356 let h2 = hash_content(b"hello");
357 assert_eq!(h1, h2);
358 }
359
360 #[test]
361 fn hash_content_differs_for_different_inputs() {
362 let h1 = hash_content(b"hello");
363 let h2 = hash_content(b"world");
364 assert_ne!(h1, h2);
365 }
366
367 #[test]
368 fn install_strategy_defaults_to_copy() {
369 let entry: AppEntry = toml::from_str(
370 r#"
371source = "app/test/foo.toml"
372destination = "/tmp/foo.toml"
373content_hash = 7
374"#,
375 )
376 .unwrap();
377
378 assert_eq!(entry.install_strategy, AppInstallStrategy::Copy);
379 }
380}