use anyhow::{Context, Result};
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Debug, serde::Deserialize, Default)]
struct CargoToml {
workspace: Option<CargoWorkspace>,
package: Option<CargoPackage>,
bin: Option<Vec<CargoBin>>,
dependencies: Option<toml::Value>,
}
#[derive(Debug, serde::Deserialize)]
struct CargoWorkspace {
members: Option<Vec<String>>,
package: Option<CargoPackage>,
}
#[derive(Debug, serde::Deserialize, Clone)]
struct CargoPackage {
name: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct CargoBin {}
#[derive(Debug)]
struct CrateInfo {
name: String,
path: String,
is_binary: bool,
depends_on: Vec<String>, }
pub fn run() -> Result<()> {
let config_path = ".anodizer.yaml";
if std::path::Path::new(config_path).exists() {
anyhow::bail!("config file '{}' already exists", config_path);
}
let yaml = generate_config(".")?;
std::fs::write(config_path, &yaml)
.with_context(|| format!("failed to write {}", config_path))?;
println!("Created {}", config_path);
let gitignore_path = ".gitignore";
let gitignore = std::fs::read_to_string(gitignore_path).unwrap_or_default();
if !gitignore.contains("dist/") {
let mut f = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(gitignore_path)
.with_context(|| format!("failed to open {}", gitignore_path))?;
use std::io::Write;
if !gitignore.is_empty() && !gitignore.ends_with('\n') {
writeln!(f)?;
}
writeln!(f, "dist/")?;
println!("Added 'dist/' to {}", gitignore_path);
}
Ok(())
}
pub fn generate_config(root: &str) -> Result<String> {
let root_path = Path::new(root);
let cargo_path = root_path.join("Cargo.toml");
let cargo_content = std::fs::read_to_string(&cargo_path)
.with_context(|| format!("cannot read {}", cargo_path.display()))?;
let root_cargo: CargoToml = toml::from_str(&cargo_content)
.with_context(|| format!("cannot parse {}", cargo_path.display()))?;
let crates = if let Some(ws) = &root_cargo.workspace {
discover_workspace_crates(root, ws)?
} else {
let name = root_cargo
.package
.as_ref()
.and_then(|p| p.name.as_deref())
.map(|s| s.to_string())
.unwrap_or_else(|| {
root_path
.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "project".to_string())
});
let is_binary = root_cargo
.bin
.as_ref()
.map(|b| !b.is_empty())
.unwrap_or(false)
|| root_path.join("src/main.rs").exists();
vec![CrateInfo {
name: name.clone(),
path: ".".to_string(),
is_binary,
depends_on: vec![],
}]
};
let project_name = root_cargo
.workspace
.as_ref()
.and_then(|ws| ws.package.as_ref())
.and_then(|p| p.name.as_deref())
.map(|s| s.to_string())
.or_else(|| {
root_cargo
.package
.as_ref()
.and_then(|p| p.name.as_deref())
.map(|s| s.to_string())
})
.unwrap_or_else(|| {
root_path
.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "project".to_string())
});
let sorted = topological_sort(&crates);
render_yaml(&project_name, &sorted)
}
fn discover_workspace_crates(root: &str, ws: &CargoWorkspace) -> Result<Vec<CrateInfo>> {
let root_path = Path::new(root);
let members = ws.members.as_deref().unwrap_or(&[]);
let mut member_names: HashSet<String> = HashSet::new();
for glob_pattern in members {
for member_path in expand_glob(root, glob_pattern) {
let cargo_path = root_path.join(&member_path).join("Cargo.toml");
if let Ok(content) = std::fs::read_to_string(&cargo_path)
&& let Ok(cargo) = toml::from_str::<CargoToml>(&content)
&& let Some(name) = cargo.package.as_ref().and_then(|p| p.name.as_deref())
{
member_names.insert(name.to_string());
}
}
}
let mut crates = vec![];
for glob_pattern in members {
for member_path in expand_glob(root, glob_pattern) {
let cargo_path = root_path.join(&member_path).join("Cargo.toml");
if let Ok(content) = std::fs::read_to_string(&cargo_path)
&& let Ok(cargo) = toml::from_str::<CargoToml>(&content)
{
let name = match cargo.package.as_ref().and_then(|p| p.name.as_deref()) {
Some(n) => n.to_string(),
None => continue,
};
let is_binary = cargo.bin.as_ref().map(|b| !b.is_empty()).unwrap_or(false)
|| root_path.join(&member_path).join("src/main.rs").exists();
let depends_on = extract_workspace_deps(&cargo, &member_names);
crates.push(CrateInfo {
name,
path: member_path,
is_binary,
depends_on,
});
}
}
}
Ok(crates)
}
fn expand_glob(root: &str, pattern: &str) -> Vec<String> {
let root_path = Path::new(root);
if pattern.contains('*') {
let prefix = pattern.trim_end_matches('*').trim_end_matches('/');
let dir = root_path.join(prefix);
if let Ok(entries) = std::fs::read_dir(&dir) {
return entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| {
e.path()
.strip_prefix(root_path)
.ok()
.map(|p| p.to_string_lossy().to_string())
})
.collect();
}
vec![]
} else {
vec![pattern.to_string()]
}
}
fn extract_workspace_deps(cargo: &CargoToml, member_names: &HashSet<String>) -> Vec<String> {
let mut deps = vec![];
if let Some(toml::Value::Table(table)) = &cargo.dependencies {
for (dep_name, val) in table {
let is_member = member_names.contains(dep_name) && {
match val {
toml::Value::Table(t) => {
t.contains_key("path")
|| t.get("workspace")
.is_some_and(|v| v.as_bool() == Some(true))
}
_ => false,
}
};
if is_member {
deps.push(dep_name.clone());
}
}
}
deps.sort();
deps
}
fn topological_sort(crates: &[CrateInfo]) -> Vec<&CrateInfo> {
let items: Vec<(String, Vec<String>)> = crates
.iter()
.map(|c| (c.name.clone(), c.depends_on.clone()))
.collect();
let sorted_names = anodizer_core::util::topological_sort(&items);
let name_to_crate: HashMap<&str, &CrateInfo> =
crates.iter().map(|c| (c.name.as_str(), c)).collect();
sorted_names
.iter()
.filter_map(|name| name_to_crate.get(name.as_str()).copied())
.collect()
}
const COMMON_TARGETS: &[&str] = &[
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
];
fn render_yaml(project_name: &str, crates: &[&CrateInfo]) -> Result<String> {
let mut out = String::new();
out.push_str(&format!("project_name: {}\n", project_name));
out.push_str("dist: ./dist\n\n");
out.push_str("defaults:\n");
out.push_str(" targets:\n");
for t in COMMON_TARGETS {
out.push_str(&format!(" - {}\n", t));
}
out.push_str(" cross: auto\n\n");
out.push_str("crates:\n");
for c in crates {
out.push_str(&format!(" - name: {}\n", c.name));
out.push_str(&format!(" path: {}\n", c.path));
out.push_str(&format!(
" tag_template: \"{}-v{{{{ .Version }}}}\"\n",
c.name
));
if let Some(deps) = non_empty_deps(&c.depends_on) {
out.push_str(" depends_on:\n");
for d in deps {
out.push_str(&format!(" - {}\n", d));
}
}
if c.is_binary {
out.push_str(" builds:\n");
out.push_str(&format!(" - binary: {}\n", c.name));
out.push_str(" archives:\n");
out.push_str(&format!(
" - name_template: \"{}-{{{{ .Version }}}}-{{{{ .Os }}}}-{{{{ .Arch }}}}\"\n",
c.name
));
out.push_str(" release:\n");
out.push_str(" github:\n");
out.push_str(" owner: YOUR_GITHUB_OWNER\n");
out.push_str(&format!(" name: {}\n", project_name));
out.push_str(" draft: false\n");
out.push_str(" prerelease: auto\n");
} else {
out.push_str(" publish:\n");
out.push_str(" cargo: {}\n");
}
out.push('\n');
}
Ok(out)
}
fn non_empty_deps(deps: &[String]) -> Option<&[String]> {
if deps.is_empty() { None } else { Some(deps) }
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_file(dir: &Path, rel: &str, content: &str) {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
#[test]
fn test_single_crate_binary() {
let tmp = TempDir::new().unwrap();
write_file(
tmp.path(),
"Cargo.toml",
r#"
[package]
name = "myapp"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "myapp"
path = "src/main.rs"
"#,
);
let yaml = generate_config(tmp.path().to_str().unwrap()).unwrap();
assert!(yaml.contains("project_name: myapp"));
assert!(yaml.contains("builds:"));
assert!(yaml.contains("binary: myapp"));
assert!(yaml.contains("archives:"));
assert!(yaml.contains("release:"));
assert!(!yaml.contains("cargo: {}"));
}
#[test]
fn test_single_crate_library() {
let tmp = TempDir::new().unwrap();
write_file(
tmp.path(),
"Cargo.toml",
r#"
[package]
name = "mylib"
version = "0.1.0"
edition = "2024"
"#,
);
let yaml = generate_config(tmp.path().to_str().unwrap()).unwrap();
assert!(yaml.contains("project_name: mylib"));
assert!(yaml.contains("cargo: {}"));
assert!(!yaml.contains("builds:"));
}
#[test]
fn test_workspace_with_mixed_crates() {
let tmp = TempDir::new().unwrap();
write_file(
tmp.path(),
"Cargo.toml",
r#"
[workspace]
resolver = "2"
members = ["crates/mylib", "crates/mybin"]
[workspace.package]
name = "myproject"
version = "0.1.0"
edition = "2024"
"#,
);
write_file(
tmp.path(),
"crates/mylib/Cargo.toml",
r#"
[package]
name = "mylib"
version = "0.1.0"
edition = "2024"
"#,
);
write_file(
tmp.path(),
"crates/mybin/Cargo.toml",
r#"
[package]
name = "mybin"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "mybin"
path = "src/main.rs"
[dependencies]
mylib = { path = "../mylib" }
"#,
);
let yaml = generate_config(tmp.path().to_str().unwrap()).unwrap();
assert!(yaml.contains("project_name: myproject"));
assert!(yaml.contains("cargo: {}"));
assert!(yaml.contains("builds:"));
assert!(yaml.contains("binary: mybin"));
assert!(yaml.contains("depends_on:"));
assert!(yaml.contains("- mylib"));
let mylib_pos = yaml.find("name: mylib").unwrap();
let mybin_pos = yaml.find("name: mybin").unwrap();
assert!(mylib_pos < mybin_pos, "mylib should appear before mybin");
}
#[test]
fn test_workspace_glob_expansion() {
let tmp = TempDir::new().unwrap();
write_file(
tmp.path(),
"Cargo.toml",
r#"
[workspace]
resolver = "2"
members = ["crates/*"]
"#,
);
write_file(
tmp.path(),
"crates/alpha/Cargo.toml",
r#"
[package]
name = "alpha"
version = "0.1.0"
edition = "2024"
"#,
);
write_file(
tmp.path(),
"crates/beta/Cargo.toml",
r#"
[package]
name = "beta"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "beta"
path = "src/main.rs"
"#,
);
let yaml = generate_config(tmp.path().to_str().unwrap()).unwrap();
assert!(yaml.contains("name: alpha"));
assert!(yaml.contains("name: beta"));
assert!(yaml.contains("binary: beta"));
}
}