use anyhow::{anyhow, bail, Result};
use std::path::Path;
pub fn reverse_from_path(path: &Path) -> Result<crate::sexp_ast::Forms> {
let detected = crate::discover::detect(path)
.ok_or_else(|| anyhow!("no ecosystem detected in {}", path.display()))?;
let mut spec = crate::scaffold::ScaffoldSpec::new(
&detected.name, detected.ecosystem,
);
enrich_from_manifest(path, detected.ecosystem, &mut spec)?;
Ok(crate::scaffold::build(&spec))
}
pub trait ReverseExtractor: Sync {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()>;
}
pub struct RustCargoExtractor;
impl ReverseExtractor for RustCargoExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
read_cargo_toml(path, spec)
}
}
pub struct NpmExtractor;
impl ReverseExtractor for NpmExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
read_package_json(path, spec)
}
}
pub struct PythonExtractor;
impl ReverseExtractor for PythonExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
read_pyproject_toml(path, spec)
}
}
pub struct HelmExtractor;
impl ReverseExtractor for HelmExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
read_chart_yaml(path, spec)
}
}
pub struct GoModExtractor;
impl ReverseExtractor for GoModExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("go.mod"))
.map_err(|e| anyhow!("read go.mod: {e}"))?;
for line in text.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix("module ") {
spec.extra_package.insert("module-path".to_string(),
rest.trim().to_string());
} else if let Some(rest) = t.strip_prefix("go ") {
let v = rest.trim();
if !v.is_empty() && !v.starts_with('(') {
spec.extra_package.insert("go-version".to_string(),
v.to_string());
}
}
}
Ok(())
}
}
pub struct RubyGemspecExtractor;
impl ReverseExtractor for RubyGemspecExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let entries = std::fs::read_dir(path).ok();
let Some(entries) = entries else { return Ok(()); };
for e in entries.flatten() {
let name = e.file_name();
let Some(n) = name.to_str() else { continue };
if !n.ends_with(".gemspec") { continue; }
let text = std::fs::read_to_string(e.path())
.map_err(|e| anyhow!("read .gemspec: {e}"))?;
if let Some(v) = read_ruby_attr(&text, "version") { spec.version = Some(v); }
if let Some(s) = read_ruby_attr(&text, "summary")
.or_else(|| read_ruby_attr(&text, "description"))
{ spec.description = Some(s); }
if let Some(l) = read_ruby_attr(&text, "license") { spec.license = Some(l); }
return Ok(());
}
Ok(())
}
}
pub struct OcamlDuneExtractor;
impl ReverseExtractor for OcamlDuneExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("dune-project"))
.map_err(|e| anyhow!("read dune-project: {e}"))?;
if let Some(v) = read_dune_form(&text, "version") { spec.version = Some(v); }
if let Some(l) = read_dune_form(&text, "license") { spec.license = Some(l); }
if let Some(d) = read_dune_form(&text, "synopsis") { spec.description = Some(d); }
Ok(())
}
}
pub struct SwiftSpmExtractor;
impl ReverseExtractor for SwiftSpmExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("Package.swift"))
.map_err(|e| anyhow!("read Package.swift: {e}"))?;
if let Some(n) = read_swift_label(&text, "name") { spec.description = spec.description.clone().or(Some(format!("Swift package {n}"))); }
if let Some(v) = text.lines().find_map(|l| l.strip_prefix("// swift-tools-version:")) {
let _ = v;
}
Ok(())
}
}
pub struct ElixirMixExtractor;
impl ReverseExtractor for ElixirMixExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("mix.exs"))
.map_err(|e| anyhow!("read mix.exs: {e}"))?;
if let Some(v) = read_kw_string(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_kw_string(&text, "description") { spec.description = Some(d); }
if let Some(l) = read_elixir_first_license(&text) { spec.license = Some(l); }
Ok(())
}
}
pub struct JavaGradleKtsExtractor;
impl ReverseExtractor for JavaGradleKtsExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("build.gradle.kts"))
.map_err(|e| anyhow!("read build.gradle.kts: {e}"))?;
if let Some(v) = read_kotlin_assign(&text, "version") { spec.version = Some(v); }
if let Some(g) = read_kotlin_assign(&text, "group") { spec.group_id = Some(g); }
Ok(())
}
}
pub struct CppCmakeExtractor;
impl ReverseExtractor for CppCmakeExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("CMakeLists.txt"))
.map_err(|e| anyhow!("read CMakeLists.txt: {e}"))?;
for line in text.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix("project(") {
let inner = rest.trim_end_matches(')');
let toks: Vec<&str> = inner.split_whitespace().collect();
for w in toks.windows(2) {
if w[0] == "VERSION" { spec.version = Some(w[1].to_string()); break; }
}
break;
}
}
Ok(())
}
}
pub struct CppMesonExtractor;
impl ReverseExtractor for CppMesonExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("meson.build"))
.map_err(|e| anyhow!("read meson.build: {e}"))?;
if let Some(v) = read_meson_kw(&text, "version") { spec.version = Some(v); }
if let Some(l) = read_meson_kw(&text, "license") { spec.license = Some(l); }
Ok(())
}
}
pub struct CppConanExtractor;
impl ReverseExtractor for CppConanExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("conanfile.py"))
.map_err(|e| anyhow!("read conanfile.py: {e}"))?;
if let Some(v) = read_python_class_attr(&text, "version") { spec.version = Some(v); }
if let Some(l) = read_python_class_attr(&text, "license") { spec.license = Some(l); }
Ok(())
}
}
pub struct HaskellCabalExtractor;
impl ReverseExtractor for HaskellCabalExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let entries = std::fs::read_dir(path).ok();
let Some(entries) = entries else { return Ok(()); };
for e in entries.flatten() {
let n = e.file_name();
let Some(n) = n.to_str() else { continue };
if !n.ends_with(".cabal") { continue; }
let text = std::fs::read_to_string(e.path())
.map_err(|e| anyhow!("read {n}: {e}"))?;
if let Some(v) = read_control_field(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_control_field(&text, "synopsis") { spec.description = Some(d); }
if let Some(l) = read_control_field(&text, "license") { spec.license = Some(l); }
return Ok(());
}
Ok(())
}
}
pub struct NimNimbleExtractor;
impl ReverseExtractor for NimNimbleExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let entries = std::fs::read_dir(path).ok();
let Some(entries) = entries else { return Ok(()); };
for e in entries.flatten() {
let n = e.file_name();
let Some(n) = n.to_str() else { continue };
if !n.ends_with(".nimble") { continue; }
let text = std::fs::read_to_string(e.path())
.map_err(|e| anyhow!("read {n}: {e}"))?;
if let Some(v) = read_nim_assign(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_nim_assign(&text, "description") { spec.description = Some(d); }
if let Some(l) = read_nim_assign(&text, "license") { spec.license = Some(l); }
return Ok(());
}
Ok(())
}
}
pub struct LuaRockspecExtractor;
impl ReverseExtractor for LuaRockspecExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let entries = std::fs::read_dir(path).ok();
let Some(entries) = entries else { return Ok(()); };
for e in entries.flatten() {
let n = e.file_name();
let Some(n) = n.to_str() else { continue };
if !n.ends_with(".rockspec") { continue; }
let text = std::fs::read_to_string(e.path())
.map_err(|e| anyhow!("read {n}: {e}"))?;
if let Some(v) = read_lua_assign(&text, "version") { spec.version = Some(v); }
if let Some(s) = read_lua_table_value(&text, "summary") { spec.description = Some(s); }
return Ok(());
}
Ok(())
}
}
pub struct RDescriptionExtractor;
impl ReverseExtractor for RDescriptionExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("DESCRIPTION"))
.map_err(|e| anyhow!("read DESCRIPTION: {e}"))?;
if let Some(v) = read_control_field(&text, "Version") { spec.version = Some(v); }
if let Some(d) = read_control_field(&text, "Title").or_else(|| read_control_field(&text, "Description")) {
spec.description = Some(d);
}
if let Some(l) = read_control_field(&text, "License") { spec.license = Some(l); }
Ok(())
}
}
pub struct PythonPipenvExtractor;
impl ReverseExtractor for PythonPipenvExtractor {
fn extract(&self, path: &Path, _spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let _ = std::fs::read_to_string(path.join("Pipfile"))
.map_err(|e| anyhow!("read Pipfile: {e}"))?;
Ok(())
}
}
pub struct NixFlakeExtractor;
impl ReverseExtractor for NixFlakeExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("flake.nix"))
.map_err(|e| anyhow!("read flake.nix: {e}"))?;
if let Some(d) = read_nix_string_field(&text, "description") {
spec.description = Some(d);
}
Ok(())
}
}
fn read_nix_string_field(text: &str, key: &str) -> Option<String> {
let needle = format!("{key} = \"");
let pos = text.find(&needle)?;
let after = &text[pos + needle.len()..];
let bytes = after.as_bytes();
let mut end = 0;
while end < bytes.len() {
if bytes[end] == b'\\' && end + 1 < bytes.len() {
end += 2;
continue;
}
if bytes[end] == b'"' { break; }
end += 1;
}
let raw = &after[..end];
let mut out = String::with_capacity(raw.len());
let mut it = raw.chars().peekable();
while let Some(c) = it.next() {
if c == '\\' {
if let Some(&n) = it.peek() {
if n == '"' || n == '\\' || n == '$' { out.push(it.next().unwrap()); continue; }
if n == 'n' { it.next(); out.push('\n'); continue; }
}
}
out.push(c);
}
Some(out)
}
pub struct GithubActionExtractor;
impl ReverseExtractor for GithubActionExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let yml = path.join("action.yml");
let path = if yml.is_file() { yml } else { path.join("action.yaml") };
let text = std::fs::read_to_string(&path)
.map_err(|e| anyhow!("read action.y(a)ml: {e}"))?;
if let Some(d) = read_yaml_string(&text, "description") { spec.description = Some(d); }
let parsed = crate::yaml::parse(&text);
if let Some(icon) = parsed.branding_icon {
spec.extra_package.insert("branding-icon".to_string(), icon);
}
if let Some(color) = parsed.branding_color {
spec.extra_package.insert("branding-color".to_string(), color);
}
Ok(())
}
}
pub struct JavaMavenExtractor;
impl ReverseExtractor for JavaMavenExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("pom.xml"))
.map_err(|e| anyhow!("read pom.xml: {e}"))?;
if let Some(v) = read_xml_tag(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_xml_tag(&text, "description") { spec.description = Some(d); }
if let Some(g) = read_xml_tag(&text, "groupId") { spec.group_id = Some(g); }
Ok(())
}
}
pub struct JsDenoExtractor;
impl ReverseExtractor for JsDenoExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let deno = path.join("deno.json");
let path = if deno.is_file() { deno } else { path.join("deno.jsonc") };
let text = std::fs::read_to_string(&path)
.map_err(|e| anyhow!("read deno.json(c): {e}"))?;
if let Some(v) = read_json_string(&text, "version") { spec.version = Some(v); }
Ok(())
}
}
pub struct CppVcpkgExtractor;
impl ReverseExtractor for CppVcpkgExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("vcpkg.json"))
.map_err(|e| anyhow!("read vcpkg.json: {e}"))?;
if let Some(v) = read_json_string(&text, "version-string")
.or_else(|| read_json_string(&text, "version")) { spec.version = Some(v); }
if let Some(d) = read_json_string(&text, "description") { spec.description = Some(d); }
if let Some(h) = read_json_string(&text, "homepage") { spec.homepage = Some(h); }
Ok(())
}
}
pub struct PythonCondaExtractor;
impl ReverseExtractor for PythonCondaExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("meta.yaml"))
.map_err(|e| anyhow!("read meta.yaml: {e}"))?;
if let Some(v) = read_yaml_string(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_yaml_string(&text, "summary") { spec.description = Some(d); }
Ok(())
}
}
pub struct AdaAlireExtractor;
impl ReverseExtractor for AdaAlireExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let entries = std::fs::read_dir(path).ok();
let Some(entries) = entries else { return Ok(()); };
for e in entries.flatten() {
let n = e.file_name();
let Some(n) = n.to_str() else { continue };
if !(n.starts_with("alire-") && n.ends_with(".toml")) { continue; }
let text = std::fs::read_to_string(e.path())
.map_err(|e| anyhow!("read {n}: {e}"))?;
if let Some(v) = read_toml_string(&text, "", "version") { spec.version = Some(v); }
if let Some(d) = read_toml_string(&text, "", "description") { spec.description = Some(d); }
if let Some(l) = read_toml_string(&text, "", "licenses") { spec.license = Some(l); }
return Ok(());
}
Ok(())
}
}
pub struct ZigExtractor;
impl ReverseExtractor for ZigExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let zon = path.join("build.zig.zon");
if !zon.is_file() { return Ok(()); }
let text = std::fs::read_to_string(&zon)
.map_err(|e| anyhow!("read build.zig.zon: {e}"))?;
if let Some(v) = read_zon_string(&text, "version") { spec.version = Some(v); }
Ok(())
}
}
pub struct FortranFpmExtractor;
impl ReverseExtractor for FortranFpmExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("fpm.toml"))
.map_err(|e| anyhow!("read fpm.toml: {e}"))?;
if let Some(v) = read_toml_string(&text, "", "version") { spec.version = Some(v); }
if let Some(l) = read_toml_string(&text, "", "license") { spec.license = Some(l); }
Ok(())
}
}
pub struct GleamExtractor;
impl ReverseExtractor for GleamExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("gleam.toml"))
.map_err(|e| anyhow!("read gleam.toml: {e}"))?;
if let Some(v) = read_toml_string(&text, "", "version") { spec.version = Some(v); }
if let Some(d) = read_toml_string(&text, "", "description") { spec.description = Some(d); }
Ok(())
}
}
pub struct RacketInfoExtractor;
impl ReverseExtractor for RacketInfoExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("info.rkt"))
.map_err(|e| anyhow!("read info.rkt: {e}"))?;
if let Some(v) = read_define_string(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_define_string(&text, "pkg-desc") { spec.description = Some(d); }
Ok(())
}
}
pub struct CrystalExtractor;
impl ReverseExtractor for CrystalExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("shard.yml"))
.map_err(|e| anyhow!("read shard.yml: {e}"))?;
if let Some(v) = read_yaml_string(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_yaml_string(&text, "description") { spec.description = Some(d); }
if let Some(l) = read_yaml_string(&text, "license") { spec.license = Some(l); }
Ok(())
}
}
pub struct DartExtractor;
impl ReverseExtractor for DartExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("pubspec.yaml"))
.map_err(|e| anyhow!("read pubspec.yaml: {e}"))?;
if let Some(v) = read_yaml_string(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_yaml_string(&text, "description") { spec.description = Some(d); }
if let Some(h) = read_yaml_string(&text, "homepage") { spec.homepage = Some(h); }
if let Some(r) = read_yaml_string(&text, "repository") { spec.repository = Some(r); }
Ok(())
}
}
pub struct ComposerExtractor;
impl ReverseExtractor for ComposerExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("composer.json"))
.map_err(|e| anyhow!("read composer.json: {e}"))?;
if let Some(v) = read_json_string(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_json_string(&text, "description") { spec.description = Some(d); }
if let Some(l) = read_json_string(&text, "license") { spec.license = Some(l); }
if let Some(h) = read_json_string(&text, "homepage") { spec.homepage = Some(h); }
Ok(())
}
}
pub struct JuliaExtractor;
impl ReverseExtractor for JuliaExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("Project.toml"))
.map_err(|e| anyhow!("read Project.toml: {e}"))?;
if let Some(v) = read_toml_string(&text, "", "version") { spec.version = Some(v); }
if let Some(d) = read_toml_string(&text, "", "description") { spec.description = Some(d); }
Ok(())
}
}
pub struct ScalaSbtExtractor;
impl ReverseExtractor for ScalaSbtExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("build.sbt"))
.map_err(|e| anyhow!("read build.sbt: {e}"))?;
if let Some(v) = read_sbt_setting(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_sbt_setting(&text, "description") { spec.description = Some(d); }
Ok(())
}
}
pub struct ClojureDepsExtractor;
impl ReverseExtractor for ClojureDepsExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let _ = (path, spec);
Ok(())
}
}
pub struct DotnetCsprojExtractor;
impl ReverseExtractor for DotnetCsprojExtractor {
fn extract(&self, path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let entries = std::fs::read_dir(path).ok();
let Some(entries) = entries else { return Ok(()); };
for e in entries.flatten() {
let name = e.file_name();
let Some(n) = name.to_str() else { continue };
if !n.ends_with(".csproj") { continue; }
let text = std::fs::read_to_string(e.path())
.map_err(|e| anyhow!("read .csproj: {e}"))?;
if let Some(v) = read_xml_tag(&text, "Version") { spec.version = Some(v); }
if let Some(d) = read_xml_tag(&text, "Description") { spec.description = Some(d); }
if let Some(l) = read_xml_tag(&text, "PackageLicenseExpression") { spec.license = Some(l); }
return Ok(());
}
Ok(())
}
}
fn enrich_from_manifest(
path: &Path,
ecosystem: &str,
spec: &mut crate::scaffold::ScaffoldSpec,
) -> Result<()> {
let extractor: Option<&dyn ReverseExtractor> = match ecosystem {
"rust-single-crate" | "rust-workspace" => Some(&RustCargoExtractor),
"npm" | "js-pnpm" => Some(&NpmExtractor),
"python" | "python-pdm" => Some(&PythonExtractor),
"helm" => Some(&HelmExtractor),
"go" => Some(&GoModExtractor),
"ruby-gem" => Some(&RubyGemspecExtractor),
"ocaml-dune" => Some(&OcamlDuneExtractor),
"dotnet-csproj" => Some(&DotnetCsprojExtractor),
"swift-spm" => Some(&SwiftSpmExtractor),
"elixir-mix" => Some(&ElixirMixExtractor),
"scala-sbt" => Some(&ScalaSbtExtractor),
"clojure-deps" => Some(&ClojureDepsExtractor),
"crystal" => Some(&CrystalExtractor),
"dart" => Some(&DartExtractor),
"composer" => Some(&ComposerExtractor),
"julia" => Some(&JuliaExtractor),
"zig" => Some(&ZigExtractor),
"fortran-fpm" => Some(&FortranFpmExtractor),
"gleam" => Some(&GleamExtractor),
"racket-info" => Some(&RacketInfoExtractor),
"java-maven" => Some(&JavaMavenExtractor),
"js-deno" => Some(&JsDenoExtractor),
"cpp-vcpkg" => Some(&CppVcpkgExtractor),
"python-conda" => Some(&PythonCondaExtractor),
"ada-alire" => Some(&AdaAlireExtractor),
"java-gradle-kts" => Some(&JavaGradleKtsExtractor),
"cpp-cmake" => Some(&CppCmakeExtractor),
"cpp-meson" => Some(&CppMesonExtractor),
"cpp-conan" => Some(&CppConanExtractor),
"haskell-cabal" => Some(&HaskellCabalExtractor),
"nim-nimble" => Some(&NimNimbleExtractor),
"lua-rockspec" => Some(&LuaRockspecExtractor),
"r-description" => Some(&RDescriptionExtractor),
"python-pipenv" => Some(&PythonPipenvExtractor),
"github-action" => Some(&GithubActionExtractor),
"nix-flake" => Some(&NixFlakeExtractor),
_ => None,
};
if let Some(e) = extractor { e.extract(path, spec)?; }
Ok(())
}
fn read_ruby_attr(text: &str, attr: &str) -> Option<String> {
let needle = {
let mut s = String::from("spec.");
s.push_str(attr);
s
};
let pos = text.find(&needle)?;
let after = &text[pos + needle.len()..];
let after = after.trim_start();
let after = after.strip_prefix('=')?.trim_start();
let (open, close) = if after.starts_with('\'') {
('\'', '\'')
} else if after.starts_with('"') {
('"', '"')
} else { return None; };
let after = after.strip_prefix(open)?;
let end = after.find(close)?;
Some(after[..end].to_string())
}
fn read_dune_form(text: &str, name: &str) -> Option<String> {
let needle = {
let mut s = String::from("(");
s.push_str(name);
s.push(' ');
s
};
let pos = text.find(&needle)?;
let after = &text[pos + needle.len()..];
let end = after.find(')')?;
let raw = after[..end].trim();
let stripped = raw.trim_matches('"');
Some(stripped.to_string())
}
fn read_swift_label(text: &str, label: &str) -> Option<String> {
let mut needle = String::from(label);
needle.push_str(":");
let pos = text.find(&needle)?;
let after = &text[pos + needle.len()..].trim_start();
let after = after.strip_prefix('"')?;
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn read_kw_string(text: &str, kw: &str) -> Option<String> {
let mut needle = String::from(kw);
needle.push_str(":");
let pos = text.find(&needle)?;
let after = &text[pos + needle.len()..].trim_start();
let after = after.strip_prefix('"')?;
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn read_elixir_first_license(text: &str) -> Option<String> {
let pos = text.find("licenses:")?;
let after = &text[pos + "licenses:".len()..];
let bracket = after.find('[')?;
let after = &after[bracket + 1..];
let after = after.trim_start().strip_prefix('"')?;
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn read_kotlin_assign(text: &str, key: &str) -> Option<String> {
for line in text.lines() {
let t = line.trim();
if !t.starts_with(key) { continue; }
let after = t.strip_prefix(key)?.trim_start();
let after = after.strip_prefix('=')?.trim_start();
let after = after.strip_prefix('"')?;
let end = after.find('"')?;
return Some(after[..end].to_string());
}
None
}
fn read_meson_kw(text: &str, key: &str) -> Option<String> {
let mut needle = String::from(key);
needle.push_str(":");
let pos = text.find(&needle)?;
let after = &text[pos + needle.len()..].trim_start();
let (open, close) = if after.starts_with('\'') { ('\'', '\'') }
else if after.starts_with('"') { ('"', '"') }
else { return None; };
let after = after.strip_prefix(open)?;
let end = after.find(close)?;
Some(after[..end].to_string())
}
fn read_python_class_attr(text: &str, attr: &str) -> Option<String> {
for line in text.lines() {
let t = line.trim();
if !t.starts_with(attr) { continue; }
let after = t.strip_prefix(attr)?.trim_start();
let after = after.strip_prefix('=')?.trim_start();
let (open, close) = if after.starts_with('\'') { ('\'', '\'') }
else if after.starts_with('"') { ('"', '"') }
else { continue; };
let after = after.strip_prefix(open)?;
let end = after.find(close)?;
return Some(after[..end].to_string());
}
None
}
fn read_control_field(text: &str, key: &str) -> Option<String> {
let key_lower = key.to_lowercase();
for line in text.lines() {
let t = line.trim_start();
let (k, rest) = t.split_once(':')?;
if k.trim().to_lowercase() != key_lower { continue; }
return Some(rest.trim().to_string());
}
None
}
fn read_nim_assign(text: &str, key: &str) -> Option<String> {
for line in text.lines() {
let t = line.trim_start();
if !t.starts_with(key) { continue; }
let after = t[key.len()..].trim_start();
let after = after.strip_prefix('=')?.trim_start();
let after = after.strip_prefix('"')?;
let end = after.find('"')?;
return Some(after[..end].to_string());
}
None
}
fn read_lua_assign(text: &str, key: &str) -> Option<String> {
for line in text.lines() {
let t = line.trim_start();
if !t.starts_with(key) { continue; }
let after = t[key.len()..].trim_start();
let after = after.strip_prefix('=')?.trim_start();
let after = after.strip_prefix('"')?;
let end = after.find('"')?;
return Some(after[..end].to_string());
}
None
}
fn read_lua_table_value(text: &str, key: &str) -> Option<String> {
let pos = text.find(key)?;
let after = &text[pos + key.len()..].trim_start();
let after = after.strip_prefix('=')?.trim_start();
let after = after.strip_prefix('"')?;
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn read_zon_string(text: &str, field: &str) -> Option<String> {
let mut needle = String::from(".");
needle.push_str(field);
needle.push_str(" = \"");
let pos = text.find(&needle)?;
let after = &text[pos + needle.len()..];
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn read_define_string(text: &str, name: &str) -> Option<String> {
let mut needle = String::from("(define ");
needle.push_str(name);
needle.push_str(" \"");
let pos = text.find(&needle)?;
let after = &text[pos + needle.len()..];
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn read_sbt_setting(text: &str, key: &str) -> Option<String> {
for line in text.lines() {
let trim = line.trim();
if !trim.starts_with(key) { continue; }
let after = trim.strip_prefix(key)?.trim_start();
let after = after.strip_prefix(":=")?.trim_start();
let after = after.strip_prefix('"')?;
let end = after.find('"')?;
return Some(after[..end].to_string());
}
None
}
fn read_xml_tag(text: &str, tag: &str) -> Option<String> {
let open = {
let mut s = String::from("<");
s.push_str(tag);
s.push('>');
s
};
let close = {
let mut s = String::from("</");
s.push_str(tag);
s.push('>');
s
};
let i = text.find(&open)?;
let after = &text[i + open.len()..];
let j = after.find(&close)?;
Some(after[..j].trim().to_string())
}
fn read_toml_string(text: &str, header: &str, key: &str) -> Option<String> {
let mut in_section = false;
for line in text.lines() {
let trim = line.trim();
if trim.starts_with('[') {
in_section = trim == header;
continue;
}
if !in_section { continue; }
let stripped = match trim.strip_prefix(key) {
Some(rest) => rest.trim_start(),
None => continue,
};
let after_eq = stripped.strip_prefix('=')?.trim_start();
let val = after_eq.trim_matches('"').trim_matches('\'');
let val = match val.find('#') {
Some(i) => &val[..i],
None => val,
};
return Some(val.trim().trim_matches('"').to_string());
}
None
}
fn read_json_string(text: &str, key: &str) -> Option<String> {
let needle = {
let mut s = String::from("\"");
s.push_str(key);
s.push_str("\":");
s
};
let pos = text.find(&needle)?;
let after = &text[pos + needle.len()..];
let after = after.trim_start();
let after = after.strip_prefix('"')?;
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn read_yaml_string(text: &str, key: &str) -> Option<String> {
let needle = {
let mut s = String::from(key);
s.push(':');
s
};
for line in text.lines() {
if let Some(rest) = line.trim_start().strip_prefix(&needle) {
let val = rest.trim();
if let Some(inner) = val.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
let mut out = String::with_capacity(inner.len());
let mut it = inner.chars().peekable();
while let Some(c) = it.next() {
if c == '\\' {
if let Some(&n) = it.peek() {
if n == '"' || n == '\\' { out.push(it.next().unwrap()); continue; }
if n == 'n' { it.next(); out.push('\n'); continue; }
if n == 't' { it.next(); out.push('\t'); continue; }
}
}
out.push(c);
}
return Some(out);
}
if let Some(inner) = val.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
return Some(inner.replace("''", "'"));
}
return Some(val.to_string());
}
}
None
}
fn read_cargo_toml(path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
use crate::manifest_io::{read_toml_string_array, read_cargo_field};
let text = std::fs::read_to_string(path.join("Cargo.toml"))
.map_err(|e| anyhow!("read Cargo.toml: {e}"))?;
if let Some(v) = read_cargo_field(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_cargo_field(&text, "description") { spec.description = Some(d); }
if let Some(l) = read_cargo_field(&text, "license") { spec.license = Some(l); }
if let Some(r) = read_cargo_field(&text, "repository") { spec.repository = Some(r); }
if let Some(h) = read_cargo_field(&text, "homepage") { spec.homepage = Some(h); }
let kws = read_toml_string_array(&text, "[package]", "keywords");
let kws = if kws.is_empty() { read_toml_string_array(&text, "[workspace.package]", "keywords") } else { kws };
if !kws.is_empty() { spec.keywords = kws; }
let cats = read_toml_string_array(&text, "[package]", "categories");
let cats = if cats.is_empty() { read_toml_string_array(&text, "[workspace.package]", "categories") } else { cats };
if !cats.is_empty() { spec.categories = cats; }
Ok(())
}
fn read_package_json(path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("package.json"))
.map_err(|e| anyhow!("read package.json: {e}"))?;
if let Some(v) = read_json_string(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_json_string(&text, "description") { spec.description = Some(d); }
if let Some(l) = read_json_string(&text, "license") { spec.license = Some(l); }
if let Some(h) = read_json_string(&text, "homepage") { spec.homepage = Some(h); }
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(kws) = parsed.get("keywords").and_then(|v| v.as_array()) {
spec.keywords = kws.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
}
}
Ok(())
}
fn read_pyproject_toml(path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let pyproj = path.join("pyproject.toml");
if !pyproj.is_file() {
return read_setup_py(path, spec);
}
let text = std::fs::read_to_string(&pyproj)
.map_err(|e| anyhow!("read pyproject.toml: {e}"))?;
if let Some(v) = read_toml_string(&text, "[project]", "version") { spec.version = Some(v); }
if let Some(d) = read_toml_string(&text, "[project]", "description") { spec.description = Some(d); }
if let Some(l) = read_toml_string(&text, "[project]", "license") {
spec.license = Some(l);
}
Ok(())
}
fn read_setup_py(path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let setup = path.join("setup.py");
if !setup.is_file() {
return Ok(()); }
let text = std::fs::read_to_string(&setup)
.map_err(|e| anyhow!("read setup.py: {e}"))?;
if let Some(v) = read_kwarg(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_kwarg(&text, "description") { spec.description = Some(d); }
if let Some(l) = read_kwarg(&text, "license") { spec.license = Some(l); }
if let Some(h) = read_kwarg(&text, "url") { spec.homepage = Some(h); }
Ok(())
}
fn read_kwarg(text: &str, key: &str) -> Option<String> {
let mut start = 0;
while let Some(pos) = text[start..].find(key) {
let abs = start + pos;
let prev_ok = abs == 0
|| matches!(text.as_bytes()[abs - 1], b'(' | b',' | b' ' | b'\t' | b'\n');
let after = &text[abs + key.len()..];
let after_t = after.trim_start();
let is_assign = after_t.starts_with('=');
if !prev_ok || !is_assign {
start = abs + key.len();
continue;
}
let rest = after_t[1..].trim_start();
if let Some(inner) = rest.strip_prefix('"').and_then(|s| s.find('"').map(|e| &s[..e])) {
return Some(inner.to_string());
}
if let Some(inner) = rest.strip_prefix('\'').and_then(|s| s.find('\'').map(|e| &s[..e])) {
return Some(inner.to_string());
}
start = abs + key.len();
}
None
}
fn read_chart_yaml(path: &Path, spec: &mut crate::scaffold::ScaffoldSpec) -> Result<()> {
let text = std::fs::read_to_string(path.join("Chart.yaml"))
.map_err(|e| anyhow!("read Chart.yaml: {e}"))?;
if let Some(v) = read_yaml_string(&text, "version") { spec.version = Some(v); }
if let Some(d) = read_yaml_string(&text, "description") { spec.description = Some(d); }
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Render;
fn mk(files: &[(&str, &str)]) -> tempdir::TempDir {
let tmp = tempdir::TempDir::new("reverse").expect("tempdir");
for (n, b) in files {
if let Some(parent) = std::path::Path::new(n).parent() {
let _ = std::fs::create_dir_all(tmp.path().join(parent));
}
std::fs::write(tmp.path().join(n), b).expect("write");
}
tmp
}
#[test]
fn reverse_rust_extracts_typed_fields() {
let dir = mk(&[("Cargo.toml", r#"[package]
name = "demo"
version = "1.2.3"
description = "A typed test crate"
license = "Apache-2.0"
repository = "https://github.com/x/demo"
edition = "2024"
"#)]);
let forms = reverse_from_path(dir.path()).expect("reverse");
let out = forms.render();
assert!(out.contains(":ecosystem") && out.contains(":rust-single-crate"));
assert!(out.contains("\"demo\""));
assert!(out.contains("\"1.2.3\""), "version not threaded; got: {out}");
assert!(out.contains("\"A typed test crate\""));
assert!(out.contains("\"Apache-2.0\""));
assert!(out.contains("https://github.com/x/demo"));
}
#[test]
fn reverse_rust_workspace_uses_workspace_package_header() {
let dir = mk(&[("Cargo.toml", r#"[workspace]
members = ["a"]
[workspace.package]
version = "0.5.0"
license = "MIT"
"#)]);
let forms = reverse_from_path(dir.path()).expect("reverse");
let out = forms.render();
assert!(out.contains(":rust-workspace"));
assert!(out.contains("\"0.5.0\""), "workspace.package version not threaded");
assert!(out.contains("\"MIT\""));
}
#[test]
fn reverse_npm_extracts_typed_fields() {
let dir = mk(&[("package.json", r#"{
"name": "demo-npm",
"version": "2.0.0",
"description": "An npm package",
"license": "ISC"
}"#)]);
let forms = reverse_from_path(dir.path()).expect("reverse");
let out = forms.render();
assert!(out.contains(":npm"));
assert!(out.contains("\"2.0.0\""));
assert!(out.contains("\"An npm package\""));
assert!(out.contains("\"ISC\""));
}
#[test]
fn reverse_python_extracts_typed_fields() {
let dir = mk(&[("pyproject.toml", r#"[project]
name = "demo-py"
version = "3.0.0"
description = "A python package"
license = "BSD-3-Clause"
"#)]);
let forms = reverse_from_path(dir.path()).expect("reverse");
let out = forms.render();
assert!(out.contains(":python"));
assert!(out.contains("\"3.0.0\""));
assert!(out.contains("\"A python package\""));
}
#[test]
fn reverse_helm_extracts_typed_fields() {
let dir = mk(&[("Chart.yaml", r#"apiVersion: v2
name: my-chart
version: 4.5.6
description: A helm chart
type: application
"#)]);
let forms = reverse_from_path(dir.path()).expect("reverse");
let out = forms.render();
assert!(out.contains(":helm"));
assert!(out.contains("\"4.5.6\""));
assert!(out.contains("\"A helm chart\""));
}
#[test]
fn reverse_fails_on_undetectable_dir() {
let dir = mk(&[("README.md", "no manifest here\n")]);
assert!(reverse_from_path(dir.path()).is_err());
}
#[test]
fn reverse_go_preserves_ecosystem_and_basename() {
let dir = mk(&[("go.mod", "module github.com/x/demo-go\n\ngo 1.22\n")]);
let forms = reverse_from_path(dir.path()).expect("reverse");
let out = forms.render();
assert!(out.contains(":go"), "go ecosystem keyword should be in output");
assert!(out.contains("\"demo-go\""), "go basename should be extracted");
}
#[test]
fn reverse_ruby_gemspec_extracts_typed_attrs() {
let dir = mk(&[("demo.gemspec", r#"Gem::Specification.new do |spec|
spec.name = 'demo-gem'
spec.version = '7.7.7'
spec.summary = 'A gemspec for testing'
spec.license = 'BSD-3-Clause'
end
"#)]);
let forms = reverse_from_path(dir.path()).expect("reverse");
let out = forms.render();
assert!(out.contains(":ruby-gem"));
assert!(out.contains("\"7.7.7\""), "version not threaded; got: {out}");
assert!(out.contains("\"A gemspec for testing\""));
assert!(out.contains("\"BSD-3-Clause\""));
}
#[test]
fn reverse_dune_extracts_version_and_license() {
let dir = mk(&[("dune-project", r#"(lang dune 3.14)
(name demo-dune)
(version "9.9.9")
(license ISC)
(generate_opam_files true)
"#)]);
let forms = reverse_from_path(dir.path()).expect("reverse");
let out = forms.render();
assert!(out.contains(":ocaml-dune"));
assert!(out.contains("\"9.9.9\""), "dune version not threaded; got: {out}");
assert!(out.contains("\"ISC\""), "dune license not threaded");
}
#[test]
fn reverse_csproj_extracts_xml_tag_values() {
let dir = mk(&[("Demo.csproj", r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>5.5.5</Version>
<Description>A csproj for testing</Description>
<PackageLicenseExpression>MPL-2.0</PackageLicenseExpression>
</PropertyGroup>
</Project>
"#)]);
let forms = reverse_from_path(dir.path()).expect("reverse");
let out = forms.render();
assert!(out.contains(":dotnet-csproj"));
assert!(out.contains("\"5.5.5\""), "csproj version not threaded");
assert!(out.contains("\"A csproj for testing\""));
assert!(out.contains("\"MPL-2.0\""));
}
#[test]
fn round_trip_reverse_then_render_emits_artifacts() {
let dir = mk(&[("Cargo.toml", r#"[package]
name = "round-trip"
version = "7.7.7"
description = "Round-trip demo"
license = "MIT"
"#)]);
let forms = reverse_from_path(dir.path()).expect("reverse");
let src = forms.render();
let out = tempdir::TempDir::new("round-trip-out").expect("tempdir");
crate::caixa::render(&src, out.path(), true).expect("render");
let regenerated = std::fs::read_to_string(out.path().join("Cargo.toml"))
.expect("read regenerated Cargo.toml");
assert!(regenerated.contains("name = \"round-trip\""));
assert!(regenerated.contains("version = \"7.7.7\""));
assert!(regenerated.contains("description = \"Round-trip demo\""));
assert!(regenerated.contains("license = \"MIT\""));
}
}