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 if let Some(existing) = self
67 .entries
68 .iter_mut()
69 .find(|e| e.destination == entry.destination)
70 {
71 *existing = entry;
72 } else {
73 self.entries.push(entry);
74 }
75 }
76
77 pub fn remove_by_dest(&mut self, dest: &Path) -> Option<AppEntry> {
78 if let Some(pos) = self.entries.iter().position(|e| e.destination == dest) {
79 Some(self.entries.remove(pos))
80 } else {
81 None
82 }
83 }
84
85 pub fn find_by_dest(&self, dest: &Path) -> Option<&AppEntry> {
86 self.entries.iter().find(|e| e.destination == dest)
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use tokio::fs;
94
95 async fn make_temp_dir() -> PathBuf {
96 crate::test_support::make_temp_dir("shine-manifest").await
97 }
98
99 fn sample_entry(dest: &str) -> AppEntry {
100 AppEntry {
101 source: "app/test/foo.toml".to_string(),
102 destination: PathBuf::from(dest),
103 backup: None,
104 content_hash: 42,
105 install_strategy: AppInstallStrategy::Copy,
106 uses_env: false,
107 requires_admin: false,
108 }
109 }
110
111 #[tokio::test]
112 async fn load_returns_empty_when_missing() {
113 let dir = make_temp_dir().await;
114 let manifest = AppManifest::load(&dir).await.unwrap();
115 assert!(manifest.entries.is_empty());
116 fs::remove_dir_all(&dir).await.unwrap();
117 }
118
119 #[tokio::test]
120 async fn save_and_load_roundtrip() {
121 let dir = make_temp_dir().await;
122 let mut manifest = AppManifest::default();
123 manifest.upsert(sample_entry("/tmp/foo.toml"));
124 manifest.save(&dir).await.unwrap();
125
126 let loaded = AppManifest::load(&dir).await.unwrap();
127 assert_eq!(loaded.entries.len(), 1);
128 assert_eq!(
129 loaded.entries[0].destination,
130 PathBuf::from("/tmp/foo.toml")
131 );
132 fs::remove_dir_all(&dir).await.unwrap();
133 }
134
135 #[tokio::test]
136 async fn upsert_adds_new_entry() {
137 let dir = make_temp_dir().await;
138 let mut manifest = AppManifest::default();
139 manifest.upsert(sample_entry("/tmp/a.toml"));
140 manifest.upsert(sample_entry("/tmp/b.toml"));
141 manifest.save(&dir).await.unwrap();
142
143 let loaded = AppManifest::load(&dir).await.unwrap();
144 assert_eq!(loaded.entries.len(), 2);
145 fs::remove_dir_all(&dir).await.unwrap();
146 }
147
148 #[test]
149 fn upsert_updates_existing_entry_by_destination() {
150 let mut manifest = AppManifest::default();
151 manifest.upsert(AppEntry {
152 source: "app/x/foo.toml".to_string(),
153 destination: PathBuf::from("/tmp/foo.toml"),
154 backup: None,
155 content_hash: 1,
156 install_strategy: AppInstallStrategy::Copy,
157 uses_env: false,
158 requires_admin: false,
159 });
160 manifest.upsert(AppEntry {
161 source: "app/x/foo.toml".to_string(),
162 destination: PathBuf::from("/tmp/foo.toml"),
163 backup: None,
164 content_hash: 2,
165 install_strategy: AppInstallStrategy::Copy,
166 uses_env: false,
167 requires_admin: false,
168 });
169 assert_eq!(manifest.entries.len(), 1);
170 assert_eq!(manifest.entries[0].content_hash, 2);
171 }
172
173 #[test]
174 fn remove_by_dest_removes_matching_entry() {
175 let mut manifest = AppManifest::default();
176 manifest.upsert(sample_entry("/tmp/a.toml"));
177 manifest.upsert(sample_entry("/tmp/b.toml"));
178 let removed = manifest.remove_by_dest(Path::new("/tmp/a.toml"));
179 assert!(removed.is_some());
180 assert_eq!(manifest.entries.len(), 1);
181 }
182
183 #[test]
184 fn remove_by_dest_is_no_op_for_missing_entry() {
185 let mut manifest = AppManifest::default();
186 manifest.upsert(sample_entry("/tmp/a.toml"));
187 let removed = manifest.remove_by_dest(Path::new("/tmp/nonexistent.toml"));
188 assert!(removed.is_none());
189 assert_eq!(manifest.entries.len(), 1);
190 }
191
192 #[test]
193 fn find_by_dest_returns_entry() {
194 let mut manifest = AppManifest::default();
195 manifest.upsert(sample_entry("/tmp/a.toml"));
196 assert!(manifest.find_by_dest(Path::new("/tmp/a.toml")).is_some());
197 assert!(
198 manifest
199 .find_by_dest(Path::new("/tmp/other.toml"))
200 .is_none()
201 );
202 }
203
204 #[test]
205 fn hash_content_is_deterministic() {
206 let h1 = hash_content(b"hello");
207 let h2 = hash_content(b"hello");
208 assert_eq!(h1, h2);
209 }
210
211 #[test]
212 fn hash_content_differs_for_different_inputs() {
213 let h1 = hash_content(b"hello");
214 let h2 = hash_content(b"world");
215 assert_ne!(h1, h2);
216 }
217
218 #[test]
219 fn install_strategy_defaults_to_copy() {
220 let entry: AppEntry = toml::from_str(
221 r#"
222source = "app/test/foo.toml"
223destination = "/tmp/foo.toml"
224content_hash = 7
225"#,
226 )
227 .unwrap();
228
229 assert_eq!(entry.install_strategy, AppInstallStrategy::Copy);
230 }
231}