use std::io::Write;
use std::path::{Path, PathBuf};
use dora_core::manifest::{MANIFEST_FILENAME, NodeManifest};
use dora_core::types::TypeRegistry;
use eyre::{Context, bail};
use crate::command::Executable;
const FALLBACK_NAME: &str = "my-node";
const FALLBACK_NAMESPACE: &str = "your-namespace";
fn fallback_entrypoint(node: &DetectedNode) -> String {
match node.runtime {
"rust" | "c" | "cpp" => format!("target/release/{}", node.name),
_ => node.name.clone(),
}
}
fn render_valid_manifest(mut node: DetectedNode, mut namespace: String) -> eyre::Result<String> {
let registry = TypeRegistry::new();
loop {
let content = render_manifest(&node, &namespace);
let manifest =
NodeManifest::parse(&content).context("internal error: scaffold is not valid YAML")?;
let issues = manifest.validate(®istry);
let mut changed = false;
for issue in &issues {
match issue.field.as_str() {
"name" if node.name != FALLBACK_NAME => {
node.name = FALLBACK_NAME.into();
changed = true;
}
"namespace" if namespace != FALLBACK_NAMESPACE => {
namespace = FALLBACK_NAMESPACE.into();
changed = true;
}
"entrypoint" if node.entrypoint != fallback_entrypoint(&node) => {
node.entrypoint = fallback_entrypoint(&node);
changed = true;
}
_ => {}
}
}
if !changed {
if !issues.is_empty() {
bail!(
"internal error: generated manifest is invalid: {}",
issues
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("; ")
);
}
return Ok(content);
}
}
}
#[derive(Debug, clap::Args)]
pub struct Init {
#[clap(value_name = "PATH", default_value = ".")]
path: PathBuf,
}
impl Executable for Init {
fn execute(self) -> eyre::Result<()> {
let manifest_path = self.path.join(MANIFEST_FILENAME);
if manifest_path.exists() {
bail!(
"`{}` already exists — refusing to overwrite",
manifest_path.display()
);
}
if !self.path.is_dir() {
bail!("`{}` is not a directory", self.path.display());
}
let detected = detect_node(&self.path);
let namespace = detect_namespace(&self.path);
let content = render_valid_manifest(detected, namespace)?;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&manifest_path)
.map_err(|err| match err.kind() {
std::io::ErrorKind::AlreadyExists => eyre::eyre!(
"`{}` already exists — refusing to overwrite",
manifest_path.display()
),
_ => eyre::eyre!("failed to write `{}`: {err}", manifest_path.display()),
})?;
file.write_all(content.as_bytes())
.with_context(|| format!("failed to write `{}`", manifest_path.display()))?;
println!("Wrote {}.", manifest_path.display());
println!(
"Next steps:\n \
1. fill in the typed inputs/outputs (see `libraries/core/types/std/` for available type URNs)\n \
2. check it: dora validate --node-manifest {}",
manifest_path.display()
);
Ok(())
}
}
struct DetectedNode {
name: String,
runtime: &'static str,
entrypoint: String,
description: Option<String>,
source: Option<&'static str>,
}
fn detect_node(dir: &Path) -> DetectedNode {
if let Some(detected) = detect_python(dir) {
return detected;
}
if let Some(detected) = detect_rust(dir) {
return detected;
}
let name = dir
.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.map(|n| normalize_name(&n))
.filter(|n| !n.is_empty())
.unwrap_or_else(|| FALLBACK_NAME.into());
DetectedNode {
entrypoint: name.clone(),
name,
runtime: "python",
description: None,
source: None,
}
}
fn detect_python(dir: &Path) -> Option<DetectedNode> {
let raw = std::fs::read_to_string(dir.join("pyproject.toml")).ok()?;
let parsed: toml::Table = raw.parse().ok()?;
let project = parsed.get("project").or_else(|| {
parsed
.get("tool")
.and_then(|t| t.get("poetry"))
.filter(|p| p.get("name").is_some())
})?;
let name = normalize_name(project.get("name")?.as_str()?);
if name.is_empty() {
return None;
}
let entrypoint = project
.get("scripts")
.and_then(|s| s.as_table())
.and_then(|t| {
t.keys()
.find(|k| normalize_name(k) == name)
.or_else(|| t.keys().next())
.cloned()
})
.unwrap_or_else(|| name.clone());
Some(DetectedNode {
name,
runtime: "python",
entrypoint,
description: project
.get("description")
.and_then(|d| d.as_str())
.map(String::from),
source: Some("pyproject.toml"),
})
}
fn detect_rust(dir: &Path) -> Option<DetectedNode> {
let raw = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
let parsed: toml::Table = raw.parse().ok()?;
let package = parsed.get("package")?;
let pkg_name = package.get("name")?.as_str()?;
let name = normalize_name(pkg_name);
if name.is_empty() {
return None;
}
let bin = package
.get("default-run")
.and_then(|v| v.as_str())
.or_else(|| {
parsed
.get("bin")
.and_then(|b| b.as_array())
.and_then(|a| a.first())
.and_then(|b| b.get("name"))
.and_then(|v| v.as_str())
})
.unwrap_or(pkg_name);
Some(DetectedNode {
entrypoint: format!("target/release/{bin}"),
name,
runtime: "rust",
description: package
.get("description")
.and_then(|d| d.as_str())
.map(String::from),
source: Some("Cargo.toml"),
})
}
fn detect_namespace(dir: &Path) -> String {
let output = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(dir)
.output();
if let Ok(output) = output
&& output.status.success()
&& let Ok(url) = String::from_utf8(output.stdout)
{
let url = url.trim();
let path = if let Some(rest) = url.split_once("://").map(|(_, r)| r) {
rest.split_once('/').map(|(_, p)| p)
} else {
url.split_once(':').map(|(_, p)| p)
};
let org: String = path
.and_then(|p| p.split('/').next())
.unwrap_or("")
.trim()
.to_ascii_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
if !org.is_empty() {
return org;
}
}
FALLBACK_NAMESPACE.into()
}
fn normalize_name(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut pending_sep = false;
for c in name.trim().to_ascii_lowercase().chars() {
if c.is_ascii_alphanumeric() {
if pending_sep && !out.is_empty() {
out.push('-');
}
out.push(c);
pending_sep = false;
} else if matches!(c, '-' | '_' | '.') {
pending_sep = true;
}
}
out
}
fn render_manifest(node: &DetectedNode, namespace: &str) -> String {
let DetectedNode {
name,
runtime,
entrypoint,
description,
source,
} = node;
let detected_note = match source {
Some(source) => format!(" # detected from {source}"),
None => " # TODO: verify".into(),
};
let yaml_quote = |s: &str| {
let cleaned: String = s
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', " ")
.chars()
.filter(|c| !c.is_control())
.collect();
format!("\"{cleaned}\"")
};
let description = yaml_quote(
description
.as_deref()
.unwrap_or("TODO: one-line description of what this node does"),
);
let entrypoint = yaml_quote(entrypoint);
format!(
r#"# dora-node.yml — node manifest (see `dora validate --node-manifest`)
# Spec: https://github.com/dora-rs/dora/blob/main/docs/plan-node-hub.md
apiVersion: 1
name: "{name}"{detected_note}
namespace: "{namespace}" # the GitHub org/user you publish under
description: {description}
# categories: [sensor] # one or more of: sensor, actuator, robot, transform,
# # filter, ml-inference, llm, speech, communication,
# # recorder, visualization, simulator, debug
# keywords: []
runtime: {runtime}
entrypoint: {entrypoint}{detected_note}
# Typed contracts: declare your ports so dataflows are checked at compose
# time. Type URNs come from the standard library (std/<category>/v1/<Type>) or your own
# `types:` definitions. Sources may have zero inputs; sinks zero outputs.
# (When uncommenting the examples, also remove the `{{}}` placeholder.)
inputs: {{}}
# image:
# type: std/media/v1/Image
# description: BGR frame to process
outputs: {{}}
# bbox:
# type: std/vision/v1/BoundingBox
# description: detected bounding boxes
# Documented configuration surface:
# env:
# MODEL:
# default: model.safetensors
# description: model weights to load
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaffold_parses_for_all_detection_paths() {
for node in [
DetectedNode {
name: "dora-yolo".into(),
runtime: "python",
entrypoint: "dora-yolo".into(),
description: Some("YOLO object detection".into()),
source: Some("pyproject.toml"),
},
DetectedNode {
name: "lidar".into(),
runtime: "rust",
entrypoint: "target/release/lidar".into(),
description: None,
source: Some("Cargo.toml"),
},
DetectedNode {
name: "my-node".into(),
runtime: "python",
entrypoint: "my-node".into(),
description: None,
source: None,
},
] {
let content = render_manifest(&node, "acme");
let manifest = NodeManifest::parse(&content)
.unwrap_or_else(|e| panic!("scaffold must parse: {e:#}\n{content}"));
assert_eq!(manifest.name.as_deref(), Some(node.name.as_str()));
assert_eq!(manifest.namespace, "acme");
}
}
#[test]
fn control_chars_in_description_dont_break_the_scaffold() {
let node = DetectedNode {
name: "dora-yolo".into(),
runtime: "python",
entrypoint: "dora-yolo".into(),
description: Some("detect\u{1b}[2Jobjects\r\u{0}".into()),
source: Some("pyproject.toml"),
};
let content = render_valid_manifest(node, "acme".into())
.unwrap_or_else(|e| panic!("scaffold must render: {e:#}"));
let manifest = NodeManifest::parse(&content)
.unwrap_or_else(|e| panic!("scaffold must parse: {e:#}\n{content}"));
assert_eq!(manifest.validate(&TypeRegistry::new()), vec![]);
}
fn node(name: &str, runtime: &'static str, entrypoint: &str) -> DetectedNode {
DetectedNode {
name: name.into(),
runtime,
entrypoint: entrypoint.into(),
description: None,
source: None,
}
}
#[test]
fn scaffold_is_always_valid_not_just_well_formed() {
use dora_core::types::TypeRegistry;
let registry = TypeRegistry::new();
let cases = [
(
"good",
node("dora-yolo", "rust", "target/release/dora-yolo"),
),
("reserved-ns", node("lidar", "rust", "target/release/lidar")),
("bad-entrypoint", node("lidar", "python", "weird name!")),
];
for (label, n) in cases {
let ns = match label {
"reserved-ns" => "std".to_string(),
_ => "acme".to_string(),
};
let content = render_valid_manifest(n, ns).expect(label);
let manifest = NodeManifest::parse(&content).expect(label);
assert_eq!(
manifest.validate(®istry),
vec![],
"{label}: scaffold must validate clean:\n{content}"
);
}
}
#[test]
fn reserved_namespace_falls_back() {
let content =
render_valid_manifest(node("n", "rust", "target/release/n"), "std".into()).unwrap();
let manifest = NodeManifest::parse(&content).unwrap();
assert_eq!(manifest.namespace, FALLBACK_NAMESPACE);
}
#[test]
fn detects_python_project() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("pyproject.toml"),
r#"
[project]
name = "Dora_Yolo"
description = "object detection"
[project.scripts]
dora-yolo = "dora_yolo.main:main"
"#,
)
.unwrap();
let node = detect_node(tmp.path());
assert_eq!(node.name, "dora-yolo");
assert_eq!(node.runtime, "python");
assert_eq!(node.entrypoint, "dora-yolo");
assert_eq!(node.description.as_deref(), Some("object detection"));
}
#[test]
fn detects_rust_project() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"lidar_driver\"\n",
)
.unwrap();
let node = detect_node(tmp.path());
assert_eq!(node.name, "lidar-driver");
assert_eq!(node.runtime, "rust");
assert_eq!(node.entrypoint, "target/release/lidar_driver");
}
#[test]
fn rust_entrypoint_prefers_explicit_bin_target() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"my_pkg\"\n\n[[bin]]\nname = \"custom_node\"\n",
)
.unwrap();
let node = detect_node(tmp.path());
assert_eq!(node.name, "my-pkg");
assert_eq!(node.entrypoint, "target/release/custom_node");
}
#[test]
fn normalizes_names() {
assert_eq!(normalize_name("Dora_Yolo"), "dora-yolo");
assert_eq!(normalize_name(" Node (v2)! "), "nodev2");
assert_eq!(normalize_name("_foo_"), "foo");
assert_eq!(normalize_name("(c++)"), "c");
assert_eq!(normalize_name("(++)"), "");
assert_eq!(normalize_name("My.Package__Name"), "my-package-name");
assert_eq!(normalize_name("a--b__c..d"), "a-b-c-d");
assert_eq!(normalize_name("node.v2"), "node-v2");
}
#[test]
fn detects_poetry_project() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("pyproject.toml"),
"[tool.poetry]\nname = \"dora-lidar\"\ndescription = \"driver\"\n",
)
.unwrap();
let node = detect_node(tmp.path());
assert_eq!(node.name, "dora-lidar");
assert_eq!(node.runtime, "python");
}
#[test]
fn prefers_script_matching_package_name() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("pyproject.toml"),
r#"
[project]
name = "my-node"
[project.scripts]
alpha-tool = "x:a"
my-node = "x:main"
"#,
)
.unwrap();
assert_eq!(detect_node(tmp.path()).entrypoint, "my-node");
}
#[test]
fn matches_script_by_raw_underscored_name() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("pyproject.toml"),
r#"
[project]
name = "my_pkg"
[project.scripts]
alpha = "x:a"
my_pkg = "x:main"
"#,
)
.unwrap();
let node = detect_node(tmp.path());
assert_eq!(node.name, "my-pkg");
assert_eq!(node.entrypoint, "my_pkg");
}
}