nd / nd-core/src/manifest.rs

raw file 4560 bytes
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;

/// The manifest filename, lives in every project root.
pub const MANIFEST_FILE: &str = "notdouble.toml";

/// Everything in the manifest is optional except `name`.
/// Unknown fields are preserved (flattened into `extra`) so the schema
/// can grow without breaking old manifests — nothing here is set in stone.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Manifest {
    /// Required. Becomes the url slug: "My Cool App" -> /p/my-cool-app
    pub name: String,
    /// "app" (default) or "template" — templates are project skeletons
    /// that agents pull with nd download instead of hand-scaffolding
    #[serde(default)]
    pub kind: String,
    pub version: String,
    /// One-liner shown on cards.
    pub description: String,
    /// 1-3 sentences written for humans AND llms. Powers search.
    pub llm_description: String,
    /// Topic keywords ("todo", "bot", "automation") — what it's about.
    pub keywords: Vec<String>,
    /// Code/tech keywords ("rust", "axum", "react") — how it's built.
    pub code_keywords: Vec<String>,
    pub languages: Vec<String>,
    pub author: String,
    pub license: String,
    pub homepage: String,
    /// Files an llm (or human) should read first.
    pub entry_points: Vec<String>,
    /// Shell command to install.
    pub install: String,
    /// Shell command to run.
    pub run: String,
    /// Where the vibes came from.
    pub vibe: Option<Vibe>,
    /// Machine specs gathered at upload time (opt-in via `nd upload --specs`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub specs: Option<serde_json::Value>,
    /// Any unknown field lands here and round-trips untouched.
    #[serde(flatten)]
    pub extra: BTreeMap<String, serde_json::Value>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Vibe {
    pub model: String,
    pub prompt_summary: String,
    #[serde(flatten)]
    pub extra: BTreeMap<String, serde_json::Value>,
}

pub const TEMPLATE: &str = r#"# notdouble.toml — manifest for notdoublehub (https://notdouble.com)
#
# Everything in here is searchable on the hub. Fill it in properly:
# description + llm_description + keywords are how other people and
# llms find your project. Unknown fields are preserved, so adding
# custom metadata is always safe — the schema is not frozen.

# Required. Becomes the url slug: "My Cool App" -> /p/my-cool-app
# kind: "app" (default) or "template" — templates are project
# skeletons agents can pull and start from without scaffolding by hand
name = "my-project"

version = "0.1.0"

# One-liner shown on search cards.
description = ""

# 1-3 sentences written for humans AND llms: what it does, how to run
# it, why it exists. This is the main field other agents search.
llm_description = ""

# Topic keywords — what the project IS about ("todo", "game", "cli").
keywords = []

# Code/tech keywords — what it's built WITH ("rust", "axum", "sqlite").
code_keywords = []

languages = []
author = ""
license = ""
homepage = ""

# Files to read first (relative paths).
entry_points = []

# Shell commands, shown on the project page.
install = ""
run = ""

# Where the vibes came from (all optional).
[vibe]
model = ""
prompt_summary = ""
"#;

impl Manifest {
    pub fn validate(&self) -> Result<()> {
        if self.name.trim().is_empty() {
            anyhow::bail!("manifest is missing `name`");
        }
        Ok(())
    }

    pub fn load(dir: &Path) -> Result<Manifest> {
        let path = dir.join(MANIFEST_FILE);
        let raw = std::fs::read_to_string(&path)
            .with_context(|| format!("missing {}: run `nd init`", path.display()))?;
        let m: Manifest = toml::from_str(&raw).with_context(|| format!("bad manifest {}", path.display()))?;
        m.validate()?;
        Ok(m)
    }

    pub fn to_json(&self) -> Result<String> {
        Ok(serde_json::to_string(self)?)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn roundtrip_unknown_fields() {
        let raw = r#"
name = "x"
kind = "template"
custom_field = 42
[nested_thing]
a = "b"
"#;
        let m: Manifest = toml::from_str(raw).unwrap();
        assert_eq!(m.name, "x");
        assert_eq!(m.kind, "template");
        assert!(m.extra.contains_key("custom_field"));
        assert!(m.extra.contains_key("nested_thing"));
        assert!(m.to_json().unwrap().contains("custom_field"));
    }
}