Skip to main content

morph_cache/
lib.rs

1use anyhow::{Context, Result};
2use morph_config::{MorphLock, VersionFile};
3use sha2::{Digest, Sha256};
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7/// Global cache root: ~/.morph/
8pub fn global_cache_root() -> Result<PathBuf> {
9    let home = dirs::home_dir().context("could not find home directory")?;
10    Ok(home.join(".morph"))
11}
12
13pub fn global_runtimes_dir() -> Result<PathBuf> {
14    Ok(global_cache_root()?.join("cache").join("runtimes"))
15}
16
17pub fn global_runtime_version_dir(runtime_type: &str, version: &str) -> Result<PathBuf> {
18    Ok(global_runtimes_dir()?.join(runtime_type).join(format!("v{}", version)))
19}
20
21/// Check if a runtime version is cached globally
22pub fn is_runtime_cached(runtime_type: &str, version: &str) -> bool {
23    if let Ok(dir) = global_runtime_version_dir(runtime_type, version) {
24        dir.join("manifest.json").exists() || dir.join("include").exists()
25    } else {
26        false
27    }
28}
29
30/// Write a manifest inside cached runtime for verification
31#[derive(Debug, serde::Serialize, serde::Deserialize)]
32pub struct RuntimeManifest {
33    pub version: String,
34    pub runtime_type: String,
35    pub sha256: String,
36    pub size: u64,
37    pub cached_at: String,
38}
39
40/// Compute sha256 of a file
41pub fn sha256_file(path: &Path) -> Result<String> {
42    let bytes = std::fs::read(path)?;
43    let mut h = Sha256::new();
44    h.update(&bytes);
45    Ok(hex::encode(h.finalize()))
46}
47
48/// Compute sha256 of bytes
49pub fn sha256_bytes(bytes: &[u8]) -> String {
50    let mut h = Sha256::new();
51    h.update(bytes);
52    hex::encode(h.finalize())
53}
54
55/// Download runtime from GitHub Releases
56pub fn download_runtime(runtime_type: &str, version: &str) -> Result<PathBuf> {
57    let cached_dir = global_runtime_version_dir(runtime_type, version)?;
58
59    if is_runtime_cached(runtime_type, version) {
60        println!("  ✓ Found in cache: {}", cached_dir.display());
61        return Ok(cached_dir);
62    }
63
64    // Try local runtime/ directory first (for development)
65    let local_runtime = find_local_runtime(runtime_type);
66    if let Some(local) = local_runtime {
67        println!("  ℹ Using local runtime from {}", local.display());
68        cache_local_runtime(&local, &cached_dir, runtime_type, version)?;
69        return Ok(cached_dir);
70    }
71
72    // Download from GitHub
73    let url = format!(
74        "https://github.com/Levizr/morph/releases/download/runtime-{}-v{}/morph-runtime-{}-v{}.tar.gz",
75        runtime_type, version, runtime_type, version
76    );
77
78    println!("  Downloading {} v{} from GitHub...", runtime_type, version);
79    println!("  URL: {}", url);
80
81    let bytes = download_bytes(&url).with_context(|| format!("failed to download runtime {} v{} — check that release exists or use a local runtime/", runtime_type, version))?;
82
83    // Verify not HTML error page
84    if bytes.starts_with(b"<!DOCTYPE") || bytes.starts_with(b"<html") {
85        anyhow::bail!("download returned HTML (release not found): {}", url);
86    }
87
88    std::fs::create_dir_all(&cached_dir)?;
89    extract_tar_gz(&bytes, &cached_dir)?;
90
91    // Write manifest
92    let manifest = RuntimeManifest {
93        version: version.to_string(),
94        runtime_type: runtime_type.to_string(),
95        sha256: sha256_bytes(&bytes),
96        size: bytes.len() as u64,
97        cached_at: chrono_string(),
98    };
99    std::fs::write(
100        cached_dir.join("manifest.json"),
101        serde_json::to_string_pretty(&manifest)?,
102    )?;
103
104    println!("  ✓ Cached to {}", cached_dir.display());
105    Ok(cached_dir)
106}
107
108fn chrono_string() -> String {
109    // Simple timestamp without chrono dependency
110    std::time::SystemTime::now()
111        .duration_since(std::time::UNIX_EPOCH)
112        .map(|d| format!("{}", d.as_secs()))
113        .unwrap_or_default()
114}
115
116fn find_local_runtime(runtime_type: &str) -> Option<PathBuf> {
117    let mut candidates: Vec<PathBuf> = vec![
118        PathBuf::from(format!("runtime/{}", runtime_type)),
119        PathBuf::from(format!("../runtime/{}", runtime_type)),
120        PathBuf::from(format!("../../runtime/{}", runtime_type)),
121        PathBuf::from(format!("morph/runtime/{}", runtime_type)),
122    ];
123
124    // Check executable-relative path (for installed binary)
125    if let Ok(exe) = std::env::current_exe() {
126        if let Some(exe_dir) = exe.parent() {
127            candidates.push(exe_dir.join(format!("../runtime/{}", runtime_type)));
128            candidates.push(exe_dir.join(format!("../../runtime/{}", runtime_type)));
129            candidates.push(exe_dir.join(format!("../../../runtime/{}", runtime_type)));
130        }
131    }
132
133    // Development fallback: playground absolute path
134    candidates.push(PathBuf::from(format!("/home/piyush/My_Projects/playground/morph/runtime/{}", runtime_type)));
135    candidates.push(PathBuf::from(format!("/home/piyush/My_Projects/morph/runtime/{}", runtime_type)));
136
137    // Check ancestors of current dir (up to 4 levels)
138    if let Ok(cwd) = std::env::current_dir() {
139        let mut cur = cwd.clone();
140        for _ in 0..4 {
141            candidates.push(cur.join(format!("runtime/{}", runtime_type)));
142            if let Some(parent) = cur.parent() {
143                cur = parent.to_path_buf();
144            } else {
145                break;
146            }
147        }
148    }
149
150    for p in candidates {
151        if p.join("morph_api.h").exists() || p.join("include").exists() || p.exists() && p.is_dir() && std::fs::read_dir(&p).map(|mut d| d.next().is_some()).unwrap_or(false) {
152            return Some(p);
153        }
154    }
155    None
156}
157
158fn cache_local_runtime(src: &Path, dest: &Path, runtime_type: &str, version: &str) -> Result<()> {
159    std::fs::create_dir_all(dest)?;
160
161    // If src is a directory, copy contents
162    if src.is_dir() {
163        copy_dir_recursive(src, dest)?;
164    }
165
166    // Check if we actually copied something
167    if !dest.exists() || std::fs::read_dir(dest)?.next().is_none() {
168        // Create stub if local runtime is empty/missing
169        std::fs::write(dest.join("README.md"), format!("# Morph Runtime {} v{} (stub)\n\nLocal runtime not yet populated. This is a placeholder.\n", runtime_type, version))?;
170    }
171
172    let manifest = RuntimeManifest {
173        version: version.to_string(),
174        runtime_type: runtime_type.to_string(),
175        sha256: "local".to_string(),
176        size: 0,
177        cached_at: chrono_string(),
178    };
179    std::fs::write(dest.join("manifest.json"), serde_json::to_string_pretty(&manifest)?)?;
180    Ok(())
181}
182
183fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
184    for entry in WalkDir::new(src).min_depth(1) {
185        let entry = entry?;
186        let rel = entry.path().strip_prefix(src)?;
187        let dest_path = dest.join(rel);
188        if entry.file_type().is_dir() {
189            std::fs::create_dir_all(&dest_path)?;
190        } else {
191            if let Some(parent) = dest_path.parent() {
192                std::fs::create_dir_all(parent)?;
193            }
194            std::fs::copy(entry.path(), &dest_path)?;
195        }
196    }
197    Ok(())
198}
199
200fn download_bytes(url: &str) -> Result<Vec<u8>> {
201    let client = reqwest::blocking::Client::builder()
202        .timeout(std::time::Duration::from_secs(60))
203        .user_agent("morphc/0.1.0")
204        .build()?;
205
206    let resp = client.get(url).send()?;
207
208    if !resp.status().is_success() {
209        anyhow::bail!("HTTP {} for {}", resp.status(), url);
210    }
211
212    Ok(resp.bytes()?.to_vec())
213}
214
215fn extract_tar_gz(bytes: &[u8], dest: &Path) -> Result<()> {
216    let gz = flate2::read::GzDecoder::new(bytes);
217    let mut archive = tar::Archive::new(gz);
218    archive.unpack(dest)?;
219    Ok(())
220}
221
222/// Link or copy global cache to project .morph/runtime
223pub fn link_runtime_to_project(
224    runtime_type: &str,
225    version: &str,
226    project_morph_dir: &Path,
227) -> Result<PathBuf> {
228    let cached_dir = global_runtime_version_dir(runtime_type, version)?;
229    let project_runtime = project_morph_dir.join("runtime");
230
231    // Remove existing
232    if project_runtime.exists() {
233        if project_runtime.is_symlink() {
234            std::fs::remove_file(&project_runtime)?;
235        } else {
236            std::fs::remove_dir_all(&project_runtime)?;
237        }
238    }
239
240    // Try symlink, fallback to copy
241    #[cfg(unix)]
242    {
243        if std::os::unix::fs::symlink(&cached_dir, &project_runtime).is_ok() {
244            return Ok(project_runtime);
245        }
246    }
247
248    // Fallback: copy
249    copy_dir_recursive(&cached_dir, &project_runtime)?;
250    Ok(project_runtime)
251}
252
253/// Fetch latest version from GitHub via versions/ files or API
254pub fn fetch_latest_runtime_version(runtime_type: &str) -> Result<String> {
255    // First try local versions file
256    let local_version_file = PathBuf::from(format!("versions/runtime/{}.json", runtime_type));
257    if local_version_file.exists() {
258        let vf = VersionFile::from_file(&local_version_file)?;
259        return Ok(vf.version);
260    }
261
262    // Try GitHub API (placeholder — not yet implemented)
263    let _url = format!(
264        "https://api.github.com/repos/Levizr/morph/releases/tags/runtime-{}-v{}",
265        runtime_type, "latest"
266    );
267    // For now, fallback to reading morph-config default
268    anyhow::bail!("could not determine latest version for {}", runtime_type)
269}
270
271/// Check if project has runtime installed
272pub fn is_project_runtime_installed(project_morph_dir: &Path) -> bool {
273    let runtime = project_morph_dir.join("runtime");
274    runtime.exists() && (runtime.join("manifest.json").exists() || runtime.join("include").exists() || runtime.join("morph_api.h").exists() || std::fs::read_dir(&runtime).map(|mut d| d.next().is_some()).unwrap_or(false))
275}
276
277/// Hash all files under `dir` (relative path + content). Returns an empty
278/// string if the directory does not exist. Hidden/.git contents are skipped so
279/// unrelated files (e.g. cached build artifacts) don't force rebuilds.
280pub fn hash_tree(dir: &Path) -> String {
281    if !dir.is_dir() {
282        return String::new();
283    }
284    let mut entries: Vec<String> = Vec::new();
285    for entry in WalkDir::new(dir).follow_links(false).min_depth(1) {
286        let entry = match entry {
287            Ok(e) => e,
288            Err(_) => continue,
289        };
290        if entry.file_type().is_dir() {
291            continue;
292        }
293        let rel = match entry.path().strip_prefix(dir) {
294            Ok(r) => r.to_string_lossy().replace('\\', "/"),
295            Err(_) => continue,
296        };
297        if rel.split('/').any(|c| c == ".git" || c.starts_with('.')) {
298            continue;
299        }
300        let content = std::fs::read(entry.path()).unwrap_or_default();
301        let mut h = Sha256::new();
302        h.update(&content);
303        entries.push(format!("{}:{}", rel, hex::encode(h.finalize())));
304    }
305    entries.sort();
306    sha256_string(&entries.join("\n"))
307}
308
309/// Project build-hash dir: <cwd>/.morph/hash
310pub fn project_hash_dir(cwd: &Path) -> PathBuf {
311    cwd.join(".morph").join("hash")
312}
313
314/// Compute sha256 of a string
315pub fn sha256_string(s: &str) -> String {
316    let mut h = Sha256::new();
317    h.update(s.as_bytes());
318    hex::encode(h.finalize())
319}
320
321/// Compose a fingerprint over a set of (relative_path, content) inputs.
322/// The hashes are fed through a final digest so file additions/removals and
323/// ordering changes are reflected. `inputs` earlier in the slice must map to
324/// distinct paths; content may be empty for files that were deleted.
325pub fn fingerprint_inputs(inputs: &[(&str, &str)]) -> String {
326    let mut entries: Vec<String> = inputs
327        .iter()
328        .map(|(p, c)| format!("{}:{}\n", p, sha256_string(c)))
329        .collect();
330    entries.sort();
331    sha256_string(&entries.join(""))
332}
333
334/// Read the previously stored fingerprint for `binary_name`, if any.
335pub fn read_stored_fingerprint(cwd: &Path, binary_name: &str) -> Option<String> {
336    let dir = project_hash_dir(cwd);
337    let file = dir.join(format!("{}.fingerprint", binary_name));
338    std::fs::read_to_string(&file).ok().map(|s| s.trim().to_string())
339}
340
341/// Store the fingerprint for `binary_name` under <cwd>/.morph/hash.
342pub fn write_stored_fingerprint(cwd: &Path, binary_name: &str, fingerprint: &str) -> Result<()> {
343    let dir = project_hash_dir(cwd);
344    std::fs::create_dir_all(&dir)?;
345    std::fs::write(dir.join(format!("{}.fingerprint", binary_name)), fingerprint)?;
346    Ok(())
347}
348
349/// Create morph.lock file
350pub fn write_lock_file(
351    project_dir: &Path,
352    runtime_type: &str,
353    version: &str,
354    sha256: &str,
355) -> Result<()> {
356    let lock = MorphLock {
357        runtime: morph_config::LockRuntime {
358            runtime_type: runtime_type.to_string(),
359            version: version.to_string(),
360            sha256: sha256.to_string(),
361            downloaded_at: chrono_string(),
362        },
363        generated_by: format!("morphc {}", env!("CARGO_PKG_VERSION")),
364        generated_at: chrono_string(),
365    };
366    let path = project_dir.join("morph.lock");
367    std::fs::write(&path, serde_json::to_string_pretty(&lock)?)?;
368    Ok(())
369}
370
371