nd / nd-cli/src/guess.rs

raw file 9769 bytes
//! `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();
    }
}