use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub project: ProjectConfig,
#[serde(default, rename = "endpoints")]
pub endpoints: Vec<EndpointDef>,
#[serde(default)]
pub levels: LevelConfig,
#[serde(default)]
pub defaults: HashMap<String, toml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointDef {
pub name: String,
#[serde(default)]
pub params: Vec<String>,
#[serde(default)]
pub required: Vec<String>,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LevelConfig {
#[serde(rename = "beginner-threshold", default = "default_beginner_threshold")]
pub beginner_threshold: u32,
#[serde(rename = "expert-threshold", default = "default_expert_threshold")]
pub expert_threshold: u32,
}
fn default_beginner_threshold() -> u32 {
30
}
fn default_expert_threshold() -> u32 {
70
}
impl Default for LevelConfig {
fn default() -> Self {
Self {
beginner_threshold: default_beginner_threshold(),
expert_threshold: default_expert_threshold(),
}
}
}
pub fn load_manifest(path: &str) -> Result<Manifest> {
let content =
std::fs::read_to_string(path).with_context(|| format!("Failed to read: {}", path))?;
toml::from_str(&content).with_context(|| format!("Failed to parse: {}", path))
}
pub fn validate(manifest: &Manifest) -> Result<()> {
if manifest.project.name.is_empty() {
anyhow::bail!("project.name is required");
}
if manifest.endpoints.is_empty() {
anyhow::bail!("at least one [[endpoints]] entry is required");
}
if manifest.levels.beginner_threshold >= manifest.levels.expert_threshold {
anyhow::bail!(
"beginner-threshold ({}) must be less than expert-threshold ({})",
manifest.levels.beginner_threshold,
manifest.levels.expert_threshold
);
}
for ep in &manifest.endpoints {
if ep.name.is_empty() {
anyhow::bail!("endpoint name is required");
}
if ep.params.is_empty() {
anyhow::bail!("endpoint '{}' must have at least one parameter", ep.name);
}
let param_names: Vec<String> = ep
.params
.iter()
.map(|p| extract_param_name(p))
.collect();
for req in &ep.required {
if !param_names.contains(req) {
anyhow::bail!(
"endpoint '{}': required param '{}' not found in params list",
ep.name,
req
);
}
}
}
Ok(())
}
pub fn extract_param_name(param_str: &str) -> String {
let name_part = param_str.split(':').next().unwrap_or("").trim();
name_part.trim_end_matches('?').to_string()
}
pub fn init_manifest(path: &str) -> Result<()> {
let p = Path::new(path).join("mylangiser.toml");
if p.exists() {
anyhow::bail!("mylangiser.toml already exists");
}
let template = r#"# SPDX-License-Identifier: PMPL-1.0-or-later
# mylangiser manifest — progressive-disclosure interface definition
[project]
name = "my-api"
description = "Progressive-disclosure wrapper for my API"
[[endpoints]]
name = "create_user"
params = ["username: string", "email: string", "role?: string", "permissions?: list", "mfa?: bool", "locale?: string"]
required = ["username", "email"]
[[endpoints]]
name = "search"
params = ["query: string", "filters?: map", "sort?: string", "page?: int", "limit?: int"]
required = ["query"]
[levels]
beginner-threshold = 30
expert-threshold = 70
[defaults]
role = "user"
limit = 20
page = 1
locale = "en-GB"
"#;
std::fs::write(&p, template)?;
println!("Created {}", p.display());
Ok(())
}
pub fn print_info(m: &Manifest) {
println!("=== {} ===", m.project.name);
if !m.project.description.is_empty() {
println!(" {}", m.project.description);
}
println!("Endpoints: {}", m.endpoints.len());
println!(
"Levels: beginner < {}, intermediate {}-{}, expert > {}",
m.levels.beginner_threshold,
m.levels.beginner_threshold,
m.levels.expert_threshold,
m.levels.expert_threshold
);
if !m.defaults.is_empty() {
println!("Defaults:");
for (key, val) in &m.defaults {
println!(" {} = {}", key, val);
}
}
for ep in &m.endpoints {
let optional_count = ep.params.len() - ep.required.len();
println!(
" [{}] {} params ({} required, {} optional)",
ep.name,
ep.params.len(),
ep.required.len(),
optional_count
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_param_name_required() {
assert_eq!(extract_param_name("username: string"), "username");
}
#[test]
fn test_extract_param_name_optional() {
assert_eq!(extract_param_name("role?: string"), "role");
}
#[test]
fn test_validate_empty_name_fails() {
let m = Manifest {
project: ProjectConfig {
name: String::new(),
description: String::new(),
},
endpoints: vec![],
levels: LevelConfig::default(),
defaults: HashMap::new(),
};
assert!(validate(&m).is_err());
}
}