use super::functions::*;
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[derive(Clone, Debug)]
pub struct BuildStages {
pub stages: Vec<Vec<String>>,
}
impl BuildStages {
pub fn from_graph(graph: &ModuleGraph) -> Result<Self, ProjectError> {
let mut remaining: HashSet<String> = graph.nodes.iter().cloned().collect();
let mut stages = Vec::new();
while !remaining.is_empty() {
let mut stage = Vec::new();
for node in &remaining {
let deps = graph.dependencies_of(node);
if deps.iter().all(|d| !remaining.contains(d)) {
stage.push(node.clone());
}
}
if stage.is_empty() {
let cycle_node = remaining
.iter()
.next()
.expect("remaining is non-empty: stage is empty means cycle")
.clone();
return Err(ProjectError::CyclicDependency(vec![cycle_node]));
}
stage.sort();
for node in &stage {
remaining.remove(node);
}
stages.push(stage);
}
Ok(Self { stages })
}
pub fn max_parallelism(&self) -> usize {
self.stages.iter().map(|s| s.len()).max().unwrap_or(1)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LockEntry {
pub name: String,
pub version: String,
pub source: String,
pub checksum: Option<String>,
}
#[derive(Clone, Debug)]
pub enum ProjectError {
ParseError {
line: usize,
message: String,
},
InvalidConfig(String),
NotFound(String),
IoError(String),
CyclicDependency(Vec<String>),
DependencyNotFound(String),
VersionNotFound {
name: String,
version: String,
},
BuildFailed(String),
}
#[derive(Clone, Debug)]
pub struct ModuleInfo {
pub name: String,
pub path: PathBuf,
pub dependencies: Vec<String>,
pub is_stale: bool,
pub last_modified: Option<SystemTime>,
}
#[derive(Clone, Debug)]
pub struct DiscoveryOptions {
pub extensions: Vec<String>,
pub exclude_dirs: HashSet<String>,
pub auto_namespace: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VersionConstraint {
Any,
Exact(String),
AtLeast(String),
Compatible(String),
}
impl VersionConstraint {
pub fn parse(s: &str) -> Self {
let s = s.trim();
if s == "*" || s == "latest" {
return VersionConstraint::Any;
}
if let Some(rest) = s.strip_prefix(">=") {
return VersionConstraint::AtLeast(rest.trim().to_string());
}
if let Some(rest) = s.strip_prefix('~') {
return VersionConstraint::Compatible(rest.trim().to_string());
}
VersionConstraint::Exact(s.to_string())
}
pub fn matches(&self, version: &str) -> bool {
match self {
VersionConstraint::Any => true,
VersionConstraint::Exact(v) => v == version,
VersionConstraint::AtLeast(min) => compare_versions(version, min) >= 0,
VersionConstraint::Compatible(base) => {
if !version.starts_with(&base[0..base.rfind('.').unwrap_or(0)]) {
return false;
}
compare_versions(version, base) >= 0
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DependencySource {
Path(PathBuf),
Git {
url: String,
rev: Option<String>,
},
Registry {
registry: String,
},
}
#[derive(Clone, Debug)]
pub struct ProjectConfig {
pub name: String,
pub version: String,
pub authors: Vec<String>,
pub description: String,
pub dependencies: Vec<Dependency>,
pub source_dirs: Vec<PathBuf>,
pub output_dir: PathBuf,
pub lean_version: String,
pub extra_args: Vec<String>,
}
impl ProjectConfig {
pub fn default_for(name: &str) -> Self {
Self {
name: name.to_string(),
version: "0.1.1".to_string(),
authors: Vec::new(),
description: String::new(),
dependencies: Vec::new(),
source_dirs: vec![PathBuf::from("src")],
output_dir: PathBuf::from("build"),
lean_version: "0.1.1".to_string(),
extra_args: Vec::new(),
}
}
pub fn load(content: &str) -> Result<Self, ProjectError> {
let mut config = ProjectConfig::default_for("unnamed");
let mut current_section = String::new();
let mut current_dep: Option<PartialDependency> = None;
for (line_no, raw_line) in content.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('[') {
if let Some(dep) = current_dep.take() {
config.dependencies.push(dep.finish(line_no)?);
}
let section = line.trim_matches(|c| c == '[' || c == ']').trim();
current_section = section.to_string();
if current_section == "dependencies" {
current_dep = Some(PartialDependency::default());
}
continue;
}
let (key, value) = parse_kv(line).ok_or_else(|| ProjectError::ParseError {
line: line_no + 1,
message: format!("expected key = value, got: {}", line),
})?;
match current_section.as_str() {
"package" | "" => match key.as_str() {
"name" => config.name = value,
"version" => config.version = value,
"description" => config.description = value,
"lean_version" | "oxilean_version" => config.lean_version = value,
"authors" => {
config.authors = parse_string_array(&value);
}
"source_dirs" => {
config.source_dirs = parse_string_array(&value)
.into_iter()
.map(PathBuf::from)
.collect();
}
"output_dir" => config.output_dir = PathBuf::from(value),
"extra_args" => {
config.extra_args = parse_string_array(&value);
}
_ => {
return Err(ProjectError::ParseError {
line: line_no + 1,
message: format!("unknown key '{}' in [package]", key),
});
}
},
"dependencies" => {
let dep = current_dep.get_or_insert_with(PartialDependency::default);
match key.as_str() {
"name" => dep.name = Some(value),
"version" => dep.version = Some(value),
"path" => dep.source_path = Some(PathBuf::from(value)),
"git" => dep.git_url = Some(value),
"rev" => dep.git_rev = Some(value),
"registry" => dep.registry = Some(value),
_ => {
return Err(ProjectError::ParseError {
line: line_no + 1,
message: format!("unknown key '{}' in [dependencies]", key),
});
}
}
}
other => {
return Err(ProjectError::ParseError {
line: line_no + 1,
message: format!("unknown section [{}]", other),
});
}
}
}
if let Some(dep) = current_dep.take() {
if dep.name.is_some() {
let line_no = content.lines().count();
config.dependencies.push(dep.finish(line_no)?);
}
}
Ok(config)
}
pub fn save(&self) -> String {
let mut out = String::new();
out.push_str("[package]\n");
push_kv(&mut out, "name", &self.name);
push_kv(&mut out, "version", &self.version);
if !self.description.is_empty() {
push_kv(&mut out, "description", &self.description);
}
if !self.authors.is_empty() {
out.push_str(&format!(
"authors = [{}]\n",
self.authors
.iter()
.map(|a| format!("\"{}\"", a))
.collect::<Vec<_>>()
.join(", ")
));
}
if self.source_dirs != vec![PathBuf::from("src")] {
out.push_str(&format!(
"source_dirs = [{}]\n",
self.source_dirs
.iter()
.map(|p| format!("\"{}\"", p.display()))
.collect::<Vec<_>>()
.join(", ")
));
}
if self.output_dir != Path::new("build") {
push_kv(
&mut out,
"output_dir",
&self.output_dir.display().to_string(),
);
}
push_kv(&mut out, "lean_version", &self.lean_version);
if !self.extra_args.is_empty() {
out.push_str(&format!(
"extra_args = [{}]\n",
self.extra_args
.iter()
.map(|a| format!("\"{}\"", a))
.collect::<Vec<_>>()
.join(", ")
));
}
for dep in &self.dependencies {
out.push('\n');
out.push_str("[[dependencies]]\n");
push_kv(&mut out, "name", &dep.name);
push_kv(&mut out, "version", &dep.version);
match &dep.source {
DependencySource::Path(p) => {
push_kv(&mut out, "path", &p.display().to_string());
}
DependencySource::Git { url, rev } => {
push_kv(&mut out, "git", url);
if let Some(r) = rev {
push_kv(&mut out, "rev", r);
}
}
DependencySource::Registry { registry } => {
push_kv(&mut out, "registry", registry);
}
}
}
out
}
pub fn validate(&self) -> Result<(), ProjectError> {
if self.name.is_empty() {
return Err(ProjectError::InvalidConfig("project name is empty".into()));
}
if !is_valid_semver(&self.version) {
return Err(ProjectError::InvalidConfig(format!(
"invalid version: {}",
self.version
)));
}
if self.source_dirs.is_empty() {
return Err(ProjectError::InvalidConfig(
"at least one source directory is required".into(),
));
}
let mut dep_names = HashSet::new();
for dep in &self.dependencies {
if !dep_names.insert(&dep.name) {
return Err(ProjectError::InvalidConfig(format!(
"duplicate dependency: {}",
dep.name
)));
}
}
Ok(())
}
}
#[derive(Default)]
struct PartialDependency {
name: Option<String>,
version: Option<String>,
source_path: Option<PathBuf>,
git_url: Option<String>,
git_rev: Option<String>,
registry: Option<String>,
}
impl PartialDependency {
fn finish(self, line: usize) -> Result<Dependency, ProjectError> {
let name = self.name.ok_or_else(|| ProjectError::ParseError {
line,
message: "dependency missing 'name'".into(),
})?;
let version = self.version.unwrap_or_else(|| "*".to_string());
let source = if let Some(p) = self.source_path {
DependencySource::Path(p)
} else if let Some(url) = self.git_url {
DependencySource::Git {
url,
rev: self.git_rev,
}
} else if let Some(registry) = self.registry {
DependencySource::Registry { registry }
} else {
DependencySource::Registry {
registry: "https://packages.oxilean.dev".to_string(),
}
};
Ok(Dependency {
name,
version,
source,
})
}
}
#[derive(Clone, Debug)]
pub struct Project {
pub root: PathBuf,
pub config: ProjectConfig,
pub modules: Vec<ModuleInfo>,
pub build_graph: ModuleGraph,
}
impl Project {
pub fn discover(path: &Path) -> Result<Self, ProjectError> {
let config_path = find_project_file(path)?;
let root = config_path.parent().unwrap_or(path).to_path_buf();
let content = std::fs::read_to_string(&config_path).map_err(|e| {
ProjectError::IoError(format!("failed to read {}: {}", config_path.display(), e))
})?;
let config = ProjectConfig::load(&content)?;
Ok(Self {
root,
config,
modules: Vec::new(),
build_graph: ModuleGraph::new(),
})
}
pub fn from_config(root: PathBuf, config: ProjectConfig) -> Self {
Self {
root,
config,
modules: Vec::new(),
build_graph: ModuleGraph::new(),
}
}
pub fn find_modules(&mut self) -> Result<(), ProjectError> {
self.modules.clear();
for src_dir in &self.config.source_dirs {
let abs_dir = self.root.join(src_dir);
collect_modules(&abs_dir, &abs_dir, &mut self.modules)?;
}
Ok(())
}
pub fn build_dependency_graph(&mut self) {
self.build_graph = build_module_graph(&self.modules);
}
pub fn initialize(&mut self) -> Result<(), ProjectError> {
self.find_modules()?;
self.build_dependency_graph();
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct BuildPlan {
pub modules_to_build: Vec<String>,
pub order: Vec<String>,
pub parallelism: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Dependency {
pub name: String,
pub version: String,
pub source: DependencySource,
}
impl Dependency {
pub fn path(name: &str, version: &str, path: PathBuf) -> Self {
Self {
name: name.to_string(),
version: version.to_string(),
source: DependencySource::Path(path),
}
}
pub fn git(name: &str, version: &str, url: &str, rev: Option<&str>) -> Self {
Self {
name: name.to_string(),
version: version.to_string(),
source: DependencySource::Git {
url: url.to_string(),
rev: rev.map(String::from),
},
}
}
pub fn registry(name: &str, version: &str, registry: &str) -> Self {
Self {
name: name.to_string(),
version: version.to_string(),
source: DependencySource::Registry {
registry: registry.to_string(),
},
}
}
}
#[derive(Clone, Debug)]
pub struct ResolvedDep {
pub name: String,
pub version: String,
pub local_path: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BuildResult {
Success,
Failure(String),
Cached,
}
#[derive(Clone, Debug)]
pub struct BuildReport {
pub total: usize,
pub succeeded: usize,
pub failed: usize,
pub cached: usize,
pub elapsed: Duration,
pub results: HashMap<String, BuildResult>,
}
impl BuildReport {
pub fn new() -> Self {
Self {
total: 0,
succeeded: 0,
failed: 0,
cached: 0,
elapsed: Duration::ZERO,
results: HashMap::new(),
}
}
pub fn is_success(&self) -> bool {
self.failed == 0
}
pub fn summary(&self) -> String {
format!(
"Build: {} total, {} succeeded, {} cached, {} failed ({:.2}s)",
self.total,
self.succeeded,
self.cached,
self.failed,
self.elapsed.as_secs_f64()
)
}
}
#[derive(Clone, Debug, Default)]
pub struct LockFile {
pub entries: Vec<LockEntry>,
}
impl LockFile {
pub fn new() -> Self {
Self::default()
}
pub fn from_resolved(deps: &[ResolvedDep]) -> Self {
let entries = deps
.iter()
.map(|d| LockEntry {
name: d.name.clone(),
version: d.version.clone(),
source: d.local_path.display().to_string(),
checksum: None,
})
.collect();
Self { entries }
}
pub fn serialize(&self) -> String {
let mut out = String::from("# OxiLean lock file — do not edit manually\n\n");
for entry in &self.entries {
out.push_str("[[lock]]\n");
push_kv(&mut out, "name", &entry.name);
push_kv(&mut out, "version", &entry.version);
push_kv(&mut out, "source", &entry.source);
if let Some(cs) = &entry.checksum {
push_kv(&mut out, "checksum", cs);
}
out.push('\n');
}
out
}
pub fn deserialize(content: &str) -> Result<Self, ProjectError> {
let mut entries = Vec::new();
let mut current: Option<LockEntry> = None;
for (line_no, raw_line) in content.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line == "[[lock]]" {
if let Some(entry) = current.take() {
entries.push(entry);
}
current = Some(LockEntry {
name: String::new(),
version: String::new(),
source: String::new(),
checksum: None,
});
continue;
}
if let Some((key, value)) = parse_kv(line) {
let entry = current.as_mut().ok_or_else(|| ProjectError::ParseError {
line: line_no + 1,
message: "key=value outside [[lock]] section".into(),
})?;
match key.as_str() {
"name" => entry.name = value,
"version" => entry.version = value,
"source" => entry.source = value,
"checksum" => entry.checksum = Some(value),
_ => {}
}
}
}
if let Some(entry) = current {
entries.push(entry);
}
Ok(Self { entries })
}
pub fn is_locked(&self, name: &str, version: &str) -> bool {
self.entries
.iter()
.any(|e| e.name == name && e.version == version)
}
}
#[derive(Clone, Debug, Default)]
pub struct PackageRegistry {
pub packages: HashMap<String, PackageEntry>,
}
impl PackageRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, entry: PackageEntry) {
self.packages.insert(entry.name.clone(), entry);
}
pub fn find(&self, name: &str) -> Option<&PackageEntry> {
self.packages.get(name)
}
pub fn version_matches(constraint: &str, version: &str) -> bool {
if constraint == "*" {
return true;
}
constraint == version
}
}
#[derive(Clone, Debug)]
pub struct ProjectInitOptions {
pub name: String,
pub version: String,
pub authors: Vec<String>,
pub description: Option<String>,
pub with_git: bool,
pub with_examples: bool,
}
impl ProjectInitOptions {
pub fn new(name: String) -> Self {
Self {
name,
version: "0.1.1".to_string(),
authors: Vec::new(),
description: None,
with_git: true,
with_examples: false,
}
}
}
#[derive(Clone, Debug)]
pub struct DependencySolver {
pub registry: PackageRegistry,
pub resolved: HashMap<String, ResolvedDep>,
pub conflicts: Vec<(String, String, String)>,
}
impl DependencySolver {
pub fn new(registry: PackageRegistry) -> Self {
Self {
registry,
resolved: HashMap::new(),
conflicts: Vec::new(),
}
}
pub fn resolve_all(&mut self, deps: &[Dependency]) -> Result<Vec<ResolvedDep>, ProjectError> {
let mut work_queue = Vec::new();
for dep in deps {
work_queue.push(dep.clone());
}
while let Some(dep) = work_queue.pop() {
if self.resolved.contains_key(&dep.name) {
continue;
}
let resolved_dep = self.resolve_one(&dep)?;
if let DependencySource::Registry { .. } = &dep.source {
if let Some(pkg) = self.registry.find(&dep.name) {
for transitive in &pkg.deps {
if !self.resolved.contains_key(&transitive.name) {
work_queue.push(transitive.clone());
}
}
}
}
self.resolved.insert(dep.name.clone(), resolved_dep);
}
Ok(self.resolved.values().cloned().collect())
}
fn resolve_one(&self, dep: &Dependency) -> Result<ResolvedDep, ProjectError> {
match &dep.source {
DependencySource::Path(p) => Ok(ResolvedDep {
name: dep.name.clone(),
version: dep.version.clone(),
local_path: p.clone(),
}),
DependencySource::Git { .. } => Ok(ResolvedDep {
name: dep.name.clone(),
version: dep.version.clone(),
local_path: PathBuf::from(format!(".oxilean/deps/{}", dep.name)),
}),
DependencySource::Registry { .. } => {
let pkg = self
.registry
.find(&dep.name)
.ok_or_else(|| ProjectError::DependencyNotFound(dep.name.clone()))?;
let constraint = VersionConstraint::parse(&dep.version);
let matched = pkg
.versions
.iter()
.find(|v| constraint.matches(v))
.ok_or_else(|| ProjectError::VersionNotFound {
name: dep.name.clone(),
version: dep.version.clone(),
})?;
Ok(ResolvedDep {
name: dep.name.clone(),
version: matched.clone(),
local_path: PathBuf::from(format!(
".oxilean/registry/{}/{}",
dep.name, matched
)),
})
}
}
}
}
#[derive(Clone, Debug)]
pub struct BuildArtifact {
pub module_name: String,
pub artifact_path: PathBuf,
pub source_hash: String,
pub built_at: SystemTime,
}
#[derive(Clone, Debug)]
pub struct BuildCache {
pub artifacts: BTreeMap<String, BuildArtifact>,
pub invalidated_at: Option<SystemTime>,
}
impl BuildCache {
pub fn new() -> Self {
Self {
artifacts: BTreeMap::new(),
invalidated_at: None,
}
}
pub fn insert(&mut self, artifact: BuildArtifact) {
self.artifacts
.insert(artifact.module_name.clone(), artifact);
}
pub fn is_valid(&self, module_name: &str, current_hash: &str) -> bool {
if let Some(inv_time) = self.invalidated_at {
if let Some(artifact) = self.artifacts.get(module_name) {
return artifact.source_hash == current_hash && artifact.built_at > inv_time;
}
return false;
}
self.artifacts
.get(module_name)
.map(|a| a.source_hash == current_hash)
.unwrap_or(false)
}
pub fn invalidate(&mut self) {
self.invalidated_at = Some(SystemTime::now());
}
pub fn clear(&mut self) {
self.artifacts.clear();
self.invalidated_at = None;
}
}
#[derive(Clone, Debug, Default)]
pub struct PackageEntry {
pub name: String,
pub versions: Vec<String>,
pub description: String,
pub deps: Vec<Dependency>,
}
#[derive(Clone, Debug, Default)]
pub struct ModuleGraph {
pub edges: HashMap<String, Vec<String>>,
pub reverse_edges: HashMap<String, Vec<String>>,
pub nodes: HashSet<String>,
}
impl ModuleGraph {
pub fn new() -> Self {
Self::default()
}
pub fn add_node(&mut self, name: &str) {
self.nodes.insert(name.to_string());
self.edges.entry(name.to_string()).or_default();
self.reverse_edges.entry(name.to_string()).or_default();
}
pub fn add_edge(&mut self, from: &str, to: &str) {
self.add_node(from);
self.add_node(to);
self.edges
.entry(from.to_string())
.or_default()
.push(to.to_string());
self.reverse_edges
.entry(to.to_string())
.or_default()
.push(from.to_string());
}
pub fn dependencies_of(&self, name: &str) -> &[String] {
self.edges.get(name).map(|v| v.as_slice()).unwrap_or(&[])
}
pub fn dependents_of(&self, name: &str) -> &[String] {
self.reverse_edges
.get(name)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn transitive_deps(&self, name: &str) -> HashSet<String> {
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
queue.push_back(name.to_string());
while let Some(current) = queue.pop_front() {
for dep in self.dependencies_of(¤t) {
if visited.insert(dep.clone()) {
queue.push_back(dep.clone());
}
}
}
visited
}
}