use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::stack::{is_paiml_crate, CratesIoClient};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalProject {
pub name: String,
pub path: PathBuf,
pub local_version: String,
pub published_version: Option<String>,
pub git_status: GitStatus,
pub dev_state: DevState,
pub paiml_dependencies: Vec<DependencyInfo>,
pub is_workspace: bool,
pub workspace_members: Vec<String>,
}
impl LocalProject {
pub fn effective_version(&self) -> &str {
if self.dev_state.use_local_version() {
&self.local_version
} else {
self.published_version.as_deref().unwrap_or(&self.local_version)
}
}
pub fn is_blocking(&self) -> bool {
if !self.dev_state.use_local_version() {
return false; }
match &self.published_version {
Some(pub_v) => compare_versions(&self.local_version, pub_v) == std::cmp::Ordering::Less,
None => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitStatus {
pub branch: String,
pub has_changes: bool,
pub modified_count: usize,
pub unpushed_commits: usize,
pub up_to_date: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyInfo {
pub name: String,
pub required_version: String,
pub is_path_dep: bool,
pub version_satisfied: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionDrift {
pub name: String,
pub local_version: String,
pub published_version: String,
pub drift_type: DriftType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DriftType {
LocalAhead,
LocalBehind,
InSync,
NotPublished,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DevState {
Clean,
Dirty,
Unpushed,
}
impl DevState {
pub fn use_local_version(&self) -> bool {
matches!(self, DevState::Clean)
}
pub fn safe_to_release(&self) -> bool {
matches!(self, DevState::Clean | DevState::Unpushed)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishOrder {
pub order: Vec<PublishStep>,
pub cycles: Vec<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishStep {
pub name: String,
pub version: String,
pub blocked_by: Vec<String>,
pub needs_publish: bool,
}
pub struct LocalWorkspaceOracle {
base_dir: PathBuf,
crates_io: CratesIoClient,
projects: HashMap<String, LocalProject>,
}
impl LocalWorkspaceOracle {
pub fn new() -> Result<Self> {
let home = dirs::home_dir().context("Could not find home directory")?;
let base_dir = home.join("src");
Self::with_base_dir(base_dir)
}
pub fn with_base_dir(base_dir: PathBuf) -> Result<Self> {
Ok(Self { base_dir, crates_io: CratesIoClient::new(), projects: HashMap::new() })
}
pub fn discover_projects(&mut self) -> Result<&HashMap<String, LocalProject>> {
self.projects.clear();
if !self.base_dir.exists() {
return Ok(&self.projects);
}
for entry in std::fs::read_dir(&self.base_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let cargo_toml = path.join("Cargo.toml");
if cargo_toml.exists() {
if let Ok(project) = self.analyze_project(&path) {
if is_paiml_crate(&project.name) || self.has_paiml_deps(&project) {
self.projects.insert(project.name.clone(), project);
}
}
}
}
}
Ok(&self.projects)
}
fn has_paiml_deps(&self, project: &LocalProject) -> bool {
!project.paiml_dependencies.is_empty()
}
fn analyze_project(&self, path: &Path) -> Result<LocalProject> {
let cargo_toml = path.join("Cargo.toml");
let content = std::fs::read_to_string(&cargo_toml)?;
let parsed: toml::Value = toml::from_str(&content)?;
let (name, local_version, is_workspace, workspace_members) = if let Some(package) =
parsed.get("package")
{
let name =
package.get("name").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
let version = Self::extract_version(package, &parsed);
(name, version, false, vec![])
} else if let Some(workspace) = parsed.get("workspace") {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown").to_string();
let members = workspace
.get("members")
.and_then(|m| m.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let version = workspace
.get("package")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("0.0.0")
.to_string();
(name, version, true, members)
} else {
anyhow::bail!("No [package] or [workspace] section");
};
let paiml_dependencies = self.extract_paiml_deps(&parsed);
let git_status = self.get_git_status(path);
let dev_state = if git_status.has_changes {
DevState::Dirty
} else if git_status.unpushed_commits > 0 {
DevState::Unpushed
} else {
DevState::Clean
};
Ok(LocalProject {
name,
path: path.to_path_buf(),
local_version,
published_version: None, git_status,
dev_state,
paiml_dependencies,
is_workspace,
workspace_members,
})
}
fn extract_version(package: &toml::Value, root: &toml::Value) -> String {
if let Some(version) = package.get("version") {
if let Some(v) = version.as_str() {
return v.to_string();
}
if let Some(table) = version.as_table() {
if table.get("workspace").and_then(|v| v.as_bool()) == Some(true) {
if let Some(ws_version) = root
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
{
return ws_version.to_string();
}
}
}
}
"0.0.0".to_string()
}
fn extract_paiml_deps(&self, parsed: &toml::Value) -> Vec<DependencyInfo> {
let mut deps = Vec::new();
if let Some(dependencies) = parsed.get("dependencies") {
self.collect_paiml_deps(dependencies, &mut deps);
}
if let Some(dev_deps) = parsed.get("dev-dependencies") {
self.collect_paiml_deps(dev_deps, &mut deps);
}
if let Some(workspace) = parsed.get("workspace") {
if let Some(ws_deps) = workspace.get("dependencies") {
self.collect_paiml_deps(ws_deps, &mut deps);
}
}
deps
}
fn collect_paiml_deps(&self, deps: &toml::Value, result: &mut Vec<DependencyInfo>) {
if let Some(table) = deps.as_table() {
for (name, value) in table {
if !is_paiml_crate(name) {
continue;
}
let (version, is_path) = match value {
toml::Value::String(v) => (v.clone(), false),
toml::Value::Table(t) => {
let version =
t.get("version").and_then(|v| v.as_str()).unwrap_or("*").to_string();
let is_path = t.contains_key("path");
(version, is_path)
}
_ => continue,
};
result.push(DependencyInfo {
name: name.clone(),
required_version: version,
is_path_dep: is_path,
version_satisfied: None,
});
}
}
}
fn get_git_status(&self, path: &Path) -> GitStatus {
let branch = Command::new("git")
.args(["branch", "--show-current"])
.current_dir(path)
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
let status_output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(path)
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
let modified_count = status_output.lines().count();
let has_changes = modified_count > 0;
let unpushed = Command::new("git")
.args(["log", "@{u}..HEAD", "--oneline"])
.current_dir(path)
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.lines().count())
.unwrap_or(0);
let up_to_date = unpushed == 0 && !has_changes;
GitStatus { branch, has_changes, modified_count, unpushed_commits: unpushed, up_to_date }
}
pub async fn fetch_published_versions(&mut self) -> Result<()> {
let names: Vec<String> = self.projects.keys().cloned().collect();
for name in names {
if let Ok(response) = self.crates_io.get_crate(&name).await {
if let Some(project) = self.projects.get_mut(&name) {
project.published_version = Some(response.krate.max_version.clone());
}
}
}
Ok(())
}
pub fn detect_drift(&self) -> Vec<VersionDrift> {
let mut drifts = Vec::new();
for project in self.projects.values() {
let drift_type = match &project.published_version {
None => DriftType::NotPublished,
Some(published) => {
use std::cmp::Ordering;
match compare_versions(&project.local_version, published) {
Ordering::Greater => DriftType::LocalAhead,
Ordering::Less => DriftType::LocalBehind,
Ordering::Equal => DriftType::InSync,
}
}
};
if drift_type != DriftType::InSync {
drifts.push(VersionDrift {
name: project.name.clone(),
local_version: project.local_version.clone(),
published_version: project
.published_version
.clone()
.unwrap_or_else(|| "not published".to_string()),
drift_type,
});
}
}
drifts
}
pub fn suggest_publish_order(&self) -> PublishOrder {
let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
let mut in_degree: HashMap<String, usize> = HashMap::new();
for name in self.projects.keys() {
graph.entry(name.clone()).or_default();
in_degree.entry(name.clone()).or_insert(0);
}
for project in self.projects.values() {
for dep in &project.paiml_dependencies {
if self.projects.contains_key(&dep.name) && !dep.is_path_dep {
graph.entry(dep.name.clone()).or_default().insert(project.name.clone());
*in_degree.entry(project.name.clone()).or_insert(0) += 1;
}
}
}
let mut order = Vec::new();
let mut queue: Vec<String> = in_degree
.iter()
.filter(|(_, °ree)| degree == 0)
.map(|(name, _)| name.clone())
.collect();
queue.sort();
while let Some(name) = queue.pop() {
if let Some(project) = self.projects.get(&name) {
let blocked_by: Vec<String> = project
.paiml_dependencies
.iter()
.filter(|d| self.projects.contains_key(&d.name) && !d.is_path_dep)
.map(|d| d.name.clone())
.collect();
let needs_publish = project.git_status.has_changes
|| project.git_status.unpushed_commits > 0
|| matches!(
self.detect_drift().iter().find(|d| d.name == name).map(|d| d.drift_type),
Some(DriftType::LocalAhead | DriftType::NotPublished)
);
order.push(PublishStep {
name: name.clone(),
version: project.local_version.clone(),
blocked_by,
needs_publish,
});
}
if let Some(dependents) = graph.get(&name) {
for dependent in dependents {
if let Some(degree) = in_degree.get_mut(dependent) {
*degree -= 1;
if *degree == 0 {
queue.push(dependent.clone());
queue.sort();
}
}
}
}
}
let cycles: Vec<Vec<String>> = in_degree
.iter()
.filter(|(_, °ree)| degree > 0)
.map(|(name, _)| vec![name.clone()])
.collect();
PublishOrder { order, cycles }
}
pub fn projects(&self) -> &HashMap<String, LocalProject> {
&self.projects
}
pub fn summary(&self) -> WorkspaceSummary {
let total = self.projects.len();
let with_changes = self.projects.values().filter(|p| p.git_status.has_changes).count();
let with_unpushed =
self.projects.values().filter(|p| p.git_status.unpushed_commits > 0).count();
let workspaces = self.projects.values().filter(|p| p.is_workspace).count();
WorkspaceSummary {
total_projects: total,
projects_with_changes: with_changes,
projects_with_unpushed: with_unpushed,
workspace_count: workspaces,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceSummary {
pub total_projects: usize,
pub projects_with_changes: usize,
pub projects_with_unpushed: usize,
pub workspace_count: usize,
}
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
let parse = |s: &str| -> (u32, u32, u32) {
let parts: Vec<u32> = s.split('.').take(3).map(|p| p.parse().unwrap_or(0)).collect();
(*parts.first().unwrap_or(&0), *parts.get(1).unwrap_or(&0), *parts.get(2).unwrap_or(&0))
};
parse(a).cmp(&parse(b))
}
#[cfg(test)]
#[path = "local_workspace_tests.rs"]
mod tests;