1use std::collections::HashSet;
36use std::path::{Path, PathBuf};
37
38use crate::pep::PyProject;
39use crate::workspace::Workspace;
40use crate::{Error, Result};
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum BrickType {
45 Base,
47 Component,
49 Project,
51}
52
53impl BrickType {
54 pub fn as_str(&self) -> &'static str {
55 match self {
56 BrickType::Base => "base",
57 BrickType::Component => "component",
58 BrickType::Project => "project",
59 }
60 }
61
62 pub fn dir_name(&self) -> &'static str {
63 match self {
64 BrickType::Base => "bases",
65 BrickType::Component => "components",
66 BrickType::Project => "projects",
67 }
68 }
69
70 pub fn plural(&self) -> &'static str {
71 match self {
72 BrickType::Base => "bases",
73 BrickType::Component => "components",
74 BrickType::Project => "projects",
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct Brick {
82 pub brick_type: BrickType,
84 pub name: String,
86 pub path: PathBuf,
88 pub brick_deps: Vec<String>,
90 pub external_deps: Vec<String>,
92}
93
94#[derive(Debug, Clone)]
96pub struct Polylith {
97 pub root: PathBuf,
99 pub top_namespace: String,
101 pub bases: Vec<Brick>,
103 pub components: Vec<Brick>,
105 pub projects: Vec<Brick>,
107}
108
109impl Polylith {
110 pub fn init(root: &Path, top_namespace: &str) -> Result<Self> {
112 let bases_dir = root.join("bases");
114 let components_dir = root.join("components");
115 let projects_dir = root.join("projects");
116
117 std::fs::create_dir_all(&bases_dir).map_err(Error::Io)?;
118 std::fs::create_dir_all(&components_dir).map_err(Error::Io)?;
119 std::fs::create_dir_all(&projects_dir).map_err(Error::Io)?;
120
121 let pyproject_path = root.join("pyproject.toml");
123 let content = if pyproject_path.exists() {
124 std::fs::read_to_string(&pyproject_path).map_err(Error::Io)?
125 } else {
126 format!(
127 r#"[project]
128name = "{}-workspace"
129version = "0.0.0"
130description = "Polylith workspace"
131"#,
132 top_namespace
133 )
134 };
135
136 let mut doc: toml_edit::DocumentMut = content
137 .parse()
138 .map_err(|e| Error::Config(format!("Failed to parse pyproject.toml: {}", e)))?;
139
140 if !doc.contains_key("tool") {
142 doc["tool"] = toml_edit::Item::Table(toml_edit::Table::new());
143 }
144 if !doc["tool"].as_table().unwrap().contains_key("rx") {
145 doc["tool"]["rx"] = toml_edit::Item::Table(toml_edit::Table::new());
146 }
147
148 let rx_table = doc["tool"]["rx"].as_table_mut().unwrap();
150 if !rx_table.contains_key("workspace") {
151 rx_table["workspace"] = toml_edit::Item::Table(toml_edit::Table::new());
152 }
153
154 let workspace_table = rx_table["workspace"].as_table_mut().unwrap();
155
156 let members = toml_edit::Array::from_iter(["bases/*", "components/*", "projects/*"]);
158 workspace_table["members"] = toml_edit::Item::Value(members.into());
159
160 if !rx_table.contains_key("polylith") {
162 rx_table["polylith"] = toml_edit::Item::Table(toml_edit::Table::new());
163 }
164 let polylith_table = rx_table["polylith"].as_table_mut().unwrap();
165 polylith_table["top-namespace"] = toml_edit::Item::Value(top_namespace.into());
166
167 std::fs::write(&pyproject_path, doc.to_string()).map_err(Error::Io)?;
168
169 Ok(Self {
170 root: root.to_path_buf(),
171 top_namespace: top_namespace.to_string(),
172 bases: Vec::new(),
173 components: Vec::new(),
174 projects: Vec::new(),
175 })
176 }
177
178 pub fn load(root: &Path) -> Result<Self> {
180 let pyproject = PyProject::load(root)?;
181
182 let rx_config = pyproject
183 .tool
184 .get("rx")
185 .ok_or_else(|| Error::Config("No [tool.rx] section found".to_string()))?;
186
187 let polylith_config = rx_config
188 .get("polylith")
189 .ok_or_else(|| Error::Config("No [tool.rx.polylith] section found".to_string()))?;
190
191 let top_namespace = polylith_config
192 .get("top-namespace")
193 .and_then(|v| v.as_str())
194 .unwrap_or("app")
195 .to_string();
196
197 let mut polylith = Self {
198 root: root.to_path_buf(),
199 top_namespace,
200 bases: Vec::new(),
201 components: Vec::new(),
202 projects: Vec::new(),
203 };
204
205 polylith.discover_bricks()?;
206
207 Ok(polylith)
208 }
209
210 pub fn is_polylith(root: &Path) -> bool {
212 if let Ok(pyproject) = PyProject::load(root) {
213 if let Some(rx_config) = pyproject.tool.get("rx") {
214 return rx_config.get("polylith").is_some();
215 }
216 }
217 false
218 }
219
220 fn discover_bricks(&mut self) -> Result<()> {
222 self.bases = self.discover_brick_type(BrickType::Base)?;
223 self.components = self.discover_brick_type(BrickType::Component)?;
224 self.projects = self.discover_brick_type(BrickType::Project)?;
225 Ok(())
226 }
227
228 fn discover_brick_type(&self, brick_type: BrickType) -> Result<Vec<Brick>> {
230 let dir = self.root.join(brick_type.dir_name());
231 let mut bricks = Vec::new();
232
233 if !dir.exists() {
234 return Ok(bricks);
235 }
236
237 for entry in std::fs::read_dir(&dir).map_err(Error::Io)? {
238 let entry = entry.map_err(Error::Io)?;
239 let path = entry.path();
240
241 if path.is_dir() && path.join("pyproject.toml").exists() {
242 if let Ok(brick) = self.load_brick(&path, brick_type) {
243 bricks.push(brick);
244 }
245 }
246 }
247
248 bricks.sort_by(|a, b| a.name.cmp(&b.name));
249 Ok(bricks)
250 }
251
252 fn load_brick(&self, path: &Path, brick_type: BrickType) -> Result<Brick> {
254 let pyproject = PyProject::load(path)?;
255
256 let name = pyproject
257 .name()
258 .ok_or_else(|| Error::Config("Brick has no name".to_string()))?
259 .to_string();
260
261 let mut brick_deps = Vec::new();
263 let mut external_deps = Vec::new();
264
265 for dep in pyproject.dependencies() {
266 let dep_name = dep
267 .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
268 .next()
269 .unwrap_or("")
270 .to_string();
271
272 if dep_name.starts_with(&self.top_namespace) || self.is_brick(&dep_name) {
274 brick_deps.push(dep_name);
275 } else {
276 external_deps.push(dep_name);
277 }
278 }
279
280 Ok(Brick {
281 brick_type,
282 name,
283 path: path.to_path_buf(),
284 brick_deps,
285 external_deps,
286 })
287 }
288
289 fn is_brick(&self, name: &str) -> bool {
291 let normalized = name.to_lowercase().replace('-', "_");
292
293 for brick in &self.bases {
294 if brick.name.to_lowercase().replace('-', "_") == normalized {
295 return true;
296 }
297 }
298 for brick in &self.components {
299 if brick.name.to_lowercase().replace('-', "_") == normalized {
300 return true;
301 }
302 }
303 false
304 }
305
306 pub fn create_brick(&mut self, brick_type: BrickType, name: &str) -> Result<Brick> {
308 let dir = self.root.join(brick_type.dir_name()).join(name);
309
310 if dir.exists() {
311 return Err(Error::Config(format!(
312 "{} '{}' already exists",
313 brick_type.as_str(),
314 name
315 )));
316 }
317
318 let src_dir = dir.join("src").join(name.replace('-', "_"));
320 std::fs::create_dir_all(&src_dir).map_err(Error::Io)?;
321
322 let init_content = format!(
324 r#"""{}
325
326This {} is part of the {} Polylith workspace.
327"""
328
329__version__ = "0.1.0"
330"#,
331 name.replace('-', "_"),
332 brick_type.as_str(),
333 self.top_namespace
334 );
335 std::fs::write(src_dir.join("__init__.py"), init_content).map_err(Error::Io)?;
336
337 if brick_type == BrickType::Component {
339 let interface_content = r#""""Public interface for this component.
340
341Export only what should be used by other bricks.
342"""
343
344# Export public API here
345# from .core import MyClass, my_function
346"#;
347 std::fs::write(src_dir.join("interface.py"), interface_content).map_err(Error::Io)?;
348
349 let core_content = r#""""Core implementation.
350
351Internal implementation details. Use interface.py for public API.
352"""
353"#;
354 std::fs::write(src_dir.join("core.py"), core_content).map_err(Error::Io)?;
355 }
356
357 let pyproject_content = format!(
359 r#"[project]
360name = "{name}"
361version = "0.1.0"
362description = "A {brick_type} in the {namespace} workspace"
363requires-python = ">=3.9"
364dependencies = []
365
366[build-system]
367requires = ["hatchling"]
368build-backend = "hatchling.build"
369
370[tool.hatch.build.targets.wheel]
371packages = ["src/{package_name}"]
372"#,
373 name = name,
374 brick_type = brick_type.as_str(),
375 namespace = self.top_namespace,
376 package_name = name.replace('-', "_"),
377 );
378 std::fs::write(dir.join("pyproject.toml"), pyproject_content).map_err(Error::Io)?;
379
380 let tests_dir = dir.join("tests");
382 std::fs::create_dir_all(&tests_dir).map_err(Error::Io)?;
383
384 let test_content = format!(
385 r#""""Tests for {}."""
386
387def test_placeholder():
388 """Placeholder test."""
389 assert True
390"#,
391 name
392 );
393 std::fs::write(
394 tests_dir.join(format!("test_{}.py", name.replace('-', "_"))),
395 test_content,
396 )
397 .map_err(Error::Io)?;
398
399 let brick = Brick {
400 brick_type,
401 name: name.to_string(),
402 path: dir,
403 brick_deps: Vec::new(),
404 external_deps: Vec::new(),
405 };
406
407 match brick_type {
409 BrickType::Base => self.bases.push(brick.clone()),
410 BrickType::Component => self.components.push(brick.clone()),
411 BrickType::Project => self.projects.push(brick.clone()),
412 }
413
414 Ok(brick)
415 }
416
417 pub fn create_project(
419 &mut self,
420 name: &str,
421 bases: &[String],
422 components: &[String],
423 ) -> Result<Brick> {
424 let dir = self.root.join("projects").join(name);
425
426 if dir.exists() {
427 return Err(Error::Config(format!("Project '{}' already exists", name)));
428 }
429
430 std::fs::create_dir_all(&dir).map_err(Error::Io)?;
431
432 let mut deps = Vec::new();
434 for base in bases {
435 deps.push(format!("{} @ {{root:uri}}/../bases/{}", base, base));
436 }
437 for component in components {
438 deps.push(format!(
439 "{} @ {{root:uri}}/../components/{}",
440 component, component
441 ));
442 }
443
444 let deps_str = deps
445 .iter()
446 .map(|d| format!(" \"{}\",", d))
447 .collect::<Vec<_>>()
448 .join("\n");
449
450 let pyproject_content = format!(
451 r#"[project]
452name = "{name}"
453version = "0.1.0"
454description = "Deployable project combining bases and components"
455requires-python = ">=3.9"
456dependencies = [
457{deps}
458]
459
460[build-system]
461requires = ["hatchling"]
462build-backend = "hatchling.build"
463
464[tool.rx.project]
465bases = {bases:?}
466components = {components:?}
467"#,
468 name = name,
469 deps = deps_str,
470 bases = bases,
471 components = components,
472 );
473
474 std::fs::write(dir.join("pyproject.toml"), pyproject_content).map_err(Error::Io)?;
475
476 let brick = Brick {
477 brick_type: BrickType::Project,
478 name: name.to_string(),
479 path: dir,
480 brick_deps: bases.iter().chain(components.iter()).cloned().collect(),
481 external_deps: Vec::new(),
482 };
483
484 self.projects.push(brick.clone());
485 Ok(brick)
486 }
487
488 pub fn all_bricks(&self) -> Vec<&Brick> {
490 let mut all = Vec::new();
491 all.extend(self.bases.iter());
492 all.extend(self.components.iter());
493 all.extend(self.projects.iter());
494 all
495 }
496
497 pub fn check_cycles(&self) -> Result<()> {
499 let mut visited = HashSet::new();
501 let mut rec_stack = HashSet::new();
502
503 for brick in self.all_bricks() {
504 if self.has_cycle(&brick.name, &mut visited, &mut rec_stack)? {
505 return Err(Error::Config(format!(
506 "Dependency cycle detected involving '{}'",
507 brick.name
508 )));
509 }
510 }
511
512 Ok(())
513 }
514
515 fn has_cycle(
516 &self,
517 name: &str,
518 visited: &mut HashSet<String>,
519 rec_stack: &mut HashSet<String>,
520 ) -> Result<bool> {
521 if rec_stack.contains(name) {
522 return Ok(true);
523 }
524 if visited.contains(name) {
525 return Ok(false);
526 }
527
528 visited.insert(name.to_string());
529 rec_stack.insert(name.to_string());
530
531 let brick = self.all_bricks().into_iter().find(|b| b.name == name);
533
534 if let Some(brick) = brick {
535 for dep in &brick.brick_deps {
536 if self.has_cycle(dep, visited, rec_stack)? {
537 return Ok(true);
538 }
539 }
540 }
541
542 rec_stack.remove(name);
543 Ok(false)
544 }
545
546 pub fn as_workspace(&self) -> Result<Workspace> {
548 Workspace::load_from_root(&self.root)
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use tempfile::TempDir;
556
557 #[test]
558 fn test_polylith_init() {
559 let temp = TempDir::new().unwrap();
560 let root = temp.path();
561
562 let polylith = Polylith::init(root, "myapp").unwrap();
563
564 assert_eq!(polylith.top_namespace, "myapp");
565 assert!(root.join("bases").exists());
566 assert!(root.join("components").exists());
567 assert!(root.join("projects").exists());
568
569 let content = std::fs::read_to_string(root.join("pyproject.toml")).unwrap();
571 assert!(content.contains("[tool.rx.polylith]"));
572 assert!(content.contains("top-namespace"));
573 }
574
575 #[test]
576 fn test_create_component() {
577 let temp = TempDir::new().unwrap();
578 let root = temp.path();
579
580 let mut polylith = Polylith::init(root, "myapp").unwrap();
581 let brick = polylith.create_brick(BrickType::Component, "user").unwrap();
582
583 assert_eq!(brick.name, "user");
584 assert_eq!(brick.brick_type, BrickType::Component);
585 assert!(root.join("components/user/pyproject.toml").exists());
586 assert!(root.join("components/user/src/user/__init__.py").exists());
587 assert!(root.join("components/user/src/user/interface.py").exists());
588 }
589
590 #[test]
591 fn test_create_base() {
592 let temp = TempDir::new().unwrap();
593 let root = temp.path();
594
595 let mut polylith = Polylith::init(root, "myapp").unwrap();
596 let brick = polylith.create_brick(BrickType::Base, "cli").unwrap();
597
598 assert_eq!(brick.name, "cli");
599 assert_eq!(brick.brick_type, BrickType::Base);
600 assert!(root.join("bases/cli/pyproject.toml").exists());
601 }
602
603 #[test]
604 fn test_is_polylith() {
605 let temp = TempDir::new().unwrap();
606 let root = temp.path();
607
608 assert!(!Polylith::is_polylith(root));
609
610 Polylith::init(root, "myapp").unwrap();
611
612 assert!(Polylith::is_polylith(root));
613 }
614}