cli/install_core/
manifest.rs1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5const MANIFEST_FILE: &str = "app-manifest.toml";
6
7#[derive(Serialize, Deserialize, Clone, Debug, Default)]
8pub struct AppManifest {
9 #[serde(default)]
10 pub entries: Vec<AppEntry>,
11}
12
13#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
14#[serde(tag = "mode", rename_all = "kebab-case")]
15pub enum AppInstallStrategy {
16 #[default]
17 Copy,
18 JsonMerge {
19 managed_keys: Vec<String>,
20 },
21}
22
23impl AppInstallStrategy {
24 pub fn is_copy(&self) -> bool {
25 matches!(self, Self::Copy)
26 }
27}
28
29#[derive(Serialize, Deserialize, Clone, Debug)]
30pub struct AppEntry {
31 pub source: String,
32 pub destination: PathBuf,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub backup: Option<PathBuf>,
35 pub content_hash: u64,
36 #[serde(default, skip_serializing_if = "AppInstallStrategy::is_copy")]
37 pub install_strategy: AppInstallStrategy,
38 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
41 pub uses_env: bool,
42 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44 pub requires_admin: bool,
45}
46
47pub fn hash_content(bytes: &[u8]) -> u64 {
48 const FNV_OFFSET: u64 = 14695981039346656037;
50 const FNV_PRIME: u64 = 1099511628211;
51 bytes.iter().fold(FNV_OFFSET, |hash, &byte| {
52 (hash ^ (byte as u64)).wrapping_mul(FNV_PRIME)
53 })
54}
55
56impl AppManifest {
57 pub async fn load(shine_dir: &Path) -> Result<Self> {
58 crate::persist::load_toml_or_default(&shine_dir.join(MANIFEST_FILE), "app manifest").await
59 }
60
61 pub async fn save(&self, shine_dir: &Path) -> Result<()> {
62 crate::persist::save_toml_atomic(self, &shine_dir.join(MANIFEST_FILE), "app manifest").await
63 }
64
65 pub fn upsert(&mut self, entry: AppEntry) {
66 self.entries.retain(|existing| {
67 existing.destination != entry.destination && existing.source != entry.source
68 });
69 self.entries.push(entry);
70 }
71
72 pub fn remove_by_dest(&mut self, dest: &Path) -> Option<AppEntry> {
73 if let Some(pos) = self.entries.iter().position(|e| e.destination == dest) {
74 Some(self.entries.remove(pos))
75 } else {
76 None
77 }
78 }
79
80 pub fn find_by_dest(&self, dest: &Path) -> Option<&AppEntry> {
81 self.entries.iter().find(|e| e.destination == dest)
82 }
83
84 pub fn find_by_source(&self, source: &str) -> Option<&AppEntry> {
85 self.entries.iter().find(|entry| entry.source == source)
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use tokio::fs;
93
94 async fn make_temp_dir() -> PathBuf {
95 crate::test_support::make_temp_dir("shine-manifest").await
96 }
97
98 fn sample_entry(dest: &str) -> AppEntry {
99 AppEntry {
100 source: format!(
101 "app/test/{}",
102 Path::new(dest).file_name().unwrap().to_string_lossy()
103 ),
104 destination: PathBuf::from(dest),
105 backup: None,
106 content_hash: 42,
107 install_strategy: AppInstallStrategy::Copy,
108 uses_env: false,
109 requires_admin: false,
110 }
111 }
112
113 #[tokio::test]
114 async fn load_returns_empty_when_missing() {
115 let dir = make_temp_dir().await;
116 let manifest = AppManifest::load(&dir).await.unwrap();
117 assert!(manifest.entries.is_empty());
118 fs::remove_dir_all(&dir).await.unwrap();
119 }
120
121 #[tokio::test]
122 async fn save_and_load_roundtrip() {
123 let dir = make_temp_dir().await;
124 let mut manifest = AppManifest::default();
125 manifest.upsert(sample_entry("/tmp/foo.toml"));
126 manifest.save(&dir).await.unwrap();
127
128 let loaded = AppManifest::load(&dir).await.unwrap();
129 assert_eq!(loaded.entries.len(), 1);
130 assert_eq!(
131 loaded.entries[0].destination,
132 PathBuf::from("/tmp/foo.toml")
133 );
134 fs::remove_dir_all(&dir).await.unwrap();
135 }
136
137 #[tokio::test]
138 async fn upsert_adds_new_entry() {
139 let dir = make_temp_dir().await;
140 let mut manifest = AppManifest::default();
141 manifest.upsert(sample_entry("/tmp/a.toml"));
142 manifest.upsert(sample_entry("/tmp/b.toml"));
143 manifest.save(&dir).await.unwrap();
144
145 let loaded = AppManifest::load(&dir).await.unwrap();
146 assert_eq!(loaded.entries.len(), 2);
147 fs::remove_dir_all(&dir).await.unwrap();
148 }
149
150 #[test]
151 fn upsert_updates_existing_entry_by_destination() {
152 let mut manifest = AppManifest::default();
153 manifest.upsert(AppEntry {
154 source: "app/x/foo.toml".to_string(),
155 destination: PathBuf::from("/tmp/foo.toml"),
156 backup: None,
157 content_hash: 1,
158 install_strategy: AppInstallStrategy::Copy,
159 uses_env: false,
160 requires_admin: false,
161 });
162 manifest.upsert(AppEntry {
163 source: "app/x/foo.toml".to_string(),
164 destination: PathBuf::from("/tmp/foo.toml"),
165 backup: None,
166 content_hash: 2,
167 install_strategy: AppInstallStrategy::Copy,
168 uses_env: false,
169 requires_admin: false,
170 });
171 assert_eq!(manifest.entries.len(), 1);
172 assert_eq!(manifest.entries[0].content_hash, 2);
173 }
174
175 #[test]
176 fn upsert_relocates_existing_entry_by_source() {
177 let mut manifest = AppManifest::default();
178 let old = sample_entry("/tmp/old.toml");
179 let mut relocated = sample_entry("/tmp/new.toml");
180 relocated.source = old.source.clone();
181 manifest.upsert(old.clone());
182 manifest.upsert(relocated);
183 assert_eq!(manifest.entries.len(), 1);
184 assert_eq!(manifest.entries[0].destination, Path::new("/tmp/new.toml"));
185 assert!(manifest.find_by_source(&old.source).is_some());
186 }
187
188 #[test]
189 fn remove_by_dest_removes_matching_entry() {
190 let mut manifest = AppManifest::default();
191 manifest.upsert(sample_entry("/tmp/a.toml"));
192 manifest.upsert(sample_entry("/tmp/b.toml"));
193 let removed = manifest.remove_by_dest(Path::new("/tmp/a.toml"));
194 assert!(removed.is_some());
195 assert_eq!(manifest.entries.len(), 1);
196 }
197
198 #[test]
199 fn remove_by_dest_is_no_op_for_missing_entry() {
200 let mut manifest = AppManifest::default();
201 manifest.upsert(sample_entry("/tmp/a.toml"));
202 let removed = manifest.remove_by_dest(Path::new("/tmp/nonexistent.toml"));
203 assert!(removed.is_none());
204 assert_eq!(manifest.entries.len(), 1);
205 }
206
207 #[test]
208 fn find_by_dest_returns_entry() {
209 let mut manifest = AppManifest::default();
210 manifest.upsert(sample_entry("/tmp/a.toml"));
211 assert!(manifest.find_by_dest(Path::new("/tmp/a.toml")).is_some());
212 assert!(
213 manifest
214 .find_by_dest(Path::new("/tmp/other.toml"))
215 .is_none()
216 );
217 }
218
219 #[test]
220 fn hash_content_is_deterministic() {
221 let h1 = hash_content(b"hello");
222 let h2 = hash_content(b"hello");
223 assert_eq!(h1, h2);
224 }
225
226 #[test]
227 fn hash_content_differs_for_different_inputs() {
228 let h1 = hash_content(b"hello");
229 let h2 = hash_content(b"world");
230 assert_ne!(h1, h2);
231 }
232
233 #[test]
234 fn install_strategy_defaults_to_copy() {
235 let entry: AppEntry = toml::from_str(
236 r#"
237source = "app/test/foo.toml"
238destination = "/tmp/foo.toml"
239content_hash = 7
240"#,
241 )
242 .unwrap();
243
244 assert_eq!(entry.install_strategy, AppInstallStrategy::Copy);
245 }
246}