use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const MANIFEST_FILE: &str = "app-manifest.toml";
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct AppManifest {
#[serde(default)]
pub entries: Vec<AppEntry>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
#[serde(tag = "mode", rename_all = "kebab-case")]
pub enum AppInstallStrategy {
#[default]
Copy,
JsonMerge {
managed_keys: Vec<String>,
},
}
impl AppInstallStrategy {
pub fn is_copy(&self) -> bool {
matches!(self, Self::Copy)
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AppEntry {
pub source: String,
pub destination: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backup: Option<PathBuf>,
pub content_hash: u64,
#[serde(default, skip_serializing_if = "AppInstallStrategy::is_copy")]
pub install_strategy: AppInstallStrategy,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub uses_env: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub requires_admin: bool,
}
pub fn hash_content(bytes: &[u8]) -> u64 {
const FNV_OFFSET: u64 = 14695981039346656037;
const FNV_PRIME: u64 = 1099511628211;
bytes.iter().fold(FNV_OFFSET, |hash, &byte| {
(hash ^ (byte as u64)).wrapping_mul(FNV_PRIME)
})
}
impl AppManifest {
pub async fn load(shine_dir: &Path) -> Result<Self> {
crate::persist::load_toml_or_default(&shine_dir.join(MANIFEST_FILE), "app manifest").await
}
pub async fn save(&self, shine_dir: &Path) -> Result<()> {
crate::persist::save_toml_atomic(self, &shine_dir.join(MANIFEST_FILE), "app manifest").await
}
pub fn upsert(&mut self, entry: AppEntry) {
self.entries.retain(|existing| {
existing.destination != entry.destination && existing.source != entry.source
});
self.entries.push(entry);
}
pub fn remove_by_dest(&mut self, dest: &Path) -> Option<AppEntry> {
if let Some(pos) = self.entries.iter().position(|e| e.destination == dest) {
Some(self.entries.remove(pos))
} else {
None
}
}
pub fn find_by_dest(&self, dest: &Path) -> Option<&AppEntry> {
self.entries.iter().find(|e| e.destination == dest)
}
pub fn find_by_source(&self, source: &str) -> Option<&AppEntry> {
self.entries.iter().find(|entry| entry.source == source)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::fs;
async fn make_temp_dir() -> PathBuf {
crate::test_support::make_temp_dir("shine-manifest").await
}
fn sample_entry(dest: &str) -> AppEntry {
AppEntry {
source: format!(
"app/test/{}",
Path::new(dest).file_name().unwrap().to_string_lossy()
),
destination: PathBuf::from(dest),
backup: None,
content_hash: 42,
install_strategy: AppInstallStrategy::Copy,
uses_env: false,
requires_admin: false,
}
}
#[tokio::test]
async fn load_returns_empty_when_missing() {
let dir = make_temp_dir().await;
let manifest = AppManifest::load(&dir).await.unwrap();
assert!(manifest.entries.is_empty());
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn save_and_load_roundtrip() {
let dir = make_temp_dir().await;
let mut manifest = AppManifest::default();
manifest.upsert(sample_entry("/tmp/foo.toml"));
manifest.save(&dir).await.unwrap();
let loaded = AppManifest::load(&dir).await.unwrap();
assert_eq!(loaded.entries.len(), 1);
assert_eq!(
loaded.entries[0].destination,
PathBuf::from("/tmp/foo.toml")
);
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn upsert_adds_new_entry() {
let dir = make_temp_dir().await;
let mut manifest = AppManifest::default();
manifest.upsert(sample_entry("/tmp/a.toml"));
manifest.upsert(sample_entry("/tmp/b.toml"));
manifest.save(&dir).await.unwrap();
let loaded = AppManifest::load(&dir).await.unwrap();
assert_eq!(loaded.entries.len(), 2);
fs::remove_dir_all(&dir).await.unwrap();
}
#[test]
fn upsert_updates_existing_entry_by_destination() {
let mut manifest = AppManifest::default();
manifest.upsert(AppEntry {
source: "app/x/foo.toml".to_string(),
destination: PathBuf::from("/tmp/foo.toml"),
backup: None,
content_hash: 1,
install_strategy: AppInstallStrategy::Copy,
uses_env: false,
requires_admin: false,
});
manifest.upsert(AppEntry {
source: "app/x/foo.toml".to_string(),
destination: PathBuf::from("/tmp/foo.toml"),
backup: None,
content_hash: 2,
install_strategy: AppInstallStrategy::Copy,
uses_env: false,
requires_admin: false,
});
assert_eq!(manifest.entries.len(), 1);
assert_eq!(manifest.entries[0].content_hash, 2);
}
#[test]
fn upsert_relocates_existing_entry_by_source() {
let mut manifest = AppManifest::default();
let old = sample_entry("/tmp/old.toml");
let mut relocated = sample_entry("/tmp/new.toml");
relocated.source = old.source.clone();
manifest.upsert(old.clone());
manifest.upsert(relocated);
assert_eq!(manifest.entries.len(), 1);
assert_eq!(manifest.entries[0].destination, Path::new("/tmp/new.toml"));
assert!(manifest.find_by_source(&old.source).is_some());
}
#[test]
fn remove_by_dest_removes_matching_entry() {
let mut manifest = AppManifest::default();
manifest.upsert(sample_entry("/tmp/a.toml"));
manifest.upsert(sample_entry("/tmp/b.toml"));
let removed = manifest.remove_by_dest(Path::new("/tmp/a.toml"));
assert!(removed.is_some());
assert_eq!(manifest.entries.len(), 1);
}
#[test]
fn remove_by_dest_is_no_op_for_missing_entry() {
let mut manifest = AppManifest::default();
manifest.upsert(sample_entry("/tmp/a.toml"));
let removed = manifest.remove_by_dest(Path::new("/tmp/nonexistent.toml"));
assert!(removed.is_none());
assert_eq!(manifest.entries.len(), 1);
}
#[test]
fn find_by_dest_returns_entry() {
let mut manifest = AppManifest::default();
manifest.upsert(sample_entry("/tmp/a.toml"));
assert!(manifest.find_by_dest(Path::new("/tmp/a.toml")).is_some());
assert!(
manifest
.find_by_dest(Path::new("/tmp/other.toml"))
.is_none()
);
}
#[test]
fn hash_content_is_deterministic() {
let h1 = hash_content(b"hello");
let h2 = hash_content(b"hello");
assert_eq!(h1, h2);
}
#[test]
fn hash_content_differs_for_different_inputs() {
let h1 = hash_content(b"hello");
let h2 = hash_content(b"world");
assert_ne!(h1, h2);
}
#[test]
fn install_strategy_defaults_to_copy() {
let entry: AppEntry = toml::from_str(
r#"
source = "app/test/foo.toml"
destination = "/tmp/foo.toml"
content_hash = 7
"#,
)
.unwrap();
assert_eq!(entry.install_strategy, AppInstallStrategy::Copy);
}
}