1use anyhow::{Context, Result};
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct WorkspaceManifest {
23 pub name: String,
25 #[serde(default = "default_store_path")]
27 pub store_path: String,
28 #[serde(default)]
30 pub repos: Vec<WorkspaceRepo>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct WorkspaceRepo {
36 pub path: String,
38 #[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 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 pub fn store_path(&self, workspace_root: &Path) -> PathBuf {
80 workspace_root.join(&self.store_path)
81 }
82
83 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 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
103pub 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 let mut manifest = WorkspaceManifest {
123 name: name.to_string(),
124 store_path: store_dir.to_string(),
125 repos: Vec::new(),
126 };
127
128 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 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 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 let backend = crate::db::GitBackend::new(&store_full)?;
171 let store = crate::models::RequirementsStore::new();
172 backend.save(&store)?;
173
174 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 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 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 let (root, found) = WorkspaceManifest::discover(dir.path()).unwrap();
239 assert_eq!(root, dir.path());
240 assert_eq!(found.name, "test");
241
242 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 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 assert!(ws.join("repo-a/.aida/config.toml").exists());
270 assert!(ws.join("repo-b/.aida/config.toml").exists());
271 }
272}