use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use eyre::Result;
use crate::dirs;
use crate::file;
use crate::hash::{file_hash_blake3, hash_to_str};
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub(crate) struct DepsState {
#[serde(default)]
pub providers: BTreeMap<String, BTreeMap<String, String>>,
#[serde(default)]
pub seen_outputs: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub command_hashes: BTreeMap<String, String>,
#[serde(default)]
pub output_rule_hashes: BTreeMap<String, String>,
}
impl DepsState {
pub(crate) fn load(project_root: &Path) -> Self {
let path = state_path(project_root);
if !path.exists() {
return Self::default();
}
match file::read_to_string(&path) {
Ok(contents) => match toml::from_str(&contents) {
Ok(state) => state,
Err(e) => {
warn!("failed to parse {}: {e}", path.display());
Self::default()
}
},
Err(e) => {
warn!("failed to read {}: {e}", path.display());
Self::default()
}
}
}
pub(crate) fn save(&self, project_root: &Path) -> Result<()> {
let path = state_path(project_root);
file::create_dir_all(path.parent().unwrap())?;
let contents = toml::to_string_pretty(self)?;
file::write(&path, contents)?;
Ok(())
}
pub(crate) fn get_hashes(&self, provider_id: &str) -> Option<&BTreeMap<String, String>> {
self.providers.get(provider_id)
}
pub(crate) fn set_hashes(&mut self, provider_id: &str, hashes: BTreeMap<String, String>) {
self.providers.insert(provider_id.to_string(), hashes);
}
pub(crate) fn get_seen_outputs(&self, provider_id: &str) -> Option<&Vec<String>> {
self.seen_outputs.get(provider_id)
}
pub(crate) fn set_seen_outputs(&mut self, provider_id: &str, outputs: Vec<String>) {
self.seen_outputs.insert(provider_id.to_string(), outputs);
}
pub(crate) fn get_command_hash(&self, provider_id: &str) -> Option<&str> {
self.command_hashes.get(provider_id).map(String::as_str)
}
pub(crate) fn set_command_hash(&mut self, provider_id: &str, hash: String) {
self.command_hashes.insert(provider_id.to_string(), hash);
}
pub(crate) fn get_output_rules(&self, provider_id: &str) -> Option<&String> {
self.output_rule_hashes.get(provider_id)
}
pub(crate) fn set_output_rules(&mut self, provider_id: &str, rules: String) {
self.output_rule_hashes
.insert(provider_id.to_string(), rules);
}
}
pub(crate) fn relative_str(path: &Path, project_root: &Path) -> String {
path.strip_prefix(project_root)
.unwrap_or(path)
.to_string_lossy()
.to_string()
}
pub(crate) fn hash_sources(
sources: &[PathBuf],
project_root: &Path,
) -> Result<BTreeMap<String, String>> {
let mut hashes = BTreeMap::new();
for source in sources {
if !source.exists() {
continue;
}
if source.is_dir() {
hash_dir_files(&mut hashes, source, project_root, 3)?;
} else {
let hash = file_hash_blake3(source, None)?;
hashes.insert(relative_str(source, project_root), hash);
}
}
Ok(hashes)
}
fn hash_dir_files(
hashes: &mut BTreeMap<String, String>,
dir: &Path,
project_root: &Path,
max_depth: usize,
) -> Result<()> {
if max_depth == 0 {
return Ok(());
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
hash_dir_files(hashes, &path, project_root, max_depth - 1)?;
} else {
let hash = file_hash_blake3(&path, None)?;
hashes.insert(relative_str(&path, project_root), hash);
}
}
}
Ok(())
}
fn state_path(project_root: &Path) -> PathBuf {
dirs::STATE
.join("deps")
.join(format!("{}.toml", hash_to_str(&project_root)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_state_without_command_hashes_is_supported() {
let state: DepsState = toml::from_str(
r#"
[providers.example]
input = "hash"
[seen_outputs]
example = ["output"]
"#,
)
.unwrap();
assert_eq!(state.get_hashes("example").unwrap()["input"], "hash");
assert_eq!(state.get_seen_outputs("example").unwrap(), &["output"]);
assert_eq!(state.get_command_hash("example"), None);
}
#[test]
fn command_hashes_round_trip() {
let mut state = DepsState::default();
state.set_command_hash("example", "digest".to_string());
let serialized = toml::to_string(&state).unwrap();
assert!(!serialized.contains("run command"));
let restored: DepsState = toml::from_str(&serialized).unwrap();
assert_eq!(restored.get_command_hash("example"), Some("digest"));
}
#[test]
fn old_state_without_output_rule_hashes_remains_compatible() {
let state: DepsState = toml::from_str(
r#"
[seen_outputs]
npm = ["node_modules"]
"#,
)
.unwrap();
assert_eq!(state.get_seen_outputs("npm").unwrap(), &["node_modules"]);
assert!(state.get_output_rules("npm").is_none());
}
}