1use std::path::{Path, PathBuf};
2
3use crate::edit::EditModule;
4use crate::error::{ForgeError, Result};
5use crate::storage::UnifiedGraphStore;
6use crate::types::Language;
7
8#[derive(Debug, Clone)]
9pub struct ProjectInfo {
10 pub root: PathBuf,
11 pub language: Language,
12 pub entry_point: PathBuf,
13 pub manifest: Option<PathBuf>,
14 pub source_dir: PathBuf,
15}
16
17pub struct ProjectModule {
18 store: std::sync::Arc<UnifiedGraphStore>,
19}
20
21impl ProjectModule {
22 pub fn new(store: std::sync::Arc<UnifiedGraphStore>) -> Self {
23 Self { store }
24 }
25
26 pub async fn scaffold(&self, name: &str, language: Language) -> Result<ProjectInfo> {
27 let project_root = self.store.codebase_path.join(name);
28 if project_root.exists() {
29 return Err(ForgeError::FileAlreadyExists(project_root));
30 }
31
32 let template = project_template(&language, name);
33 let edit = EditModule::new(self.store.clone());
34
35 for (rel_path, content) in &template.files {
36 edit.create_file(Path::new(&format!("{}/{}", name, rel_path)), content)
37 .await?;
38 }
39
40 Ok(ProjectInfo {
41 root: project_root.clone(),
42 language,
43 entry_point: project_root.join(&template.entry_point),
44 manifest: template.manifest.map(|m| project_root.join(m)),
45 source_dir: project_root.join(&template.source_dir),
46 })
47 }
48
49 pub fn detect(&self) -> Option<ProjectInfo> {
50 let root = &self.store.codebase_path;
51 detect_project(root)
52 }
53}
54
55fn detect_project(root: &Path) -> Option<ProjectInfo> {
56 let lang_and_manifest: Option<(Language, PathBuf)> = if root.join("Cargo.toml").exists() {
57 Some((Language::Rust, root.join("Cargo.toml")))
58 } else if root.join("go.mod").exists() {
59 Some((Language::Go, root.join("go.mod")))
60 } else if root.join("pom.xml").exists() {
61 Some((Language::Java, root.join("pom.xml")))
62 } else if root.join("package.json").exists() {
63 let ext = if root.join("tsconfig.json").exists() {
64 Language::TypeScript
65 } else {
66 Language::JavaScript
67 };
68 Some((ext, root.join("package.json")))
69 } else if root.join("Makefile").exists() || root.join("makefile").exists() {
70 Some((Language::C, root.join("Makefile")))
71 } else if root.join("pyproject.toml").exists() || root.join("setup.py").exists() {
72 Some((Language::Python, root.join("pyproject.toml")))
73 } else {
74 None
75 };
76
77 let (language, manifest) = lang_and_manifest?;
78
79 let (source_dir, entry_point) = match &language {
80 Language::Rust => ("src".to_string(), "src/main.rs".to_string()),
81 Language::Python => ("src".to_string(), "src/main.py".to_string()),
82 Language::Java => (
83 "src/main/java".to_string(),
84 "src/main/java/Main.java".to_string(),
85 ),
86 Language::C => ("src".to_string(), "src/main.c".to_string()),
87 Language::TypeScript | Language::JavaScript => {
88 ("src".to_string(), "src/index.ts".to_string())
89 }
90 Language::Go => (".".to_string(), "main.go".to_string()),
91 _ => ("src".to_string(), "src/main".to_string()),
92 };
93
94 Some(ProjectInfo {
95 root: root.to_path_buf(),
96 language,
97 entry_point: root.join(&entry_point),
98 manifest: Some(manifest),
99 source_dir: root.join(&source_dir),
100 })
101}
102
103struct ProjectTemplate {
104 files: Vec<(String, String)>,
105 entry_point: String,
106 manifest: Option<String>,
107 source_dir: String,
108}
109
110fn project_template(lang: &Language, name: &str) -> ProjectTemplate {
111 match lang {
112 Language::Rust => ProjectTemplate {
113 files: vec![
114 (
115 "Cargo.toml".to_string(),
116 format!(
117 "[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n",
118 name
119 ),
120 ),
121 ("src/main.rs".to_string(), "fn main() {\n println!(\"Hello from {}!\");\n}\n".replace("{}", name)),
122 ],
123 entry_point: "src/main.rs".to_string(),
124 manifest: Some("Cargo.toml".to_string()),
125 source_dir: "src".to_string(),
126 },
127 Language::Python => ProjectTemplate {
128 files: vec![
129 (
130 "pyproject.toml".to_string(),
131 format!(
132 "[project]\nname = \"{}\"\nversion = \"0.1.0\"\nrequires-python = \">=3.8\"\n",
133 name
134 ),
135 ),
136 ("src/__init__.py".to_string(), String::new()),
137 (
138 "src/main.py".to_string(),
139 "def main():\n print(\"Hello!\")\n\nif __name__ == \"__main__\":\n main()\n".to_string(),
140 ),
141 ],
142 entry_point: "src/main.py".to_string(),
143 manifest: Some("pyproject.toml".to_string()),
144 source_dir: "src".to_string(),
145 },
146 Language::Java => ProjectTemplate {
147 files: vec![
148 (
149 "pom.xml".to_string(),
150 format!(
151 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project>\n <modelVersion>4.0.0</modelVersion>\n <groupId>com.example</groupId>\n <artifactId>{}</artifactId>\n <version>0.1.0</version>\n</project>\n",
152 name
153 ),
154 ),
155 (
156 "src/main/java/Main.java".to_string(),
157 "public class Main {\n public static void main(String[] args) {\n System.out.println(\"Hello!\");\n }\n}\n".to_string(),
158 ),
159 ],
160 entry_point: "src/main/java/Main.java".to_string(),
161 manifest: Some("pom.xml".to_string()),
162 source_dir: "src/main/java".to_string(),
163 },
164 Language::C => ProjectTemplate {
165 files: vec![
166 (
167 "Makefile".to_string(),
168 format!("CC = gcc\nCFLAGS = -Wall -Wextra\n\n{}.out: src/main.o\n\t$(CC) $(CFLAGS) -o $@ $^\n\nsrc/main.o: src/main.c\n\t$(CC) $(CFLAGS) -c -o $@ $<\n\nclean:\n\trm -f *.out src/*.o\n", name),
169 ),
170 (
171 "src/main.c".to_string(),
172 "#include <stdio.h>\n\nint main(void) {\n printf(\"Hello!\\n\");\n return 0;\n}\n".to_string(),
173 ),
174 (
175 "include/.gitkeep".to_string(),
176 String::new(),
177 ),
178 ],
179 entry_point: "src/main.c".to_string(),
180 manifest: Some("Makefile".to_string()),
181 source_dir: "src".to_string(),
182 },
183 Language::TypeScript => ProjectTemplate {
184 files: vec![
185 (
186 "package.json".to_string(),
187 format!(
188 "{{\"name\": \"{}\", \"version\": \"0.1.0\", \"main\": \"src/index.ts\", \"scripts\": {{\"build\": \"tsc\", \"test\": \"echo \\\"no tests\\\"\"}}}}\n",
189 name
190 ),
191 ),
192 (
193 "tsconfig.json".to_string(),
194 "{{\"compilerOptions\": {{\"target\": \"ES2020\", \"module\": \"commonjs\", \"outDir\": \"./dist\", \"strict\": true}}, \"include\": [\"src/**/*\"]}}\n".to_string(),
195 ),
196 (
197 "src/index.ts".to_string(),
198 "console.log(\"Hello!\");\n".to_string(),
199 ),
200 ],
201 entry_point: "src/index.ts".to_string(),
202 manifest: Some("package.json".to_string()),
203 source_dir: "src".to_string(),
204 },
205 _ => ProjectTemplate {
206 files: vec![("README.md".to_string(), format!("# {}\n", name))],
207 entry_point: "README.md".to_string(),
208 manifest: None,
209 source_dir: ".".to_string(),
210 },
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::storage::BackendKind;
218
219 async fn make_store(dir: &Path) -> std::sync::Arc<UnifiedGraphStore> {
220 std::sync::Arc::new(
221 UnifiedGraphStore::open_with_path(dir, dir.join("test.db"), BackendKind::default())
222 .await
223 .unwrap(),
224 )
225 }
226
227 #[tokio::test]
228 async fn test_scaffold_rust() {
229 let temp = tempfile::tempdir().unwrap();
230 let store = make_store(temp.path()).await;
231 let module = ProjectModule::new(store);
232
233 let info = module.scaffold("my-lib", Language::Rust).await.unwrap();
234 assert!(info.root.ends_with("my-lib"));
235 assert_eq!(info.language, Language::Rust);
236 assert!(info.entry_point.ends_with("src/main.rs"));
237 assert!(info.manifest.is_some());
238 assert!(temp.path().join("my-lib/Cargo.toml").exists());
239 assert!(temp.path().join("my-lib/src/main.rs").exists());
240 }
241
242 #[tokio::test]
243 async fn test_scaffold_python() {
244 let temp = tempfile::tempdir().unwrap();
245 let store = make_store(temp.path()).await;
246 let module = ProjectModule::new(store);
247
248 let info = module.scaffold("my-py", Language::Python).await.unwrap();
249 assert_eq!(info.language, Language::Python);
250 assert!(temp.path().join("my-py/pyproject.toml").exists());
251 assert!(temp.path().join("my-py/src/__init__.py").exists());
252 assert!(temp.path().join("my-py/src/main.py").exists());
253 }
254
255 #[tokio::test]
256 async fn test_scaffold_java() {
257 let temp = tempfile::tempdir().unwrap();
258 let store = make_store(temp.path()).await;
259 let module = ProjectModule::new(store);
260
261 let info = module.scaffold("my-java", Language::Java).await.unwrap();
262 assert_eq!(info.language, Language::Java);
263 assert!(temp.path().join("my-java/pom.xml").exists());
264 assert!(temp.path().join("my-java/src/main/java/Main.java").exists());
265 }
266
267 #[tokio::test]
268 async fn test_scaffold_c() {
269 let temp = tempfile::tempdir().unwrap();
270 let store = make_store(temp.path()).await;
271 let module = ProjectModule::new(store);
272
273 let info = module.scaffold("my-c", Language::C).await.unwrap();
274 assert_eq!(info.language, Language::C);
275 assert!(temp.path().join("my-c/Makefile").exists());
276 assert!(temp.path().join("my-c/src/main.c").exists());
277 }
278
279 #[tokio::test]
280 async fn test_scaffold_typescript() {
281 let temp = tempfile::tempdir().unwrap();
282 let store = make_store(temp.path()).await;
283 let module = ProjectModule::new(store);
284
285 let info = module
286 .scaffold("my-ts", Language::TypeScript)
287 .await
288 .unwrap();
289 assert_eq!(info.language, Language::TypeScript);
290 assert!(temp.path().join("my-ts/package.json").exists());
291 assert!(temp.path().join("my-ts/tsconfig.json").exists());
292 assert!(temp.path().join("my-ts/src/index.ts").exists());
293 }
294
295 #[tokio::test]
296 async fn test_scaffold_rejects_existing() {
297 let temp = tempfile::tempdir().unwrap();
298 tokio::fs::create_dir(temp.path().join("already-here"))
299 .await
300 .unwrap();
301 let store = make_store(temp.path()).await;
302 let module = ProjectModule::new(store);
303
304 let result = module.scaffold("already-here", Language::Rust).await;
305 assert!(result.is_err());
306 }
307
308 #[test]
309 fn test_detect_rust_project() {
310 let temp = tempfile::tempdir().unwrap();
311 std::fs::write(
312 temp.path().join("Cargo.toml"),
313 "[package]\nname = \"test\"\n",
314 )
315 .unwrap();
316 let info = detect_project(temp.path()).unwrap();
317 assert_eq!(info.language, Language::Rust);
318 }
319
320 #[test]
321 fn test_detect_python_project() {
322 let temp = tempfile::tempdir().unwrap();
323 std::fs::write(temp.path().join("pyproject.toml"), "[project]\n").unwrap();
324 let info = detect_project(temp.path()).unwrap();
325 assert_eq!(info.language, Language::Python);
326 }
327
328 #[test]
329 fn test_detect_typescript_over_js() {
330 let temp = tempfile::tempdir().unwrap();
331 std::fs::write(temp.path().join("package.json"), "{}").unwrap();
332 std::fs::write(temp.path().join("tsconfig.json"), "{}").unwrap();
333 let info = detect_project(temp.path()).unwrap();
334 assert_eq!(info.language, Language::TypeScript);
335 }
336
337 #[test]
338 fn test_detect_nothing() {
339 let temp = tempfile::tempdir().unwrap();
340 assert!(detect_project(temp.path()).is_none());
341 }
342}