//! `nd init --guess` — fill manifest fields by inspecting the repo:
//! Cargo.toml / package.json / pyproject.toml / go.mod / README / git.
use nd_core::manifest::Manifest;
use std::path::Path;
pub fn guess(dir: &Path) -> Manifest {
let name = dir
.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
.unwrap_or_else(|| "my-project".into());
let mut m = Manifest { name, ..Default::default() };
let mut languages: Vec<String> = Vec::new();
let mut code_keywords: Vec<String> = Vec::new();
let mut entry_points: Vec<String> = Vec::new();
// rust
if dir.join("Cargo.toml").is_file() {
languages.push("rust".into());
if let Ok(raw) = std::fs::read_to_string(dir.join("Cargo.toml")) {
if let Ok(v) = raw.parse::<toml::Value>() {
let pkg = v.get("package");
if let Some(n) = pkg.and_then(|p| p.get("name")).and_then(|x| x.as_str()) {
m.name = n.to_string();
}
m.version = pkg
.and_then(|p| p.get("version"))
.and_then(|x| x.as_str())
.unwrap_or("0.1.0")
.to_string();
m.license = pkg
.and_then(|p| p.get("license"))
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string();
if let Some(deps) = v.get("dependencies").and_then(|d| d.as_table()) {
code_keywords.extend(deps.keys().take(8).cloned());
}
}
}
for ep in ["src/main.rs", "src/lib.rs"] {
if dir.join(ep).is_file() {
entry_points.push(ep.into());
}
}
m.install = "cargo build --release".into();
m.run = "cargo run".into();
}
// node / typescript
if dir.join("package.json").is_file() {
languages.push(
if dir.join("tsconfig.json").is_file() {
"typescript"
} else {
"javascript"
}
.into(),
);
if let Ok(raw) = std::fs::read_to_string(dir.join("package.json")) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
if let Some(n) = v.get("name").and_then(|x| x.as_str()) {
m.name = n.to_string();
}
if let Some(ver) = v.get("version").and_then(|x| x.as_str()) {
m.version = ver.into();
}
if let Some(lic) = v.get("license").and_then(|x| x.as_str()) {
m.license = lic.into();
}
if let Some(deps) = v.get("dependencies").and_then(|d| d.as_object()) {
code_keywords.extend(deps.keys().take(8).cloned());
}
if let Some(main) = v.get("main").and_then(|x| x.as_str()) {
entry_points.push(main.to_string());
}
m.install = "npm install".into();
if v.get("scripts").and_then(|s| s.get("start")).is_some() {
m.run = "npm start".into();
}
}
}
}
// python
if dir.join("pyproject.toml").is_file()
|| dir.join("requirements.txt").is_file()
|| dir.join("setup.py").is_file()
{
languages.push("python".into());
if let Ok(raw) = std::fs::read_to_string(dir.join("pyproject.toml")) {
if let Ok(v) = raw.parse::<toml::Value>() {
if let Some(proj) = v.get("project") {
if let Some(ver) = proj.get("version").and_then(|x| x.as_str()) {
m.version = ver.into();
}
if let Some(deps) = proj.get("dependencies").and_then(|d| d.as_array()) {
for d in deps.iter().filter_map(|x| x.as_str()).take(8) {
let name = d
.split(|c: char| {
matches!(c, '=' | '<' | '>' | '~' | '!' | ';' | '[')
})
.next()
.unwrap_or(d);
code_keywords.push(name.trim().to_string());
}
}
}
}
}
if dir.join("main.py").is_file() {
entry_points.push("main.py".into());
m.run = "python main.py".into();
}
}
// go
if dir.join("go.mod").is_file() {
languages.push("go".into());
if dir.join("main.go").is_file() {
entry_points.push("main.go".into());
m.install = "go build .".into();
m.run = "go run .".into();
}
}
// description: first meaningful readme line
for cand in ["README.md", "readme.md", "README"] {
let Ok(raw) = std::fs::read_to_string(dir.join(cand)) else {
continue;
};
for line in raw.lines() {
let t = line.trim();
if t.is_empty()
|| t.starts_with('#')
|| t.starts_with("![")
|| t.starts_with("[!")
|| t.starts_with('<')
|| t.starts_with('|')
{
continue;
}
m.description = t.chars().take(140).collect();
m.description = m.description.trim_end().to_string();
break;
}
break;
}
// author from git config
if let Ok(out) = std::process::Command::new("git")
.args(["config", "--get", "user.name"])
.output()
{
if out.status.success() {
let n = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !n.is_empty() {
m.author = n;
}
}
}
code_keywords.sort();
code_keywords.dedup();
entry_points.sort();
entry_points.dedup();
m.languages = languages;
m.code_keywords = code_keywords;
m.entry_points = entry_points;
m
}
/// Render the guessed manifest as commented toml (the commented template
/// is part of the llm-facing contract, so keep it readable).
pub fn render(m: &Manifest) -> String {
let q = |s: &str| toml::Value::String(s.to_string()).to_string();
let list = |v: &[String]| {
toml::Value::Array(v.iter().map(|s| toml::Value::String(s.clone())).collect()).to_string()
};
let mut out = String::from(
"# notdouble.toml — generated by `nd init --guess` from repo inspection.\n\
# guessed fields are marked; the schema is not frozen — add any custom metadata.\n\n",
);
out += &format!("name = {}\n", q(&m.name));
if !m.version.is_empty() {
out += &format!("version = {}\n", q(&m.version));
}
if !m.description.is_empty() {
out += &format!("\n# from the readme\ndescription = {}\n", q(&m.description));
}
out += "\n# FILL THIS IN — 1-3 sentences; the main field other agents search\nllm_description = \"\"\n";
if m.keywords.is_empty() {
out += "\n# topic words — what the project IS about\nkeywords = []\n";
} else {
out += &format!("\nkeywords = {}\n", list(&m.keywords));
}
if !m.code_keywords.is_empty() {
out += &format!(
"\n# guessed from dependencies\ncode_keywords = {}\n",
list(&m.code_keywords)
);
}
if !m.languages.is_empty() {
out += &format!("\n# guessed\nlanguages = {}\n", list(&m.languages));
}
if !m.author.is_empty() {
out += &format!("\nauthor = {}\n", q(&m.author));
}
if !m.license.is_empty() {
out += &format!("license = {}\n", q(&m.license));
}
if !m.entry_points.is_empty() {
out += &format!("\n# guessed\nentry_points = {}\n", list(&m.entry_points));
}
if !m.install.is_empty() {
out += &format!("\n# guessed\ninstall = {}\n", q(&m.install));
}
if !m.run.is_empty() {
out += &format!("run = {}\n", q(&m.run));
}
let model = m.vibe.as_ref().map(|v| v.model.clone()).unwrap_or_default();
out += &format!("\n[vibe]\nmodel = {}\nprompt_summary = \"\"\n", q(&model));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guesses_rust_project() {
let base = std::env::temp_dir().join(format!("nd-guess-{}", std::process::id()));
let proj = base.join("my-rust-thing");
std::fs::create_dir_all(proj.join("src")).unwrap();
std::fs::write(
proj.join("Cargo.toml"),
"[package]\nname = \"x\"\nversion = \"1.2.3\"\nlicense = \"MIT\"\n\n[dependencies]\nclap = \"4\"\nserde = \"1\"\n",
)
.unwrap();
std::fs::write(proj.join("src/main.rs"), "fn main(){}").unwrap();
std::fs::write(proj.join("README.md"), "# my-rust-thing\n\nDoes cool things quickly.\n").unwrap();
let m = guess(&proj);
// name is preferred from Cargo.toml, not the dir name
assert_eq!(m.name, "x");
assert_eq!(m.version, "1.2.3");
assert_eq!(m.license, "MIT");
assert_eq!(m.languages, vec!["rust"]);
assert!(m.code_keywords.contains(&"clap".to_string()));
assert!(m.entry_points.contains(&"src/main.rs".to_string()));
assert_eq!(m.description, "Does cool things quickly.");
assert!(m.run == "cargo run");
let rendered = render(&m);
assert!(rendered.contains("name = \"x\""));
assert!(rendered.contains("languages = [\"rust\"]"));
std::fs::remove_dir_all(&base).ok();
}
}