Skip to main content

batuta/oracle/
local_workspace.rs

1//! Local Workspace Oracle - Multi-project development intelligence
2//!
3//! Provides discovery and analysis of local PAIML projects for intelligent
4//! orchestration across 10+ active development projects.
5//!
6//! ## Features
7//!
8//! - Auto-discover PAIML projects in ~/src
9//! - Track git status across all projects
10//! - Build cross-project dependency graph
11//! - Detect version drift (local vs crates.io)
12//! - Suggest publish order for dependent crates
13//!
14//! ## Toyota Way Principles
15//!
16//! - **Genchi Genbutsu**: Go and see the actual local state
17//! - **Jidoka**: Stop on version conflicts before publishing
18//! - **Just-in-Time**: Pull-based publish ordering
19
20use 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/// A discovered local project
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct LocalProject {
32    /// Project name (from Cargo.toml)
33    pub name: String,
34    /// Path to project root
35    pub path: PathBuf,
36    /// Local version from Cargo.toml
37    pub local_version: String,
38    /// Published version on crates.io (if any)
39    pub published_version: Option<String>,
40    /// Git status
41    pub git_status: GitStatus,
42    /// Development state (clean/dirty/unpushed)
43    pub dev_state: DevState,
44    /// Dependencies on other PAIML crates
45    pub paiml_dependencies: Vec<DependencyInfo>,
46    /// Whether this is a workspace
47    pub is_workspace: bool,
48    /// Workspace members (if workspace)
49    pub workspace_members: Vec<String>,
50}
51impl LocalProject {
52    /// Get the effective version for dependency resolution
53    /// - Dirty projects: use crates.io version (local is WIP)
54    /// - Clean projects: use local version
55    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    /// Is this project blocking the stack?
64    /// Only clean projects with version drift block the stack
65    pub fn is_blocking(&self) -> bool {
66        if !self.dev_state.use_local_version() {
67            return false; // Dirty projects don't block
68        }
69        // Clean project - check if version is behind
70        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/// Git repository status
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct GitStatus {
80    /// Current branch
81    pub branch: String,
82    /// Whether there are uncommitted changes
83    pub has_changes: bool,
84    /// Number of modified files
85    pub modified_count: usize,
86    /// Number of unpushed commits
87    pub unpushed_commits: usize,
88    /// Whether the branch is up to date with remote
89    pub up_to_date: bool,
90}
91
92/// Information about a PAIML dependency
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct DependencyInfo {
95    /// Dependency name
96    pub name: String,
97    /// Required version (from Cargo.toml)
98    pub required_version: String,
99    /// Whether this is a path dependency
100    pub is_path_dep: bool,
101    /// Whether the local version satisfies the requirement
102    pub version_satisfied: Option<bool>,
103}
104
105/// Version drift information
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct VersionDrift {
108    /// Crate name
109    pub name: String,
110    /// Local version
111    pub local_version: String,
112    /// Published version
113    pub published_version: String,
114    /// Drift type
115    pub drift_type: DriftType,
116}
117
118/// Type of version drift
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120pub enum DriftType {
121    /// Local is ahead of published (ready to publish)
122    LocalAhead,
123    /// Local is behind published (need to update)
124    LocalBehind,
125    /// Versions match
126    InSync,
127    /// Not published yet
128    NotPublished,
129}
130
131/// Development state of a project
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133pub enum DevState {
134    /// Clean - no uncommitted changes, safe to use local version
135    Clean,
136    /// Dirty - active development, use crates.io version for deps
137    Dirty,
138    /// Unpushed - clean but has unpushed commits
139    Unpushed,
140}
141impl DevState {
142    /// Should this project's local version be used for dependency resolution?
143    pub fn use_local_version(&self) -> bool {
144        matches!(self, DevState::Clean)
145    }
146
147    /// Is this project safe to release?
148    pub fn safe_to_release(&self) -> bool {
149        matches!(self, DevState::Clean | DevState::Unpushed)
150    }
151}
152
153/// Publish order for coordinated releases
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct PublishOrder {
156    /// Ordered list of crates to publish
157    pub order: Vec<PublishStep>,
158    /// Detected cycles (if any)
159    pub cycles: Vec<Vec<String>>,
160}
161
162/// A step in the publish order
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct PublishStep {
165    /// Crate name
166    pub name: String,
167    /// Current local version
168    pub version: String,
169    /// Dependencies that must be published first
170    pub blocked_by: Vec<String>,
171    /// Whether this crate has unpublished changes
172    pub needs_publish: bool,
173}
174
175/// Local workspace oracle for multi-project intelligence
176pub struct LocalWorkspaceOracle {
177    /// Base directory to scan (typically ~/src)
178    base_dir: PathBuf,
179    /// Crates.io client for version checks
180    crates_io: CratesIoClient,
181    /// Discovered projects
182    projects: HashMap<String, LocalProject>,
183}
184
185impl LocalWorkspaceOracle {
186    /// Create a new oracle with default base directory (~//src)
187    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    /// Create with a specific base directory
194    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    /// Discover all PAIML projects in the base directory
199    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        // Scan for Cargo.toml files
207        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                        // Only include PAIML projects
216                        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    /// Check if project has PAIML dependencies
228    fn has_paiml_deps(&self, project: &LocalProject) -> bool {
229        !project.paiml_dependencies.is_empty()
230    }
231
232    /// Analyze a single project
233    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        // Get package info (handle workspaces)
239        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            // Workspace - use directory name
250            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        // Get dependencies
271        let paiml_dependencies = self.extract_paiml_deps(&parsed);
272
273        // Get git status
274        let git_status = self.get_git_status(path);
275
276        // Determine development state
277        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, // Filled in later
290            git_status,
291            dev_state,
292            paiml_dependencies,
293            is_workspace,
294            workspace_members,
295        })
296    }
297
298    /// Extract version handling workspace inheritance
299    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            // Check for workspace = true
305            if let Some(table) = version.as_table() {
306                if table.get("workspace").and_then(|v| v.as_bool()) == Some(true) {
307                    // Get from workspace.package.version
308                    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    /// Extract PAIML dependencies from Cargo.toml
323    fn extract_paiml_deps(&self, parsed: &toml::Value) -> Vec<DependencyInfo> {
324        let mut deps = Vec::new();
325
326        // Check [dependencies]
327        if let Some(dependencies) = parsed.get("dependencies") {
328            self.collect_paiml_deps(dependencies, &mut deps);
329        }
330
331        // Check [dev-dependencies]
332        if let Some(dev_deps) = parsed.get("dev-dependencies") {
333            self.collect_paiml_deps(dev_deps, &mut deps);
334        }
335
336        // Check workspace dependencies
337        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    /// Get git status for a project
375    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        // Check unpushed commits
397        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    /// Fetch published versions from crates.io
412    pub async fn fetch_published_versions(&mut self) -> Result<()> {
413        // Collect project names first to avoid borrow issues
414        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    /// Detect version drift between local and published
427    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    /// Build cross-project dependency graph and suggest publish order
460    pub fn suggest_publish_order(&self) -> PublishOrder {
461        // Build dependency graph
462        let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
463        let mut in_degree: HashMap<String, usize> = HashMap::new();
464
465        // Initialize all projects
466        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        // Add edges for dependencies
472        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        // Topological sort (Kahn's algorithm)
482        let mut order = Vec::new();
483        let mut queue: Vec<String> = in_degree
484            .iter()
485            .filter(|(_, &degree)| degree == 0)
486            .map(|(name, _)| name.clone())
487            .collect();
488
489        queue.sort(); // Deterministic ordering
490
491        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            // Decrease in-degree of dependents
516            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        // Detect cycles (remaining nodes with non-zero in-degree)
530        let cycles: Vec<Vec<String>> = in_degree
531            .iter()
532            .filter(|(_, &degree)| degree > 0)
533            .map(|(name, _)| vec![name.clone()])
534            .collect();
535
536        PublishOrder { order, cycles }
537    }
538
539    /// Get all discovered projects
540    pub fn projects(&self) -> &HashMap<String, LocalProject> {
541        &self.projects
542    }
543
544    /// Get summary statistics
545    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/// Summary of workspace state
562#[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
570/// Compare semver versions
571fn 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;