Skip to main content

pro_core/
polylith.rs

1//! Polylith architecture support
2//!
3//! Polylith is a software architecture that applies functional thinking
4//! to the system level, organizing code into:
5//!
6//! - **bases/**: Entry points (CLI, web server, Lambda handler, etc.)
7//! - **components/**: Reusable building blocks with well-defined interfaces
8//! - **projects/**: Deployable artifacts that combine bases and components
9//!
10//! ```text
11//! workspace/
12//! ├── bases/
13//! │   └── myapp-cli/           # CLI entry point
14//! │       ├── pyproject.toml
15//! │       └── src/myapp_cli/
16//! ├── components/
17//! │   ├── user/                # User component
18//! │   │   ├── pyproject.toml
19//! │   │   └── src/user/
20//! │   └── database/            # Database component
21//! │       ├── pyproject.toml
22//! │       └── src/database/
23//! ├── projects/
24//! │   └── myapp/               # Deployable project
25//! │       └── pyproject.toml   # Combines base + components
26//! └── pyproject.toml           # Workspace root
27//! ```
28//!
29//! Benefits:
30//! - Clear separation of concerns
31//! - High code reuse across projects
32//! - Independent testing of components
33//! - Easy to reason about dependencies
34
35use std::collections::HashSet;
36use std::path::{Path, PathBuf};
37
38use crate::pep::PyProject;
39use crate::workspace::Workspace;
40use crate::{Error, Result};
41
42/// Polylith brick types
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum BrickType {
45    /// Entry point (CLI, API, etc.)
46    Base,
47    /// Reusable building block
48    Component,
49    /// Deployable artifact
50    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/// A Polylith brick (base, component, or project)
80#[derive(Debug, Clone)]
81pub struct Brick {
82    /// Brick type
83    pub brick_type: BrickType,
84    /// Brick name
85    pub name: String,
86    /// Path to the brick
87    pub path: PathBuf,
88    /// Dependencies on other bricks
89    pub brick_deps: Vec<String>,
90    /// External dependencies
91    pub external_deps: Vec<String>,
92}
93
94/// Polylith workspace configuration
95#[derive(Debug, Clone)]
96pub struct Polylith {
97    /// Workspace root
98    pub root: PathBuf,
99    /// Top namespace for all bricks
100    pub top_namespace: String,
101    /// All bases
102    pub bases: Vec<Brick>,
103    /// All components
104    pub components: Vec<Brick>,
105    /// All projects
106    pub projects: Vec<Brick>,
107}
108
109impl Polylith {
110    /// Initialize a new Polylith workspace
111    pub fn init(root: &Path, top_namespace: &str) -> Result<Self> {
112        // Create directory structure
113        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        // Update or create pyproject.toml with polylith config
122        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        // Ensure [tool.rx] exists
141        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        // Add workspace config
149        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        // Set members to include all polylith directories
157        let members = toml_edit::Array::from_iter(["bases/*", "components/*", "projects/*"]);
158        workspace_table["members"] = toml_edit::Item::Value(members.into());
159
160        // Add polylith config
161        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    /// Load an existing Polylith workspace
179    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    /// Check if a directory is a Polylith workspace
211    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    /// Discover all bricks in the workspace
221    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    /// Discover bricks of a specific type
229    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    /// Load a brick from its directory
253    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        // Parse dependencies
262        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            // Check if it's a brick dependency (starts with top namespace)
273            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    /// Check if a package name is a brick
290    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    /// Create a new brick
307    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        // Create directory structure
319        let src_dir = dir.join("src").join(name.replace('-', "_"));
320        std::fs::create_dir_all(&src_dir).map_err(Error::Io)?;
321
322        // Create __init__.py
323        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        // Create interface module for components
338        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        // Create pyproject.toml
358        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        // Create tests directory
381        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        // Add to appropriate list
408        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    /// Create a project that combines bases and components
418    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        // Build dependencies list
433        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    /// Get all bricks
489    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    /// Check for dependency cycles
498    pub fn check_cycles(&self) -> Result<()> {
499        // Build dependency graph
500        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        // Find the brick
532        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    /// Get the workspace for this polylith
547    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        // Check pyproject.toml was created
570        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}