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        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}