1use std::collections::{HashMap, HashSet};
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24use anyhow::{Context, Result};
25use serde::{Deserialize, Serialize};
26
27use crate::stack::{is_paiml_crate, CratesIoClient};
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct LocalProject {
32 pub name: String,
34 pub path: PathBuf,
36 pub local_version: String,
38 pub published_version: Option<String>,
40 pub git_status: GitStatus,
42 pub dev_state: DevState,
44 pub paiml_dependencies: Vec<DependencyInfo>,
46 pub is_workspace: bool,
48 pub workspace_members: Vec<String>,
50}
51impl LocalProject {
52 pub fn effective_version(&self) -> &str {
56 if self.dev_state.use_local_version() {
57 &self.local_version
58 } else {
59 self.published_version.as_deref().unwrap_or(&self.local_version)
60 }
61 }
62
63 pub fn is_blocking(&self) -> bool {
66 if !self.dev_state.use_local_version() {
67 return false; }
69 match &self.published_version {
71 Some(pub_v) => compare_versions(&self.local_version, pub_v) == std::cmp::Ordering::Less,
72 None => false,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct GitStatus {
80 pub branch: String,
82 pub has_changes: bool,
84 pub modified_count: usize,
86 pub unpushed_commits: usize,
88 pub up_to_date: bool,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct DependencyInfo {
95 pub name: String,
97 pub required_version: String,
99 pub is_path_dep: bool,
101 pub version_satisfied: Option<bool>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct VersionDrift {
108 pub name: String,
110 pub local_version: String,
112 pub published_version: String,
114 pub drift_type: DriftType,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120pub enum DriftType {
121 LocalAhead,
123 LocalBehind,
125 InSync,
127 NotPublished,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133pub enum DevState {
134 Clean,
136 Dirty,
138 Unpushed,
140}
141impl DevState {
142 pub fn use_local_version(&self) -> bool {
144 matches!(self, DevState::Clean)
145 }
146
147 pub fn safe_to_release(&self) -> bool {
149 matches!(self, DevState::Clean | DevState::Unpushed)
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct PublishOrder {
156 pub order: Vec<PublishStep>,
158 pub cycles: Vec<Vec<String>>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct PublishStep {
165 pub name: String,
167 pub version: String,
169 pub blocked_by: Vec<String>,
171 pub needs_publish: bool,
173}
174
175pub struct LocalWorkspaceOracle {
177 base_dir: PathBuf,
179 crates_io: CratesIoClient,
181 projects: HashMap<String, LocalProject>,
183}
184
185impl LocalWorkspaceOracle {
186 pub fn new() -> Result<Self> {
188 let home = dirs::home_dir().context("Could not find home directory")?;
189 let base_dir = home.join("src");
190 Self::with_base_dir(base_dir)
191 }
192
193 pub fn with_base_dir(base_dir: PathBuf) -> Result<Self> {
195 Ok(Self { base_dir, crates_io: CratesIoClient::new(), projects: HashMap::new() })
196 }
197
198 pub fn discover_projects(&mut self) -> Result<&HashMap<String, LocalProject>> {
200 self.projects.clear();
201
202 if !self.base_dir.exists() {
203 return Ok(&self.projects);
204 }
205
206 for entry in std::fs::read_dir(&self.base_dir)? {
208 let entry = entry?;
209 let path = entry.path();
210
211 if path.is_dir() {
212 let cargo_toml = path.join("Cargo.toml");
213 if cargo_toml.exists() {
214 if let Ok(project) = self.analyze_project(&path) {
215 if is_paiml_crate(&project.name) || self.has_paiml_deps(&project) {
217 self.projects.insert(project.name.clone(), project);
218 }
219 }
220 }
221 }
222 }
223
224 Ok(&self.projects)
225 }
226
227 fn has_paiml_deps(&self, project: &LocalProject) -> bool {
229 !project.paiml_dependencies.is_empty()
230 }
231
232 fn analyze_project(&self, path: &Path) -> Result<LocalProject> {
234 let cargo_toml = path.join("Cargo.toml");
235 let content = std::fs::read_to_string(&cargo_toml)?;
236 let parsed: toml::Value = toml::from_str(&content)?;
237
238 let (name, local_version, is_workspace, workspace_members) = if let Some(package) =
240 parsed.get("package")
241 {
242 let name =
243 package.get("name").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
244
245 let version = Self::extract_version(package, &parsed);
246
247 (name, version, false, vec![])
248 } else if let Some(workspace) = parsed.get("workspace") {
249 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown").to_string();
251
252 let members = workspace
253 .get("members")
254 .and_then(|m| m.as_array())
255 .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
256 .unwrap_or_default();
257
258 let version = workspace
259 .get("package")
260 .and_then(|p| p.get("version"))
261 .and_then(|v| v.as_str())
262 .unwrap_or("0.0.0")
263 .to_string();
264
265 (name, version, true, members)
266 } else {
267 anyhow::bail!("No [package] or [workspace] section");
268 };
269
270 let paiml_dependencies = self.extract_paiml_deps(&parsed);
272
273 let git_status = self.get_git_status(path);
275
276 let dev_state = if git_status.has_changes {
278 DevState::Dirty
279 } else if git_status.unpushed_commits > 0 {
280 DevState::Unpushed
281 } else {
282 DevState::Clean
283 };
284
285 Ok(LocalProject {
286 name,
287 path: path.to_path_buf(),
288 local_version,
289 published_version: None, git_status,
291 dev_state,
292 paiml_dependencies,
293 is_workspace,
294 workspace_members,
295 })
296 }
297
298 fn extract_version(package: &toml::Value, root: &toml::Value) -> String {
300 if let Some(version) = package.get("version") {
301 if let Some(v) = version.as_str() {
302 return v.to_string();
303 }
304 if let Some(table) = version.as_table() {
306 if table.get("workspace").and_then(|v| v.as_bool()) == Some(true) {
307 if let Some(ws_version) = root
309 .get("workspace")
310 .and_then(|w| w.get("package"))
311 .and_then(|p| p.get("version"))
312 .and_then(|v| v.as_str())
313 {
314 return ws_version.to_string();
315 }
316 }
317 }
318 }
319 "0.0.0".to_string()
320 }
321
322 fn extract_paiml_deps(&self, parsed: &toml::Value) -> Vec<DependencyInfo> {
324 let mut deps = Vec::new();
325
326 if let Some(dependencies) = parsed.get("dependencies") {
328 self.collect_paiml_deps(dependencies, &mut deps);
329 }
330
331 if let Some(dev_deps) = parsed.get("dev-dependencies") {
333 self.collect_paiml_deps(dev_deps, &mut deps);
334 }
335
336 if let Some(workspace) = parsed.get("workspace") {
338 if let Some(ws_deps) = workspace.get("dependencies") {
339 self.collect_paiml_deps(ws_deps, &mut deps);
340 }
341 }
342
343 deps
344 }
345
346 fn collect_paiml_deps(&self, deps: &toml::Value, result: &mut Vec<DependencyInfo>) {
347 if let Some(table) = deps.as_table() {
348 for (name, value) in table {
349 if !is_paiml_crate(name) {
350 continue;
351 }
352
353 let (version, is_path) = match value {
354 toml::Value::String(v) => (v.clone(), false),
355 toml::Value::Table(t) => {
356 let version =
357 t.get("version").and_then(|v| v.as_str()).unwrap_or("*").to_string();
358 let is_path = t.contains_key("path");
359 (version, is_path)
360 }
361 _ => continue,
362 };
363
364 result.push(DependencyInfo {
365 name: name.clone(),
366 required_version: version,
367 is_path_dep: is_path,
368 version_satisfied: None,
369 });
370 }
371 }
372 }
373
374 fn get_git_status(&self, path: &Path) -> GitStatus {
376 let branch = Command::new("git")
377 .args(["branch", "--show-current"])
378 .current_dir(path)
379 .output()
380 .ok()
381 .and_then(|o| String::from_utf8(o.stdout).ok())
382 .map(|s| s.trim().to_string())
383 .unwrap_or_else(|| "unknown".to_string());
384
385 let status_output = Command::new("git")
386 .args(["status", "--porcelain"])
387 .current_dir(path)
388 .output()
389 .ok()
390 .and_then(|o| String::from_utf8(o.stdout).ok())
391 .unwrap_or_default();
392
393 let modified_count = status_output.lines().count();
394 let has_changes = modified_count > 0;
395
396 let unpushed = Command::new("git")
398 .args(["log", "@{u}..HEAD", "--oneline"])
399 .current_dir(path)
400 .output()
401 .ok()
402 .and_then(|o| String::from_utf8(o.stdout).ok())
403 .map(|s| s.lines().count())
404 .unwrap_or(0);
405
406 let up_to_date = unpushed == 0 && !has_changes;
407
408 GitStatus { branch, has_changes, modified_count, unpushed_commits: unpushed, up_to_date }
409 }
410
411 pub async fn fetch_published_versions(&mut self) -> Result<()> {
413 let names: Vec<String> = self.projects.keys().cloned().collect();
415
416 for name in names {
417 if let Ok(response) = self.crates_io.get_crate(&name).await {
418 if let Some(project) = self.projects.get_mut(&name) {
419 project.published_version = Some(response.krate.max_version.clone());
420 }
421 }
422 }
423 Ok(())
424 }
425
426 pub fn detect_drift(&self) -> Vec<VersionDrift> {
428 let mut drifts = Vec::new();
429
430 for project in self.projects.values() {
431 let drift_type = match &project.published_version {
432 None => DriftType::NotPublished,
433 Some(published) => {
434 use std::cmp::Ordering;
435 match compare_versions(&project.local_version, published) {
436 Ordering::Greater => DriftType::LocalAhead,
437 Ordering::Less => DriftType::LocalBehind,
438 Ordering::Equal => DriftType::InSync,
439 }
440 }
441 };
442
443 if drift_type != DriftType::InSync {
444 drifts.push(VersionDrift {
445 name: project.name.clone(),
446 local_version: project.local_version.clone(),
447 published_version: project
448 .published_version
449 .clone()
450 .unwrap_or_else(|| "not published".to_string()),
451 drift_type,
452 });
453 }
454 }
455
456 drifts
457 }
458
459 pub fn suggest_publish_order(&self) -> PublishOrder {
461 let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
463 let mut in_degree: HashMap<String, usize> = HashMap::new();
464
465 for name in self.projects.keys() {
467 graph.entry(name.clone()).or_default();
468 in_degree.entry(name.clone()).or_insert(0);
469 }
470
471 for project in self.projects.values() {
473 for dep in &project.paiml_dependencies {
474 if self.projects.contains_key(&dep.name) && !dep.is_path_dep {
475 graph.entry(dep.name.clone()).or_default().insert(project.name.clone());
476 *in_degree.entry(project.name.clone()).or_insert(0) += 1;
477 }
478 }
479 }
480
481 let mut order = Vec::new();
483 let mut queue: Vec<String> = in_degree
484 .iter()
485 .filter(|(_, °ree)| degree == 0)
486 .map(|(name, _)| name.clone())
487 .collect();
488
489 queue.sort(); while let Some(name) = queue.pop() {
492 if let Some(project) = self.projects.get(&name) {
493 let blocked_by: Vec<String> = project
494 .paiml_dependencies
495 .iter()
496 .filter(|d| self.projects.contains_key(&d.name) && !d.is_path_dep)
497 .map(|d| d.name.clone())
498 .collect();
499
500 let needs_publish = project.git_status.has_changes
501 || project.git_status.unpushed_commits > 0
502 || matches!(
503 self.detect_drift().iter().find(|d| d.name == name).map(|d| d.drift_type),
504 Some(DriftType::LocalAhead | DriftType::NotPublished)
505 );
506
507 order.push(PublishStep {
508 name: name.clone(),
509 version: project.local_version.clone(),
510 blocked_by,
511 needs_publish,
512 });
513 }
514
515 if let Some(dependents) = graph.get(&name) {
517 for dependent in dependents {
518 if let Some(degree) = in_degree.get_mut(dependent) {
519 *degree -= 1;
520 if *degree == 0 {
521 queue.push(dependent.clone());
522 queue.sort();
523 }
524 }
525 }
526 }
527 }
528
529 let cycles: Vec<Vec<String>> = in_degree
531 .iter()
532 .filter(|(_, °ree)| degree > 0)
533 .map(|(name, _)| vec![name.clone()])
534 .collect();
535
536 PublishOrder { order, cycles }
537 }
538
539 pub fn projects(&self) -> &HashMap<String, LocalProject> {
541 &self.projects
542 }
543
544 pub fn summary(&self) -> WorkspaceSummary {
546 let total = self.projects.len();
547 let with_changes = self.projects.values().filter(|p| p.git_status.has_changes).count();
548 let with_unpushed =
549 self.projects.values().filter(|p| p.git_status.unpushed_commits > 0).count();
550 let workspaces = self.projects.values().filter(|p| p.is_workspace).count();
551
552 WorkspaceSummary {
553 total_projects: total,
554 projects_with_changes: with_changes,
555 projects_with_unpushed: with_unpushed,
556 workspace_count: workspaces,
557 }
558 }
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct WorkspaceSummary {
564 pub total_projects: usize,
565 pub projects_with_changes: usize,
566 pub projects_with_unpushed: usize,
567 pub workspace_count: usize,
568}
569
570fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
572 let parse = |s: &str| -> (u32, u32, u32) {
573 let parts: Vec<u32> = s.split('.').take(3).map(|p| p.parse().unwrap_or(0)).collect();
574 (*parts.first().unwrap_or(&0), *parts.get(1).unwrap_or(&0), *parts.get(2).unwrap_or(&0))
575 };
576
577 parse(a).cmp(&parse(b))
578}
579
580#[cfg(test)]
581#[path = "local_workspace_tests.rs"]
582mod tests;