Skip to main content

aida_core/
workspace.rs

1// trace:ARCH-distributed-workspace | ai:claude
2//! Multi-repo workspace support for distributed AIDA.
3//!
4//! A workspace groups multiple code repos that share a single AIDA store.
5//! Each code repo has a `.aida/config.toml` pointing to the shared store.
6//!
7//! Layout:
8//! ```text
9//! workspace/
10//!   pacgate/              ← code repo 1
11//!   pacinet/              ← code repo 2
12//!   aida-store/           ← shared requirements store
13//!   .aida-workspace       ← workspace config
14//! ```
15
16use anyhow::{Context, Result};
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19
20/// Workspace configuration stored in `.aida-workspace`.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct WorkspaceManifest {
23    /// Human-readable workspace name
24    pub name: String,
25    /// Path to the shared AIDA store (relative to workspace root)
26    #[serde(default = "default_store_path")]
27    pub store_path: String,
28    /// Code repos in this workspace
29    #[serde(default)]
30    pub repos: Vec<WorkspaceRepo>,
31}
32
33/// A code repo entry in the workspace manifest.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct WorkspaceRepo {
36    /// Directory name (relative to workspace root)
37    pub path: String,
38    /// Optional display name
39    #[serde(default)]
40    pub name: String,
41}
42
43fn default_store_path() -> String {
44    "aida-store".into()
45}
46
47impl Default for WorkspaceManifest {
48    fn default() -> Self {
49        Self {
50            name: String::new(),
51            store_path: default_store_path(),
52            repos: Vec::new(),
53        }
54    }
55}
56
57const WORKSPACE_FILE: &str = ".aida-workspace";
58
59impl WorkspaceManifest {
60    /// Discover a workspace by walking up from a directory.
61    pub fn discover(from: &Path) -> Option<(PathBuf, Self)> {
62        let mut current = from.to_path_buf();
63        loop {
64            let candidate = current.join(WORKSPACE_FILE);
65            if candidate.exists() {
66                if let Ok(content) = std::fs::read_to_string(&candidate) {
67                    if let Ok(manifest) = toml::from_str::<WorkspaceManifest>(&content) {
68                        return Some((current, manifest));
69                    }
70                }
71            }
72            if !current.pop() {
73                return None;
74            }
75        }
76    }
77
78    /// Get the absolute store path.
79    pub fn store_path(&self, workspace_root: &Path) -> PathBuf {
80        workspace_root.join(&self.store_path)
81    }
82
83    /// Save the workspace manifest.
84    pub fn save(&self, workspace_root: &Path) -> Result<()> {
85        let path = workspace_root.join(WORKSPACE_FILE);
86        let content = toml::to_string_pretty(self)?;
87        std::fs::write(&path, content)
88            .with_context(|| format!("Failed to write {}", path.display()))?;
89        Ok(())
90    }
91
92    /// Add a repo to the workspace.
93    pub fn add_repo(&mut self, path: &str, name: &str) {
94        if !self.repos.iter().any(|r| r.path == path) {
95            self.repos.push(WorkspaceRepo {
96                path: path.to_string(),
97                name: name.to_string(),
98            });
99        }
100    }
101}
102
103/// Initialize a multi-repo workspace.
104///
105/// Creates:
106/// - `.aida-workspace` manifest
107/// - `aida-store/` directory with git init
108/// - `.aida/config.toml` in each discovered repo
109pub fn init_workspace(
110    workspace_root: &Path,
111    name: &str,
112    store_path: Option<&str>,
113    registry_remote: Option<&str>,
114) -> Result<WorkspaceManifest> {
115    use crate::db::DatabaseBackend;
116    use crate::git_ops;
117
118    let store_dir = store_path.unwrap_or("aida-store");
119    let store_full = workspace_root.join(store_dir);
120
121    // Create workspace manifest
122    let mut manifest = WorkspaceManifest {
123        name: name.to_string(),
124        store_path: store_dir.to_string(),
125        repos: Vec::new(),
126    };
127
128    // Discover code repos (directories with .git)
129    for entry in std::fs::read_dir(workspace_root)? {
130        let entry = entry?;
131        let path = entry.path();
132        if path.is_dir() && path.join(".git").exists() && path.file_name().map(|n| n != "aida-store").unwrap_or(false) {
133            let dir_name = path.file_name().unwrap().to_string_lossy().to_string();
134            manifest.add_repo(&dir_name, &dir_name);
135        }
136    }
137
138    // Create the store
139    if !store_full.exists() {
140        std::fs::create_dir_all(&store_full)?;
141    }
142
143    if !git_ops::is_git_repo(&store_full) {
144        git_ops::init(&store_full)?;
145        let git_name = git_ops::git_config_get("user.name")
146            .unwrap_or_else(|_| "AIDA".to_string());
147        let git_email = git_ops::git_config_get("user.email")
148            .unwrap_or_else(|_| "aida@localhost".to_string());
149        git_ops::configure_user(&store_full, &git_name, &git_email)?;
150    }
151
152    // Add remote if provided
153    if let Some(remote) = registry_remote {
154        let has_remote = std::process::Command::new("git")
155            .current_dir(&store_full)
156            .args(["remote", "get-url", "origin"])
157            .output()
158            .map(|o| o.status.success())
159            .unwrap_or(false);
160
161        if !has_remote {
162            std::process::Command::new("git")
163                .current_dir(&store_full)
164                .args(["remote", "add", "origin", remote])
165                .output()?;
166        }
167    }
168
169    // Initialize git backend in the store
170    let backend = crate::db::GitBackend::new(&store_full)?;
171    let store = crate::models::RequirementsStore::new();
172    backend.save(&store)?;
173
174    // Initial commit
175    git_ops::add(&store_full, &["metadata.yaml"])?;
176    std::fs::create_dir_all(store_full.join("objects"))?;
177    std::fs::write(store_full.join("objects/.gitkeep"), "")?;
178    git_ops::add(&store_full, &["objects/.gitkeep"])?;
179
180    let gitignore = "# Node-local state\n.aida/\n*.lock\n";
181    std::fs::write(store_full.join(".gitignore"), gitignore)?;
182    git_ops::add(&store_full, &[".gitignore"])?;
183    git_ops::commit(&store_full, "chore: initialize AIDA workspace store")?;
184
185    // Create .aida/config.toml in each repo
186    for repo in &manifest.repos {
187        let repo_path = workspace_root.join(&repo.path);
188        let aida_dir = repo_path.join(".aida");
189        std::fs::create_dir_all(&aida_dir)?;
190
191        let relative_store = format!("../{}", store_dir);
192        let config = format!(
193            "# AIDA workspace configuration\n\
194             [deployment]\n\
195             mode = \"distributed\"\n\
196             store_path = \"{}\"\n\
197             store_type = \"sibling\"\n\
198             workspace = \"{}\"\n",
199            relative_store, name
200        );
201        std::fs::write(aida_dir.join("config.toml"), config)?;
202    }
203
204    // Save workspace manifest
205    manifest.save(workspace_root)?;
206
207    Ok(manifest)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_workspace_manifest_serde() {
216        let mut manifest = WorkspaceManifest::default();
217        manifest.name = "test-workspace".into();
218        manifest.add_repo("pacgate", "PacGate");
219        manifest.add_repo("pacinet", "PacInet");
220
221        let toml_str = toml::to_string_pretty(&manifest).unwrap();
222        let back: WorkspaceManifest = toml::from_str(&toml_str).unwrap();
223        assert_eq!(back.name, "test-workspace");
224        assert_eq!(back.repos.len(), 2);
225    }
226
227    #[test]
228    fn test_workspace_discover() {
229        let dir = tempfile::tempdir().unwrap();
230        let manifest = WorkspaceManifest {
231            name: "test".into(),
232            store_path: "aida-store".into(),
233            repos: Vec::new(),
234        };
235        manifest.save(dir.path()).unwrap();
236
237        // Discover from workspace root
238        let (root, found) = WorkspaceManifest::discover(dir.path()).unwrap();
239        assert_eq!(root, dir.path());
240        assert_eq!(found.name, "test");
241
242        // Discover from subdirectory
243        let sub = dir.path().join("subdir");
244        std::fs::create_dir_all(&sub).unwrap();
245        let (root2, _) = WorkspaceManifest::discover(&sub).unwrap();
246        assert_eq!(root2, dir.path());
247    }
248
249    #[test]
250    fn test_init_workspace() {
251        let dir = tempfile::tempdir().unwrap();
252        let ws = dir.path();
253
254        // Create fake code repos
255        for name in &["repo-a", "repo-b"] {
256            let repo = ws.join(name);
257            std::fs::create_dir_all(repo.join(".git")).unwrap();
258        }
259
260        let manifest = init_workspace(ws, "test-ws", None, None).unwrap();
261
262        assert_eq!(manifest.name, "test-ws");
263        assert_eq!(manifest.repos.len(), 2);
264        assert!(ws.join(".aida-workspace").exists());
265        assert!(ws.join("aida-store/metadata.yaml").exists());
266        assert!(ws.join("aida-store/.git").exists());
267
268        // Check each repo got a config
269        assert!(ws.join("repo-a/.aida/config.toml").exists());
270        assert!(ws.join("repo-b/.aida/config.toml").exists());
271    }
272}