use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, fmt::Display, path::PathBuf}; use tracing::{debug, instrument, trace}; pub const NILLA_PROJECT_FORMAT_VERSION: u32 = 0; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { pub version: u32, pub name: String, pub languages: Option>, pub packages: Option>, pub shells: Option>, pub extensions_dict: Option>, } #[derive(Debug)] pub enum CacheError { IoError(std::io::Error), ParseError(serde_json::Error), VersionMismatch(u32), UpdateRequired, } impl From for CacheError { fn from(e: std::io::Error) -> Self { CacheError::IoError(e) } } impl From for CacheError { fn from(e: serde_json::Error) -> Self { CacheError::ParseError(e) } } impl Display for CacheError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CacheError::IoError(e) => write!(f, "IO error: {}", e), CacheError::ParseError(e) => write!(f, "Parse error: {}", e), CacheError::VersionMismatch(v) => { write!( f, "Format version mismatch. Update the project dir. Expected version {}, found {}", NILLA_PROJECT_FORMAT_VERSION, v ) } CacheError::UpdateRequired => write!(f, "Project dir out of date. Update required"), } } } impl Project { pub fn new(name: String) -> Self { Self { version: NILLA_PROJECT_FORMAT_VERSION, name, languages: Some(Vec::new()), packages: Some(Vec::new()), shells: Some(Vec::new()), extensions_dict: None, } } #[instrument(name = "load project")] pub fn load(path: &PathBuf) -> Result { let nilla_project_dir = path.join(".nilla"); let nilla_project_dir_meta = std::fs::metadata(&nilla_project_dir)?; let nilla_nix_meta = std::fs::metadata(&path.join("nilla.nix"))?; trace!( "comparing nilla.nix modified time ({:?}) to nilla project dir modified time ({:?}), to ensure project dir is up to date", nilla_nix_meta.modified()?, nilla_project_dir_meta.modified()? ); if nilla_nix_meta.modified()? > nilla_project_dir_meta.modified()? { return Err(CacheError::UpdateRequired); } debug!("loading project from nilla project dir"); let nilla_project_file = std::fs::File::open(&nilla_project_dir.join("project.json"))?; debug!("parsing project from nilla project dir"); let mut rdr = std::io::BufReader::new(nilla_project_file); let mut nilla_project: Project = serde_json::from_reader(&mut rdr)?; let files = nilla_project_dir.read_dir()?; let mut extensions_dict = BTreeMap::new(); for file in files { let file = file?; let file_name = file.file_name().to_string_lossy().into_owned(); if file_name == "project.json" { continue; } if file_name.ends_with(".json") { let extension_name = file_name.trim_end_matches(".json"); let extension_file = std::fs::File::open(file.path())?; let extension: serde_json::Value = serde_json::from_reader(extension_file)?; extensions_dict.insert(extension_name.to_lowercase(), extension); } } nilla_project.extensions_dict = Some(extensions_dict); if nilla_project.version != NILLA_PROJECT_FORMAT_VERSION { return Err(CacheError::VersionMismatch(nilla_project.version)); } debug!("project loaded successfully"); Ok(nilla_project) } fn base(self) -> Self { Self { version: self.version, name: self.name.clone(), languages: self.languages.clone(), packages: self.packages.clone(), shells: self.shells.clone(), extensions_dict: None, } } #[instrument(name = "save project")] pub fn save(self, path: &PathBuf) -> Result<(), CacheError> { let nilla_project_dir = path.join(".nilla"); std::fs::create_dir_all(&nilla_project_dir)?; debug!("saving project to {}", nilla_project_dir.display()); if let Some(extensions_dict) = &self.extensions_dict { for (name, extension) in extensions_dict { let extension_file = std::fs::File::create(&nilla_project_dir.join(format!("{}.json", name)))?; serde_json::to_writer(extension_file, extension)?; } } let nilla_project_file = std::fs::File::create(&nilla_project_dir.join("project.json"))?; serde_json::to_writer(nilla_project_file, &self.base())?; debug!("project saved successfully"); Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn save_project() { let temp_project_dir = tempfile::tempdir().unwrap(); std::fs::File::create(&temp_project_dir.path().join("nilla.nix")).unwrap(); let project = Project::new("test".to_string()); project .save(&PathBuf::from(temp_project_dir.path())) .unwrap(); assert!( temp_project_dir .path() .join(".nilla") .join("project.json") .exists() ); temp_project_dir.close().unwrap(); } #[test] fn load_project() { let temp_project_dir = tempfile::tempdir().unwrap(); std::fs::File::create(&temp_project_dir.path().join("nilla.nix")).unwrap(); let mut project = Project::new("test".to_string()); project.languages.as_mut().unwrap().push("rust".to_string()); project .shells .as_mut() .unwrap() .append(&mut vec!["default".to_string()]); project .packages .as_mut() .unwrap() .append(&mut vec!["default".to_string()]); project .save(&PathBuf::from(temp_project_dir.path())) .unwrap(); let loaded_project = Project::load(&temp_project_dir.path().into()).unwrap(); assert_eq!(loaded_project.name, "test"); assert_eq!(loaded_project.languages, Some(vec!["rust".to_string()])); assert_eq!(loaded_project.shells, Some(vec!["default".to_string()])); assert_eq!(loaded_project.packages, Some(vec!["default".to_string()])); temp_project_dir.close().unwrap(); } #[test] fn extensions_test() { let temp_project_dir = tempfile::tempdir().unwrap(); std::fs::File::create(&temp_project_dir.path().join("nilla.nix")).unwrap(); let mut project = Project::new("test".to_string()); assert!(project.extensions_dict.is_none()); project.extensions_dict = Some(BTreeMap::new()); let test_extension = serde_json::json!({ "systems": vec!["default".to_string()] }); project .extensions_dict .as_mut() .unwrap() .insert("test".to_string(), test_extension); assert!(project.extensions_dict.is_some()); project .save(&PathBuf::from(temp_project_dir.path())) .unwrap(); assert!( temp_project_dir .path() .join(".nilla") .join("test.json") .exists() ); let loaded_project = Project::load(&temp_project_dir.path().into()).unwrap(); assert!(loaded_project.extensions_dict.is_some()); assert!( loaded_project .extensions_dict .as_ref() .unwrap() .contains_key("test") ); assert_eq!( loaded_project .extensions_dict .as_ref() .unwrap() .get("test") .unwrap(), &serde_json::json!({ "systems": vec!["default".to_string()] }) ); temp_project_dir.close().unwrap(); } #[test] fn needs_update() { let temp_project_dir = tempfile::tempdir().unwrap(); let mut project = Project::new("test".to_string()); project.languages.as_mut().unwrap().push("rust".to_string()); project .shells .as_mut() .unwrap() .append(&mut vec!["default".to_string()]); project .packages .as_mut() .unwrap() .append(&mut vec!["default".to_string()]); project .save(&PathBuf::from(temp_project_dir.path())) .unwrap(); std::thread::sleep(std::time::Duration::from_millis(1)); std::fs::File::create(&temp_project_dir.path().join("nilla.nix")).unwrap(); let loaded_project = Project::load(&temp_project_dir.path().into()); assert!(loaded_project.is_err()); assert_eq!( loaded_project.unwrap_err().to_string(), "Project dir out of date. Update required" ); temp_project_dir.close().unwrap(); } #[test] fn version_mismatch() { let temp_project_dir = tempfile::tempdir().unwrap(); let json = serde_json::json!({ "version": u32::MAX, "name": "test", "languages": vec!["rust".to_string()], "shells": vec!["default".to_string()], "packages": vec!["default".to_string()], }); std::fs::File::create(&temp_project_dir.path().join("nilla.nix")).unwrap(); std::fs::create_dir(&temp_project_dir.path().join(".nilla")).unwrap(); let f = std::fs::File::create(&temp_project_dir.path().join(".nilla").join("project.json")) .unwrap(); serde_json::to_writer(f, &json).unwrap(); let loaded_project = Project::load(&temp_project_dir.path().into()); assert!(loaded_project.is_err()); assert_eq!( loaded_project.unwrap_err().to_string(), format!( "Format version mismatch. Update the project dir. Expected version {}, found {}", NILLA_PROJECT_FORMAT_VERSION, u32::MAX ) ); temp_project_dir.close().unwrap(); } }