From b5a2107b5d2674bea061e85fbc82475b56c71050 Mon Sep 17 00:00:00 2001 From: Sandeep Selvaraj Date: Fri, 27 Feb 2026 08:53:59 +0100 Subject: [PATCH 01/11] chore: make fmt happy --- src/commands/add.rs | 19 +++++++----- src/commands/build.rs | 6 +++- src/commands/check.rs | 8 +++-- src/commands/create.rs | 8 ++--- src/commands/deps.rs | 17 +++++++---- src/commands/diff.rs | 67 ++++++++++++++++++++++++++++-------------- src/commands/info.rs | 13 ++++---- src/commands/run.rs | 6 +++- src/display.rs | 8 ++++- src/main.rs | 6 +++- src/uv.rs | 7 ++++- src/workspace.rs | 53 +++++++++++++++++++++++---------- 12 files changed, 150 insertions(+), 68 deletions(-) diff --git a/src/commands/add.rs b/src/commands/add.rs index 4304ed6..f6c87e9 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -1,7 +1,7 @@ use anyhow::{bail, Result}; use indexmap::IndexMap; -use crate::config::{UvSource, UvToolConfig, ToolConfig}; +use crate::config::{ToolConfig, UvSource, UvToolConfig}; use crate::display; use crate::workspace::{read_pyproject, write_pyproject, Workspace}; @@ -17,7 +17,11 @@ pub fn run(package: &str, to: &str) -> Result<()> { anyhow::anyhow!( "Package '{}' not found in workspace. Available: {}", package, - ws.packages.iter().map(|b| b.name.as_str()).collect::>().join(", ") + ws.packages + .iter() + .map(|b| b.name.as_str()) + .collect::>() + .join(", ") ) })?; @@ -41,7 +45,11 @@ pub fn run(package: &str, to: &str) -> Result<()> { // Check if dependency already present if let Some(project) = &pyproject.project { - if project.dependencies.iter().any(|d| d.starts_with(&dep_name)) { + if project + .dependencies + .iter() + .any(|d| d.starts_with(&dep_name)) + { display::warning(&format!( "'{}' is already a dependency of '{}'", dep_name, to @@ -68,10 +76,7 @@ pub fn run(package: &str, to: &str) -> Result<()> { display::modified(&pyproject_path.to_string_lossy()); println!(); - display::success(&format!( - "Added '{}' as a dependency of '{}'", - dep_name, to - )); + display::success(&format!("Added '{}' as a dependency of '{}'", dep_name, to)); println!(" Run `pascal sync` to update the UV lockfile."); Ok(()) diff --git a/src/commands/build.rs b/src/commands/build.rs index 4106fa6..3ea7b4c 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -15,7 +15,11 @@ pub fn run(app_name: &str) -> Result<()> { anyhow::anyhow!( "App '{}' not found. Available apps: {}", app_name, - ws.apps.iter().map(|a| a.name.as_str()).collect::>().join(", ") + ws.apps + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", ") ) })?; diff --git a/src/commands/check.rs b/src/commands/check.rs index 8465a5b..47d411d 100644 --- a/src/commands/check.rs +++ b/src/commands/check.rs @@ -43,7 +43,9 @@ pub fn run() -> Result<()> { .replace('-', "_"); if member_names.contains(&dep_norm) { - if let (Some(&src), Some(&dst)) = (node_map.get(&brick.name), node_map.get(&dep_norm)) { + if let (Some(&src), Some(&dst)) = + (node_map.get(&brick.name), node_map.get(&dep_norm)) + { g.add_edge(src, dst, ()); } } @@ -83,7 +85,9 @@ pub fn run() -> Result<()> { if member_names.contains(&dep_norm) { let in_sources = sources - .map(|s| s.contains_key(&dep_norm) || s.contains_key(&dep_norm.replace('_', "-"))) + .map(|s| { + s.contains_key(&dep_norm) || s.contains_key(&dep_norm.replace('_', "-")) + }) .unwrap_or(false); if !in_sources { diff --git a/src/commands/create.rs b/src/commands/create.rs index da71e93..e02242c 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -59,9 +59,7 @@ fn scaffold_brick(dir: &Path, name: &str, python: &str, is_app: bool) -> Result< std::fs::create_dir_all(&src_pkg)?; std::fs::create_dir_all(&tests_dir)?; - let rel = |path: &Path| -> String { - path.to_string_lossy().into_owned() - }; + let rel = |path: &Path| -> String { path.to_string_lossy().into_owned() }; // pyproject.toml let pyproject_content = if is_app { @@ -94,7 +92,9 @@ fn scaffold_brick(dir: &Path, name: &str, python: &str, is_app: bool) -> Result< } fn validate_name(name: &str) -> Result<()> { - let valid = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + let valid = name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); if !valid || name.is_empty() { bail!( "Invalid name '{}': must contain only lowercase letters, digits, underscores, or hyphens", diff --git a/src/commands/deps.rs b/src/commands/deps.rs index dd0a92f..2ca7811 100644 --- a/src/commands/deps.rs +++ b/src/commands/deps.rs @@ -75,7 +75,11 @@ fn print_list(ws: &Workspace, member_names: &[String]) { .next() .unwrap_or(d) .replace('-', "_"); - if member_names.contains(&name) { Some(name) } else { None } + if member_names.contains(&name) { + Some(name) + } else { + None + } }) .collect(); @@ -87,7 +91,11 @@ fn print_list(ws: &Workspace, member_names: &[String]) { .next() .unwrap_or(d) .replace('-', "_"); - if !member_names.contains(&name) { Some(d.clone()) } else { None } + if !member_names.contains(&name) { + Some(d.clone()) + } else { + None + } }) .collect(); @@ -123,10 +131,7 @@ fn print_graph( Some(&i) => i, None => continue, }; - let neighbors: Vec = g - .neighbors(idx) - .map(|n| g[n].clone()) - .collect(); + let neighbors: Vec = g.neighbors(idx).map(|n| g[n].clone()).collect(); print!(" {}", brick.name.bold()); if neighbors.is_empty() { diff --git a/src/commands/diff.rs b/src/commands/diff.rs index 24bff2f..805293d 100644 --- a/src/commands/diff.rs +++ b/src/commands/diff.rs @@ -37,14 +37,18 @@ pub fn run(since: Option) -> Result<()> { for brick in &all_bricks { // Check if any changed file belongs to this brick - let brick_rel = ws.root.join("packages") + let brick_rel = ws + .root + .join("packages") .join(&brick.name) .strip_prefix(&ws.root) .unwrap_or_else(|_| std::path::Path::new("")) .to_string_lossy() .into_owned(); - let brick_rel_app = ws.root.join("apps") + let brick_rel_app = ws + .root + .join("apps") .join(&brick.name) .strip_prefix(&ws.root) .unwrap_or_else(|_| std::path::Path::new("")) @@ -52,7 +56,8 @@ pub fn run(since: Option) -> Result<()> { .into_owned(); // Also compute relative path of the brick from root - let brick_path_rel = brick.path + let brick_path_rel = brick + .path .strip_prefix(&ws.root) .unwrap_or(&brick.path) .to_string_lossy() @@ -76,35 +81,53 @@ pub fn run(since: Option) -> Result<()> { println!(" {} Changed bricks:", "◈".bright_blue()); for name in &changed_bricks { - let kind = if ws.packages.iter().any(|b| &b.name == name) { "package" } else { "app" }; - println!(" {} {} {}", "◆".yellow(), name.bold(), format!("[{kind}]").dimmed()); + let kind = if ws.packages.iter().any(|b| &b.name == name) { + "package" + } else { + "app" + }; + println!( + " {} {} {}", + "◆".yellow(), + name.bold(), + format!("[{kind}]").dimmed() + ); } // Find apps that depend on changed packages - let affected_apps: Vec<&Brick> = ws.apps.iter().filter(|app| { - let deps = app - .pyproject - .project - .as_ref() - .map(|p| p.dependencies.clone()) - .unwrap_or_default(); - - deps.iter().any(|d| { - let name = d - .split(['>', '<', '=', '[', ';', ' ']) - .next() - .unwrap_or(d) - .replace('-', "_"); - changed_bricks.contains(&name) + let affected_apps: Vec<&Brick> = ws + .apps + .iter() + .filter(|app| { + let deps = app + .pyproject + .project + .as_ref() + .map(|p| p.dependencies.clone()) + .unwrap_or_default(); + + deps.iter().any(|d| { + let name = d + .split(['>', '<', '=', '[', ';', ' ']) + .next() + .unwrap_or(d) + .replace('-', "_"); + changed_bricks.contains(&name) + }) }) - }).collect(); + .collect(); if !affected_apps.is_empty() { println!(); println!(" {} Apps affected by changed packages:", "◈".bright_blue()); for app in &affected_apps { if !changed_bricks.contains(&app.name) { - println!(" {} {} {}", "▶".cyan(), app.name.bold(), "[app — transitive]".dimmed()); + println!( + " {} {} {}", + "▶".cyan(), + app.name.bold(), + "[app — transitive]".dimmed() + ); } } } diff --git a/src/commands/info.rs b/src/commands/info.rs index 3d69a65..020e884 100644 --- a/src/commands/info.rs +++ b/src/commands/info.rs @@ -21,7 +21,11 @@ pub fn run() -> Result<()> { // Packages println!(); - println!(" {} ({})", "Packages".bold().bright_blue(), ws.packages.len()); + println!( + " {} ({})", + "Packages".bold().bright_blue(), + ws.packages.len() + ); if ws.packages.is_empty() { println!(" {}", "(none)".dimmed()); } else { @@ -38,12 +42,7 @@ pub fn run() -> Result<()> { .as_ref() .map(|p| p.dependencies.len()) .unwrap_or(0); - display::tree_item( - 1, - "◆", - &pkg.name, - &format!("v{version} {dep_count} deps"), - ); + display::tree_item(1, "◆", &pkg.name, &format!("v{version} {dep_count} deps")); } } diff --git a/src/commands/run.rs b/src/commands/run.rs index 40c7eb8..61125c2 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -15,7 +15,11 @@ pub fn run(app_name: &str, extra: &[String]) -> Result<()> { anyhow::anyhow!( "App '{}' not found. Available apps: {}", app_name, - ws.apps.iter().map(|a| a.name.as_str()).collect::>().join(", ") + ws.apps + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", ") ) })?; diff --git a/src/display.rs b/src/display.rs index a82e202..3ba864c 100644 --- a/src/display.rs +++ b/src/display.rs @@ -10,7 +10,13 @@ pub fn section_header(title: &str) { let right = padding - left; println!( "{}", - format!("│ {}{}{} │", " ".repeat(left), title.bold(), " ".repeat(right)).bright_blue() + format!( + "│ {}{}{} │", + " ".repeat(left), + title.bold(), + " ".repeat(right) + ) + .bright_blue() ); println!("{}", format!("└{}┘", line).bright_blue()); } diff --git a/src/main.rs b/src/main.rs index 5c79d1c..4427b15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,7 +50,11 @@ fn main() -> Result<()> { commands::diff::run(since)?; } - Commands::Test { changed, name, extra } => { + Commands::Test { + changed, + name, + extra, + } => { commands::test::run(changed, name, &extra)?; } diff --git a/src/uv.rs b/src/uv.rs index bd32da5..bb836e2 100644 --- a/src/uv.rs +++ b/src/uv.rs @@ -32,7 +32,12 @@ pub fn capture_uv(args: &[&str], cwd: &Path) -> Result { } /// `uv run --project [extra_args]` -pub fn uv_run(project_dir: &Path, entry: &str, extra: &[String], workspace_root: &Path) -> Result { +pub fn uv_run( + project_dir: &Path, + entry: &str, + extra: &[String], + workspace_root: &Path, +) -> Result { let mut args: Vec = vec![ "run".into(), "--project".into(), diff --git a/src/workspace.rs b/src/workspace.rs index b0166ed..cc27e4e 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -12,8 +12,8 @@ pub struct Brick { pub name: String, #[allow(dead_code)] pub kind: BrickKind, - pub path: PathBuf, // absolute path to the brick directory - pub pyproject: PyProject, // parsed pyproject.toml + pub path: PathBuf, // absolute path to the brick directory + pub pyproject: PyProject, // parsed pyproject.toml } #[derive(Debug, Clone, PartialEq, Eq)] @@ -59,17 +59,29 @@ impl Workspace { let packages = discover_bricks(root, BrickKind::Package, &config)?; let apps = discover_bricks(root, BrickKind::App, &config)?; - Ok(Workspace { root: root.to_path_buf(), config, packages, apps }) + Ok(Workspace { + root: root.to_path_buf(), + config, + packages, + apps, + }) } /// Find a brick by name (searches both packages and apps) pub fn find_brick(&self, name: &str) -> Option<&Brick> { - self.packages.iter().chain(self.apps.iter()).find(|b| b.name == name) + self.packages + .iter() + .chain(self.apps.iter()) + .find(|b| b.name == name) } /// All workspace member names pub fn member_names(&self) -> Vec { - self.packages.iter().chain(self.apps.iter()).map(|b| b.name.clone()).collect() + self.packages + .iter() + .chain(self.apps.iter()) + .map(|b| b.name.clone()) + .collect() } } @@ -95,12 +107,16 @@ fn discover_bricks(root: &Path, kind: BrickKind, config: &PascalConfig) -> Resul // Use explicit list if provided, otherwise auto-discover let explicit_paths: Option> = match &kind { - BrickKind::Package => config.workspace.packages.as_ref().map(|ps| { - ps.iter().map(|p| root.join(p)).collect() - }), - BrickKind::App => config.workspace.apps.as_ref().map(|ps| { - ps.iter().map(|p| root.join(p)).collect() - }), + BrickKind::Package => config + .workspace + .packages + .as_ref() + .map(|ps| ps.iter().map(|p| root.join(p)).collect()), + BrickKind::App => config + .workspace + .apps + .as_ref() + .map(|ps| ps.iter().map(|p| root.join(p)).collect()), }; let dirs: Vec = if let Some(paths) = explicit_paths { @@ -138,10 +154,18 @@ fn discover_bricks(root: &Path, kind: BrickKind, config: &PascalConfig) -> Resul .as_ref() .map(|p| p.name.replace('-', "_")) .unwrap_or_else(|| { - dir.file_name().unwrap_or_default().to_string_lossy().into_owned() + dir.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() }); - bricks.push(Brick { name, kind: kind.clone(), path: dir, pyproject }); + bricks.push(Brick { + name, + kind: kind.clone(), + path: dir, + pyproject, + }); } Ok(bricks) @@ -151,8 +175,7 @@ fn discover_bricks(root: &Path, kind: BrickKind, config: &PascalConfig) -> Resul pub fn read_pyproject(path: &Path) -> Result { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; - toml::from_str(&content) - .with_context(|| format!("Failed to parse {}", path.display())) + toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display())) } /// Write a pyproject.toml (serialized from struct) From 8535bd6e6b7003d234856fb8b4eb9e9ec33cf663 Mon Sep 17 00:00:00 2001 From: Sandeep Selvaraj Date: Fri, 27 Feb 2026 08:56:05 +0100 Subject: [PATCH 02/11] ci: have a pre-commit configuration --- .pre-commit-config.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e3ca0df --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: local + hooks: + - id: cargo-fmt + name: cargo fmt + entry: cargo fmt -- + language: system + types: [rust] + pass_filenames: true + + - id: cargo-clippy + name: cargo clippy + entry: cargo clippy -- -D warnings + language: system + types: [rust] + pass_filenames: false From 0909dc39a0ba18e6673e117c5cb746f75985dbbb Mon Sep 17 00:00:00 2001 From: Sandeep Selvaraj Date: Fri, 27 Feb 2026 08:59:48 +0100 Subject: [PATCH 03/11] build: add tempfile required for testsing --- Cargo.lock | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +++ 2 files changed, 58 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index e8157d9..3030323 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -381,6 +397,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -399,6 +421,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -438,6 +466,7 @@ dependencies = [ "indexmap", "petgraph", "serde", + "tempfile", "thiserror", "toml", "walkdir", @@ -498,6 +527,19 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -592,6 +634,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 730c529..9a04c4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,6 @@ petgraph = "0.6" git2 = { version = "0.19", default-features = false, features = ["vendored-openssl"] } indexmap = { version = "2", features = ["serde"] } thiserror = "1" + +[dev-dependencies] +tempfile = "3" From 856ae08f22a144a2a396b94a578662be565a51ba Mon Sep 17 00:00:00 2001 From: Sandeep Selvaraj Date: Fri, 27 Feb 2026 09:19:06 +0100 Subject: [PATCH 04/11] tests: test validity of names, empty spaces --- src/commands/create.rs | 42 +++++++++++++ src/template.rs | 134 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/src/commands/create.rs b/src/commands/create.rs index e02242c..c77332d 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -103,3 +103,45 @@ fn validate_name(name: &str) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_name_accepts_simple_name() { + assert!(validate_name("cart").is_ok()); + } + + #[test] + fn validate_name_accepts_hyphens_and_underscores() { + assert!(validate_name("my-pkg").is_ok()); + assert!(validate_name("my_pkg").is_ok()); + assert!(validate_name("my-pkg_v2").is_ok()); + } + + #[test] + fn validate_name_accepts_digits() { + assert!(validate_name("pkg2").is_ok()); + } + + #[test] + fn validate_name_rejects_empty() { + assert!(validate_name("").is_err()); + } + + #[test] + fn validate_name_rejects_spaces() { + assert!(validate_name("my pkg").is_err()); + } + + #[test] + fn validate_name_rejects_dots() { + assert!(validate_name("my.pkg").is_err()); + } + + #[test] + fn validate_name_rejects_slashes() { + assert!(validate_name("a/b").is_err()); + } +} diff --git a/src/template.rs b/src/template.rs index 3c42f88..334ca1f 100644 --- a/src/template.rs +++ b/src/template.rs @@ -1,4 +1,4 @@ -/// Templates for generated files +//! Templates for generated files pub fn pascal_toml(name: &str, python: &str) -> String { format!( @@ -94,3 +94,135 @@ members = ["packages/*", "apps/*"] "# ) } + +#[cfg(test)] +mod tests { + use super::*; + + // ── pascal_toml ────────────────────────────────────────────────────────── + + #[test] + fn pascal_toml_contains_name_and_python() { + let out = pascal_toml("my-ws", "3.12"); + assert!(out.contains("my-ws")); + assert!(out.contains("3.12")); + } + + #[test] + fn pascal_toml_is_valid_toml() { + let out = pascal_toml("ws", "3.11"); + toml::from_str::(&out).expect("should be valid TOML"); + } + + // ── package_pyproject ──────────────────────────────────────────────────── + + #[test] + fn package_pyproject_normalizes_underscores_to_hyphens() { + // project name in pyproject.toml should use hyphens + let out = package_pyproject("my_pkg", "3.12"); + assert!(out.contains("my-pkg")); + } + + #[test] + fn package_pyproject_contains_python_constraint() { + let out = package_pyproject("cart", "3.11"); + assert!(out.contains(">=3.11")); + } + + #[test] + fn package_pyproject_uses_hatchling() { + let out = package_pyproject("cart", "3.12"); + assert!(out.contains("hatchling")); + } + + #[test] + fn package_pyproject_is_valid_toml() { + let out = package_pyproject("cart", "3.12"); + toml::from_str::(&out).expect("should be valid TOML"); + } + + // ── app_pyproject ──────────────────────────────────────────────────────── + + #[test] + fn app_pyproject_has_scripts_section() { + let out = app_pyproject("api_service", "3.12"); + assert!(out.contains("[project.scripts]")); + } + + #[test] + fn app_pyproject_script_key_uses_snake_case() { + let out = app_pyproject("api-service", "3.12"); + assert!(out.contains("api_service")); + } + + #[test] + fn app_pyproject_normalizes_hyphens_in_name() { + let out = app_pyproject("my_app", "3.12"); + assert!(out.contains("my-app")); + } + + #[test] + fn app_pyproject_is_valid_toml() { + let out = app_pyproject("api_service", "3.12"); + toml::from_str::(&out).expect("should be valid TOML"); + } + + // ── init_py ────────────────────────────────────────────────────────────── + + #[test] + fn init_py_contains_version_var() { + let out = init_py("cart"); + assert!(out.contains("__version__")); + } + + #[test] + fn init_py_snake_cases_name() { + let out = init_py("my-pkg"); + assert!(out.contains("my_pkg")); + assert!(!out.contains("my-pkg")); + } + + // ── app_main_py ────────────────────────────────────────────────────────── + + #[test] + fn app_main_py_has_main_function() { + let out = app_main_py("api_service"); + assert!(out.contains("def main()")); + } + + #[test] + fn app_main_py_has_dunder_main_guard() { + let out = app_main_py("api_service"); + assert!(out.contains("__main__")); + } + + // ── test_stub_py ───────────────────────────────────────────────────────── + + #[test] + fn test_stub_py_function_matches_name() { + let out = test_stub_py("cart"); + assert!(out.contains("def test_cart()")); + } + + #[test] + fn test_stub_py_snake_cases_hyphenated_name() { + let out = test_stub_py("my-pkg"); + assert!(out.contains("def test_my_pkg()")); + } + + // ── root_pyproject ─────────────────────────────────────────────────────── + + #[test] + fn root_pyproject_has_uv_workspace_members() { + let out = root_pyproject("my-ws", "3.12"); + assert!(out.contains("[tool.uv.workspace]")); + assert!(out.contains("packages/*")); + assert!(out.contains("apps/*")); + } + + #[test] + fn root_pyproject_is_valid_toml() { + let out = root_pyproject("my-ws", "3.12"); + toml::from_str::(&out).expect("should be valid TOML"); + } +} From 09c64907c978c7aec2ad170d39e32528ac40a1d6 Mon Sep 17 00:00:00 2001 From: Sandeep Selvaraj Date: Fri, 27 Feb 2026 09:19:56 +0100 Subject: [PATCH 05/11] tests: test pascal+pyproject parsing --- src/config.rs | 105 +++++++++++++++++++++++++++++++++ src/workspace.rs | 148 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) diff --git a/src/config.rs b/src/config.rs index b57fbd7..edd070e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -77,3 +77,108 @@ pub struct BuildSystem { #[serde(rename = "build-backend")] pub build_backend: String, } + +#[cfg(test)] +mod tests { + use super::*; + + // ── PascalConfig ───────────────────────────────────────────────────────── + + #[test] + fn parse_minimal_pascal_toml() { + let toml = r#" +[workspace] +name = "my-ws" +python = "3.12" +"#; + let cfg: PascalConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.workspace.name, "my-ws"); + assert_eq!(cfg.workspace.python, "3.12"); + assert!(cfg.workspace.description.is_none()); + assert!(cfg.workspace.packages.is_none()); + assert!(cfg.workspace.apps.is_none()); + } + + #[test] + fn parse_pascal_toml_with_explicit_lists() { + let toml = r#" +[workspace] +name = "ws" +python = "3.11" +description = "a workspace" +packages = ["packages/cart", "packages/auth"] +apps = ["apps/api"] +"#; + let cfg: PascalConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.workspace.description.as_deref(), Some("a workspace")); + assert_eq!( + cfg.workspace.packages.as_deref(), + Some(&["packages/cart".to_string(), "packages/auth".to_string()][..]) + ); + assert_eq!( + cfg.workspace.apps.as_deref(), + Some(&["apps/api".to_string()][..]) + ); + } + + // ── PyProject ──────────────────────────────────────────────────────────── + + #[test] + fn parse_pyproject_with_dependencies() { + let toml = r#" +[project] +name = "api" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = ["cart", "httpx>=0.27"] +"#; + let pp: PyProject = toml::from_str(toml).unwrap(); + let proj = pp.project.unwrap(); + assert_eq!(proj.name, "api"); + assert_eq!(proj.dependencies, vec!["cart", "httpx>=0.27"]); + assert_eq!(proj.requires_python.as_deref(), Some(">=3.12")); + } + + #[test] + fn pyproject_defaults_to_empty_dependencies() { + let toml = r#" +[project] +name = "cart" +"#; + let pp: PyProject = toml::from_str(toml).unwrap(); + assert!(pp.project.unwrap().dependencies.is_empty()); + } + + // ── UvSource ───────────────────────────────────────────────────────────── + + #[test] + fn parse_uv_source_workspace_variant() { + let toml = r#" +[project] +name = "api" + +[tool.uv.sources] +cart = { workspace = true } +"#; + let pp: PyProject = toml::from_str(toml).unwrap(); + let sources = pp.tool.unwrap().uv.unwrap().sources.unwrap(); + assert!(matches!( + sources["cart"], + UvSource::Workspace { workspace: true } + )); + } + + #[test] + fn parse_uv_source_path_variant() { + let toml = r#" +[project] +name = "api" + +[tool.uv.sources] +cart = { path = "../cart" } +"#; + let pp: PyProject = toml::from_str(toml).unwrap(); + let sources = pp.tool.unwrap().uv.unwrap().sources.unwrap(); + assert!(matches!(&sources["cart"], UvSource::Path { path } if path == "../cart")); + } +} diff --git a/src/workspace.rs b/src/workspace.rs index cc27e4e..dfe8b6e 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -184,3 +184,151 @@ pub fn write_pyproject(path: &Path, pyproject: &PyProject) -> Result<()> { std::fs::write(path, content)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ProjectMeta, WorkspaceConfig}; + + fn make_brick(name: &str, kind: BrickKind) -> Brick { + Brick { + name: name.to_string(), + kind, + path: PathBuf::from(format!("/fake/{name}")), + pyproject: PyProject::default(), + } + } + + fn make_workspace(packages: Vec, apps: Vec) -> Workspace { + Workspace { + root: PathBuf::from("/fake/root"), + config: PascalConfig { + workspace: WorkspaceConfig { + name: "test-ws".to_string(), + python: "3.12".to_string(), + description: None, + packages: None, + apps: None, + }, + }, + packages, + apps, + } + } + + // ── find_brick ─────────────────────────────────────────────────────────── + + #[test] + fn find_brick_finds_package_by_name() { + let ws = make_workspace(vec![make_brick("cart", BrickKind::Package)], vec![]); + assert!(ws.find_brick("cart").is_some()); + } + + #[test] + fn find_brick_finds_app_by_name() { + let ws = make_workspace(vec![], vec![make_brick("api", BrickKind::App)]); + assert!(ws.find_brick("api").is_some()); + } + + #[test] + fn find_brick_returns_none_for_unknown() { + let ws = make_workspace(vec![make_brick("cart", BrickKind::Package)], vec![]); + assert!(ws.find_brick("nope").is_none()); + } + + // ── member_names ───────────────────────────────────────────────────────── + + #[test] + fn member_names_includes_packages_and_apps() { + let ws = make_workspace( + vec![ + make_brick("cart", BrickKind::Package), + make_brick("auth", BrickKind::Package), + ], + vec![make_brick("api", BrickKind::App)], + ); + let names = ws.member_names(); + assert_eq!(names, vec!["cart", "auth", "api"]); + } + + #[test] + fn member_names_empty_when_no_bricks() { + let ws = make_workspace(vec![], vec![]); + assert!(ws.member_names().is_empty()); + } + + // ── read_pyproject / write_pyproject ───────────────────────────────────── + + #[test] + fn read_write_pyproject_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pyproject.toml"); + + let mut pp = PyProject::default(); + pp.project = Some(ProjectMeta { + name: "cart".to_string(), + version: Some("0.1.0".to_string()), + dependencies: vec!["httpx".to_string()], + ..Default::default() + }); + + write_pyproject(&path, &pp).unwrap(); + let loaded = read_pyproject(&path).unwrap(); + + let proj = loaded.project.unwrap(); + assert_eq!(proj.name, "cart"); + assert_eq!(proj.version.as_deref(), Some("0.1.0")); + assert_eq!(proj.dependencies, vec!["httpx"]); + } + + #[test] + fn read_pyproject_error_on_missing_file() { + let result = read_pyproject(Path::new("/nonexistent/pyproject.toml")); + assert!(result.is_err()); + } + + // ── load_from ──────────────────────────────────────────────────────────── + + #[test] + fn load_from_auto_discovers_packages_and_apps() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + // pascal.toml + std::fs::write( + root.join("pascal.toml"), + "[workspace]\nname = \"ws\"\npython = \"3.12\"\n", + ) + .unwrap(); + + // packages/cart/pyproject.toml + let cart = root.join("packages").join("cart"); + std::fs::create_dir_all(&cart).unwrap(); + std::fs::write(cart.join("pyproject.toml"), "[project]\nname = \"cart\"\n").unwrap(); + + // apps/api/pyproject.toml + let api = root.join("apps").join("api"); + std::fs::create_dir_all(&api).unwrap(); + std::fs::write(api.join("pyproject.toml"), "[project]\nname = \"api\"\n").unwrap(); + + let ws = Workspace::load_from(root).unwrap(); + assert_eq!(ws.packages.len(), 1); + assert_eq!(ws.packages[0].name, "cart"); + assert_eq!(ws.apps.len(), 1); + assert_eq!(ws.apps[0].name, "api"); + } + + #[test] + fn load_from_returns_empty_vecs_when_no_subdirs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("pascal.toml"), + "[workspace]\nname = \"ws\"\npython = \"3.12\"\n", + ) + .unwrap(); + + let ws = Workspace::load_from(dir.path()).unwrap(); + assert!(ws.packages.is_empty()); + assert!(ws.apps.is_empty()); + } +} From bb770e2c722df2364f6e8b9e57275336a56c7d0b Mon Sep 17 00:00:00 2001 From: Sandeep Selvaraj Date: Fri, 27 Feb 2026 09:24:50 +0100 Subject: [PATCH 06/11] tests: integration test covering the whole process --- tests/integration.rs | 280 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 tests/integration.rs diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..61620c5 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,280 @@ +//! Integration tests: spawn the real `pascal` binary against a temp directory. + +use std::path::Path; +use std::process::{Command, Output}; + +// Cargo injects the path to the compiled binary for integration tests. +fn pascal_bin() -> &'static str { + env!("CARGO_BIN_EXE_pascal") +} + +/// Run `pascal ` with `cwd` as the working directory. +fn run(args: &[&str], cwd: &Path) -> Output { + Command::new(pascal_bin()) + .args(args) + .current_dir(cwd) + .output() + .expect("failed to spawn pascal") +} + +/// Assert the command succeeded, printing stdout/stderr on failure. +#[track_caller] +fn assert_ok(out: &Output) { + if !out.status.success() { + eprintln!("--- stdout ---\n{}", String::from_utf8_lossy(&out.stdout)); + eprintln!("--- stderr ---\n{}", String::from_utf8_lossy(&out.stderr)); + panic!("pascal exited with {}", out.status); + } +} + +/// Assert the command failed (non-zero exit). +#[track_caller] +fn assert_err(out: &Output) { + assert!( + !out.status.success(), + "expected pascal to fail, but it exited with {}", + out.status + ); +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// Init a workspace named `name` inside `dir`, return the workspace root. +fn init_workspace(dir: &Path, name: &str) -> std::path::PathBuf { + let ws_dir = dir.join(name); + std::fs::create_dir_all(&ws_dir).unwrap(); + assert_ok(&run(&["init", name], &ws_dir)); + ws_dir +} + +// ── pascal init ─────────────────────────────────────────────────────────────── + +#[test] +fn init_creates_pascal_toml() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "my-ws"); + assert!(ws.join("pascal.toml").exists()); +} + +#[test] +fn init_creates_root_pyproject_toml() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "my-ws"); + assert!(ws.join("pyproject.toml").exists()); +} + +#[test] +fn init_creates_packages_and_apps_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "my-ws"); + assert!(ws.join("packages").is_dir()); + assert!(ws.join("apps").is_dir()); +} + +#[test] +fn init_embeds_workspace_name_in_pascal_toml() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "cool-ws"); + let contents = std::fs::read_to_string(ws.join("pascal.toml")).unwrap(); + assert!(contents.contains("cool-ws")); +} + +#[test] +fn init_fails_if_already_initialized() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "my-ws"); + // second init in the same directory should fail + assert_err(&run(&["init", "my-ws"], &ws)); +} + +// ── pascal create package ──────────────────────────────────────────────────── + +#[test] +fn create_package_generates_expected_files() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + assert_ok(&run(&["create", "package", "cart"], &ws)); + + let pkg = ws.join("packages").join("cart"); + assert!(pkg.join("pyproject.toml").exists()); + assert!(pkg.join("src").join("cart").join("__init__.py").exists()); + assert!(pkg.join("tests").join("test_cart.py").exists()); +} + +#[test] +fn create_package_hyphenated_name_uses_snake_case_for_src_dir() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + assert_ok(&run(&["create", "package", "my-pkg"], &ws)); + + // src dir should be snake_case + assert!(ws + .join("packages") + .join("my-pkg") + .join("src") + .join("my_pkg") + .join("__init__.py") + .exists()); +} + +#[test] +fn create_package_fails_on_duplicate() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + assert_ok(&run(&["create", "package", "cart"], &ws)); + assert_err(&run(&["create", "package", "cart"], &ws)); +} + +#[test] +fn create_package_fails_on_invalid_name() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + assert_err(&run(&["create", "package", "bad name!"], &ws)); +} + +// ── pascal create app ──────────────────────────────────────────────────────── + +#[test] +fn create_app_generates_expected_files() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + assert_ok(&run(&["create", "app", "api"], &ws)); + + let app = ws.join("apps").join("api"); + assert!(app.join("pyproject.toml").exists()); + assert!(app.join("src").join("api").join("__init__.py").exists()); + assert!(app.join("src").join("api").join("main.py").exists()); + assert!(app.join("tests").join("test_api.py").exists()); +} + +#[test] +fn create_app_pyproject_contains_scripts_section() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + assert_ok(&run(&["create", "app", "api"], &ws)); + + let content = + std::fs::read_to_string(ws.join("apps").join("api").join("pyproject.toml")).unwrap(); + assert!(content.contains("[project.scripts]")); +} + +// ── pascal add ─────────────────────────────────────────────────────────────── + +#[test] +fn add_inserts_dependency_into_app_pyproject() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + assert_ok(&run(&["create", "package", "cart"], &ws)); + assert_ok(&run(&["create", "app", "api"], &ws)); + assert_ok(&run(&["add", "cart", "--to", "api"], &ws)); + + let content = + std::fs::read_to_string(ws.join("apps").join("api").join("pyproject.toml")).unwrap(); + assert!(content.contains("cart")); +} + +#[test] +fn add_inserts_uv_sources_entry() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + assert_ok(&run(&["create", "package", "cart"], &ws)); + assert_ok(&run(&["create", "app", "api"], &ws)); + assert_ok(&run(&["add", "cart", "--to", "api"], &ws)); + + let content = + std::fs::read_to_string(ws.join("apps").join("api").join("pyproject.toml")).unwrap(); + // toml::to_string_pretty inlines the key: [tool.uv.sources.cart] + assert!(content.contains("tool.uv.sources")); + assert!(content.contains("workspace = true")); +} + +#[test] +fn add_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + assert_ok(&run(&["create", "package", "cart"], &ws)); + assert_ok(&run(&["create", "app", "api"], &ws)); + assert_ok(&run(&["add", "cart", "--to", "api"], &ws)); + // second add should succeed (no-op with a warning, not an error) + assert_ok(&run(&["add", "cart", "--to", "api"], &ws)); +} + +#[test] +fn add_fails_for_unknown_package() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + assert_ok(&run(&["create", "app", "api"], &ws)); + assert_err(&run(&["add", "ghost", "--to", "api"], &ws)); +} + +// ── pascal check ───────────────────────────────────────────────────────────── + +#[test] +fn check_passes_on_fresh_workspace() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + assert_ok(&run(&["create", "package", "cart"], &ws)); + assert_ok(&run(&["create", "app", "api"], &ws)); + // check exits 0 even with warnings about missing src dirs on a fresh create + // (the src dirs do exist, so it should be clean) + let out = run(&["check"], &ws); + assert_ok(&out); +} + +#[test] +fn check_fails_outside_workspace() { + let tmp = tempfile::tempdir().unwrap(); + // no pascal.toml → should fail + assert_err(&run(&["check"], tmp.path())); +} + +// ── pascal sync ─────────────────────────────────────────────────────────────── + +#[test] +fn sync_regenerates_root_pyproject() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "ws"); + + // overwrite the root pyproject to something wrong + std::fs::write(ws.join("pyproject.toml"), "# corrupted").unwrap(); + + assert_ok(&run(&["sync"], &ws)); + + let content = std::fs::read_to_string(ws.join("pyproject.toml")).unwrap(); + assert!(content.contains("[tool.uv.workspace]")); +} + +// ── full workflow ───────────────────────────────────────────────────────────── + +#[test] +fn full_workflow_init_create_add_check() { + let tmp = tempfile::tempdir().unwrap(); + let ws = init_workspace(tmp.path(), "shop"); + + assert_ok(&run(&["create", "package", "cart"], &ws)); + assert_ok(&run(&["create", "package", "auth"], &ws)); + assert_ok(&run(&["create", "app", "storefront"], &ws)); + assert_ok(&run(&["add", "cart", "--to", "storefront"], &ws)); + assert_ok(&run(&["add", "auth", "--to", "storefront"], &ws)); + assert_ok(&run(&["check"], &ws)); + assert_ok(&run(&["info"], &ws)); + assert_ok(&run(&["deps"], &ws)); + assert_ok(&run(&["sync"], &ws)); + + // storefront should declare both deps + let content = + std::fs::read_to_string(ws.join("apps").join("storefront").join("pyproject.toml")).unwrap(); + assert!(content.contains("cart")); + assert!(content.contains("auth")); +} From 5e73ee3108ef4a3b203693f6c2796fee70a4e1a5 Mon Sep 17 00:00:00 2001 From: Sandeep Selvaraj Date: Fri, 27 Feb 2026 09:51:04 +0100 Subject: [PATCH 07/11] docs: clean up readme --- README.md | 275 +++++++++++++++++++----------------------------------- 1 file changed, 98 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index 04640ed..51da00e 100644 --- a/README.md +++ b/README.md @@ -6,225 +6,114 @@ **Fast Python monorepo manager powered by Rust and UV.** -Pascal is a CLI tool that makes managing Python monorepos straightforward. It handles workspace scaffolding, dependency wiring, cross-package testing, and UV workspace sync — so you can focus on code, not configuration. +Pascal handles workspace scaffolding, dependency wiring, and UV workspace sync — so you can focus on code, not configuration. -**Why pascal?** -- UV-native: all package operations delegate to `uv`, the fastest Python package manager -- Monorepo-aware: understands the difference between reusable packages and deployable apps -- Zero config for simple cases: auto-discovers packages and apps from your directory layout -- Single binary: no Python runtime needed, no virtualenv, no PATH fiddling +**[Documentation](https://sandeep-selvaraj.github.io/pascal)** · [Installation](https://sandeep-selvaraj.github.io/pascal/installation/) · [Quickstart](https://sandeep-selvaraj.github.io/pascal/quickstart/) · [Commands](https://sandeep-selvaraj.github.io/pascal/commands/) --- ## Installation ```bash -# pip -pip install pascal-cli - -# uv (recommended) -uv tool install pascal-cli - -# pipx +uv tool install pascal-cli # recommended pipx install pascal-cli - -# cargo (build from source) -cargo install --git https://github.com/sandeep-selvaraj/pascal +pip install pascal-cli ``` -After installation, `pascal --version` should print the version. - ---- - -## Quickstart - -``` -# 1. Create a new workspace -$ pascal init my-workspace - Created my-workspace/pascal.toml - Created my-workspace/pyproject.toml (UV workspace root) - Created my-workspace/uv.lock - -$ cd my-workspace - -# 2. Add a reusable library package -$ pascal create package cart - Created packages/cart/pyproject.toml - Created packages/cart/src/cart/__init__.py - Created packages/cart/tests/test_cart.py - -# 3. Add a deployable app -$ pascal create app api_service - Created apps/api_service/pyproject.toml - Created apps/api_service/src/api_service/__init__.py - Created apps/api_service/src/api_service/main.py - Created apps/api_service/tests/test_api_service.py - -# 4. Wire the package into the app -$ pascal add cart --to api_service - Updated apps/api_service/pyproject.toml - Ran: uv sync - -# 5. Check everything looks good -$ pascal info - Workspace: my-workspace (python 3.12) - ├── packages - │ └── cart 0.1.0 - └── apps - └── api_service 0.1.0 - └── depends on: cart +```bash +pascal --version ``` --- -## Workspace Layout - -``` -my-workspace/ - pascal.toml # workspace manifest (pascal reads this) - pyproject.toml # UV workspace root (auto-generated by pascal) - uv.lock # lockfile (committed to git) - packages/ - cart/ # reusable library - pyproject.toml - src/cart/__init__.py - tests/test_cart.py - auth/ - pyproject.toml - src/auth/__init__.py - tests/test_auth.py - apps/ - api_service/ # deployable entry-point - pyproject.toml # declares: dependencies = ["cart", "auth"] - src/api_service/__init__.py - src/api_service/main.py - tests/test_api_service.py -``` - -### `pascal.toml` schema - -```toml -[workspace] -name = "my-workspace" -python = "3.12" -description = "My Python monorepo" # optional - -# Explicit lists are optional — pascal auto-discovers from packages/ and apps/ -packages = ["packages/cart", "packages/auth"] -apps = ["apps/api_service"] -``` - -Pascal auto-generates the UV workspace root `pyproject.toml`: - -```toml -[tool.uv.workspace] -members = ["packages/*", "apps/*"] -``` - -Local packages are referenced as UV path dependencies: +## Quickstart -```toml -# apps/api_service/pyproject.toml -[tool.uv.sources] -cart = { workspace = true } -auth = { workspace = true } +```bash +# Bootstrap a workspace +mkdir shop && cd shop +pascal init shop + +# Add a library and an app +pascal create package cart +pascal create app storefront + +# Wire them together +pascal add cart --to storefront +uv sync + +# Validate and inspect +pascal check +pascal info +pascal test ``` --- -## Command Reference +## Command reference | Command | Description | |---|---| -| `pascal init [name]` | Bootstrap a new workspace with `pascal.toml`, root `pyproject.toml`, and `uv.lock` | -| `pascal create package ` | Scaffold a new reusable library under `packages/` | -| `pascal create app ` | Scaffold a new deployable app under `apps/` | -| `pascal add --to ` | Add a workspace package as a dependency in an app's `pyproject.toml` | -| `pascal info` | Pretty-print workspace overview: packages, apps, dependency wiring | -| `pascal deps [--graph]` | Print the dependency tree; `--graph` emits Graphviz DOT format | -| `pascal check` | Validate the workspace: missing deps, undeclared packages, circular references | -| `pascal diff [--since ]` | Show packages/apps changed since a git ref or tag (defaults to latest tag) | -| `pascal test [--changed] [name]` | Run pytest for one or all packages/apps via UV; `--changed` runs only affected bricks | -| `pascal build ` | Run `uv build` for an app (produces a wheel/sdist) | -| `pascal run [-- args]` | Run an app's entry-point via `uv run` | -| `pascal sync` | Regenerate root `pyproject.toml` and UV workspace config from `pascal.toml` | - ---- - -## UV Integration - -Pascal is designed to sit alongside UV, not replace it. Pascal handles monorepo structure; UV handles packages, lockfiles, and virtual environments. - -``` -pascal init → creates UV workspace skeleton -pascal create → adds a new UV workspace member -pascal add → edits pyproject.toml, then calls `uv sync` -pascal sync → regenerates the UV workspace config -pascal test → delegates to `uv run pytest` -pascal build → delegates to `uv build` -pascal run → delegates to `uv run` -``` - -You can always drop into raw UV commands at any time — pascal writes standard UV workspace files that any UV-aware tooling can read. +| `pascal init [name]` | Bootstrap a new workspace | +| `pascal create package ` | Scaffold a reusable library | +| `pascal create app ` | Scaffold a deployable app | +| `pascal add --to ` | Add a workspace package as a dependency | +| `pascal info` | Print workspace overview | +| `pascal deps [--graph]` | Show the dependency tree | +| `pascal check` | Validate workspace health | +| `pascal diff [--since ]` | Show changed packages since a git ref | +| `pascal test [--changed] [name]` | Run tests via UV | +| `pascal build ` | Build an app wheel | +| `pascal run [-- args]` | Run an app entry-point | +| `pascal sync` | Regenerate UV workspace config | --- -## CI/CD in Your Workspace - -Pascal's `--changed` and `--since` flags make it easy to run targeted CI in your monorepo. Here's a GitHub Actions snippet: - -```yaml -# .github/workflows/test.yml (inside your USER workspace, not the pascal source repo) -name: Test changed packages +## Development -on: - pull_request: - push: - branches: [main] +### Prerequisites -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # needed so pascal can read git history - - - uses: astral-sh/setup-uv@v4 +| Tool | Purpose | +|---|---| +| [Rust ≥ 1.75](https://rustup.rs) | Compile the binary | +| [pre-commit](https://pre-commit.com) | Git hook runner (`uv tool install pre-commit`) | +| [maturin](https://maturin.rs) *(optional)* | Test PyPI packaging locally | - - name: Install pascal - run: uv tool install pascal-cli +### Build - - name: Run tests for changed packages - run: pascal test --changed --since origin/main +```bash +git clone https://github.com/sandeep-selvaraj/pascal +cd pascal +cargo build ``` -For a full run (e.g., on merge to main): +### Test -```yaml - - name: Run all tests - run: pascal test +```bash +cargo test # unit + integration +cargo test --lib # unit tests only +cargo test --test integration # integration tests only ``` ---- +Integration tests in `tests/integration.rs` spawn the real binary against `tempfile` directories — no mocking. -## Contributing +### Lint and format ```bash -git clone https://github.com/sandeep-selvaraj/pascal -cd pascal -cargo build -cargo test +cargo fmt +cargo clippy -- -D warnings ``` -Lint and format: +### Pre-commit hooks + +Install once after cloning: ```bash -cargo fmt -cargo clippy -- -D warnings +pre-commit install ``` -To test the PyPI packaging locally: +Every `git commit` will then run `cargo fmt` and `cargo clippy` automatically. + +### Test PyPI packaging ```bash pip install maturin @@ -233,4 +122,36 @@ pip install target/wheels/*.whl pascal --version ``` -Please open an issue before sending a large PR so we can discuss the approach. +### Serve docs locally + +```bash +pip install mkdocs-material +mkdocs serve +``` + +### Project layout + +``` +src/ + main.rs # CLI entry-point + cli.rs # clap argument structs + config.rs # serde types (pascal.toml, pyproject.toml) + workspace.rs # workspace discovery and loading + template.rs # generated file content + display.rs # coloured output helpers + uv.rs # uv subprocess wrappers + git.rs # git2 helpers + commands/ # one module per subcommand +tests/ + integration.rs # end-to-end CLI tests +docs/ # MkDocs source (mkdocs.yml at root) +.github/workflows/ + ci.yml # test on push / PR + release.yml # publish to PyPI on git tag +``` + +--- + +## License + +MIT — see [LICENSE](LICENSE). From 81517ace8b454ce85e51b593e4920a5c457ce38b Mon Sep 17 00:00:00 2001 From: Sandeep Selvaraj Date: Fri, 27 Feb 2026 09:51:50 +0100 Subject: [PATCH 08/11] docs: provide mkdocs for user info --- docs/ci-cd.md | 105 ++++++++++++++++++++++++++ docs/commands/add.md | 63 ++++++++++++++++ docs/commands/build.md | 44 +++++++++++ docs/commands/check.md | 40 ++++++++++ docs/commands/create.md | 74 ++++++++++++++++++ docs/commands/deps.md | 43 +++++++++++ docs/commands/diff.md | 63 ++++++++++++++++ docs/commands/index.md | 28 +++++++ docs/commands/info.md | 30 ++++++++ docs/commands/init.md | 42 +++++++++++ docs/commands/run.md | 46 ++++++++++++ docs/commands/sync.md | 41 ++++++++++ docs/commands/test.md | 55 ++++++++++++++ docs/contributing.md | 140 ++++++++++++++++++++++++++++++++++ docs/index.md | 67 +++++++++++++++++ docs/installation.md | 73 ++++++++++++++++++ docs/quickstart.md | 162 ++++++++++++++++++++++++++++++++++++++++ docs/uv-integration.md | 87 +++++++++++++++++++++ docs/workspace.md | 162 ++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 90 ++++++++++++++++++++++ 20 files changed, 1455 insertions(+) create mode 100644 docs/ci-cd.md create mode 100644 docs/commands/add.md create mode 100644 docs/commands/build.md create mode 100644 docs/commands/check.md create mode 100644 docs/commands/create.md create mode 100644 docs/commands/deps.md create mode 100644 docs/commands/diff.md create mode 100644 docs/commands/index.md create mode 100644 docs/commands/info.md create mode 100644 docs/commands/init.md create mode 100644 docs/commands/run.md create mode 100644 docs/commands/sync.md create mode 100644 docs/commands/test.md create mode 100644 docs/contributing.md create mode 100644 docs/index.md create mode 100644 docs/installation.md create mode 100644 docs/quickstart.md create mode 100644 docs/uv-integration.md create mode 100644 docs/workspace.md create mode 100644 mkdocs.yml diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 0000000..1e12b27 --- /dev/null +++ b/docs/ci-cd.md @@ -0,0 +1,105 @@ +# CI/CD + +## Testing changed packages only + +Pascal's `--changed` flag makes pull-request CI fast — only bricks touched by the PR get tested. + +```yaml +# .github/workflows/test.yml (in your USER workspace repo, not the pascal source) +name: Test + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # required for git history used by pascal diff/test + + - uses: astral-sh/setup-uv@v4 + + - name: Install pascal + run: uv tool install pascal-cli + + - name: Validate workspace + run: pascal check + + - name: Test changed packages (PR) + if: github.event_name == 'pull_request' + run: pascal test --changed --since origin/${{ github.base_ref }} + + - name: Test all packages (push to main) + if: github.event_name == 'push' + run: pascal test +``` + +## Full matrix test + +For thorough CI on pushes to main or release branches: + +```yaml +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + python-version: ${{ matrix.python }} + - name: Install pascal + run: uv tool install pascal-cli + - run: pascal test +``` + +## Building and publishing an app + +```yaml +name: Release app + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - name: Install pascal + run: uv tool install pascal-cli + - name: Build + run: pascal build storefront + - uses: actions/upload-artifact@v4 + with: + name: dist + path: apps/storefront/dist/ +``` + +## Caching + +Add UV caching to speed up CI: + +```yaml + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "**/uv.lock" +``` + +## Key tips + +!!! tip "Always pass `fetch-depth: 0`" + `pascal diff` and `pascal test --changed` need full git history to compute what changed. Without `fetch-depth: 0`, GitHub Actions does a shallow clone and pascal falls back to comparing against the first commit. + +!!! tip "Commit `uv.lock`" + Commit your `uv.lock` file. This ensures CI uses exactly the same package versions as your local machine and makes installs faster (UV can skip resolution). diff --git a/docs/commands/add.md b/docs/commands/add.md new file mode 100644 index 0000000..3cc7469 --- /dev/null +++ b/docs/commands/add.md @@ -0,0 +1,63 @@ +# pascal add + +Add a workspace package as a dependency of another brick. + +``` +pascal add --to +``` + +## Arguments + +| Argument | Description | +|---|---| +| `PACKAGE` | Name of the workspace package to add | +| `--to TARGET` | Name of the app (or package) that should depend on it | + +## What it does + +1. Appends `` to the `dependencies` list in `/pyproject.toml` +2. Adds a `[tool.uv.sources]` entry so UV resolves it from the workspace rather than PyPI: + +```toml +[tool.uv.sources] +cart = { workspace = true } +``` + +3. Prints a reminder to run `uv sync` + +!!! note + `pascal add` does **not** call `uv sync` automatically. Run `uv sync` after wiring dependencies to update the lockfile. + +## Example + +```bash +pascal create package cart +pascal create app storefront +pascal add cart --to storefront + +# then install +uv sync +``` + +Resulting `apps/storefront/pyproject.toml`: + +```toml +[project] +name = "storefront" +dependencies = ["cart"] + +[tool.uv.sources.cart] +workspace = true +``` + +## Idempotent + +Running `pascal add cart --to storefront` a second time is safe — pascal detects the dependency is already present and exits cleanly with a warning. + +## Errors + +| Condition | Message | +|---|---| +| Package not in workspace | `Package 'x' not found in workspace` | +| Target not in workspace | `Target 'x' not found in workspace` | +| Target has no `[project]` section | `Target 'x' has no [project] section` | diff --git a/docs/commands/build.md b/docs/commands/build.md new file mode 100644 index 0000000..a99c480 --- /dev/null +++ b/docs/commands/build.md @@ -0,0 +1,44 @@ +# pascal build + +Build a wheel and/or sdist for an app. + +``` +pascal build +``` + +## Arguments + +| Argument | Description | +|---|---| +| `APP` | Name of the app to build | + +## What it does + +Runs `uv build` in the app's directory, producing a wheel (`.whl`) and source distribution (`.tar.gz`) under `apps//dist/`. + +## Example + +```bash +pascal build storefront +``` + +``` + Building app: storefront + + uv build → apps/storefront/dist/storefront-0.1.0-py3-none-any.whl + uv build → apps/storefront/dist/storefront-0.1.0.tar.gz + +✓ Build complete +``` + +## Output location + +``` +apps// + dist/ + --py3-none-any.whl + -.tar.gz +``` + +!!! note + `pascal build` only applies to apps, not packages. Packages are typically published as library wheels — run `uv build` directly inside `packages//` if needed. diff --git a/docs/commands/check.md b/docs/commands/check.md new file mode 100644 index 0000000..5e4ee48 --- /dev/null +++ b/docs/commands/check.md @@ -0,0 +1,40 @@ +# pascal check + +Validate workspace health. + +``` +pascal check +``` + +## What it checks + +| Check | Severity | +|---|---| +| Circular dependencies in the workspace graph | Error | +| Workspace package missing from `[tool.uv.sources]` | Warning | +| Missing `src//` directory in a brick | Warning | +| Missing `pyproject.toml` in a declared brick | Error | + +## Output + +``` +┌──────────────────────────────────┐ +│ Pascal Workspace Check │ +└──────────────────────────────────┘ + +✓ No circular dependencies +⚠ storefront: 'cart' is a workspace dep but missing from [tool.uv.sources] +⚠ auth: expected src/auth/ directory not found + +1 warning(s) found +``` + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | No errors (warnings are allowed) | +| `1` | One or more errors found | + +!!! tip + Run `pascal check` in CI before building or deploying to catch dependency wiring mistakes early. diff --git a/docs/commands/create.md b/docs/commands/create.md new file mode 100644 index 0000000..0a35cb2 --- /dev/null +++ b/docs/commands/create.md @@ -0,0 +1,74 @@ +# pascal create + +Scaffold a new package or app inside the workspace. + +``` +pascal create package +pascal create app +``` + +## Subcommands + +### `create package` + +Creates a reusable library under `packages//`. + +**Files generated:** + +``` +packages// + pyproject.toml + src/ + / + __init__.py + tests/ + test_.py +``` + +### `create app` + +Creates a deployable application under `apps//`. + +**Files generated:** + +``` +apps// + pyproject.toml # includes [project.scripts] + src/ + / + __init__.py + main.py + tests/ + test_.py +``` + +## Name rules + +- Allowed characters: ASCII letters, digits, hyphens (`-`), underscores (`_`) +- Hyphens and underscores are equivalent for lookup; the `pyproject.toml` name uses hyphens, the `src/` directory uses underscores + +## Examples + +```bash +pascal create package cart +pascal create package my-utils +pascal create app storefront +pascal create app data-pipeline +``` + +## After creating + +Run `pascal sync` to update the UV workspace root `pyproject.toml` and then `uv sync` to install the new member: + +```bash +pascal sync +uv sync +``` + +## Errors + +| Condition | Message | +|---|---| +| Directory already exists | `Package 'x' already exists at …` | +| Invalid name | `Invalid name 'x': must contain only …` | +| Not inside a workspace | `pascal.toml not found` | diff --git a/docs/commands/deps.md b/docs/commands/deps.md new file mode 100644 index 0000000..b6f5c54 --- /dev/null +++ b/docs/commands/deps.md @@ -0,0 +1,43 @@ +# pascal deps + +Print the dependency tree for all workspace members. + +``` +pascal deps [--graph] +``` + +## Flags + +| Flag | Description | +|---|---| +| `--graph` | Print an adjacency-list style graph instead of the tree view | + +## Output (default) + +Internal workspace dependencies are shown in green. External (PyPI) dependencies are dimmed. + +``` + ◆ cart + (no dependencies) + + ◆ auth + (no dependencies) + + ▶ storefront + → cart # workspace dep (green) + → auth # workspace dep (green) + → httpx # external dep (dimmed) +``` + +Icons: + +- `◆` — package +- `▶` — app + +## Output (`--graph`) + +``` + cart (no internal deps) + auth (no internal deps) + storefront → cart, auth +``` diff --git a/docs/commands/diff.md b/docs/commands/diff.md new file mode 100644 index 0000000..3f63d56 --- /dev/null +++ b/docs/commands/diff.md @@ -0,0 +1,63 @@ +# pascal diff + +Show which packages and apps have changed since a git ref. + +``` +pascal diff [--since ] +``` + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--since REF` | latest git tag | Git ref (tag, branch, commit SHA) to compare against | + +## Behaviour + +1. Uses `git2` to find all files changed between `REF` and `HEAD` +2. Maps changed file paths back to workspace bricks +3. Also reports apps that **transitively** depend on changed packages + +## Output + +``` + Changes since 'v0.2.0' + + ◈ Changed bricks: + ◆ cart [package] + + ◈ Apps affected by changed packages: + ▶ storefront [app — transitive] +``` + +## Examples + +```bash +# Compare against latest tag (default) +pascal diff + +# Compare against a specific tag +pascal diff --since v0.1.0 + +# Compare against a branch +pascal diff --since origin/main + +# Compare against a commit SHA +pascal diff --since abc1234 +``` + +## Use in CI + +```bash +# In a pull request workflow: +pascal diff --since origin/main +``` + +Combined with `pascal test --changed`: + +```bash +pascal test --changed --since origin/main +``` + +!!! note + `pascal diff` requires the workspace to be inside a git repository. It exits with an error if no git repo is found. diff --git a/docs/commands/index.md b/docs/commands/index.md new file mode 100644 index 0000000..6565429 --- /dev/null +++ b/docs/commands/index.md @@ -0,0 +1,28 @@ +# Command Reference + +All pascal commands follow the pattern `pascal [args] [flags]`. + +Run `pascal --help` or `pascal --help` for built-in help text. + +## Overview + +| Command | Description | +|---|---| +| [`pascal init`](init.md) | Bootstrap a new workspace | +| [`pascal create package`](create.md) | Scaffold a new reusable library | +| [`pascal create app`](create.md) | Scaffold a new deployable app | +| [`pascal add`](add.md) | Wire a package into an app | +| [`pascal info`](info.md) | Print workspace overview | +| [`pascal deps`](deps.md) | Show the dependency tree | +| [`pascal check`](check.md) | Validate workspace health | +| [`pascal diff`](diff.md) | Show changed packages since a git ref | +| [`pascal test`](test.md) | Run tests via UV | +| [`pascal build`](build.md) | Build an app wheel | +| [`pascal run`](run.md) | Run an app entry-point | +| [`pascal sync`](sync.md) | Regenerate UV workspace config | + +## Global behaviour + +- **Workspace detection**: every command (except `init`) walks up from the current directory to find `pascal.toml`. You can run commands from any subdirectory. +- **Exit codes**: `0` on success, `1` on error. `pascal check` exits `1` if errors (not warnings) are found. +- **Colour**: output is coloured by default. Pipe to a file or set `NO_COLOR=1` to disable. diff --git a/docs/commands/info.md b/docs/commands/info.md new file mode 100644 index 0000000..0e7b103 --- /dev/null +++ b/docs/commands/info.md @@ -0,0 +1,30 @@ +# pascal info + +Print a formatted overview of the workspace. + +``` +pascal info +``` + +## Output + +``` + Workspace: shop (python 3.12) + ├── packages + │ ├── cart 0.1.0 + │ └── auth 0.1.0 + └── apps + └── storefront 0.1.0 + ├── depends on: cart + └── depends on: auth +``` + +Shows: + +- Workspace name and Python version +- All packages with their versions +- All apps with their versions and internal dependency links + +## No arguments + +`pascal info` takes no arguments or flags. diff --git a/docs/commands/init.md b/docs/commands/init.md new file mode 100644 index 0000000..9b0e533 --- /dev/null +++ b/docs/commands/init.md @@ -0,0 +1,42 @@ +# pascal init + +Bootstrap a new Pascal workspace in the current directory. + +``` +pascal init [NAME] [--python VERSION] +``` + +## Arguments + +| Argument | Default | Description | +|---|---|---| +| `NAME` | current directory name | Workspace name written into `pascal.toml` | +| `--python` | `3.12` | Minimum Python version for the workspace | + +## What it creates + +``` +./ + pascal.toml # workspace manifest + pyproject.toml # UV workspace root + .gitignore # created if not already present + packages/ # empty directory + apps/ # empty directory +``` + +## Example + +```bash +mkdir my-workspace && cd my-workspace +pascal init my-workspace --python 3.11 +``` + +!!! note + `pascal init` initialises the **current directory** as the workspace root. It does not create a subdirectory. Create and `cd` into your directory first. + +## Errors + +| Condition | Message | +|---|---| +| `pascal.toml` already exists | `Workspace already initialized (pascal.toml exists)` | +| Empty name | `Workspace name cannot be empty` | diff --git a/docs/commands/run.md b/docs/commands/run.md new file mode 100644 index 0000000..9c48209 --- /dev/null +++ b/docs/commands/run.md @@ -0,0 +1,46 @@ +# pascal run + +Run an app's entry-point via `uv run`. + +``` +pascal run [-- ] +``` + +## Arguments + +| Argument | Description | +|---|---| +| `APP` | Name of the app to run | +| `-- ` | Arguments forwarded to the app's entry-point | + +## What it does + +Resolves the app's `[project.scripts]` entry and calls: + +```bash +uv run --project apps/ [args] +``` + +## Example + +```bash +# Run the app +pascal run storefront + +# Pass arguments to the app +pascal run storefront -- --port 8080 --debug +``` + +## Entry-point resolution + +The entry-point is derived from the app's `[project.scripts]` in `pyproject.toml`. For an app named `storefront`, the generated script is: + +```toml +[project.scripts] +storefront = "storefront.main:main" +``` + +So `pascal run storefront` calls `storefront.main:main`. + +!!! note + `pascal run` requires `uv` to be available on `PATH`. The app and its workspace dependencies are automatically available in the UV-managed environment. diff --git a/docs/commands/sync.md b/docs/commands/sync.md new file mode 100644 index 0000000..bb3bd50 --- /dev/null +++ b/docs/commands/sync.md @@ -0,0 +1,41 @@ +# pascal sync + +Regenerate the UV workspace root `pyproject.toml` from `pascal.toml`. + +``` +pascal sync +``` + +## What it does + +Rewrites `/pyproject.toml` with the correct UV workspace config: + +```toml +[project] +name = "" +version = "0.1.0" +requires-python = ">= " + +[tool.uv.workspace] +members = ["packages/*", "apps/*"] +``` + +## When to run + +| Situation | Action | +|---|---| +| Added a new package or app with `pascal create` | `pascal sync && uv sync` | +| Changed `pascal.toml` (workspace name, python version) | `pascal sync && uv sync` | +| Root `pyproject.toml` got corrupted or manually edited | `pascal sync` | + +!!! tip + `pascal sync` regenerates the UV workspace config. To also install all packages and update the lockfile, follow it with `uv sync`. + +## Difference from `uv sync` + +| Command | What it does | +|---|---| +| `pascal sync` | Rewrites the root `pyproject.toml` (workspace config only) | +| `uv sync` | Reads `pyproject.toml` and `uv.lock`, installs packages, updates lockfile | + +They're complementary: run `pascal sync` first, then `uv sync`. diff --git a/docs/commands/test.md b/docs/commands/test.md new file mode 100644 index 0000000..5c31d86 --- /dev/null +++ b/docs/commands/test.md @@ -0,0 +1,55 @@ +# pascal test + +Run tests for workspace packages and apps using `uv run pytest`. + +``` +pascal test [NAME] [--changed] [--since ] [-- ] +``` + +## Arguments and flags + +| Argument / Flag | Description | +|---|---| +| `NAME` | Run tests only for this brick (package or app name) | +| `--changed` | Only run tests for bricks changed since `--since` ref | +| `--since REF` | Git ref for `--changed` comparison (default: latest tag) | +| `-- ` | Extra arguments forwarded to pytest | + +## Examples + +```bash +# Run all tests +pascal test + +# Run tests for one brick +pascal test cart + +# Run tests only for changed bricks +pascal test --changed + +# Run only for bricks changed since a branch +pascal test --changed --since origin/main + +# Pass extra pytest flags +pascal test -- -x -v --tb=short +``` + +## Under the hood + +For each brick being tested, pascal runs: + +```bash +uv run --project pytest tests/ +``` + +Tests run in dependency order — if `storefront` depends on `cart`, `cart` is tested first. + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | All tests passed | +| Non-zero | At least one test suite failed | + +!!! tip + Use `pascal test --changed` in pull request CI to avoid re-running tests for unmodified packages. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..fefaf89 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,140 @@ +# Contributing + +## Prerequisites + +| Tool | Purpose | Install | +|---|---|---| +| **Rust ≥ 1.75** | Compile the binary | [rustup.rs](https://rustup.rs) | +| **cargo** | Build and test runner | Included with Rust | +| **pre-commit** | Git hook runner | `uv tool install pre-commit` | +| **maturin** *(optional)* | Test PyPI packaging locally | `uv tool install maturin` | + +## Clone and build + +```bash +git clone https://github.com/sandeep-selvaraj/pascal +cd pascal +cargo build +``` + +The debug binary is at `target/debug/pascal`. + +For a release build: + +```bash +cargo build --release +# target/release/pascal +``` + +## Running tests + +### Unit tests + +```bash +cargo test --lib +``` + +Tests live alongside the source in `#[cfg(test)]` modules inside each `.rs` file. + +### Integration tests + +```bash +cargo test --test integration +``` + +Integration tests are in `tests/integration.rs`. Each test spawns the real `pascal` binary against a temporary directory using `CARGO_BIN_EXE_pascal`. + +### All tests + +```bash +cargo test +``` + +## Pre-commit hooks + +Install the hooks once after cloning: + +```bash +pre-commit install +``` + +After that, every `git commit` automatically runs: + +1. `cargo fmt` — formats changed Rust files +2. `cargo clippy -- -D warnings` — lints the whole crate + +To run hooks manually without committing: + +```bash +pre-commit run --all-files +``` + +## Code style + +- Formatting is enforced by `rustfmt` (via `cargo fmt`) +- Lints are enforced by `clippy -D warnings` — no warnings allowed +- Match the style of the surrounding code for new contributions + +## Project structure + +``` +pascal/ + Cargo.toml # dependencies, binary definition + pyproject.toml # maturin packaging config (PyPI) + mkdocs.yml # docs site config + .pre-commit-config.yaml + src/ + main.rs # CLI entry-point, dispatch + cli.rs # clap structs — Commands enum and args + error.rs # PascalError type + config.rs # serde types for pascal.toml and pyproject.toml + workspace.rs # workspace discovery and loading + template.rs # file content templates + display.rs # coloured terminal output helpers + uv.rs # uv subprocess wrappers + git.rs # git2 helpers (diff, latest tag) + commands/ + mod.rs + init.rs + create.rs + add.rs + info.rs + deps.rs + check.rs + diff.rs + test.rs + build.rs + run.rs + sync.rs + tests/ + integration.rs # end-to-end CLI tests + docs/ # MkDocs source + .github/ + workflows/ + ci.yml # build + test on push/PR + release.yml # publish to PyPI on git tag +``` + +## Testing PyPI packaging locally + +```bash +pip install maturin +maturin build +pip install target/wheels/*.whl +pascal --version +``` + +## Serving docs locally + +```bash +pip install mkdocs-material +mkdocs serve +# open http://127.0.0.1:8000 +``` + +## Opening a PR + +1. Open an issue first for non-trivial changes so we can agree on the approach +2. Fork the repo, create a branch from `master` +3. Make your changes — make sure `cargo test` and `cargo clippy` pass +4. Submit a pull request with a clear description of what changed and why diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..2b54b1d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,67 @@ +# pascal + +**Fast Python monorepo manager powered by Rust and UV.** + +[![PyPI version](https://img.shields.io/pypi/v/pascal-cli.svg)](https://pypi.org/project/pascal-cli/) +[![CI](https://github.com/sandeep-selvaraj/pascal/actions/workflows/ci.yml/badge.svg)](https://github.com/sandeep-selvaraj/pascal/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/sandeep-selvaraj/pascal/blob/master/LICENSE) + +--- + +Pascal is a CLI tool that makes managing Python monorepos straightforward. It handles workspace scaffolding, dependency wiring, cross-package testing, and UV workspace sync — so you can focus on code, not configuration. + +## Why pascal? + +
+ +- :zap: **Single binary, zero overhead** + + Written in Rust. No Python runtime needed to run the CLI itself — just install and go. + +- :package: **UV-native** + + All package operations delegate to `uv`. Pascal manages the structure; `uv` manages the packages. + +- :mag: **Monorepo-aware** + + Understands the difference between reusable *packages* and deployable *apps*. Tracks cross-brick dependencies automatically. + +- :wrench: **Zero config for simple cases** + + Drop a `pascal.toml` at the root and run. Pascal auto-discovers `packages/` and `apps/` — no manifest required. + +
+ +## Quick look + +```bash +# Bootstrap a workspace +pascal init my-workspace && cd my-workspace + +# Add a library and an app +pascal create package cart +pascal create app storefront + +# Wire them together +pascal add cart --to storefront + +# Validate, inspect, run +pascal check +pascal info +pascal test +``` + +## Concepts + +| Term | Meaning | +|---|---| +| **workspace** | The root of your monorepo. Contains `pascal.toml` and a UV workspace root `pyproject.toml`. | +| **package** | A reusable library under `packages/`. Installable by other packages or apps. | +| **app** | A deployable entry-point under `apps/`. Has a `[project.scripts]` entry and may depend on workspace packages. | +| **brick** | Internal term for any workspace member (package or app). | + +## Next steps + +- [Install pascal](installation.md) +- [Follow the quickstart](quickstart.md) +- [Browse all commands](commands/index.md) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..cd3df62 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,73 @@ +# Installation + +Pascal ships as a native binary wrapped in a Python wheel, so it installs like any other Python tool — no Rust toolchain required for end users. + +## pip / uv / pipx + +=== "uv (recommended)" + + ```bash + uv tool install pascal-cli + ``` + + This installs pascal into an isolated uv-managed environment and puts the `pascal` binary on your `PATH`. Recommended because uv itself is what pascal delegates package operations to. + +=== "pipx" + + ```bash + pipx install pascal-cli + ``` + +=== "pip" + + ```bash + pip install pascal-cli + ``` + + Installing into a virtualenv or system Python works, but `uv tool` or `pipx` are better choices for CLI tools. + +## Verify + +```bash +pascal --version +``` + +## From source (cargo) + +If you have the Rust toolchain installed: + +```bash +cargo install --git https://github.com/sandeep-selvaraj/pascal +``` + +Or clone and build locally: + +```bash +git clone https://github.com/sandeep-selvaraj/pascal +cd pascal +cargo build --release +# binary is at target/release/pascal +``` + +## System requirements + +| Requirement | Notes | +|---|---| +| **uv** | Required at runtime for `test`, `build`, `run`, and `sync` commands. Install from [astral.sh/uv](https://astral.sh/uv). | +| **Python ≥ 3.8** | Only needed if you install via pip/pipx. The pascal binary itself has no Python dependency. | +| **git** | Required for `pascal diff`. Optional otherwise. | + +## Shell completions + +Pascal uses `clap` and can generate shell completions: + +```bash +# bash +pascal --generate-shell-completion bash >> ~/.bashrc + +# zsh +pascal --generate-shell-completion zsh >> ~/.zshrc + +# fish +pascal --generate-shell-completion fish > ~/.config/fish/completions/pascal.fish +``` diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..323dee5 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,162 @@ +# Quickstart + +This guide walks you through building a small Python monorepo from scratch using pascal. It takes about five minutes. + +## Prerequisites + +- `pascal` installed ([see Installation](installation.md)) +- `uv` installed (`curl -LsSf https://astral.sh/uv/install.sh | sh`) + +--- + +## Step 1 — Bootstrap a workspace + +```bash +mkdir shop && cd shop +pascal init shop +``` + +``` +┌──────────────────────────────────────────┐ +│ Pascal — Initializing Workspace │ +└──────────────────────────────────────────┘ + + create packages/ + create apps/ + create pascal.toml + create pyproject.toml + create .gitignore + +✓ Workspace 'shop' initialized +``` + +This creates: + +``` +shop/ + pascal.toml # workspace manifest + pyproject.toml # UV workspace root (auto-managed) + .gitignore + packages/ + apps/ +``` + +--- + +## Step 2 — Add a reusable package + +```bash +pascal create package cart +``` + +``` + create packages/cart/pyproject.toml + create packages/cart/src/cart/__init__.py + create packages/cart/tests/test_cart.py + +✓ Package 'cart' created +``` + +--- + +## Step 3 — Add a deployable app + +```bash +pascal create app storefront +``` + +``` + create apps/storefront/pyproject.toml + create apps/storefront/src/storefront/__init__.py + create apps/storefront/src/storefront/main.py + create apps/storefront/tests/test_storefront.py + +✓ App 'storefront' created +``` + +--- + +## Step 4 — Wire the package into the app + +```bash +pascal add cart --to storefront +``` + +This updates `apps/storefront/pyproject.toml` to declare `cart` as a dependency and adds the `[tool.uv.sources]` entry so UV resolves it from the workspace: + +```toml +[project] +name = "storefront" +dependencies = ["cart"] + +[tool.uv.sources] +cart = { workspace = true } +``` + +Then sync the UV lockfile: + +```bash +uv sync +``` + +--- + +## Step 5 — Inspect and validate + +```bash +pascal info +``` + +``` + Workspace: shop (python 3.12) + ├── packages + │ └── cart 0.1.0 + └── apps + └── storefront 0.1.0 + └── depends on: cart +``` + +```bash +pascal check +``` + +``` +✓ No circular dependencies +✓ Workspace is healthy +``` + +```bash +pascal deps +``` + +``` + ◆ cart + (no dependencies) + + ▶ storefront + → cart +``` + +--- + +## Step 6 — Run tests + +```bash +pascal test +``` + +Pascal calls `uv run pytest` for each brick in dependency order. + +To run tests only for packages that changed since the last git tag: + +```bash +pascal test --changed +``` + +--- + +## What's next? + +- Explore the full [command reference](commands/index.md) +- Understand the [workspace layout](workspace.md) +- See how to use pascal in [CI/CD pipelines](ci-cd.md) diff --git a/docs/uv-integration.md b/docs/uv-integration.md new file mode 100644 index 0000000..dd28ac3 --- /dev/null +++ b/docs/uv-integration.md @@ -0,0 +1,87 @@ +# UV Integration + +Pascal is designed to sit **alongside** UV, not replace it. The division of responsibility is clean: + +| Concern | Tool | +|---|---| +| Workspace structure, scaffolding, dependency wiring | **pascal** | +| Package installation, lockfile management, virtual envs | **uv** | +| Running scripts, building wheels, publishing | **uv** (via pascal shims) | + +## How they fit together + +``` +pascal init → writes pascal.toml + UV workspace root pyproject.toml +pascal create → adds a new UV workspace member +pascal add → edits pyproject.toml, adds [tool.uv.sources] entry +pascal sync → regenerates UV workspace root pyproject.toml +pascal test → calls: uv run pytest +pascal build → calls: uv build +pascal run → calls: uv run +``` + +You can always drop into raw `uv` commands — pascal only ever writes standard UV workspace files. + +## UV workspace model + +A UV workspace is a monorepo layout where multiple `pyproject.toml` files share a single `uv.lock`. Pascal generates and maintains the workspace root: + +```toml +# /pyproject.toml +[tool.uv.workspace] +members = ["packages/*", "apps/*"] +``` + +Each member is a standard Python package with its own `pyproject.toml`. UV resolves all members together into a single lockfile. + +## Path dependencies via `[tool.uv.sources]` + +When you run `pascal add cart --to storefront`, pascal writes: + +```toml +# apps/storefront/pyproject.toml +[project] +dependencies = ["cart"] + +[tool.uv.sources] +cart = { workspace = true } +``` + +The `workspace = true` source tells UV to resolve `cart` from the local workspace rather than downloading it from PyPI. + +## Typical workflow + +```bash +# Day 1: set up +pascal init my-ws && cd my-ws +pascal create package cart +pascal create app storefront +pascal add cart --to storefront +uv sync # installs everything, creates uv.lock + +# Day N: add a dependency +pascal add auth --to storefront +uv sync # updates lockfile + +# Day N: run things +pascal test +pascal run storefront + +# CI: targeted testing +pascal test --changed --since origin/main +``` + +## Lock file + +`uv.lock` is managed entirely by UV. Pascal never touches it. Commit it to git — it ensures reproducible installs across machines and CI. + +## Virtual environments + +UV creates a single `.venv` at the workspace root shared by all members. You don't need to activate it manually — `uv run` handles it automatically. + +```bash +# These all work without activating a venv: +uv run python -c "import cart" +uv run pytest packages/cart/tests/ +pascal test cart +``` diff --git a/docs/workspace.md b/docs/workspace.md new file mode 100644 index 0000000..23bfbdc --- /dev/null +++ b/docs/workspace.md @@ -0,0 +1,162 @@ +# Workspace + +## Directory layout + +A pascal workspace is a standard UV workspace with a `pascal.toml` manifest at the root. + +``` +my-workspace/ + pascal.toml # pascal reads this + pyproject.toml # UV workspace root — auto-managed by pascal + uv.lock # lockfile — commit this to git + packages/ + cart/ + pyproject.toml + src/ + cart/ + __init__.py + tests/ + test_cart.py + auth/ + pyproject.toml + src/ + auth/ + __init__.py + tests/ + test_auth.py + apps/ + storefront/ + pyproject.toml # dependencies = ["cart", "auth"] + src/ + storefront/ + __init__.py + main.py + tests/ + test_storefront.py +``` + +## `pascal.toml` reference + +```toml +[workspace] +name = "my-workspace" # required +python = "3.12" # required — minimum Python version +description = "My monorepo" # optional + +# Optional explicit member lists. +# If omitted, pascal auto-discovers from packages/*/pyproject.toml +# and apps/*/pyproject.toml. +packages = ["packages/cart", "packages/auth"] +apps = ["apps/storefront"] +``` + +### Auto-discovery + +When `packages` and `apps` are not listed in `pascal.toml`, pascal scans: + +- `/packages/*/pyproject.toml` → registered as packages +- `/apps/*/pyproject.toml` → registered as apps + +This means adding a new directory under `packages/` or `apps/` is enough — no manifest update required. + +### Workspace root detection + +Pascal walks **up** from the current working directory until it finds `pascal.toml`, the same way cargo and git find their roots. You can run pascal commands from any subdirectory inside the workspace. + +## Generated files + +### Root `pyproject.toml` + +Auto-generated by `pascal init` and kept in sync by `pascal sync`: + +```toml +[project] +name = "my-workspace" +version = "0.1.0" +requires-python = ">=3.12" + +[tool.uv.workspace] +members = ["packages/*", "apps/*"] +``` + +!!! warning "Do not edit manually" + This file is managed by pascal. Run `pascal sync` after changing `pascal.toml` to regenerate it. + +### Package `pyproject.toml` + +Generated by `pascal create package`: + +```toml +[project] +name = "cart" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +### App `pyproject.toml` + +Generated by `pascal create app`. Includes a `[project.scripts]` entry: + +```toml +[project] +name = "storefront" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [] + +[project.scripts] +storefront = "storefront.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +After `pascal add cart --to storefront`: + +```toml hl_lines="7 10 11" +[project] +name = "storefront" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = ["cart"] + +[project.scripts] +storefront = "storefront.main:main" + +[tool.uv.sources] +cart = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +## Source layout + +Pascal uses the [src layout](https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/) for all generated packages and apps: + +``` +packages/cart/ + src/ + cart/ # importable package lives here + __init__.py + tests/ + test_cart.py +``` + +This keeps tests outside the importable package tree and avoids import confusion during development. + +## Name normalisation + +Pascal normalises brick names the same way Python packaging does: + +- Hyphens and underscores are treated as equivalent +- `pascal create package my-pkg` creates `packages/my-pkg/` with `src/my_pkg/` +- The `[project]` name in `pyproject.toml` uses hyphens (`my-pkg`) +- The source directory uses underscores (`my_pkg`) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..3d785f1 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,90 @@ +site_name: pascal +site_description: Fast Python monorepo manager powered by Rust and UV +site_url: https://sandeep-selvaraj.github.io/pascal +repo_url: https://github.com/sandeep-selvaraj/pascal +repo_name: sandeep-selvaraj/pascal +edit_uri: edit/master/docs/ + +theme: + name: material + palette: + - scheme: default + primary: deep purple + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: deep purple + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + font: + text: Inter + code: JetBrains Mono + features: + - navigation.tabs + - navigation.sections + - navigation.indexes + - navigation.top + - navigation.footer + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + - content.tabs.link + icon: + repo: fontawesome/brands/github + logo: material/package-variant-closed + +plugins: + - search + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.tabbed: + alternate_style: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - attr_list + - def_list + - tables + - toc: + permalink: true + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/sandeep-selvaraj/pascal + +nav: + - Home: index.md + - Installation: installation.md + - Quickstart: quickstart.md + - Workspace: workspace.md + - Commands: + - commands/index.md + - init: commands/init.md + - create: commands/create.md + - add: commands/add.md + - info: commands/info.md + - deps: commands/deps.md + - check: commands/check.md + - diff: commands/diff.md + - test: commands/test.md + - build: commands/build.md + - run: commands/run.md + - sync: commands/sync.md + - UV Integration: uv-integration.md + - CI/CD: ci-cd.md + - Contributing: contributing.md From 385b80a8e7f0e71f56daad13eb522c85b7840094 Mon Sep 17 00:00:00 2001 From: Sandeep Selvaraj Date: Fri, 27 Feb 2026 09:53:18 +0100 Subject: [PATCH 09/11] ci: provide action for publishing docs --- .github/workflows/docs.yml | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..c42888e --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,51 @@ +name: Docs + +on: + push: + branches: [master] + +permissions: + contents: read + pages: write + id-token: write + +# Only one deployment runs at a time; in-progress runs are not cancelled +# so a deploy already underway can finish cleanly. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed for git-revision-date if added later + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install MkDocs Material + run: pip install mkdocs-material + + - uses: actions/configure-pages@v4 + + - name: Build docs + run: mkdocs build --strict + + - uses: actions/upload-pages-artifact@v3 + with: + path: site/ + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 From c7009169e219b541cb8554a6921db2e4c262b3ed Mon Sep 17 00:00:00 2001 From: Sandeep Selvaraj Date: Fri, 27 Feb 2026 09:57:31 +0100 Subject: [PATCH 10/11] chore: generate initial version of CHANGELOG --- .github/workflows/release.yml | 33 +++++++++++++++- CHANGELOG.md | 10 +++++ cliff.toml | 54 +++++++++++++++++++++++++ docs/contributing.md | 74 ++++++++++++++++++++++++++++++++++- 4 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 cliff.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eefd239..7d75429 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -119,6 +119,37 @@ jobs: needs: [linux, macos-x86_64, macos-arm64, windows, sdist] environment: pypi # configure trusted publishing in this environment on PyPI steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # git-cliff needs full history to build the changelog + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate changelog for this release (release body) + uses: orhun/git-cliff-action@v3 + id: cliff_body + with: + config: cliff.toml + args: --current --strip all + env: + OUTPUT: body.md + + - name: Regenerate full CHANGELOG.md + uses: orhun/git-cliff-action@v3 + with: + config: cliff.toml + args: --output CHANGELOG.md + env: + OUTPUT: CHANGELOG.md + + - name: Commit updated CHANGELOG.md + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git diff --staged --quiet \ + || git commit -m "chore: update CHANGELOG for ${{ github.ref_name }}" + git push origin HEAD:master + - uses: actions/download-artifact@v4 with: pattern: wheels-* @@ -134,4 +165,4 @@ jobs: uses: softprops/action-gh-release@v2 with: files: dist/* - generate_release_notes: true + body_path: body.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cf7fbd1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to pascal are documented here. + +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Commits follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) spec. + +## [Unreleased] + +_No unreleased changes yet._ diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..4dbd7f6 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,54 @@ +[changelog] +header = """ +# Changelog + +All notable changes to pascal are documented here. + +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Commits follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) spec.\n +""" +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] — {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [Unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }} ([`{{ commit.id | truncate(length=7, end="") }}`]({{ commit.id }}))\ + {% endfor %} +{% endfor %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false + +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^test", group = "Tests" }, + { message = "^ci", group = "CI" }, + { message = "^chore", group = "Miscellaneous" }, + { message = "^revert", group = "Reverts" }, + # Ignore changelog update commits themselves + { message = "^chore: update CHANGELOG", skip = true }, +] + +# Only include commits that touch the package (ignore pure-docs or CI tweaks +# from appearing in the release body — they still appear in CHANGELOG.md) +protect_breaking_commits = true +filter_commits = false +tag_pattern = "v[0-9].*" +skip_tags = "" +ignore_tags = "rc|alpha|beta" +topo_order = false +sort_commits = "newest" diff --git a/docs/contributing.md b/docs/contributing.md index fefaf89..378f3bb 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -132,9 +132,81 @@ mkdocs serve # open http://127.0.0.1:8000 ``` +## Commit messages — Conventional Commits + +Pascal uses [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). This is what drives automatic `CHANGELOG.md` generation via [git-cliff](https://git-cliff.org/). + +``` +[optional scope]: + +[optional body] + +[optional footer] +``` + +### Types + +| Type | When to use | Changelog section | +|---|---|---| +| `feat` | New user-facing feature | **Features** | +| `fix` | Bug fix | **Bug Fixes** | +| `perf` | Performance improvement | **Performance** | +| `refactor` | Internal restructuring, no behaviour change | **Refactoring** | +| `docs` | Documentation only | **Documentation** | +| `test` | Adding or fixing tests | **Tests** | +| `ci` | CI/CD workflow changes | **CI** | +| `chore` | Tooling, deps, release plumbing | **Miscellaneous** | +| `revert` | Reverts a previous commit | **Reverts** | + +### Breaking changes + +Add `!` after the type, or include a `BREAKING CHANGE:` footer: + +``` +feat!: remove --python flag from init (use pascal.toml instead) + +BREAKING CHANGE: The --python flag is no longer accepted by `pascal init`. +Set `python` in pascal.toml instead. +``` + +Breaking changes appear in bold in the changelog and bump the major version. + +### Examples + +``` +feat: add pascal diff --stat flag +fix: handle workspaces with no packages directory +docs: add UV integration page to docs site +test: add integration test for pascal sync +chore: bump clap to 4.5 +ci: cache Rust build artifacts in release workflow +``` + +## Generating the changelog locally + +Install git-cliff (`cargo install git-cliff`) and run: + +```bash +# Preview what the next release entry will look like +git cliff --unreleased + +# Regenerate the full CHANGELOG.md +git cliff --output CHANGELOG.md +``` + +The release workflow runs this automatically when a `v*` tag is pushed. + +## Release process + +1. Bump `version` in `Cargo.toml` and `pyproject.toml` to the new semver +2. Commit: `chore: release v0.2.0` +3. Tag: `git tag v0.2.0 && git push origin v0.2.0` +4. The release workflow builds wheels, publishes to PyPI, commits an updated `CHANGELOG.md`, and creates a GitHub release with the generated notes + ## Opening a PR 1. Open an issue first for non-trivial changes so we can agree on the approach 2. Fork the repo, create a branch from `master` 3. Make your changes — make sure `cargo test` and `cargo clippy` pass -4. Submit a pull request with a clear description of what changed and why +4. Write conventional commit messages +5. Submit a pull request with a clear description of what changed and why From f32c3faa26366e906c6fc69197b815bb9b5c2583 Mon Sep 17 00:00:00 2001 From: Sandeep Selvaraj Date: Fri, 27 Feb 2026 10:01:58 +0100 Subject: [PATCH 11/11] chore: give sample usage in README --- README.md | 79 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 51da00e..f25862e 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,63 @@ pascal test --- +## Workspace layout + +After the quickstart above, your workspace looks like this: + +``` +shop/ + pascal.toml # workspace manifest + pyproject.toml # UV workspace root — managed by pascal + uv.lock # lockfile — commit to git + packages/ + cart/ + pyproject.toml + src/cart/__init__.py + tests/test_cart.py + apps/ + storefront/ + pyproject.toml # depends on cart + src/storefront/__init__.py + src/storefront/main.py + tests/test_storefront.py +``` + +**`pascal.toml`** + +```toml +[workspace] +name = "shop" +python = "3.12" +description = "My Python monorepo" + +# Optional — pascal auto-discovers from packages/ and apps/ if omitted +packages = ["packages/cart"] +apps = ["apps/storefront"] +``` + +**`apps/storefront/pyproject.toml`** (after `pascal add cart --to storefront`) + +```toml +[project] +name = "storefront" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = ["cart"] + +[project.scripts] +storefront = "storefront.main:main" + +[tool.uv.sources] +cart = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +--- + ## Command reference | Command | Description | @@ -128,28 +185,6 @@ pascal --version pip install mkdocs-material mkdocs serve ``` - -### Project layout - -``` -src/ - main.rs # CLI entry-point - cli.rs # clap argument structs - config.rs # serde types (pascal.toml, pyproject.toml) - workspace.rs # workspace discovery and loading - template.rs # generated file content - display.rs # coloured output helpers - uv.rs # uv subprocess wrappers - git.rs # git2 helpers - commands/ # one module per subcommand -tests/ - integration.rs # end-to-end CLI tests -docs/ # MkDocs source (mkdocs.yml at root) -.github/workflows/ - ci.yml # test on push / PR - release.yml # publish to PyPI on git tag -``` - --- ## License