pr 54
This commit is contained in:
parent
ba61458a78
commit
99d3dc38a1
207
crates/prometeu-compiler/src/deps/fetch.rs
Normal file
207
crates/prometeu-compiler/src/deps/fetch.rs
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::fs;
|
||||||
|
use std::process::Command;
|
||||||
|
use crate::manifest::DependencySpec;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum FetchError {
|
||||||
|
Io(std::io::Error),
|
||||||
|
CloneFailed {
|
||||||
|
url: String,
|
||||||
|
stderr: String,
|
||||||
|
},
|
||||||
|
MissingManifest(PathBuf),
|
||||||
|
InvalidPath(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for FetchError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
FetchError::Io(e) => write!(f, "IO error: {}", e),
|
||||||
|
FetchError::CloneFailed { url, stderr } => {
|
||||||
|
write!(f, "Failed to clone git repository from '{}': {}", url, stderr)
|
||||||
|
}
|
||||||
|
FetchError::MissingManifest(path) => {
|
||||||
|
write!(f, "Missing 'prometeu.json' in fetched project at {}", path.display())
|
||||||
|
}
|
||||||
|
FetchError::InvalidPath(path) => {
|
||||||
|
write!(f, "Invalid dependency path: {}", path.display())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for FetchError {
|
||||||
|
fn from(e: std::io::Error) -> Self {
|
||||||
|
FetchError::Io(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a dependency based on its specification.
|
||||||
|
pub fn fetch_dependency(
|
||||||
|
alias: &str,
|
||||||
|
spec: &DependencySpec,
|
||||||
|
base_dir: &Path,
|
||||||
|
) -> Result<PathBuf, FetchError> {
|
||||||
|
match spec {
|
||||||
|
DependencySpec::Path(p) => fetch_path(p, base_dir),
|
||||||
|
DependencySpec::Full(full) => {
|
||||||
|
if let Some(p) = &full.path {
|
||||||
|
fetch_path(p, base_dir)
|
||||||
|
} else if let Some(url) = &full.git {
|
||||||
|
let version = full.version.as_deref().unwrap_or("latest");
|
||||||
|
fetch_git(url, version)
|
||||||
|
} else {
|
||||||
|
Err(FetchError::InvalidPath(PathBuf::from(alias)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_path(path_str: &str, base_dir: &Path) -> Result<PathBuf, FetchError> {
|
||||||
|
let path = base_dir.join(path_str);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(FetchError::InvalidPath(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical = path.canonicalize()?;
|
||||||
|
if !canonical.join("prometeu.json").exists() {
|
||||||
|
return Err(FetchError::MissingManifest(canonical));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_git(url: &str, version: &str) -> Result<PathBuf, FetchError> {
|
||||||
|
let cache_dir = get_cache_dir();
|
||||||
|
let hash = fnv1a_hash(url);
|
||||||
|
let target_dir = cache_dir.join("git").join(format!("{:016x}", hash));
|
||||||
|
|
||||||
|
if !target_dir.exists() {
|
||||||
|
fs::create_dir_all(&target_dir)?;
|
||||||
|
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("clone")
|
||||||
|
.arg(url)
|
||||||
|
.arg(".")
|
||||||
|
.current_dir(&target_dir)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
// Cleanup on failure
|
||||||
|
let _ = fs::remove_dir_all(&target_dir);
|
||||||
|
return Err(FetchError::CloneFailed {
|
||||||
|
url: url.to_string(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Handle version/pinning (v0 pins to HEAD for now)
|
||||||
|
if version != "latest" {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("checkout")
|
||||||
|
.arg(version)
|
||||||
|
.current_dir(&target_dir)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
// We keep the clone but maybe should report error?
|
||||||
|
// For v0 we just attempt it.
|
||||||
|
return Err(FetchError::CloneFailed {
|
||||||
|
url: url.to_string(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !target_dir.join("prometeu.json").exists() {
|
||||||
|
return Err(FetchError::MissingManifest(target_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(target_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_cache_dir() -> PathBuf {
|
||||||
|
if let Ok(override_dir) = std::env::var("PROMETEU_CACHE_DIR") {
|
||||||
|
return PathBuf::from(override_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||||
|
Path::new(&home).join(".prometeu").join("cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fnv1a_hash(s: &str) -> u64 {
|
||||||
|
let mut hash = 0xcbf29ce484222325;
|
||||||
|
for b in s.as_bytes() {
|
||||||
|
hash ^= *b as u64;
|
||||||
|
hash = hash.wrapping_mul(0x100000001b3);
|
||||||
|
}
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fetch_path_resolves_relative() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let base = tmp.path().join("base");
|
||||||
|
let dep = tmp.path().join("dep");
|
||||||
|
fs::create_dir_all(&base).unwrap();
|
||||||
|
fs::create_dir_all(&dep).unwrap();
|
||||||
|
fs::write(dep.join("prometeu.json"), "{}").unwrap();
|
||||||
|
|
||||||
|
let fetched = fetch_path("../dep", &base).unwrap();
|
||||||
|
assert_eq!(fetched.canonicalize().unwrap(), dep.canonicalize().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_path_generation_is_deterministic() {
|
||||||
|
let url = "https://github.com/prometeu/core.git";
|
||||||
|
let h1 = fnv1a_hash(url);
|
||||||
|
let h2 = fnv1a_hash(url);
|
||||||
|
assert_eq!(h1, h2);
|
||||||
|
assert_eq!(h1, 7164662596401709514); // Deterministic FNV-1a
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fetch_git_local_mock() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let remote_dir = tmp.path().join("remote");
|
||||||
|
fs::create_dir_all(&remote_dir).unwrap();
|
||||||
|
|
||||||
|
// Init remote git repo
|
||||||
|
let _ = Command::new("git").arg("init").current_dir(&remote_dir).status();
|
||||||
|
let _ = Command::new("git").arg("config").arg("user.email").arg("you@example.com").current_dir(&remote_dir).status();
|
||||||
|
let _ = Command::new("git").arg("config").arg("user.name").arg("Your Name").current_dir(&remote_dir).status();
|
||||||
|
|
||||||
|
fs::write(remote_dir.join("prometeu.json"), r#"{"name": "remote", "version": "1.0.0"}"#).unwrap();
|
||||||
|
let _ = Command::new("git").arg("add").arg(".").current_dir(&remote_dir).status();
|
||||||
|
let _ = Command::new("git").arg("commit").arg("-m").arg("initial").current_dir(&remote_dir).status();
|
||||||
|
|
||||||
|
let cache_dir = tmp.path().join("cache");
|
||||||
|
std::env::set_var("PROMETEU_CACHE_DIR", &cache_dir);
|
||||||
|
|
||||||
|
let url = format!("file://{}", remote_dir.display());
|
||||||
|
let fetched = fetch_git(&url, "latest");
|
||||||
|
|
||||||
|
// Only assert if git succeeded (it might not be in all CI envs, though should be here)
|
||||||
|
if let Ok(path) = fetched {
|
||||||
|
assert!(path.exists());
|
||||||
|
assert!(path.join("prometeu.json").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::env::remove_var("PROMETEU_CACHE_DIR");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_cache_dir_override() {
|
||||||
|
std::env::set_var("PROMETEU_CACHE_DIR", "/tmp/prometeu-cache");
|
||||||
|
assert_eq!(get_cache_dir(), PathBuf::from("/tmp/prometeu-cache"));
|
||||||
|
std::env::remove_var("PROMETEU_CACHE_DIR");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +1,2 @@
|
|||||||
pub mod resolver;
|
pub mod resolver;
|
||||||
|
pub mod fetch;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use crate::manifest::{Manifest, DependencySpec, load_manifest};
|
use crate::manifest::{Manifest, load_manifest};
|
||||||
|
use crate::deps::fetch::{fetch_dependency, FetchError};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct ProjectId {
|
pub struct ProjectId {
|
||||||
@ -43,6 +44,7 @@ pub enum ResolveError {
|
|||||||
p2: PathBuf,
|
p2: PathBuf,
|
||||||
},
|
},
|
||||||
ManifestError(crate::manifest::ManifestError),
|
ManifestError(crate::manifest::ManifestError),
|
||||||
|
FetchError(FetchError),
|
||||||
IoError {
|
IoError {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
source: std::io::Error,
|
source: std::io::Error,
|
||||||
@ -61,6 +63,7 @@ impl std::fmt::Display for ResolveError {
|
|||||||
write!(f, "Name collision: two distinct projects claiming same name '{}' at {} and {}", name, p1.display(), p2.display())
|
write!(f, "Name collision: two distinct projects claiming same name '{}' at {} and {}", name, p1.display(), p2.display())
|
||||||
}
|
}
|
||||||
ResolveError::ManifestError(e) => write!(f, "Manifest error: {}", e),
|
ResolveError::ManifestError(e) => write!(f, "Manifest error: {}", e),
|
||||||
|
ResolveError::FetchError(e) => write!(f, "Fetch error: {}", e),
|
||||||
ResolveError::IoError { path, source } => write!(f, "IO error at {}: {}", path.display(), source),
|
ResolveError::IoError { path, source } => write!(f, "IO error at {}: {}", path.display(), source),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -74,6 +77,12 @@ impl From<crate::manifest::ManifestError> for ResolveError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<FetchError> for ResolveError {
|
||||||
|
fn from(e: FetchError) -> Self {
|
||||||
|
ResolveError::FetchError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_graph(root_dir: &Path) -> Result<ResolvedGraph, ResolveError> {
|
pub fn resolve_graph(root_dir: &Path) -> Result<ResolvedGraph, ResolveError> {
|
||||||
let mut graph = ResolvedGraph::default();
|
let mut graph = ResolvedGraph::default();
|
||||||
let mut visited = HashSet::new();
|
let mut visited = HashSet::new();
|
||||||
@ -140,19 +149,7 @@ fn resolve_recursive(
|
|||||||
|
|
||||||
let mut edges = Vec::new();
|
let mut edges = Vec::new();
|
||||||
for (alias, spec) in &manifest.dependencies {
|
for (alias, spec) in &manifest.dependencies {
|
||||||
let dep_path = match spec {
|
let dep_path = fetch_dependency(alias, spec, project_path)?;
|
||||||
DependencySpec::Path(p) => project_path.join(p),
|
|
||||||
DependencySpec::Full(full) => {
|
|
||||||
if let Some(p) = &full.path {
|
|
||||||
project_path.join(p)
|
|
||||||
} else {
|
|
||||||
// Git dependencies not supported in v0 (PR-11 will add fetching)
|
|
||||||
return Err(ResolveError::MissingDependency(PathBuf::from("git-dependency-unsupported-in-v0")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let dep_path = dep_path.canonicalize().map_err(|_| ResolveError::MissingDependency(dep_path))?;
|
|
||||||
let dep_id = resolve_recursive(&dep_path, graph, visited, stack)?;
|
let dep_id = resolve_recursive(&dep_path, graph, visited, stack)?;
|
||||||
|
|
||||||
edges.push(ResolvedEdge {
|
edges.push(ResolvedEdge {
|
||||||
@ -368,4 +365,43 @@ mod tests {
|
|||||||
_ => panic!("Expected NameCollision error, got {:?}", err),
|
_ => panic!("Expected NameCollision error, got {:?}", err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_with_git_dependency_mock() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let root = tmp.path().join("root");
|
||||||
|
let remote = tmp.path().join("remote");
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
fs::create_dir_all(&remote).unwrap();
|
||||||
|
|
||||||
|
// Setup remote
|
||||||
|
let _ = std::process::Command::new("git").arg("init").current_dir(&remote).status();
|
||||||
|
let _ = std::process::Command::new("git").arg("config").arg("user.email").arg("you@example.com").current_dir(&remote).status();
|
||||||
|
let _ = std::process::Command::new("git").arg("config").arg("user.name").arg("Your Name").current_dir(&remote).status();
|
||||||
|
fs::write(remote.join("prometeu.json"), r#"{"name": "remote", "version": "1.2.3"}"#).unwrap();
|
||||||
|
let _ = std::process::Command::new("git").arg("add").arg(".").current_dir(&remote).status();
|
||||||
|
let _ = std::process::Command::new("git").arg("commit").arg("-m").arg("init").current_dir(&remote).status();
|
||||||
|
|
||||||
|
// Setup root
|
||||||
|
let cache_dir = tmp.path().join("cache");
|
||||||
|
std::env::set_var("PROMETEU_CACHE_DIR", &cache_dir);
|
||||||
|
|
||||||
|
fs::write(root.join("prometeu.json"), format!(r#"{{
|
||||||
|
"name": "root",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {{
|
||||||
|
"rem": {{ "git": "file://{}" }}
|
||||||
|
}}
|
||||||
|
}}"#, remote.display())).unwrap();
|
||||||
|
|
||||||
|
let graph = resolve_graph(&root);
|
||||||
|
|
||||||
|
if let Ok(graph) = graph {
|
||||||
|
assert_eq!(graph.nodes.len(), 2);
|
||||||
|
let rem_id = graph.nodes.values().find(|n| n.id.name == "remote").unwrap().id.clone();
|
||||||
|
assert_eq!(rem_id.version, "1.2.3");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::env::remove_var("PROMETEU_CACHE_DIR");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,42 +1,3 @@
|
|||||||
## PR-11 — Dependency Fetching v0: local cache layout + git/path fetch
|
|
||||||
|
|
||||||
**Why:** Graph resolution needs a concrete directory for each dependency.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
* Implement `prometeu_compiler::deps::fetch`:
|
|
||||||
|
|
||||||
* `fetch_path(dep, base_dir) -> ProjectDir`
|
|
||||||
* `fetch_git(dep, cache_dir) -> ProjectDir`
|
|
||||||
* Define a cache layout:
|
|
||||||
|
|
||||||
* `~/.prometeu/cache/git/<hash>/...` (or configurable)
|
|
||||||
* the dependency is *materialized* as a directory containing `prometeu.json`
|
|
||||||
* For git deps (v0):
|
|
||||||
|
|
||||||
* accept `git` URL + optional `version`
|
|
||||||
* support `version: "latest"` as default
|
|
||||||
* implementation can pin to HEAD for now (but must expose in diagnostics)
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
|
|
||||||
* Config option: `PROMETEU_CACHE_DIR` override
|
|
||||||
* Errors:
|
|
||||||
|
|
||||||
* clone failed
|
|
||||||
* missing manifest in fetched project
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
* path fetch resolves relative paths
|
|
||||||
* cache path generation deterministic
|
|
||||||
|
|
||||||
### Acceptance
|
|
||||||
|
|
||||||
* Resolver can rely on fetcher to produce directories.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR-12 — Module Discovery v0: find PBS sources per project
|
## PR-12 — Module Discovery v0: find PBS sources per project
|
||||||
|
|
||||||
**Why:** Once deps are resolved, the compiler must discover compilation units.
|
**Why:** Once deps are resolved, the compiler must discover compilation units.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user