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"));
}
}