Skip to main content

cli/install_core/
manifest.rs

1use 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    /// True when the `template` transform was applied during install.
39    /// Used by config upgrade to skip files that never used env vars.
40    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
41    pub uses_env: bool,
42    /// True when installing/removing this file requires elevated (sudo) permissions.
43    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44    pub requires_admin: bool,
45}
46
47pub fn hash_content(bytes: &[u8]) -> u64 {
48    // FNV-1a: stable across Rust versions, unlike DefaultHasher
49    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}