Skip to main content

dlin_core/parser/
manifest_cache.rs

1use std::path::{Path, PathBuf};
2use std::time::SystemTime;
3
4use serde::{Deserialize, Serialize};
5
6use crate::graph::types::LineageGraph;
7
8const CACHE_DIR: &str = ".dlin_cache";
9const CACHE_FILENAME: &str = "manifest_graph_cache.json";
10
11#[derive(Debug, Serialize, Deserialize)]
12struct ManifestCacheFile {
13    #[serde(default)]
14    version: String,
15    entry: Option<ManifestCacheEntry>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19struct ManifestCacheEntry {
20    #[serde(default)]
21    manifest_identity: String,
22    mtime_secs: u64,
23    #[serde(default)]
24    mtime_nanos: u32,
25    file_size: u64,
26    #[serde(default)]
27    content_hash: u64,
28    graph: LineageGraph,
29}
30
31pub struct ManifestGraphCache {
32    version: String,
33    entry: Option<ManifestCacheEntry>,
34    cache_path: Option<PathBuf>,
35    dirty: bool,
36}
37
38impl ManifestGraphCache {
39    pub fn disabled() -> Self {
40        Self {
41            version: String::new(),
42            entry: None,
43            cache_path: None,
44            dirty: false,
45        }
46    }
47
48    pub fn load(project_dir: &Path, cache_dir: Option<&Path>) -> Self {
49        let cache_path = match cache_dir {
50            Some(dir) => dir.join(CACHE_FILENAME),
51            None => project_dir.join(CACHE_DIR).join(CACHE_FILENAME),
52        };
53        let version = env!("CARGO_PKG_VERSION").to_string();
54        let entry = std::fs::read_to_string(&cache_path)
55            .ok()
56            .and_then(|content| serde_json::from_str::<ManifestCacheFile>(&content).ok())
57            .filter(|cf| cf.version == version)
58            .and_then(|cf| cf.entry);
59
60        Self {
61            version,
62            entry,
63            cache_path: Some(cache_path),
64            dirty: false,
65        }
66    }
67
68    pub fn fresh(project_dir: &Path, cache_dir: Option<&Path>) -> Self {
69        let cache_path = match cache_dir {
70            Some(dir) => dir.join(CACHE_FILENAME),
71            None => project_dir.join(CACHE_DIR).join(CACHE_FILENAME),
72        };
73        Self {
74            version: env!("CARGO_PKG_VERSION").to_string(),
75            entry: None,
76            cache_path: Some(cache_path),
77            dirty: false,
78        }
79    }
80
81    pub fn get(&self, manifest_path: &Path) -> Option<&LineageGraph> {
82        let entry = self.entry.as_ref()?;
83        let stat = file_stat(manifest_path)?;
84        let identity = manifest_identity(manifest_path);
85        if entry.manifest_identity == identity
86            && entry.mtime_secs == stat.mtime_secs
87            && entry.mtime_nanos == stat.mtime_nanos
88            && entry.file_size == stat.file_size
89            && entry.content_hash == stat.content_hash
90        {
91            Some(&entry.graph)
92        } else {
93            None
94        }
95    }
96
97    pub fn insert_if_fingerprint_matches(
98        &mut self,
99        manifest_path: &Path,
100        graph: &LineageGraph,
101        expected: (u64, u32, u64, u64),
102    ) -> bool {
103        let Some(stat) = file_stat(manifest_path) else {
104            return false;
105        };
106        if (
107            stat.mtime_secs,
108            stat.mtime_nanos,
109            stat.file_size,
110            stat.content_hash,
111        ) != expected
112        {
113            return false;
114        }
115        self.entry = Some(ManifestCacheEntry {
116            manifest_identity: manifest_identity(manifest_path),
117            mtime_secs: stat.mtime_secs,
118            mtime_nanos: stat.mtime_nanos,
119            file_size: stat.file_size,
120            content_hash: stat.content_hash,
121            graph: graph.clone(),
122        });
123        self.dirty = true;
124        true
125    }
126
127    pub fn save(&self) {
128        let cache_path = match (&self.cache_path, self.dirty) {
129            (Some(p), true) => p,
130            _ => return,
131        };
132        let cf = ManifestCacheFile {
133            version: self.version.clone(),
134            entry: self.entry.clone(),
135        };
136        if let Some(parent) = cache_path.parent() {
137            if std::fs::create_dir_all(parent).is_err() {
138                crate::warn!("could not create cache directory: {}", parent.display());
139                return;
140            }
141            let gitignore = parent.join(".gitignore");
142            if !gitignore.exists()
143                && let Err(e) = std::fs::write(&gitignore, "# Automatically created by dlin\n*\n")
144            {
145                crate::warn!("could not create {}: {}", gitignore.display(), e);
146            }
147        }
148        match serde_json::to_string(&cf) {
149            Ok(json) => {
150                if let Err(e) = std::fs::write(cache_path, json) {
151                    crate::warn!("could not write cache file {}: {}", cache_path.display(), e);
152                }
153            }
154            Err(e) => {
155                crate::warn!("could not serialize manifest graph cache: {}", e);
156            }
157        }
158    }
159}
160
161struct FileStat {
162    mtime_secs: u64,
163    mtime_nanos: u32,
164    file_size: u64,
165    content_hash: u64,
166}
167
168fn file_stat(path: &Path) -> Option<FileStat> {
169    let meta = std::fs::metadata(path).ok()?;
170    let content = std::fs::read(path).ok()?;
171    let mtime_secs = meta
172        .modified()
173        .ok()?
174        .duration_since(SystemTime::UNIX_EPOCH)
175        .ok()?
176        .as_secs();
177    let mtime_nanos = meta
178        .modified()
179        .ok()?
180        .duration_since(SystemTime::UNIX_EPOCH)
181        .ok()?
182        .subsec_nanos();
183    Some(FileStat {
184        mtime_secs,
185        mtime_nanos,
186        file_size: meta.len(),
187        content_hash: hash_bytes(&content),
188    })
189}
190
191fn manifest_identity(path: &Path) -> String {
192    path.canonicalize()
193        .unwrap_or_else(|_| path.to_path_buf())
194        .to_string_lossy()
195        .to_string()
196}
197
198fn hash_bytes(bytes: &[u8]) -> u64 {
199    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
200    const FNV_PRIME: u64 = 0x100000001b3;
201    let mut hash = FNV_OFFSET_BASIS;
202    for &b in bytes {
203        hash ^= b as u64;
204        hash = hash.wrapping_mul(FNV_PRIME);
205    }
206    hash
207}