use anyhow::{Context, Result};
use clap::Parser;
use plissken_core::{
CARGO_MANIFEST, Config, CrossRef, DEFAULT_CRATES, DEFAULT_OUTPUT_FORMAT, DEFAULT_OUTPUT_PATH,
DocModel, ModuleRenderer, PLISSKEN_CONFIG, PYPROJECT_MANIFEST, ProjectMetadata, PythonModule,
Renderer, RustModule, TEMPLATE_MDBOOK, TEMPLATE_MKDOCS_MATERIAL, VERSION_SOURCE_CARGO,
VERSION_SOURCE_PYPROJECT, build_cross_refs, synthesize_python_from_rust,
synthesize_python_modules_from_rust,
};
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(name = "plissken")]
#[command(about = "Documentation generator for Rust-Python hybrid projects")]
struct Cli {
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
#[command(subcommand)]
command: Commands,
}
macro_rules! verbose {
($level:expr, $verbosity:expr, $($arg:tt)*) => {
if $verbosity >= $level {
eprintln!($($arg)*);
}
};
}
fn warn_parse_error(file_type: &str, file: &Path, error: &dyn std::fmt::Display) {
eprintln!("warning: failed to parse {} file", file_type);
eprintln!(" --> {}", file.display());
eprintln!(" {}", error);
}
struct CliError {
message: String,
hint: Option<String>,
context: Option<String>,
}
impl CliError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
hint: None,
context: None,
}
}
fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
}
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)?;
if let Some(ctx) = &self.context {
write!(f, "\n --> {}", ctx)?;
}
if let Some(hint) = &self.hint {
write!(f, "\nhint: {}", hint)?;
}
Ok(())
}
}
impl std::fmt::Debug for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(self, f)
}
}
impl std::error::Error for CliError {}
#[derive(clap::Subcommand)]
enum Commands {
Generate {
#[arg(default_value = ".")]
path: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
pretty: bool,
},
Render {
#[arg(default_value = ".")]
path: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
template: Option<String>,
#[arg(long)]
prefix: Option<String>,
},
Init {
#[arg(long)]
force: bool,
},
Check {
#[arg(default_value = ".")]
path: String,
#[arg(long, default_value = "text")]
format: String,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
let verbosity = cli.verbose;
match cli.command {
Commands::Generate {
path,
output,
pretty,
} => generate(&path, output, pretty, verbosity),
Commands::Render {
path,
output,
template,
prefix,
} => render(&path, output, template, prefix, verbosity),
Commands::Init { force } => init(force, verbosity),
Commands::Check { path, format } => check(&path, &format, verbosity),
}
}
fn generate(path: &str, output: Option<PathBuf>, pretty: bool, verbosity: u8) -> Result<()> {
let project_path = Path::new(path);
let config_path = find_config(project_path)?;
let project_root = config_path
.parent()
.ok_or_else(|| CliError::new("Config path has no parent directory"))?;
verbose!(
1,
verbosity,
"Loading config from: {}",
config_path.display()
);
let config = load_config(&config_path)?;
let rust_modules = parse_rust_sources(&config, project_root, verbosity)?;
verbose!(1, verbosity, "Parsed {} Rust module(s)", rust_modules.len());
let python_modules = parse_python_sources(&config, project_root, verbosity)?;
verbose!(
1,
verbosity,
"Parsed {} Python module(s)",
python_modules.len()
);
let (python_modules, cross_refs) =
synthesize_python_if_needed(&config, python_modules, &rust_modules);
verbose!(
1,
verbosity,
"Built {} cross-reference(s)",
cross_refs.len()
);
let model = DocModel {
metadata: ProjectMetadata {
name: config.project.name.clone(),
version: get_project_version(&config, project_root),
description: None,
git_ref: get_git_ref(project_root),
git_commit: get_git_commit(project_root),
generated_at: chrono_lite_now(),
},
rust_modules,
python_modules,
cross_refs,
};
let json = if pretty {
serde_json::to_string_pretty(&model)?
} else {
serde_json::to_string(&model)?
};
if let Some(output_path) = output {
std::fs::write(&output_path, &json)
.with_context(|| format!("Failed to write to {}", output_path.display()))?;
verbose!(1, verbosity, "Wrote output to: {}", output_path.display());
} else {
println!("{}", json);
}
Ok(())
}
fn render(
path: &str,
output_override: Option<PathBuf>,
template_override: Option<String>,
prefix_override: Option<String>,
verbosity: u8,
) -> Result<()> {
let (config, project_root) = load_project_config(path, verbosity)?;
let output_dir = resolve_output_directory(&config, &project_root, output_override);
let template = template_override.or_else(|| config.output.template.clone());
let prefix = resolve_prefix(prefix_override, &config);
log_output_settings(&output_dir, template.as_deref(), verbosity);
if let Some(ref p) = prefix {
verbose!(1, verbosity, "Nav prefix: {}", p);
}
let (python_modules, rust_modules, cross_refs) =
parse_and_merge_modules(&config, &project_root, verbosity)?;
let renderer = create_renderer(template.as_deref(), &project_root)?;
let module_renderer = ModuleRenderer::with_cross_refs(&renderer, cross_refs);
create_output_directory(&output_dir)?;
let content_dir = resolve_content_directory(&output_dir, template.as_deref());
let files_written = write_rendered_pages(
&module_renderer,
&python_modules,
&rust_modules,
&content_dir,
verbosity,
)?;
let ssg_files = generate_ssg_files(
&module_renderer,
&python_modules,
&rust_modules,
&config,
&output_dir,
template.as_deref(),
prefix.as_deref(),
verbosity,
)?;
verbose!(
1,
verbosity,
"\nRendered {} file(s) to {}",
files_written + ssg_files,
output_dir.display()
);
Ok(())
}
fn load_project_config(path: &str, verbosity: u8) -> Result<(Config, PathBuf)> {
let project_path = Path::new(path);
let config_path = find_config(project_path)?;
let project_root = config_path
.parent()
.ok_or_else(|| CliError::new("Config path has no parent directory"))?
.to_path_buf();
verbose!(
1,
verbosity,
"Loading config from: {}",
config_path.display()
);
let config = load_config(&config_path)?;
Ok((config, project_root))
}
fn resolve_output_directory(
config: &Config,
project_root: &Path,
output_override: Option<PathBuf>,
) -> PathBuf {
output_override
.map(|p| {
if p.is_relative() {
project_root.join(p)
} else {
p
}
})
.unwrap_or_else(|| project_root.join(&config.output.path))
}
fn resolve_prefix(prefix_override: Option<String>, config: &Config) -> Option<String> {
let raw = prefix_override.or_else(|| config.output.prefix.clone());
raw.map(|p| p.trim_end_matches('/').to_string())
.filter(|p| !p.is_empty())
}
fn log_output_settings(output_dir: &Path, template: Option<&str>, verbosity: u8) {
verbose!(1, verbosity, "Output directory: {}", output_dir.display());
if let Some(t) = template {
verbose!(1, verbosity, "Using template: {}", t);
}
}
fn parse_and_merge_modules(
config: &Config,
project_root: &Path,
verbosity: u8,
) -> Result<(Vec<PythonModule>, Vec<RustModule>, Vec<CrossRef>)> {
let rust_modules = parse_rust_sources(config, project_root, verbosity)?;
verbose!(1, verbosity, "Parsed {} Rust module(s)", rust_modules.len());
let mut python_modules = parse_python_sources(config, project_root, verbosity)?;
verbose!(
1,
verbosity,
"Parsed {} Python module(s)",
python_modules.len()
);
for module in &mut python_modules {
module.path = normalize_python_module_path(&module.path, config, project_root);
}
let (python_modules, initial_cross_refs) =
merge_synthesized_python_modules(config, python_modules, &rust_modules, verbosity);
let (python_modules, cross_refs) =
build_cross_references(config, python_modules, &rust_modules, initial_cross_refs);
verbose!(
1,
verbosity,
"Built {} cross-reference(s)",
cross_refs.len()
);
Ok((python_modules, rust_modules, cross_refs))
}
fn create_renderer(template: Option<&str>, project_root: &Path) -> Result<Renderer> {
Renderer::new(template, Some(project_root)).map_err(|e| {
CliError::new(format!("failed to create renderer: {}", e))
.with_hint(format!(
"valid templates are '{}' and '{}'",
TEMPLATE_MKDOCS_MATERIAL, TEMPLATE_MDBOOK
))
.into()
})
}
fn create_output_directory(output_dir: &Path) -> Result<()> {
std::fs::create_dir_all(output_dir).map_err(|e| {
CliError::new(format!(
"failed to create output directory: {}",
output_dir.display()
))
.with_context(e.to_string())
.with_hint("check that you have write permissions to this location")
})?;
Ok(())
}
fn resolve_content_directory(output_dir: &Path, template: Option<&str>) -> PathBuf {
if template == Some(TEMPLATE_MDBOOK) {
output_dir.join("src")
} else {
output_dir.to_path_buf()
}
}
fn merge_synthesized_python_modules(
config: &Config,
mut python_modules: Vec<PythonModule>,
rust_modules: &[RustModule],
verbosity: u8,
) -> (Vec<PythonModule>, Vec<CrossRef>) {
let python_package = config
.python
.as_ref()
.map(|p| p.package.clone())
.unwrap_or_else(|| config.project.name.clone());
let rust_entry_point = config
.rust
.as_ref()
.and_then(|r| r.entry_point.clone())
.unwrap_or_else(|| config.project.name.clone());
let synth_results =
synthesize_python_modules_from_rust(rust_modules, &python_package, &rust_entry_point);
let mut all_cross_refs: Vec<CrossRef> = Vec::new();
for (synth_module, synth_refs) in synth_results {
let existing = python_modules
.iter_mut()
.find(|m| m.path == synth_module.path);
if let Some(existing_module) = existing {
for synth_item in synth_module.items {
let item_name = match &synth_item {
plissken_core::PythonItem::Class(c) => c.name.clone(),
plissken_core::PythonItem::Function(f) => f.name.clone(),
plissken_core::PythonItem::Variable(v) => v.name.clone(),
};
let exists = existing_module
.items
.iter()
.any(|item| match (item, &synth_item) {
(
plissken_core::PythonItem::Class(a),
plissken_core::PythonItem::Class(b),
) => a.name == b.name,
(
plissken_core::PythonItem::Function(a),
plissken_core::PythonItem::Function(b),
) => a.name == b.name,
(
plissken_core::PythonItem::Variable(a),
plissken_core::PythonItem::Variable(b),
) => a.name == b.name,
_ => false,
});
if !exists {
verbose!(
2,
verbosity,
" Merging synthesized {} into {}",
item_name,
existing_module.path
);
existing_module.items.push(synth_item);
}
}
} else {
verbose!(
2,
verbosity,
" Synthesized Python module: {} (from Rust bindings)",
synth_module.path
);
python_modules.push(synth_module);
}
all_cross_refs.extend(synth_refs);
}
(python_modules, all_cross_refs)
}
fn synthesize_python_if_needed(
config: &Config,
python_modules: Vec<PythonModule>,
rust_modules: &[RustModule],
) -> (Vec<PythonModule>, Vec<CrossRef>) {
if python_modules.is_empty() && !rust_modules.is_empty() && config.python.is_some() {
let module_name = config
.python
.as_ref()
.map(|p| p.package.clone())
.unwrap_or_else(|| config.project.name.clone());
let (synth_module, synth_refs) = synthesize_python_from_rust(rust_modules, &module_name);
(vec![synth_module], synth_refs)
} else {
build_cross_refs(config, rust_modules, python_modules)
}
}
fn build_cross_references(
config: &Config,
python_modules: Vec<PythonModule>,
rust_modules: &[RustModule],
initial_cross_refs: Vec<CrossRef>,
) -> (Vec<PythonModule>, Vec<CrossRef>) {
let (python_modules, mut cross_refs) =
synthesize_python_if_needed(config, python_modules, rust_modules);
cross_refs.extend(initial_cross_refs);
(python_modules, cross_refs)
}
fn write_rendered_pages(
module_renderer: &ModuleRenderer,
python_modules: &[PythonModule],
rust_modules: &[RustModule],
content_dir: &Path,
verbosity: u8,
) -> Result<usize> {
let mut files_written = 0;
for module in python_modules {
let pages = module_renderer.render_python_module(module).map_err(|e| {
CliError::new(format!("failed to render Python module '{}'", module.path))
.with_context(e.to_string())
.with_hint("this may indicate a bug in plissken - please report it")
})?;
for page in pages {
let output_path = content_dir.join(&page.path);
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
std::fs::write(&output_path, &page.content)
.with_context(|| format!("Failed to write: {}", output_path.display()))?;
verbose!(2, verbosity, " Wrote: {}", output_path.display());
files_written += 1;
}
}
for module in rust_modules {
let pages = module_renderer.render_rust_module(module).map_err(|e| {
CliError::new(format!("failed to render Rust module '{}'", module.path))
.with_context(e.to_string())
.with_hint("this may indicate a bug in plissken - please report it")
})?;
for page in pages {
let output_path = content_dir.join(&page.path);
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
std::fs::write(&output_path, &page.content)
.with_context(|| format!("Failed to write: {}", output_path.display()))?;
verbose!(2, verbosity, " Wrote: {}", output_path.display());
files_written += 1;
}
}
Ok(files_written)
}
#[allow(clippy::too_many_arguments)]
fn generate_ssg_files(
module_renderer: &ModuleRenderer,
python_modules: &[PythonModule],
rust_modules: &[RustModule],
config: &Config,
output_dir: &Path,
template: Option<&str>,
prefix: Option<&str>,
verbosity: u8,
) -> Result<usize> {
let mut files_written = 0;
let is_mdbook = template == Some(TEMPLATE_MDBOOK);
if is_mdbook {
let summary =
module_renderer.generate_mdbook_summary(python_modules, rust_modules, prefix);
let summary_path = output_dir.join("src/SUMMARY.md");
if let Some(parent) = summary_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&summary_path, &summary)
.with_context(|| format!("Failed to write SUMMARY.md: {}", summary_path.display()))?;
verbose!(2, verbosity, " Wrote: {}", summary_path.display());
files_written += 1;
let authors = vec![config.project.name.clone()];
let book_config = module_renderer.generate_mdbook_config(&config.project.name, &authors);
let config_path = output_dir.join("book.toml");
std::fs::write(&config_path, &book_config)
.with_context(|| format!("Failed to write book.toml: {}", config_path.display()))?;
verbose!(2, verbosity, " Wrote: {}", config_path.display());
files_written += 1;
let css = module_renderer.generate_mdbook_css();
let css_dir = output_dir.join("theme");
std::fs::create_dir_all(&css_dir)?;
let css_path = css_dir.join("custom.css");
std::fs::write(&css_path, &css)
.with_context(|| format!("Failed to write custom.css: {}", css_path.display()))?;
verbose!(2, verbosity, " Wrote: {}", css_path.display());
files_written += 1;
} else {
let nav_yaml = module_renderer.generate_nav_yaml(python_modules, rust_modules, prefix);
let nav_path = output_dir.join("_nav.yml");
std::fs::write(&nav_path, &nav_yaml)
.with_context(|| format!("Failed to write nav file: {}", nav_path.display()))?;
verbose!(2, verbosity, " Wrote: {}", nav_path.display());
files_written += 1;
}
Ok(files_written)
}
fn init(force: bool, verbosity: u8) -> Result<()> {
let config_path = PathBuf::from(PLISSKEN_CONFIG);
if config_path.exists() && !force {
return Err(CliError::new(format!("{} already exists", PLISSKEN_CONFIG))
.with_hint("use --force to overwrite the existing configuration")
.into());
}
verbose!(1, verbosity, "Detecting project type...");
let project = detect_project()?;
verbose!(1, verbosity, "Detected: {}", project.description());
let config_content = generate_config(&project)?;
std::fs::write(&config_path, &config_content).map_err(|e| {
CliError::new(format!("failed to write {}", PLISSKEN_CONFIG))
.with_context(e.to_string())
.with_hint("check that you have write permissions in this directory")
})?;
verbose!(1, verbosity, "Created {}", PLISSKEN_CONFIG);
Ok(())
}
#[derive(serde::Serialize)]
struct ValidationIssue {
severity: String,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
hint: Option<String>,
}
#[derive(serde::Serialize)]
struct CliValidationResult {
valid: bool,
config_path: String,
issues: Vec<ValidationIssue>,
}
impl CliValidationResult {
fn new(config_path: &Path) -> Self {
Self {
valid: true,
config_path: config_path.display().to_string(),
issues: Vec::new(),
}
}
fn add_error(&mut self, message: impl Into<String>, hint: Option<String>) {
self.valid = false;
self.issues.push(ValidationIssue {
severity: "error".to_string(),
message: message.into(),
hint,
});
}
fn add_warning(&mut self, message: impl Into<String>, hint: Option<String>) {
self.issues.push(ValidationIssue {
severity: "warning".to_string(),
message: message.into(),
hint,
});
}
}
fn check(path: &str, format: &str, verbosity: u8) -> Result<()> {
let project_path = Path::new(path);
let config_path = match find_config(project_path) {
Ok(p) => p,
Err(e) => {
if format == "json" {
let result = CliValidationResult {
valid: false,
config_path: project_path.join(PLISSKEN_CONFIG).display().to_string(),
issues: vec![ValidationIssue {
severity: "error".to_string(),
message: "configuration file not found".to_string(),
hint: Some(
"run 'plissken init' to create a configuration file".to_string(),
),
}],
};
println!("{}", serde_json::to_string_pretty(&result)?);
std::process::exit(1);
}
return Err(e);
}
};
let project_root = config_path
.parent()
.ok_or_else(|| CliError::new("Config path has no parent directory"))?;
verbose!(1, verbosity, "Checking config: {}", config_path.display());
let mut result = CliValidationResult::new(&config_path);
let config = match load_config(&config_path) {
Ok(c) => c,
Err(e) => {
result.add_error(
format!("failed to parse configuration: {}", e),
Some("check TOML syntax and required fields".to_string()),
);
return output_result(&result, format, verbosity);
}
};
verbose!(1, verbosity, "Config parsed successfully");
match config.validate(project_root) {
Ok(core_result) => {
for warning in core_result.warnings {
result.add_warning(&warning.message, warning.hint);
}
}
Err(e) => {
let hint = match &e {
plissken_core::ConfigError::NoLanguageConfigured => {
Some("add at least one source section to generate documentation".to_string())
}
plissken_core::ConfigError::VersionSourceNotFound(_, _) => {
Some("create the file or change version_from to another source (cargo, pyproject, git)".to_string())
}
plissken_core::ConfigError::RustCrateNotFound(_) => {
Some("check the crates array in [rust] section".to_string())
}
plissken_core::ConfigError::PythonSourceNotFound(_) => {
Some("check the 'source' or 'package' field in [python] section".to_string())
}
plissken_core::ConfigError::GitRepoNotFound => {
Some("initialize git with 'git init' or change version_from".to_string())
}
};
result.add_error(e.to_string(), hint);
}
}
output_result(&result, format, verbosity)
}
fn output_result(result: &CliValidationResult, format: &str, verbosity: u8) -> Result<()> {
if format == "json" {
println!("{}", serde_json::to_string_pretty(result)?);
} else {
if result.valid {
verbose!(1, verbosity, "Configuration is valid");
if !result.issues.is_empty() {
for issue in &result.issues {
eprintln!("warning: {}", issue.message);
if let Some(ref hint) = issue.hint {
eprintln!("hint: {}", hint);
}
}
}
} else {
for issue in &result.issues {
if issue.severity == "error" {
eprintln!("error: {}", issue.message);
} else {
eprintln!("warning: {}", issue.message);
}
if let Some(ref hint) = issue.hint {
eprintln!("hint: {}", hint);
}
}
}
}
if result.valid {
Ok(())
} else {
std::process::exit(1);
}
}
struct DetectedProject {
name: String,
has_rust: bool,
has_python: bool,
rust_crates: Vec<PathBuf>,
rust_entry_point: Option<String>,
python_package: Option<String>,
python_source: Option<PathBuf>,
is_hybrid: bool,
}
impl DetectedProject {
fn description(&self) -> String {
match (self.has_rust, self.has_python, self.is_hybrid) {
(true, true, true) => format!("hybrid PyO3 project '{}'", self.name),
(true, true, false) => format!("Rust + Python project '{}'", self.name),
(true, false, _) => format!("Rust project '{}'", self.name),
(false, true, _) => format!("Python project '{}'", self.name),
(false, false, _) => format!("unknown project '{}'", self.name),
}
}
}
fn detect_project() -> Result<DetectedProject> {
let cwd = std::env::current_dir()?;
let mut project = DetectedProject {
name: cwd
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project")
.to_string(),
has_rust: false,
has_python: false,
rust_crates: Vec::new(),
rust_entry_point: None,
python_package: None,
python_source: None,
is_hybrid: false,
};
let cargo_toml = cwd.join(CARGO_MANIFEST);
if cargo_toml.exists() {
project.has_rust = true;
project.rust_crates.push(PathBuf::from("."));
if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
if let Some(name) = extract_cargo_name(&content) {
project.name = name.clone();
project.rust_entry_point = Some(name.replace('-', "_"));
}
if content.contains("pyo3") || content.contains("maturin") {
project.is_hybrid = true;
}
}
}
let pyproject_toml = cwd.join(PYPROJECT_MANIFEST);
if pyproject_toml.exists() {
project.has_python = true;
if let Ok(content) = std::fs::read_to_string(&pyproject_toml) {
if let Some(name) = extract_pyproject_name(&content) {
if !project.has_rust {
project.name = name.clone();
}
project.python_package = Some(name.replace('-', "_"));
}
project.python_source = extract_python_source(&content);
if content.contains("maturin") {
project.is_hybrid = true;
}
}
}
let setup_py = cwd.join("setup.py");
if setup_py.exists() && !project.has_python {
project.has_python = true;
if project.python_package.is_none() {
project.python_package = Some(project.name.replace('-', "_"));
}
}
if !project.has_rust && !project.has_python {
return Err(CliError::new("no Rust or Python project detected")
.with_hint(format!(
"run this command from a directory containing {} or {}",
CARGO_MANIFEST, PYPROJECT_MANIFEST
))
.into());
}
Ok(project)
}
fn extract_cargo_name(content: &str) -> Option<String> {
let mut in_package = false;
for line in content.lines() {
let line = line.trim();
if line == "[package]" {
in_package = true;
continue;
}
if line.starts_with('[') {
in_package = false;
continue;
}
if in_package
&& line.starts_with("name")
&& let Some(val) = line.split('=').nth(1)
{
let name = val.trim().trim_matches('"').trim_matches('\'');
return Some(name.to_string());
}
}
None
}
fn extract_pyproject_name(content: &str) -> Option<String> {
let mut in_project = false;
let mut in_poetry = false;
for line in content.lines() {
let line = line.trim();
if line == "[project]" {
in_project = true;
in_poetry = false;
continue;
}
if line == "[tool.poetry]" {
in_poetry = true;
in_project = false;
continue;
}
if line.starts_with('[') {
in_project = false;
in_poetry = false;
continue;
}
if (in_project || in_poetry)
&& line.starts_with("name")
&& let Some(val) = line.split('=').nth(1)
{
let name = val.trim().trim_matches('"').trim_matches('\'');
return Some(name.to_string());
}
}
None
}
fn extract_python_source(content: &str) -> Option<PathBuf> {
let mut in_maturin = false;
for line in content.lines() {
let line = line.trim();
if line == "[tool.maturin]" {
in_maturin = true;
continue;
}
if line.starts_with('[') && in_maturin {
in_maturin = false;
continue;
}
if in_maturin
&& line.starts_with("python-source")
&& let Some(val) = line.split('=').nth(1)
{
let source = val.trim().trim_matches('"').trim_matches('\'');
return Some(PathBuf::from(source));
}
}
let mut in_find = false;
for line in content.lines() {
let line = line.trim();
if line == "[tool.setuptools.packages.find]" {
in_find = true;
continue;
}
if line.starts_with('[') && in_find {
in_find = false;
continue;
}
if in_find
&& line.starts_with("where")
&& let Some(val) = line.split('=').nth(1)
{
let val = val.trim();
if val.starts_with('[') {
let inner = val.trim_start_matches('[').trim_end_matches(']');
if let Some(first) = inner.split(',').next() {
let source = first.trim().trim_matches('"').trim_matches('\'');
if !source.is_empty() && source != "." {
return Some(PathBuf::from(source));
}
}
}
}
}
None
}
fn generate_config(project: &DetectedProject) -> Result<String> {
let mut config = String::new();
config.push_str("[project]\n");
config.push_str(&format!("name = \"{}\"\n", project.name));
if project.has_rust {
config.push_str(&format!("version_from = \"{}\"\n", VERSION_SOURCE_CARGO));
} else {
config.push_str(&format!(
"version_from = \"{}\"\n",
VERSION_SOURCE_PYPROJECT
));
}
config.push('\n');
config.push_str("[output]\n");
config.push_str(&format!("format = \"{}\"\n", DEFAULT_OUTPUT_FORMAT));
config.push_str(&format!("path = \"{}\"\n", DEFAULT_OUTPUT_PATH));
config.push_str(&format!("template = \"{}\"\n", TEMPLATE_MKDOCS_MATERIAL));
config.push('\n');
if project.has_rust {
config.push_str("[rust]\n");
config.push_str(&format!("crates = [\"{}\"]\n", DEFAULT_CRATES));
if let Some(ref entry_point) = project.rust_entry_point {
config.push_str(&format!("entry_point = \"{}\"\n", entry_point));
}
config.push('\n');
}
if project.has_python || project.is_hybrid {
config.push_str("[python]\n");
let package = project
.python_package
.as_ref()
.unwrap_or(&project.name)
.replace('-', "_");
config.push_str(&format!("package = \"{}\"\n", package));
if let Some(ref source) = project.python_source {
config.push_str(&format!("source = \"{}\"\n", source.display()));
}
config.push('\n');
}
Ok(config)
}
fn find_config(path: &Path) -> Result<PathBuf> {
let path = if path.is_relative() {
std::env::current_dir()?.join(path)
} else {
path.to_path_buf()
};
if path.is_file()
&& path
.file_name()
.map(|f| f == PLISSKEN_CONFIG)
.unwrap_or(false)
{
return Ok(path);
}
let config_path = if path.is_dir() {
path.join(PLISSKEN_CONFIG)
} else {
path
};
if config_path.exists() {
Ok(config_path)
} else {
Err(CliError::new(format!(
"{} not found at {}",
PLISSKEN_CONFIG,
config_path.display()
))
.with_hint("run 'plissken init' to create a configuration file")
.into())
}
}
fn load_config(path: &Path) -> Result<Config> {
let content = std::fs::read_to_string(path).map_err(|e| {
CliError::new(format!("failed to read {}", path.display()))
.with_context(e.to_string())
.with_hint("check that the file exists and is readable")
})?;
toml::from_str(&content).map_err(|e: toml::de::Error| {
let mut err = CliError::new("invalid configuration file");
let err_str = e.to_string();
if let Some(line_info) = extract_toml_location(&err_str) {
err = err.with_context(format!("{}:{}", path.display(), line_info));
} else {
err = err.with_context(path.display().to_string());
}
err = CliError::new(format!(
"invalid configuration: {}",
summarize_toml_error(&err_str)
))
.with_context(err.context.unwrap_or_default())
.with_hint(suggest_config_fix(&err_str));
err.into()
})
}
fn extract_toml_location(err: &str) -> Option<String> {
if let Some(idx) = err.find("at line") {
let rest = &err[idx..];
if let Some(end) = rest.find(['\n', ',']) {
return Some(rest[..end].to_string());
}
return Some(rest.to_string());
}
None
}
fn summarize_toml_error(err: &str) -> String {
if err.contains("missing field")
&& let Some(start) = err.find('`')
&& let Some(end) = err[start + 1..].find('`')
{
let field = &err[start + 1..start + 1 + end];
return format!("missing required field '{}'", field);
}
if err.contains("unknown field")
&& let Some(start) = err.find('`')
&& let Some(end) = err[start + 1..].find('`')
{
let field = &err[start + 1..start + 1 + end];
return format!("unknown field '{}'", field);
}
if err.contains("invalid type") {
return "invalid value type".to_string();
}
err.lines().next().unwrap_or(err).to_string()
}
fn suggest_config_fix(err: &str) -> String {
if err.contains("missing field `name`") || err.contains("missing field `project`") {
return "ensure [project] section has a 'name' field".to_string();
}
if err.contains("missing field `path`") || err.contains("missing field `output`") {
return "ensure [output] section has a 'path' field".to_string();
}
if err.contains("unknown field") {
return "check spelling of field names in plissken.toml".to_string();
}
if err.contains("invalid type") {
return "check that field values have the correct type (string, array, etc.)".to_string();
}
if err.contains("expected") {
return "check TOML syntax - strings need quotes, arrays use brackets".to_string();
}
"check plissken.toml syntax and refer to documentation for config format".to_string()
}
fn parse_rust_sources(
config: &Config,
project_root: &Path,
verbosity: u8,
) -> Result<Vec<RustModule>> {
let Some(ref rust_config) = config.rust else {
return Ok(Vec::new());
};
let parser = plissken_core::parser::RustParser::new();
let mut modules = Vec::new();
for crate_path in &rust_config.crates {
let crate_dir = project_root.join(crate_path);
let crate_name = read_crate_name(&crate_dir)?;
verbose!(
2,
verbosity,
" Crate: {} ({})",
crate_name,
crate_dir.display()
);
let src_dir = if crate_dir.join("src").exists() {
crate_dir.join("src")
} else if crate_dir.join("rust").exists() && crate_dir.join("rust").join("lib.rs").exists()
{
crate_dir.join("rust")
} else {
crate_dir.clone()
};
let rs_files = find_rust_files(&crate_dir)?;
for rs_file in rs_files {
match parser.parse_file(&rs_file) {
Ok(mut module) => {
module.path = file_to_module_path(&rs_file, &crate_name, &src_dir);
verbose!(2, verbosity, " {} -> {}", rs_file.display(), module.path);
modules.push(module);
}
Err(e) => {
warn_parse_error("Rust", &rs_file, &e);
}
}
}
}
verbose!(2, verbosity, " Found {} Rust files", modules.len());
Ok(modules)
}
fn read_crate_name(crate_dir: &Path) -> Result<String> {
let cargo_toml = crate_dir.join(CARGO_MANIFEST);
if !cargo_toml.exists() {
return Ok(crate_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string());
}
let content = std::fs::read_to_string(&cargo_toml)
.with_context(|| format!("failed to read {}", cargo_toml.display()))?;
let parsed: toml::Value = toml::from_str(&content)
.with_context(|| format!("failed to parse {}", cargo_toml.display()))?;
if parsed.get("workspace").is_some() && parsed.get("package").is_none() {
return Err(CliError::new(format!(
"{} is a workspace manifest, not a crate",
cargo_toml.display()
))
.with_hint("In plissken.toml [rust] section, specify individual crate paths instead of the workspace root, e.g.: crates = [\"crates/my-crate\"]")
.into());
}
let name = parsed
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.ok_or_else(|| {
CliError::new(format!("missing package.name in {}", cargo_toml.display()))
})?;
Ok(name.to_string())
}
fn file_to_module_path(file_path: &Path, crate_name: &str, src_dir: &Path) -> String {
let relative = match file_path.strip_prefix(src_dir) {
Ok(rel) => rel,
Err(_) => return crate_name.to_string(),
};
let file_name = relative.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name == "lib.rs" || file_name == "main.rs" {
return crate_name.to_string();
}
let mut parts: Vec<&str> = Vec::new();
for component in relative.parent().unwrap_or(Path::new("")).components() {
if let std::path::Component::Normal(name) = component
&& let Some(name_str) = name.to_str()
{
parts.push(name_str);
}
}
if file_name == "mod.rs" {
if parts.is_empty() {
return crate_name.to_string();
}
return format!("{}::{}", crate_name, parts.join("::"));
}
if let Some(stem) = relative.file_stem().and_then(|s| s.to_str()) {
parts.push(stem);
}
if parts.is_empty() {
crate_name.to_string()
} else {
format!("{}::{}", crate_name, parts.join("::"))
}
}
fn find_rust_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if !dir.exists() {
return Ok(files);
}
let search_dir = if dir.join("src").exists() {
dir.join("src")
} else if dir.join("rust").exists() && dir.join("rust").join("lib.rs").exists() {
dir.join("rust")
} else {
dir.to_path_buf()
};
let search_dir = &search_dir;
fn walk_dir(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
if !dir.is_dir() {
return Ok(());
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
walk_dir(&path, files)?;
} else if path.extension().map(|e| e == "rs").unwrap_or(false) {
files.push(path);
}
}
Ok(())
}
walk_dir(search_dir, &mut files)?;
Ok(files)
}
fn parse_python_sources(
config: &Config,
project_root: &Path,
verbosity: u8,
) -> Result<Vec<PythonModule>> {
let Some(ref python_config) = config.python else {
return Ok(Vec::new());
};
let mut parser = plissken_core::parser::PythonParser::new();
let mut modules = Vec::new();
let python_dir = if let Some(ref source) = python_config.source {
project_root.join(source)
} else {
project_root.join(&python_config.package)
};
if !python_dir.exists() {
return Ok(modules);
}
let py_files: Vec<PathBuf> = if python_config.auto_discover {
verbose!(
1,
verbosity,
"Auto-discovering Python modules in {}...",
python_dir.display()
);
let discovered =
plissken_core::discover_python_modules(&python_dir, &python_config.package)
.map_err(|e| CliError::new(format!("failed to discover Python modules: {}", e)))?;
verbose!(
2,
verbosity,
" Discovered {} Python modules",
discovered.len()
);
discovered.into_iter().map(|m| m.path).collect()
} else {
find_python_files(&python_dir)?
};
for py_file in py_files {
match parser.parse_file(&py_file) {
Ok(module) => modules.push(module),
Err(e) => {
warn_parse_error("Python", &py_file, &e);
}
}
}
verbose!(2, verbosity, " Found {} Python files", modules.len());
Ok(modules)
}
fn find_python_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
fn walk_dir(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
if !dir.is_dir() {
return Ok(());
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
walk_dir(&path, files)?;
} else if path.extension().map(|e| e == "py").unwrap_or(false) {
if !path
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with("__pycache__"))
{
files.push(path);
}
}
}
Ok(())
}
walk_dir(dir, &mut files)?;
Ok(files)
}
fn get_project_version(config: &Config, project_root: &Path) -> Option<String> {
use plissken_core::config::VersionSource;
match config.project.version_from {
VersionSource::Cargo => {
let cargo_toml = project_root.join(CARGO_MANIFEST);
if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
for line in content.lines() {
let line = line.trim();
if line.starts_with("version")
&& let Some(val) = line.split('=').nth(1)
{
let version = val.trim().trim_matches('"').trim_matches('\'');
return Some(version.to_string());
}
}
}
None
}
VersionSource::Pyproject => {
let pyproject = project_root.join(PYPROJECT_MANIFEST);
if let Ok(content) = std::fs::read_to_string(&pyproject) {
for line in content.lines() {
let line = line.trim();
if line.starts_with("version")
&& let Some(val) = line.split('=').nth(1)
{
let version = val.trim().trim_matches('"').trim_matches('\'');
return Some(version.to_string());
}
}
}
None
}
VersionSource::Git => {
std::process::Command::new("git")
.args(["describe", "--tags", "--always"])
.current_dir(project_root)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
})
}
}
}
fn get_git_ref(project_root: &Path) -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(project_root)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
})
}
fn get_git_commit(project_root: &Path) -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(project_root)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
})
}
fn chrono_lite_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days = secs / 86400;
let remaining = secs % 86400;
let hours = remaining / 3600;
let minutes = (remaining % 3600) / 60;
let seconds = remaining % 60;
let mut year = 1970;
let mut remaining_days = days as i64;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let days_in_months: [i64; 12] = if is_leap_year(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1;
for &days_in_month in &days_in_months {
if remaining_days < days_in_month {
break;
}
remaining_days -= days_in_month;
month += 1;
}
let day = remaining_days + 1;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
)
}
fn is_leap_year(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
fn normalize_python_module_path(file_path: &str, config: &Config, project_root: &Path) -> String {
let path = Path::new(file_path);
let relative = path.strip_prefix(project_root).unwrap_or(path);
let package_path = config
.python
.as_ref()
.map(|p| Path::new(&p.package))
.unwrap_or(Path::new(""));
let package_name = package_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_else(|| &config.project.name);
let python_source = config
.python
.as_ref()
.and_then(|p| p.source.as_ref())
.map(|s| s.as_path())
.unwrap_or_else(|| package_path.parent().unwrap_or(Path::new("")));
let module_path = relative.strip_prefix(python_source).unwrap_or(relative);
let path_str = module_path
.with_extension("")
.to_string_lossy()
.replace(['/', '\\'], ".");
if path_str.ends_with(".__init__") {
path_str[..path_str.len() - 9].to_string()
} else if path_str == "__init__" {
package_name.to_string()
} else {
path_str
}
}